feat(cli): keyed credentials — servers:, the token chain, login/logout (RFC-007 PR 2)

The operator config gains servers: (name -> url; never a token). A remote
command whose URL prefix-matches an operator server resolves its bearer
token through the keyed chain first — OMNIGRAPH_TOKEN_<NAME> env, then the
[<name>] section of ~/.omnigraph/credentials (created 0600 via temp+rename,
#139 finding 7; group/world-readable files refused loudly) — falling
through to the legacy chain unchanged. URL keying makes §D5 rule 3
structural: a token is only ever sent to the server it is keyed to.
Longest-prefix matching with a path-boundary check (http://h:8080 never
matches http://h:8080-evil). Inserting the keyed hop above the legacy chain
is safe by construction — no existing setup can have servers: defined.

omnigraph login <name> stores/rotates one section (token from --token or
one stdin line — the pipe flow keeps secrets out of shell history);
omnigraph logout removes it, idempotently; logging in before declaring the
server warns instead of failing (the gh model).

Coverage: URL-match/no-substring-trap, credentials round-trip preserving
sibling sections, 0600 write + over-permissive refusal, env-name mapping;
the legacy resolve test is now hermetic against a real ~/.omnigraph and
asserts byte-identical legacy behavior with no servers defined; one
spawned-binary e2e walks the whole lifecycle against an authed server:
refusal -> wrong-token login (stdin) -> rotate (--token) -> authorized read
-> env-beats-file -> non-matching-URL negative -> logout revokes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
aaltshuler 2026-06-11 21:24:51 +03:00
parent 5db42fb660
commit a819ab500e
10 changed files with 603 additions and 4 deletions

View file

@ -259,6 +259,15 @@ pub fn spawn_server_with_cluster_env(cluster_dir: &Path, envs: &[(&str, &str)])
spawn_server_process(command)
}
pub fn spawn_server_with_env(graph: &Path, envs: &[(&str, &str)]) -> TestServer {
let mut command = server_process();
command.arg(graph);
for (name, value) in envs {
command.env(name, value);
}
spawn_server_process(command)
}
pub fn spawn_server_with_config_env(config: &Path, envs: &[(&str, &str)]) -> TestServer {
let mut command = server_process();
command.arg("--config").arg(config);

View file

@ -2309,3 +2309,118 @@ fn cluster_server_boot_ignores_local_config_in_cwd() {
let response = reqwest::blocking::get(format!("{}/healthz", server.base_url)).unwrap();
assert!(response.status().is_success());
}
/// RFC-007 PR 2: keyed credentials end to end — `login` stores a 0600
/// credential, the URL-matched server's token chain authenticates remote
/// reads (env > file), a non-matching URL never sees the token (§D5 rule
/// 3), and `logout` revokes.
#[test]
fn local_cli_keyed_credentials_authenticate_url_matched_server() {
let graph = SystemGraph::loaded();
let server = spawn_server_with_env(
graph.path(),
&[("OMNIGRAPH_SERVER_BEARER_TOKEN", "secret-tok")],
);
let operator_home = tempfile::tempdir().unwrap();
let write_server_url = |url: &str| {
fs::write(
operator_home.path().join("config.yaml"),
format!("servers:\n test-srv:\n url: {url}\n"),
)
.unwrap();
};
write_server_url(&server.base_url);
let remote_read = |envs: &[(&str, &str)]| {
let mut command = cli();
command.env("OMNIGRAPH_HOME", operator_home.path());
for (name, value) in envs {
command.env(name, value);
}
command
.arg("read")
.arg(&server.base_url)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
.arg("--json")
.output()
.unwrap()
};
// No credential anywhere: the server refuses.
let output = remote_read(&[]);
assert!(!output.status.success(), "{output:?}");
// login with a WRONG token (via stdin, the documented pipe flow).
let output = cli()
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("login")
.arg("test-srv")
.write_stdin("wrong-tok\n")
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
let output = remote_read(&[]);
assert!(!output.status.success(), "wrong token must not authenticate");
// Re-login rotates to the right token (via --token); 0600 on disk.
let output = cli()
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("login")
.arg("test-srv")
.arg("--token")
.arg("secret-tok")
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
let credentials = operator_home.path().join("credentials");
let text = fs::read_to_string(&credentials).unwrap();
assert!(text.contains("[test-srv]"), "{text}");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&credentials).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "{:o}", mode & 0o777);
}
let output = remote_read(&[]);
assert!(
output.status.success(),
"keyed credential must authenticate the URL-matched server: {output:?}"
);
let payload: serde_json::Value =
serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(payload["rows"][0]["p.name"], "Alice");
// OMNIGRAPH_TOKEN_<NAME> env outranks the credentials file.
let output = remote_read(&[("OMNIGRAPH_TOKEN_TEST_SRV", "env-wrong")]);
assert!(
!output.status.success(),
"keyed env token must outrank the credentials file"
);
// §D5 rule 3: a URL matching no operator server never sees the token.
write_server_url("http://127.0.0.1:1");
let output = remote_read(&[]);
assert!(
!output.status.success(),
"token keyed to another url must not be sent here"
);
write_server_url(&server.base_url);
// logout revokes; idempotent.
for _ in 0..2 {
let output = cli()
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("logout")
.arg("test-srv")
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
}
let output = remote_read(&[]);
assert!(!output.status.success(), "logout must revoke access");
}