mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-24 02:38:06 +02:00
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:
parent
5db42fb660
commit
a819ab500e
10 changed files with 603 additions and 4 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue