mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-09 01:35:18 +02:00
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) <noreply@anthropic.com>
310 lines
10 KiB
Rust
310 lines
10 KiB
Rust
//! 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"));
|
|
}
|
|
}
|