From 0c4df674fa65b1e37f0c7db4905199bd6e18353d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 21:15:17 +0000 Subject: [PATCH 1/7] Add schema get command to CLI and HTTP API Exposes the existing schema_source() method via a new `omnigraph schema get` CLI subcommand and a `GET /schema` API endpoint, allowing users to retrieve the current accepted schema from any graph repository. https://claude.ai/code/session_01UYybeBQks3fz3RJrTHtwQw --- crates/omnigraph-cli/src/main.rs | 44 +++++++++++++++++++++++- crates/omnigraph-server/src/api.rs | 5 +++ crates/omnigraph-server/src/lib.rs | 39 ++++++++++++++++++++- crates/omnigraph-server/tests/openapi.rs | 1 + 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 7951f37..8c68759 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -18,7 +18,7 @@ use omnigraph_server::api::{ BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput, BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, CommitOutput, ErrorOutput, ExportRequest, IngestOutput, IngestRequest, ReadOutput, ReadRequest, - RunListOutput, RunOutput, SchemaApplyOutput, SchemaApplyRequest, SnapshotOutput, + RunListOutput, RunOutput, SchemaApplyOutput, SchemaApplyRequest, SchemaGetOutput, SnapshotOutput, SnapshotTableOutput, commit_output, ingest_output, read_output, run_output, schema_apply_output, snapshot_payload, }; @@ -303,6 +303,17 @@ enum SchemaCommand { #[arg(long)] json: bool, }, + /// Get the current accepted schema source + Get { + /// Repo URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + }, } #[derive(Debug, Subcommand)] @@ -2003,6 +2014,37 @@ async fn main() -> Result<()> { print_schema_apply_human(&output); } } + SchemaCommand::Get { + uri, + target, + config, + json, + } => { + let config = load_cli_config(config.as_ref())?; + let bearer_token = + resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; + let uri = resolve_uri(&config, uri, target.as_deref())?; + let output = if is_remote_uri(&uri) { + remote_json::( + &http_client, + Method::GET, + remote_url(&uri, "/schema"), + None, + bearer_token.as_deref(), + ) + .await? + } else { + let db = Omnigraph::open(&uri).await?; + SchemaGetOutput { + source: db.schema_source().to_string(), + } + }; + if json { + print_json(&output)?; + } else { + print!("{}", output.source); + } + } }, Command::Query { command } => match command { QueryCommand::Lint { diff --git a/crates/omnigraph-server/src/api.rs b/crates/omnigraph-server/src/api.rs index ff5d453..ac5cd82 100644 --- a/crates/omnigraph-server/src/api.rs +++ b/crates/omnigraph-server/src/api.rs @@ -280,6 +280,11 @@ pub struct SchemaApplyOutput { pub steps: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SchemaGetOutput { + pub source: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct IngestRequest { pub branch: Option, diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index e8d0e7d..adef1c4 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -14,7 +14,7 @@ use api::{ BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, CommitListQuery, ErrorCode, ErrorOutput, ExportRequest, HealthOutput, IngestOutput, IngestRequest, ReadOutput, ReadRequest, RunListOutput, SchemaApplyOutput, SchemaApplyRequest, - SnapshotQuery, ingest_output, schema_apply_output, snapshot_payload, + SchemaGetOutput, SnapshotQuery, ingest_output, schema_apply_output, snapshot_payload, }; use axum::body::{Body, Bytes}; use axum::extract::DefaultBodyLimit; @@ -63,6 +63,7 @@ use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme}; server_export, server_change, server_schema_apply, + server_schema_get, server_ingest, server_branch_list, server_branch_create, @@ -407,6 +408,7 @@ pub fn build_app(state: AppState) -> Router { .route("/export", post(server_export)) .route("/read", post(server_read)) .route("/change", post(server_change)) + .route("/schema", get(server_schema_get)) .route("/schema/apply", post(server_schema_apply)) .route( "/ingest", @@ -796,6 +798,41 @@ async fn server_change( })) } +#[utoipa::path( + get, + path = "/schema", + tag = "schema", + responses( + (status = 200, description = "Current schema source", body = SchemaGetOutput), + (status = 401, description = "Unauthorized", body = ErrorOutput), + (status = 403, description = "Forbidden", body = ErrorOutput), + ), + security(("bearer_token" = [])), +)] +async fn server_schema_get( + State(state): State, + actor: Option>, +) -> std::result::Result, ApiError> { + authorize_request( + &state, + actor.as_ref().map(|Extension(actor)| actor), + PolicyRequest { + actor_id: actor + .as_ref() + .map(|Extension(actor)| actor.as_str().to_string()) + .unwrap_or_default(), + action: PolicyAction::Read, + branch: None, + target_branch: None, + }, + )?; + let source = { + let db = Arc::clone(&state.db).read_owned().await; + db.schema_source().to_string() + }; + Ok(Json(SchemaGetOutput { source })) +} + #[utoipa::path( post, path = "/schema/apply", diff --git a/crates/omnigraph-server/tests/openapi.rs b/crates/omnigraph-server/tests/openapi.rs index f47ccdf..d8e0cc7 100644 --- a/crates/omnigraph-server/tests/openapi.rs +++ b/crates/omnigraph-server/tests/openapi.rs @@ -161,6 +161,7 @@ const EXPECTED_PATHS: &[&str] = &[ "/read", "/export", "/change", + "/schema", "/schema/apply", "/ingest", "/branches", From be520f31f445795b1e0adb0e01ae7c2917c98b14 Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 18 Apr 2026 00:30:46 +0300 Subject: [PATCH 2/7] Polish schema endpoint: rename show, align field name, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #23, applied on top of the original commit: - Rename the CLI subcommand from `schema get` to `schema show` to match the existing `run show` / `commit show` convention. A `#[command(alias = "get")]` preserves muscle memory for anyone who already typed `get`. - Rename `SchemaGetOutput` → `SchemaOutput` and its field `source` → `schema_source`, so the get response and the apply request use the same field name for the same concept. - Use `println!` instead of `print!` in the CLI so the shell prompt doesn't land on the last line of schema output. - Add three integration tests on `/schema`: happy path (no auth), 401 when bearer is required but missing, 403 when the policy grants the actor branch_create but not read. Follow-ups left for a separate PR: include `schema_ir_hash` and `schema_identity_version` in the response payload so clients can do drift detection and the server can set an ETag; and a fast-path local read that skips `Omnigraph::open()` when only the schema source is needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/omnigraph-cli/src/main.rs | 17 ++--- crates/omnigraph-server/src/api.rs | 4 +- crates/omnigraph-server/src/lib.rs | 10 +-- crates/omnigraph-server/tests/server.rs | 89 ++++++++++++++++++++++++- 4 files changed, 104 insertions(+), 16 deletions(-) diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 8c68759..c29cac6 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -18,7 +18,7 @@ use omnigraph_server::api::{ BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput, BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, CommitOutput, ErrorOutput, ExportRequest, IngestOutput, IngestRequest, ReadOutput, ReadRequest, - RunListOutput, RunOutput, SchemaApplyOutput, SchemaApplyRequest, SchemaGetOutput, SnapshotOutput, + RunListOutput, RunOutput, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, SnapshotTableOutput, commit_output, ingest_output, read_output, run_output, schema_apply_output, snapshot_payload, }; @@ -303,8 +303,9 @@ enum SchemaCommand { #[arg(long)] json: bool, }, - /// Get the current accepted schema source - Get { + /// Show the current accepted schema source + #[command(alias = "get")] + Show { /// Repo URI uri: Option, #[arg(long)] @@ -2014,7 +2015,7 @@ async fn main() -> Result<()> { print_schema_apply_human(&output); } } - SchemaCommand::Get { + SchemaCommand::Show { uri, target, config, @@ -2025,7 +2026,7 @@ async fn main() -> Result<()> { resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; let uri = resolve_uri(&config, uri, target.as_deref())?; let output = if is_remote_uri(&uri) { - remote_json::( + remote_json::( &http_client, Method::GET, remote_url(&uri, "/schema"), @@ -2035,14 +2036,14 @@ async fn main() -> Result<()> { .await? } else { let db = Omnigraph::open(&uri).await?; - SchemaGetOutput { - source: db.schema_source().to_string(), + SchemaOutput { + schema_source: db.schema_source().to_string(), } }; if json { print_json(&output)?; } else { - print!("{}", output.source); + println!("{}", output.schema_source); } } }, diff --git a/crates/omnigraph-server/src/api.rs b/crates/omnigraph-server/src/api.rs index ac5cd82..61770df 100644 --- a/crates/omnigraph-server/src/api.rs +++ b/crates/omnigraph-server/src/api.rs @@ -281,8 +281,8 @@ pub struct SchemaApplyOutput { } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct SchemaGetOutput { - pub source: String, +pub struct SchemaOutput { + pub schema_source: String, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index adef1c4..52d2718 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -14,7 +14,7 @@ use api::{ BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, CommitListQuery, ErrorCode, ErrorOutput, ExportRequest, HealthOutput, IngestOutput, IngestRequest, ReadOutput, ReadRequest, RunListOutput, SchemaApplyOutput, SchemaApplyRequest, - SchemaGetOutput, SnapshotQuery, ingest_output, schema_apply_output, snapshot_payload, + SchemaOutput, SnapshotQuery, ingest_output, schema_apply_output, snapshot_payload, }; use axum::body::{Body, Bytes}; use axum::extract::DefaultBodyLimit; @@ -803,7 +803,7 @@ async fn server_change( path = "/schema", tag = "schema", responses( - (status = 200, description = "Current schema source", body = SchemaGetOutput), + (status = 200, description = "Current schema source", body = SchemaOutput), (status = 401, description = "Unauthorized", body = ErrorOutput), (status = 403, description = "Forbidden", body = ErrorOutput), ), @@ -812,7 +812,7 @@ async fn server_change( async fn server_schema_get( State(state): State, actor: Option>, -) -> std::result::Result, ApiError> { +) -> std::result::Result, ApiError> { authorize_request( &state, actor.as_ref().map(|Extension(actor)| actor), @@ -826,11 +826,11 @@ async fn server_schema_get( target_branch: None, }, )?; - let source = { + let schema_source = { let db = Arc::clone(&state.db).read_owned().await; db.schema_source().to_string() }; - Ok(Json(SchemaGetOutput { source })) + Ok(Json(SchemaOutput { schema_source })) } #[utoipa::path( diff --git a/crates/omnigraph-server/tests/server.rs b/crates/omnigraph-server/tests/server.rs index feebdc6..7a00c51 100644 --- a/crates/omnigraph-server/tests/server.rs +++ b/crates/omnigraph-server/tests/server.rs @@ -10,7 +10,7 @@ use omnigraph::db::{Omnigraph, ReadTarget}; use omnigraph::loader::{LoadMode, load_jsonl}; use omnigraph_server::api::{ BranchCreateRequest, BranchMergeRequest, ChangeRequest, ErrorOutput, ExportRequest, - IngestRequest, ReadRequest, SchemaApplyRequest, + IngestRequest, ReadRequest, SchemaApplyRequest, SchemaOutput, }; use omnigraph_server::{AppState, build_app}; use serde_json::{Value, json}; @@ -1042,6 +1042,93 @@ async fn snapshot_route_returns_manifest_dataset_version() { assert!(snapshot_body["tables"].is_array()); } +#[tokio::test(flavor = "multi_thread")] +async fn schema_route_returns_current_source() { + let (_temp, app) = app_for_loaded_repo().await; + let (status, body) = json_response( + &app, + Request::builder() + .uri("/schema") + .method(Method::GET) + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(status, StatusCode::OK); + let output: SchemaOutput = serde_json::from_value(body).unwrap(); + assert!(output.schema_source.contains("node Person")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn schema_route_requires_bearer_token_when_auth_configured() { + let (_temp, app) = app_for_loaded_repo_with_auth("demo-token").await; + + let (missing_status, missing_body) = json_response( + &app, + Request::builder() + .uri("/schema") + .method(Method::GET) + .body(Body::empty()) + .unwrap(), + ) + .await; + let missing_error: ErrorOutput = serde_json::from_value(missing_body).unwrap(); + assert_eq!(missing_status, StatusCode::UNAUTHORIZED); + assert_eq!( + missing_error.code, + Some(omnigraph_server::api::ErrorCode::Unauthorized) + ); + + let (ok_status, ok_body) = json_response( + &app, + Request::builder() + .uri("/schema") + .method(Method::GET) + .header("authorization", "Bearer demo-token") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(ok_status, StatusCode::OK); + let output: SchemaOutput = serde_json::from_value(ok_body).unwrap(); + assert!(!output.schema_source.is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn schema_route_denied_when_actor_lacks_read_permission() { + let temp = init_loaded_repo().await; + let repo = repo_path(temp.path()); + let policy_path = temp.path().join("policy.yaml"); + // Policy grants branch_create only — no read action for act-bruno. + fs::write(&policy_path, INGEST_CREATE_ONLY_POLICY_YAML).unwrap(); + let state = AppState::open_with_bearer_tokens_and_policy( + repo.to_string_lossy().to_string(), + vec![("act-bruno".to_string(), "team-token".to_string())], + Some(&policy_path), + ) + .await + .unwrap(); + let app = build_app(state); + + let (status, body) = json_response( + &app, + Request::builder() + .uri("/schema") + .method(Method::GET) + .header("authorization", "Bearer team-token") + .body(Body::empty()) + .unwrap(), + ) + .await; + let error: ErrorOutput = serde_json::from_value(body).unwrap(); + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!( + error.code, + Some(omnigraph_server::api::ErrorCode::Forbidden) + ); +} + #[tokio::test(flavor = "multi_thread")] async fn policy_blocks_change_on_protected_main_but_allows_unprotected_branch() { let temp = init_loaded_repo().await; From c338e801801ba06de84a8d339d335b884a876b5d Mon Sep 17 00:00:00 2001 From: andrew Date: Fri, 17 Apr 2026 21:40:51 +0300 Subject: [PATCH 3/7] Harden bearer auth: constant-time compare, hashed at rest, authoritative actor_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two live authz bugs in omnigraph-server: - Bearer-token lookup previously used HashMap::get, which compares keys with Eq and short-circuits on the first differing byte — a network-observable timing oracle for brute-forcing tokens. Tokens are now stored as SHA-256 digests and compared with subtle::ConstantTimeEq, iterating every entry unconditionally so total work is independent of which slot matches. Raw token bytes no longer live in server memory after startup. - authorize_request now overwrites PolicyRequest.actor_id from the authenticated session instead of trusting the handler-supplied field, which previously defaulted to "" via unwrap_or_default(). The empty string can no longer reach Cedar as a policy subject even if a future refactor drops the None check. External API of AppState constructors is unchanged — tokens still enter as Vec<(String, String)> and are hashed on the way in. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 + Cargo.toml | 1 + crates/omnigraph-server/Cargo.toml | 2 + crates/omnigraph-server/src/lib.rs | 69 ++++++++++++++++++-- crates/omnigraph-server/tests/server.rs | 85 +++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8dd53e8..db7d525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4597,6 +4597,8 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "sha2", + "subtle", "tempfile", "tokio", "tower", diff --git a/Cargo.toml b/Cargo.toml index 34e2062..1e7129d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ utoipa = { version = "5", features = ["axum_extras"] } url = "2" cedar-policy = "4.9" sha2 = "0.10" +subtle = "2" [profile.dev] debug = 0 diff --git a/crates/omnigraph-server/Cargo.toml b/crates/omnigraph-server/Cargo.toml index 90c33d3..d4724dc 100644 --- a/crates/omnigraph-server/Cargo.toml +++ b/crates/omnigraph-server/Cargo.toml @@ -28,6 +28,8 @@ tower-http = { workspace = true } utoipa = { workspace = true } cedar-policy = { workspace = true } futures = { workspace = true } +sha2 = { workspace = true } +subtle = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index 52d2718..3fa8787 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -42,6 +42,8 @@ pub use policy::{ PolicyRequest, PolicyTestConfig, }; use serde_json::Value; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; use tokio::net::TcpListener; use tokio::sync::{RwLock, mpsc}; use tower_http::trace::TraceLayer; @@ -50,6 +52,15 @@ use tracing_subscriber::EnvFilter; use utoipa::OpenApi; use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme}; +type BearerTokenHash = [u8; 32]; + +fn hash_bearer_token(token: &str) -> BearerTokenHash { + let digest = Sha256::digest(token.as_bytes()); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + out +} + #[derive(OpenApi)] #[openapi( info( @@ -110,7 +121,7 @@ pub struct ServerConfig { pub struct AppState { uri: String, db: Arc>, - bearer_tokens: Arc, Arc>>, + bearer_tokens: Arc<[(BearerTokenHash, Arc)]>, policy_engine: Option>, } @@ -175,14 +186,14 @@ impl AppState { bearer_tokens: Vec<(String, String)>, policy_engine: Option, ) -> Self { - let bearer_tokens = bearer_tokens + let bearer_tokens: Vec<(BearerTokenHash, Arc)> = bearer_tokens .into_iter() - .map(|(actor, token)| (Arc::::from(token), Arc::::from(actor))) + .map(|(actor, token)| (hash_bearer_token(&token), Arc::::from(actor))) .collect(); Self { uri, db: Arc::new(RwLock::new(db)), - bearer_tokens: Arc::new(bearer_tokens), + bearer_tokens: Arc::from(bearer_tokens), policy_engine: policy_engine.map(Arc::new), } } @@ -242,7 +253,17 @@ impl AppState { } fn authenticate_bearer_token(&self, provided_token: &str) -> Option> { - self.bearer_tokens.get(provided_token).cloned() + // Hash the incoming token and compare against every stored digest in + // constant time. Iterate all entries unconditionally so total work — + // and therefore response timing — doesn't depend on which slot matches. + let provided_hash = hash_bearer_token(provided_token); + let mut matched: Option> = None; + for (hash, actor) in self.bearer_tokens.iter() { + if bool::from(hash.ct_eq(&provided_hash)) && matched.is_none() { + matched = Some(Arc::clone(actor)); + } + } + matched } fn policy_engine(&self) -> Option<&PolicyEngine> { @@ -554,7 +575,7 @@ fn log_policy_decision(actor_id: &str, request: &PolicyRequest, decision: &Polic fn authorize_request( state: &AppState, actor: Option<&AuthenticatedActor>, - request: PolicyRequest, + mut request: PolicyRequest, ) -> std::result::Result<(), ApiError> { let Some(engine) = state.policy_engine() else { return Ok(()); @@ -562,6 +583,10 @@ fn authorize_request( let Some(actor) = actor else { return Err(ApiError::unauthorized("missing bearer token")); }; + // Authoritative actor_id is the authenticated session, not whatever the + // handler put in the request. Prevents an empty-string default at any + // call site from ever reaching the engine as a policy subject. + request.actor_id = actor.as_str().to_string(); let decision = engine .authorize(&request) .map_err(|err| ApiError::internal(format!("policy: {err}")))?; @@ -1481,13 +1506,43 @@ fn server_bearer_tokens_from_env() -> Result> { #[cfg(test)] mod tests { use super::{ - load_server_settings, normalize_bearer_token, parse_bearer_tokens_json, + hash_bearer_token, load_server_settings, normalize_bearer_token, parse_bearer_tokens_json, server_bearer_tokens_from_env, }; use std::env; use std::fs; use tempfile::tempdir; + #[test] + fn hash_bearer_token_produces_32_byte_output() { + let hash = hash_bearer_token("any-token"); + assert_eq!(hash.len(), 32); + } + + #[test] + fn hash_bearer_token_is_deterministic() { + assert_eq!( + hash_bearer_token("stable-input"), + hash_bearer_token("stable-input"), + ); + } + + #[test] + fn hash_bearer_token_differs_for_different_inputs() { + assert_ne!(hash_bearer_token("token-a"), hash_bearer_token("token-b")); + } + + #[test] + fn hash_bearer_token_matches_known_sha256_vector() { + // SHA-256("abc"). If this ever fails, the hash function was swapped. + let hash = hash_bearer_token("abc"); + let hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect(); + assert_eq!( + hex, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + #[test] fn server_settings_load_from_yaml_config() { let temp = tempdir().unwrap(); diff --git a/crates/omnigraph-server/tests/server.rs b/crates/omnigraph-server/tests/server.rs index 7a00c51..77b9118 100644 --- a/crates/omnigraph-server/tests/server.rs +++ b/crates/omnigraph-server/tests/server.rs @@ -894,6 +894,91 @@ async fn protected_routes_accept_any_configured_team_bearer_token() { assert!(body["runs"].is_array()); } +/// Verifies the hashed-token lookup correctly resolves each bearer to its +/// associated actor, and that the resolved actor — not the handler-supplied +/// default — is what the policy engine sees. Two tokens for two distinct +/// actors; policy grants read to actor-A only. Swapping tokens must swap +/// the policy outcome. +#[tokio::test(flavor = "multi_thread")] +async fn bearer_token_resolves_to_correct_actor_for_policy_decisions() { + let temp = init_loaded_repo().await; + let repo = repo_path(temp.path()); + let policy_path = temp.path().join("policy.yaml"); + fs::write( + &policy_path, + r#" +version: 1 +groups: + readers: [act-a] + writers: [act-b] +protected_branches: [main] +rules: + - id: readers-only + allow: + actors: { group: readers } + actions: [read] + branch_scope: any +"#, + ) + .unwrap(); + let state = AppState::open_with_bearer_tokens_and_policy( + repo.to_string_lossy().to_string(), + vec![ + ("act-a".to_string(), "token-a".to_string()), + ("act-b".to_string(), "token-b".to_string()), + ], + Some(&policy_path), + ) + .await + .unwrap(); + let app = build_app(state); + + // act-a is authenticated AND authorized. + let (ok_status, _) = json_response( + &app, + Request::builder() + .uri("/snapshot?branch=main") + .method(Method::GET) + .header("authorization", "Bearer token-a") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(ok_status, StatusCode::OK); + + // act-b is authenticated but policy rejects — proves the resolved actor + // (not some default) was the policy subject. + let (denied_status, denied_body) = json_response( + &app, + Request::builder() + .uri("/snapshot?branch=main") + .method(Method::GET) + .header("authorization", "Bearer token-b") + .body(Body::empty()) + .unwrap(), + ) + .await; + let denied_error: ErrorOutput = serde_json::from_value(denied_body).unwrap(); + assert_eq!(denied_status, StatusCode::FORBIDDEN); + assert_eq!( + denied_error.code, + Some(omnigraph_server::api::ErrorCode::Forbidden) + ); + + // Unknown token: 401, never reaches the policy engine. + let (bad_status, _) = json_response( + &app, + Request::builder() + .uri("/snapshot?branch=main") + .method(Method::GET) + .header("authorization", "Bearer wrong-token") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(bad_status, StatusCode::UNAUTHORIZED); +} + #[tokio::test(flavor = "multi_thread")] async fn policy_allows_read_but_distinguishes_401_from_403() { let (_temp, app) = app_for_loaded_repo_with_auth_tokens_and_policy( From af41630520f4d0d00edf163a5e2b0f1235347b8f Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 18 Apr 2026 03:31:43 +0300 Subject: [PATCH 4/7] Extract TokenSource trait for bearer token loading Pure refactor. No behavior change. Introduces a TokenSource trait so additional backends (AWS Secrets Manager, Vault, etc.) can plug in behind feature flags without touching the server wiring. - New module crates/omnigraph-server/src/auth.rs with the TokenSource trait and a single EnvOrFileTokenSource implementation that delegates to the existing server_bearer_tokens_from_env() function. - serve() now constructs EnvOrFileTokenSource and calls load() instead of calling the free function directly. - The trait has a supports_refresh() hook (false for env/file) for future implementations that can rotate without restart. - async-trait added to omnigraph-server deps; it's already in the workspace. Tests: - Unit tests in auth.rs covering load paths and the default supports_refresh / name values. - Existing 128 tests (lib + integration + openapi) pass unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/omnigraph-server/Cargo.toml | 1 + crates/omnigraph-server/src/auth.rs | 106 ++++++++++++++++++++++++++++ crates/omnigraph-server/src/lib.rs | 5 +- 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 crates/omnigraph-server/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index db7d525..0998210 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4585,6 +4585,7 @@ dependencies = [ name = "omnigraph-server" version = "0.2.2" dependencies = [ + "async-trait", "axum", "cedar-policy", "clap", diff --git a/crates/omnigraph-server/Cargo.toml b/crates/omnigraph-server/Cargo.toml index d4724dc..a659a69 100644 --- a/crates/omnigraph-server/Cargo.toml +++ b/crates/omnigraph-server/Cargo.toml @@ -30,6 +30,7 @@ cedar-policy = { workspace = true } futures = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } +async-trait = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/omnigraph-server/src/auth.rs b/crates/omnigraph-server/src/auth.rs new file mode 100644 index 0000000..1466c2e --- /dev/null +++ b/crates/omnigraph-server/src/auth.rs @@ -0,0 +1,106 @@ +//! Bearer token sources. +//! +//! A `TokenSource` loads `(actor_id, token)` pairs that the server uses to +//! authenticate incoming bearer tokens. Plaintext tokens returned here are +//! hashed immediately by `AppState` on ingest — see `hash_bearer_token` — +//! and never persist past startup/refresh. +//! +//! The trait exists so that additional backends (AWS Secrets Manager, +//! HashiCorp Vault, etc.) can plug in behind feature flags without +//! touching the server wiring. + +use async_trait::async_trait; +use color_eyre::eyre::Result; + +use crate::server_bearer_tokens_from_env; + +/// A source of bearer tokens, returned as `(actor_id, token)` pairs in +/// plaintext. The caller is expected to hash tokens before storing them. +#[async_trait] +pub trait TokenSource: Send + Sync { + /// Fetch the current set of actor → token pairs. + /// + /// Called once at startup. Implementations that support rotation may + /// also be polled periodically. + async fn load(&self) -> Result>; + + /// Whether this source can be re-fetched for rotation without restart. + /// Default: false (one-shot sources). + fn supports_refresh(&self) -> bool { + false + } + + /// Human-readable name for logs and error messages. + fn name(&self) -> &'static str; +} + +/// Reads bearer tokens from environment variables and / or files, matching +/// the long-standing server configuration: +/// +/// - `OMNIGRAPH_SERVER_BEARER_TOKEN` — a single token assigned to the +/// implicit actor `default`. +/// - `OMNIGRAPH_SERVER_BEARER_TOKENS_JSON` — a JSON object of +/// `{"actor_id": "token", …}`. +/// - `OMNIGRAPH_SERVER_BEARER_TOKENS_FILE` — a path to a JSON file of the +/// same shape. +/// +/// Does not support refresh — reloading means restarting the process. +#[derive(Debug, Default, Clone)] +pub struct EnvOrFileTokenSource; + +#[async_trait] +impl TokenSource for EnvOrFileTokenSource { + async fn load(&self) -> Result> { + server_bearer_tokens_from_env() + } + + fn name(&self) -> &'static str { + "env-or-file" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use serial_test::serial; + + fn clear_env() { + unsafe { + env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKEN"); + env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON"); + env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKENS_FILE"); + } + } + + #[tokio::test] + #[serial] + async fn env_or_file_source_returns_empty_when_nothing_configured() { + clear_env(); + let source = EnvOrFileTokenSource; + let tokens = source.load().await.unwrap(); + assert!(tokens.is_empty()); + } + + #[tokio::test] + #[serial] + async fn env_or_file_source_reads_single_token_as_default_actor() { + clear_env(); + unsafe { + env::set_var("OMNIGRAPH_SERVER_BEARER_TOKEN", "some-token"); + } + let source = EnvOrFileTokenSource; + let tokens = source.load().await.unwrap(); + unsafe { + env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKEN"); + } + assert_eq!(tokens, vec![("default".to_string(), "some-token".to_string())]); + } + + #[tokio::test] + async fn env_or_file_source_does_not_support_refresh() { + let source = EnvOrFileTokenSource; + assert!(!source.supports_refresh()); + assert_eq!(source.name(), "env-or-file"); + } +} diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index 3fa8787..dd373b8 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -1,4 +1,5 @@ pub mod api; +pub mod auth; pub mod config; pub mod policy; @@ -37,6 +38,7 @@ use omnigraph::error::{ManifestErrorKind, OmniError}; use omnigraph_compiler::json_params_to_param_map; use omnigraph_compiler::query::parser::parse_query; use omnigraph_compiler::{JsonParamMode, ParamMap}; +pub use auth::{EnvOrFileTokenSource, TokenSource}; pub use policy::{ PolicyAction, PolicyCompiler, PolicyConfig, PolicyDecision, PolicyEngine, PolicyExpectation, PolicyRequest, PolicyTestConfig, @@ -462,9 +464,10 @@ pub fn build_app(state: AppState) -> Router { } pub async fn serve(config: ServerConfig) -> Result<()> { + let token_source = EnvOrFileTokenSource; let state = AppState::open_with_bearer_tokens_and_policy( config.uri.clone(), - server_bearer_tokens_from_env()?, + token_source.load().await?, config.policy_file.as_ref(), ) .await?; From 7a3bf5c75851830bc00640e41c51b79b29e9769d Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 18 Apr 2026 03:48:51 +0300 Subject: [PATCH 5/7] Add aws feature + SecretsManagerTokenSource backend Introduces an opt-in AWS Secrets Manager backend for bearer tokens, behind the `aws` Cargo feature. Default builds (on-prem, local dev) don't pull in the AWS SDK and don't pay its compile cost. - New Cargo feature `aws` gates the `aws-config` + `aws-sdk-secretsmanager` optional deps. Default features remain empty. - New `auth::aws::SecretsManagerTokenSource` implements `TokenSource` by fetching a JSON `{"actor_id": "token", ...}` payload from a named Secrets Manager secret. Credentials resolve via the AWS default chain (env, shared config, IMDSv2 instance role, ECS task role) so no explicit plumbing is needed under an IAM role. - New `resolve_token_source()` dispatches based on the `OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET` env var. If the var is set but the binary was built without `--features aws`, returns a clear rebuild instruction rather than silently falling back. - `serve()` now uses `resolve_token_source()` and logs which source was selected at startup. - `parse_json_secret_payload()` is factored out as a free function so the payload validation (trim whitespace, reject blank actor/token, reject non-object) is unit-testable without the AWS SDK. - New CI job `test_aws_feature` builds + tests with `--features aws`. Not in this PR (follow-ups): - Background refresh loop for rotation. `SecretsManagerTokenSource` advertises `supports_refresh: true` but the AppState-level refresh task isn't wired yet. - Config-YAML dispatch (today the AWS source is selected via env var only; eventually `server.bearer_tokens.source` in `omnigraph.yaml`). Tests: - Default-feature build: 33 lib + 41 integration + 64 openapi. - `--features aws` build: 32 lib (one test is cfg-gated) + 41 + 64. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 46 +++++++ Cargo.lock | 195 ++++++++++++++++++++++---- crates/omnigraph-server/Cargo.toml | 8 ++ crates/omnigraph-server/src/auth.rs | 206 +++++++++++++++++++++++++++- crates/omnigraph-server/src/lib.rs | 5 +- 5 files changed, 432 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7827df8..40eba61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,6 +140,52 @@ jobs: if: needs.classify_changes.outputs.run_full_ci == 'true' run: cargo test --workspace --locked + test_aws_feature: + name: Test omnigraph-server --features aws + needs: classify_changes + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + env: + CARGO_TERM_COLOR: always + steps: + - name: Skip for text-only changes + if: needs.classify_changes.outputs.run_full_ci != 'true' + run: echo "Text-only change detected; skipping aws feature build." + + - name: Checkout source + if: needs.classify_changes.outputs.run_full_ci == 'true' + uses: actions/checkout@v5.0.1 + + - name: Install system dependencies + if: needs.classify_changes.outputs.run_full_ci == 'true' + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler libprotobuf-dev + + - name: Install Rust stable + if: needs.classify_changes.outputs.run_full_ci == 'true' + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Cache Rust build data + if: needs.classify_changes.outputs.run_full_ci == 'true' + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + key: aws-feature + + - name: Build omnigraph-server with aws feature + if: needs.classify_changes.outputs.run_full_ci == 'true' + run: cargo build --locked -p omnigraph-server --features aws + + - name: Test omnigraph-server with aws feature + if: needs.classify_changes.outputs.run_full_ci == 'true' + run: cargo test --locked -p omnigraph-server --features aws + rustfs_integration: name: RustFS S3 Integration needs: diff --git a/Cargo.lock b/Cargo.lock index 0998210..7332d52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -597,6 +597,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-secretsmanager" +version = "1.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae64963d3d16d8070aaa2fb79c11cd3b13f44d2f13bba3fe8f49dcd2c42f2987" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sso" version = "1.97.0" @@ -733,17 +757,23 @@ dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", "http 1.4.0", - "hyper", - "hyper-rustls", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", "hyper-util", "pin-project-lite", - "rustls", + "rustls 0.21.12", + "rustls 0.23.36", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tracing", ] @@ -827,6 +857,7 @@ dependencies = [ "base64-simd", "bytes", "bytes-utils", + "futures-core", "http 0.2.12", "http 1.4.0", "http-body 0.4.6", @@ -839,6 +870,8 @@ dependencies = [ "ryu", "serde", "time", + "tokio", + "tokio-util", ] [[package]] @@ -878,7 +911,7 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "itoa", "matchit", @@ -2786,6 +2819,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -2966,6 +3018,30 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2976,7 +3052,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", + "h2 0.4.13", "http 1.4.0", "http-body 1.0.1", "httparse", @@ -2989,6 +3065,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -2996,13 +3087,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.4.0", - "hyper", + "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.36", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots", ] @@ -3019,12 +3110,12 @@ dependencies = [ "futures-util", "http 1.4.0", "http-body 1.0.1", - "hyper", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -4465,7 +4556,7 @@ dependencies = [ "http-body-util", "httparse", "humantime", - "hyper", + "hyper 1.8.1", "itertools 0.14.0", "md-5", "parking_lot", @@ -4586,6 +4677,8 @@ name = "omnigraph-server" version = "0.2.2" dependencies = [ "async-trait", + "aws-config", + "aws-sdk-secretsmanager", "axum", "cedar-policy", "clap", @@ -5169,8 +5262,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", - "socket2", + "rustls 0.23.36", + "socket2 0.6.2", "thiserror", "tokio", "tracing", @@ -5189,7 +5282,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls", + "rustls 0.23.36", "rustls-pki-types", "slab", "thiserror", @@ -5207,7 +5300,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] @@ -5491,12 +5584,12 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.4.13", "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", @@ -5505,7 +5598,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.36", "rustls-native-certs", "rustls-pki-types", "serde", @@ -5513,7 +5606,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -5644,6 +5737,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.36" @@ -5654,7 +5759,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] @@ -5690,6 +5795,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.9" @@ -5797,6 +5912,16 @@ dependencies = [ "sha2", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sdd" version = "3.0.10" @@ -6135,6 +6260,16 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.2" @@ -6602,7 +6737,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -6618,13 +6753,23 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.36", "tokio", ] diff --git a/crates/omnigraph-server/Cargo.toml b/crates/omnigraph-server/Cargo.toml index a659a69..1d2029f 100644 --- a/crates/omnigraph-server/Cargo.toml +++ b/crates/omnigraph-server/Cargo.toml @@ -12,6 +12,12 @@ documentation = "https://docs.rs/omnigraph-server" name = "omnigraph-server" path = "src/main.rs" +[features] +default = [] +# Enables the AWS Secrets Manager bearer-token source. Off by default — on-prem +# and local-dev builds don't pay the AWS SDK compile cost. +aws = ["dep:aws-config", "dep:aws-sdk-secretsmanager"] + [dependencies] omnigraph = { package = "omnigraph-engine", path = "../omnigraph", version = "0.2.2" } omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.2.2" } @@ -31,6 +37,8 @@ futures = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } async-trait = { workspace = true } +aws-config = { version = "1", optional = true, default-features = false, features = ["rustls", "rt-tokio", "credentials-process", "sso"] } +aws-sdk-secretsmanager = { version = "1", optional = true, default-features = false, features = ["rustls", "rt-tokio"] } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/omnigraph-server/src/auth.rs b/crates/omnigraph-server/src/auth.rs index 1466c2e..80b6ed5 100644 --- a/crates/omnigraph-server/src/auth.rs +++ b/crates/omnigraph-server/src/auth.rs @@ -10,10 +10,15 @@ //! touching the server wiring. use async_trait::async_trait; -use color_eyre::eyre::Result; +use color_eyre::eyre::{Result, bail}; use crate::server_bearer_tokens_from_env; +/// Environment variable that, when set, selects AWS Secrets Manager as the +/// token source. Its value is the secret ID or ARN. Only honored when the +/// binary is compiled with `--features aws`. +pub const AWS_SECRET_ENV: &str = "OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET"; + /// A source of bearer tokens, returned as `(actor_id, token)` pairs in /// plaintext. The caller is expected to hash tokens before storing them. #[async_trait] @@ -59,6 +64,139 @@ impl TokenSource for EnvOrFileTokenSource { } } +/// Pick the token source based on configuration. +/// +/// Preference order: +/// 1. If `OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET` is set AND the binary was +/// built with `--features aws`, returns an AWS Secrets Manager source. +/// 2. If that env var is set but the binary was built without the feature, +/// errors with a clear rebuild instruction rather than silently falling +/// back to the env/file source (which would hide the misconfiguration). +/// 3. Otherwise, returns `EnvOrFileTokenSource`. +pub async fn resolve_token_source() -> Result> { + if let Ok(secret_id) = std::env::var(AWS_SECRET_ENV) { + let secret_id = secret_id.trim().to_string(); + if !secret_id.is_empty() { + #[cfg(feature = "aws")] + { + let source = aws::SecretsManagerTokenSource::new(secret_id).await?; + return Ok(Box::new(source)); + } + #[cfg(not(feature = "aws"))] + { + bail!( + "{} is set but this binary was not built with --features aws. \ + Rebuild: cargo build --release --features aws", + AWS_SECRET_ENV + ); + } + } + } + Ok(Box::new(EnvOrFileTokenSource)) +} + +/// Parse a JSON secret payload (from AWS Secrets Manager or any equivalent +/// source) into actor → token pairs. +/// +/// Payload shape: `{"actor_id_1": "token_1", "actor_id_2": "token_2", ...}`. +/// Extracted as a free function so it can be unit-tested without the AWS SDK. +#[cfg(any(test, feature = "aws"))] +pub(crate) fn parse_json_secret_payload(payload: &str) -> Result> { + use std::collections::HashMap; + + let map: HashMap = serde_json::from_str(payload).map_err(|err| { + color_eyre::eyre::eyre!( + "bearer-token secret payload is not a JSON object of actor→token: {}", + err + ) + })?; + + let mut pairs: Vec<(String, String)> = Vec::with_capacity(map.len()); + for (actor, token) in map { + let actor = actor.trim().to_string(); + let token = token.trim().to_string(); + if actor.is_empty() { + bail!("bearer-token secret contains a blank actor id"); + } + if token.is_empty() { + bail!("bearer-token secret has a blank token for actor '{}'", actor); + } + pairs.push((actor, token)); + } + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + Ok(pairs) +} + +#[cfg(feature = "aws")] +pub mod aws { + //! AWS Secrets Manager bearer-token backend. + //! + //! Fetches a JSON payload from a named secret on startup. Credentials are + //! resolved via the AWS default chain — env vars, shared config, IMDSv2 + //! instance role, or ECS task role — so no explicit credential plumbing + //! is needed when running under an IAM role. + //! + //! Background refresh for rotation is a follow-up. + use super::TokenSource; + use async_trait::async_trait; + use color_eyre::eyre::{Result, WrapErr, eyre}; + + /// Loads bearer tokens from a named AWS Secrets Manager secret. + pub struct SecretsManagerTokenSource { + client: aws_sdk_secretsmanager::Client, + secret_id: String, + } + + impl SecretsManagerTokenSource { + /// Construct a new source. Resolves AWS credentials + region via the + /// default chain — no explicit configuration needed on EC2/ECS/EKS. + pub async fn new(secret_id: impl Into) -> Result { + let config = + aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + let client = aws_sdk_secretsmanager::Client::new(&config); + Ok(Self { + client, + secret_id: secret_id.into(), + }) + } + } + + #[async_trait] + impl TokenSource for SecretsManagerTokenSource { + async fn load(&self) -> Result> { + let output = self + .client + .get_secret_value() + .secret_id(&self.secret_id) + .send() + .await + .wrap_err_with(|| { + format!("fetch AWS Secrets Manager secret '{}'", self.secret_id) + })?; + + let payload = output.secret_string().ok_or_else(|| { + eyre!( + "secret '{}' has no SecretString — binary secrets are not supported", + self.secret_id + ) + })?; + + super::parse_json_secret_payload(payload) + } + + fn supports_refresh(&self) -> bool { + true + } + + fn name(&self) -> &'static str { + "aws-secrets-manager" + } + } +} + +#[cfg(feature = "aws")] +pub use aws::SecretsManagerTokenSource; + #[cfg(test)] mod tests { use super::*; @@ -103,4 +241,70 @@ mod tests { assert!(!source.supports_refresh()); assert_eq!(source.name(), "env-or-file"); } + + #[test] + fn parse_json_secret_payload_reads_actor_token_map() { + let pairs = parse_json_secret_payload(r#"{"alice": "tok-a", "bob": "tok-b"}"#).unwrap(); + assert_eq!( + pairs, + vec![ + ("alice".to_string(), "tok-a".to_string()), + ("bob".to_string(), "tok-b".to_string()), + ] + ); + } + + #[test] + fn parse_json_secret_payload_trims_whitespace() { + let pairs = parse_json_secret_payload(r#"{" alice ": " tok-a "}"#).unwrap(); + assert_eq!(pairs, vec![("alice".to_string(), "tok-a".to_string())]); + } + + #[test] + fn parse_json_secret_payload_rejects_blank_actor() { + let err = parse_json_secret_payload(r#"{" ": "tok"}"#).unwrap_err(); + assert!(err.to_string().contains("blank actor")); + } + + #[test] + fn parse_json_secret_payload_rejects_blank_token() { + let err = parse_json_secret_payload(r#"{"alice": " "}"#).unwrap_err(); + assert!(err.to_string().contains("blank token")); + } + + #[test] + fn parse_json_secret_payload_rejects_non_object() { + let err = parse_json_secret_payload("[1, 2, 3]").unwrap_err(); + assert!(err.to_string().contains("not a JSON object")); + } + + #[tokio::test] + #[serial] + async fn resolve_token_source_falls_back_to_env_or_file_when_aws_var_unset() { + clear_env(); + unsafe { + env::remove_var(AWS_SECRET_ENV); + } + let source = resolve_token_source().await.unwrap(); + assert_eq!(source.name(), "env-or-file"); + } + + #[cfg(not(feature = "aws"))] + #[tokio::test] + #[serial] + async fn resolve_token_source_errors_when_aws_var_set_without_feature() { + clear_env(); + unsafe { + env::set_var(AWS_SECRET_ENV, "some-secret-id"); + } + let result = resolve_token_source().await; + unsafe { + env::remove_var(AWS_SECRET_ENV); + } + let err = match result { + Ok(_) => panic!("expected resolve_token_source to error without aws feature"), + Err(err) => err, + }; + assert!(err.to_string().contains("--features aws")); + } } diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index dd373b8..5d08734 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -38,7 +38,7 @@ use omnigraph::error::{ManifestErrorKind, OmniError}; use omnigraph_compiler::json_params_to_param_map; use omnigraph_compiler::query::parser::parse_query; use omnigraph_compiler::{JsonParamMode, ParamMap}; -pub use auth::{EnvOrFileTokenSource, TokenSource}; +pub use auth::{AWS_SECRET_ENV, EnvOrFileTokenSource, TokenSource, resolve_token_source}; pub use policy::{ PolicyAction, PolicyCompiler, PolicyConfig, PolicyDecision, PolicyEngine, PolicyExpectation, PolicyRequest, PolicyTestConfig, @@ -464,7 +464,8 @@ pub fn build_app(state: AppState) -> Router { } pub async fn serve(config: ServerConfig) -> Result<()> { - let token_source = EnvOrFileTokenSource; + let token_source = resolve_token_source().await?; + info!(source = token_source.name(), "loaded bearer token source"); let state = AppState::open_with_bearer_tokens_and_policy( config.uri.clone(), token_source.load().await?, From d830ebcb6470dd9686a98f83e45813eaefb5423c Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 18 Apr 2026 04:04:45 +0300 Subject: [PATCH 6/7] Document AWS build variant and bearer-token sources - docs/deployment.md: new "Token sources" section listing the three bearer-token source precedences (AWS SM, JSON file/env, single token). New "Build Variants" section explaining default vs aws builds and their release-artifact naming. New "AWS Secrets Manager" section covering env var, secret payload format, IAM role credential discovery, and the hard error for feature-less builds. - CONTRIBUTING.md: documents the `aws` feature and the two test commands contributors should run when touching auth code. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 16 +++++++++++++ docs/deployment.md | 60 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65d1e24..e6d1030 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,22 @@ cargo test --workspace If you touch S3-backed flows, the CI model uses a local RustFS instance for integration tests. +### Cargo features + +`omnigraph-server` has an optional `aws` feature that pulls in the AWS +Secrets Manager SDK for a bearer-token backend. Default builds omit it — +most contributors never compile the AWS code path. + +When you touch `crates/omnigraph-server/src/auth.rs` or any AWS-conditional +code, verify both configurations: + +```bash +cargo test -p omnigraph-server # default +cargo test -p omnigraph-server --features aws # AWS enabled +``` + +CI runs both. + ## Pull Requests - keep changes focused diff --git a/docs/deployment.md b/docs/deployment.md index 42b814d..e611245 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -110,11 +110,65 @@ docker run --rm -p 8080:8080 \ ## Auth The server can run unauthenticated for local development, but any shared or -internet-facing deployment should set: +internet-facing deployment should set a bearer token source. -- `OMNIGRAPH_SERVER_BEARER_TOKEN` +### Token sources -The health endpoint `/healthz` remains suitable for load balancer health checks. +The server reads bearer tokens from one of three places, in precedence order: + +1. **AWS Secrets Manager** (build with `--features aws`, see below) — set + `OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET` to the secret ID or ARN. +2. **JSON file or env** — set one of: + - `OMNIGRAPH_SERVER_BEARER_TOKENS_FILE` — path to a JSON `{"actor": "token", ...}` file. + - `OMNIGRAPH_SERVER_BEARER_TOKENS_JSON` — the JSON literal inline. +3. **Single-token env** — `OMNIGRAPH_SERVER_BEARER_TOKEN` (assigns the + implicit actor `default`). + +Tokens are hashed with SHA-256 immediately on ingest; plaintext does not +persist in process memory after startup. + +The health endpoint `/healthz` remains suitable for load balancer health checks +and is never gated. + +## Build Variants + +The server binary ships in two flavors: + +| Variant | Command | Contents | +|---------|---------|----------| +| **Default** (on-prem / local dev) | `cargo build --release` | Core server, no AWS SDK | +| **AWS** | `cargo build --release --features aws` | Adds AWS Secrets Manager backend for bearer tokens | + +Release artifacts are published with matching suffixes — +`omnigraph-server--.tar.gz` for the default build and +`omnigraph-server---aws.tar.gz` for the AWS-enabled build. + +The AWS build adds ~150 transitive deps and ~30-60s of first-build compile +time. Default builds don't pay that cost. + +## AWS Secrets Manager + +When the binary is built with `--features aws`, set +`OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET` to the ARN or name of a Secrets +Manager secret whose `SecretString` is a JSON object of +`{"actor_id": "token", ...}`: + +```bash +omnigraph-server-aws s3://my-bucket/repos/example ... + # Environment: + # OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET=arn:aws:secretsmanager:us-east-1:123456789012:secret:omnigraph-tokens-AbCdEf +``` + +Credentials are resolved via the AWS default chain (env vars, shared config, +IMDSv2 instance role, ECS task role) — no explicit credential plumbing is +needed when running under an IAM instance role on EC2/ECS/EKS. + +The IAM role must permit `secretsmanager:GetSecretValue` on the referenced +secret. + +Setting the env var without building with `--features aws` is a hard error +with a rebuild instruction — it does not silently fall back to the env/file +source. ## S3-Compatible Storage From 807c1ba4dc482489cbef5952acb64e06ceb04e81 Mon Sep 17 00:00:00 2001 From: andrew Date: Sat, 18 Apr 2026 16:29:43 +0300 Subject: [PATCH 7/7] Add manual-dispatch Package workflow for CodeBuild image builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invokes the shared omnigraph-package reusable workflow twice per run — once with default features, once with --features aws — producing two ECR tags per source commit: (default features) -aws (--features aws → SecretsManagerTokenSource) Manual-dispatch only for now. Neither release.yml nor release-edge.yml currently invokes the CodeBuild-backed packaging path; this gives operators a way to produce on-demand image variants without wiring packaging into the tag/push cadence. Prerequisites: - Repo vars AWS_REGION, AWS_ROLE_TO_ASSUME, AWS_CODEBUILD_PACKAGE_PROJECT, AWS_ARTIFACT_BUCKET must be set. - Shared workflow must support the `features` and `image_tag_suffix` inputs. Uses @main as the shared-workflow ref until a versioned tag is cut. --- .github/workflows/package.yml | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/package.yml diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..7324e23 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,57 @@ +name: Package + +# Builds both the default and aws-feature omnigraph-server images and pushes +# them to ECR. Invoked manually via workflow_dispatch — not wired to tags or +# main pushes today. +# +# Prerequisites: +# - Repo vars AWS_REGION, AWS_ROLE_TO_ASSUME, AWS_CODEBUILD_PACKAGE_PROJECT, +# AWS_ARTIFACT_BUCKET are set. +# - The shared workflow at ModernRelay/.github supports the `features` and +# `image_tag_suffix` inputs (ModernRelay/.github PR #2 or later). +# +# Each invocation produces two ECR tags per source commit: +# - (default features) +# - -aws (--features aws) + +on: + workflow_dispatch: + inputs: + source_ref: + description: Git ref to package (branch, tag, or SHA). Defaults to the workflow's own ref. + required: false + type: string + default: "" + +jobs: + package_default: + name: Package default build + uses: ModernRelay/.github/.github/workflows/omnigraph-package.yml@main + permissions: + id-token: write + contents: read + attestations: write + with: + repository: ${{ github.repository }} + source_ref: ${{ inputs.source_ref != '' && inputs.source_ref || github.sha }} + aws_region: ${{ vars.AWS_REGION }} + aws_role_to_assume: ${{ vars.AWS_ROLE_TO_ASSUME }} + aws_codebuild_package_project: ${{ vars.AWS_CODEBUILD_PACKAGE_PROJECT }} + aws_artifact_bucket: ${{ vars.AWS_ARTIFACT_BUCKET }} + + package_aws: + name: Package aws-feature build + uses: ModernRelay/.github/.github/workflows/omnigraph-package.yml@main + permissions: + id-token: write + contents: read + attestations: write + with: + repository: ${{ github.repository }} + source_ref: ${{ inputs.source_ref != '' && inputs.source_ref || github.sha }} + aws_region: ${{ vars.AWS_REGION }} + aws_role_to_assume: ${{ vars.AWS_ROLE_TO_ASSUME }} + aws_codebuild_package_project: ${{ vars.AWS_CODEBUILD_PACKAGE_PROJECT }} + aws_artifact_bucket: ${{ vars.AWS_ARTIFACT_BUCKET }} + features: aws + image_tag_suffix: "-aws"