Merge remote-tracking branch 'origin/main' into ragnorc/explore-api

# Conflicts:
#	CONTRIBUTING.md
This commit is contained in:
Ragnor Comerford 2026-04-18 20:24:39 +02:00
commit 9de2079263
No known key found for this signature in database
14 changed files with 1056 additions and 39 deletions

View file

@ -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:

57
.github/workflows/package.yml vendored Normal file
View file

@ -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:
# - <source_sha> (default features)
# - <source_sha>-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"

View file

@ -31,6 +31,22 @@ OMNIGRAPH_UPDATE_OPENAPI=1 cargo test -p omnigraph-server --test openapi openapi
The workspace test run fails if the committed `openapi.json` drifts from what
the source generates.
### 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

198
Cargo.lock generated
View file

@ -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",
@ -4585,6 +4676,9 @@ dependencies = [
name = "omnigraph-server"
version = "0.2.2"
dependencies = [
"async-trait",
"aws-config",
"aws-sdk-secretsmanager",
"axum",
"cedar-policy",
"clap",
@ -4597,6 +4691,8 @@ dependencies = [
"serde_json",
"serde_yaml",
"serial_test",
"sha2",
"subtle",
"tempfile",
"tokio",
"tower",
@ -5166,8 +5262,8 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"rustls 0.23.36",
"socket2 0.6.2",
"thiserror",
"tokio",
"tracing",
@ -5186,7 +5282,7 @@ dependencies = [
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls 0.23.36",
"rustls-pki-types",
"slab",
"thiserror",
@ -5204,7 +5300,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
@ -5488,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",
@ -5502,7 +5598,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls 0.23.36",
"rustls-native-certs",
"rustls-pki-types",
"serde",
@ -5510,7 +5606,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-rustls 0.26.4",
"tokio-util",
"tower",
"tower-http",
@ -5641,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"
@ -5651,7 +5759,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"rustls-webpki 0.103.9",
"subtle",
"zeroize",
]
@ -5687,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"
@ -5794,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"
@ -6132,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"
@ -6599,7 +6737,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.2",
"tokio-macros",
"windows-sys 0.61.2",
]
@ -6615,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",
]

View file

@ -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

View file

@ -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, SchemaOutput, SnapshotOutput,
SnapshotTableOutput, commit_output, ingest_output, read_output, run_output,
schema_apply_output, snapshot_payload,
};
@ -303,6 +303,18 @@ enum SchemaCommand {
#[arg(long)]
json: bool,
},
/// Show the current accepted schema source
#[command(alias = "get")]
Show {
/// Repo URI
uri: Option<String>,
#[arg(long)]
target: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Subcommand)]
@ -2003,6 +2015,37 @@ async fn main() -> Result<()> {
print_schema_apply_human(&output);
}
}
SchemaCommand::Show {
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::<SchemaOutput>(
&http_client,
Method::GET,
remote_url(&uri, "/schema"),
None,
bearer_token.as_deref(),
)
.await?
} else {
let db = Omnigraph::open(&uri).await?;
SchemaOutput {
schema_source: db.schema_source().to_string(),
}
};
if json {
print_json(&output)?;
} else {
println!("{}", output.schema_source);
}
}
},
Command::Query { command } => match command {
QueryCommand::Lint {

View file

@ -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" }
@ -28,6 +34,11 @@ tower-http = { workspace = true }
utoipa = { workspace = true }
cedar-policy = { workspace = true }
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 }

View file

@ -280,6 +280,11 @@ pub struct SchemaApplyOutput {
pub steps: Vec<SchemaMigrationStep>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SchemaOutput {
pub schema_source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct IngestRequest {
pub branch: Option<String>,

View file

@ -0,0 +1,310 @@
//! 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, 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]
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<Vec<(String, String)>>;
/// 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<Vec<(String, String)>> {
server_bearer_tokens_from_env()
}
fn name(&self) -> &'static str {
"env-or-file"
}
}
/// 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<Box<dyn TokenSource>> {
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<Vec<(String, String)>> {
use std::collections::HashMap;
let map: HashMap<String, String> = 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<String>) -> Result<Self> {
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<Vec<(String, String)>> {
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::*;
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");
}
#[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"));
}
}

View file

@ -1,4 +1,5 @@
pub mod api;
pub mod auth;
pub mod config;
pub mod policy;
@ -14,7 +15,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,
SchemaOutput, SnapshotQuery, ingest_output, schema_apply_output, snapshot_payload,
};
use axum::body::{Body, Bytes};
use axum::extract::DefaultBodyLimit;
@ -37,11 +38,14 @@ 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::{AWS_SECRET_ENV, EnvOrFileTokenSource, TokenSource, resolve_token_source};
pub use policy::{
PolicyAction, PolicyCompiler, PolicyConfig, PolicyDecision, PolicyEngine, PolicyExpectation,
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 +54,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(
@ -63,6 +76,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,
@ -109,7 +123,7 @@ pub struct ServerConfig {
pub struct AppState {
uri: String,
db: Arc<RwLock<Omnigraph>>,
bearer_tokens: Arc<HashMap<Arc<str>, Arc<str>>>,
bearer_tokens: Arc<[(BearerTokenHash, Arc<str>)]>,
policy_engine: Option<Arc<PolicyEngine>>,
}
@ -174,14 +188,14 @@ impl AppState {
bearer_tokens: Vec<(String, String)>,
policy_engine: Option<PolicyEngine>,
) -> Self {
let bearer_tokens = bearer_tokens
let bearer_tokens: Vec<(BearerTokenHash, Arc<str>)> = bearer_tokens
.into_iter()
.map(|(actor, token)| (Arc::<str>::from(token), Arc::<str>::from(actor)))
.map(|(actor, token)| (hash_bearer_token(&token), Arc::<str>::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),
}
}
@ -241,7 +255,17 @@ impl AppState {
}
fn authenticate_bearer_token(&self, provided_token: &str) -> Option<Arc<str>> {
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<Arc<str>> = 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> {
@ -407,6 +431,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",
@ -439,9 +464,11 @@ pub fn build_app(state: AppState) -> Router {
}
pub async fn serve(config: ServerConfig) -> Result<()> {
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(),
server_bearer_tokens_from_env()?,
token_source.load().await?,
config.policy_file.as_ref(),
)
.await?;
@ -553,7 +580,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(());
@ -561,6 +588,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}")))?;
@ -801,6 +832,42 @@ async fn server_change(
}))
}
#[utoipa::path(
get,
path = "/schema",
tag = "schema",
operation_id = "getSchema",
responses(
(status = 200, description = "Current schema source", body = SchemaOutput),
(status = 401, description = "Unauthorized", body = ErrorOutput),
(status = 403, description = "Forbidden", body = ErrorOutput),
),
security(("bearer_token" = [])),
)]
async fn server_schema_get(
State(state): State<AppState>,
actor: Option<Extension<AuthenticatedActor>>,
) -> std::result::Result<Json<SchemaOutput>, 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 schema_source = {
let db = Arc::clone(&state.db).read_owned().await;
db.schema_source().to_string()
};
Ok(Json(SchemaOutput { schema_source }))
}
#[utoipa::path(
post,
path = "/schema/apply",
@ -1461,13 +1528,43 @@ fn server_bearer_tokens_from_env() -> Result<Vec<(String, String)>> {
#[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();

View file

@ -161,6 +161,7 @@ const EXPECTED_PATHS: &[&str] = &[
"/read",
"/export",
"/change",
"/schema",
"/schema/apply",
"/ingest",
"/branches",

View file

@ -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};
@ -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(
@ -1042,6 +1127,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;

View file

@ -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-<version>-<platform>.tar.gz` for the default build and
`omnigraph-server-<version>-<platform>-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

View file

@ -922,6 +922,51 @@
]
}
},
"/schema": {
"get": {
"tags": [
"schema"
],
"operationId": "getSchema",
"responses": {
"200": {
"description": "Current schema source",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SchemaOutput"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorOutput"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorOutput"
}
}
}
}
},
"security": [
{
"bearer_token": []
}
]
}
},
"/schema/apply": {
"post": {
"tags": [
@ -1703,6 +1748,17 @@
}
}
},
"SchemaOutput": {
"type": "object",
"required": [
"schema_source"
],
"properties": {
"schema_source": {
"type": "string"
}
}
},
"SnapshotOutput": {
"type": "object",
"required": [