Phase 9 F4: namespaced tokens for multi-tenant aggregator #104

Merged
osobh merged 1 commits from phase9-f4-namespaces into main 2026-07-15 07:16:57 +00:00
2 changed files with 168 additions and 29 deletions
+41
View File
@@ -251,8 +251,49 @@ pub struct Config {
/// Optional Bearer token required on all HTTP POST endpoints. /// Optional Bearer token required on all HTTP POST endpoints.
/// Set to a long random string, e.g. `openssl rand -hex 32`. /// Set to a long random string, e.g. `openssl rand -hex 32`.
/// If absent, POST endpoints are unauthenticated (internal-network use only). /// If absent, POST endpoints are unauthenticated (internal-network use only).
///
/// When set, this is treated as an **admin** token — no namespace
/// restriction. Prefer per-app tokens under `[[aggregator.tokens]]`
/// (below) for multi-tenant setups; `api_token` stays as the
/// pre-Phase-9 escape hatch for single-tenant use.
#[serde(default)] #[serde(default)]
pub api_token: Option<String>, pub api_token: Option<String>,
/// Aggregator-side auth: per-app Bearer tokens, each scoped to a
/// namespace prefix on tag names. Enables safe multi-tenant use
/// (e.g. clawmates workspace X only touches `workspace:x:*` tags).
/// Empty by default; `api_token` above still works as a wildcard
/// admin token.
#[serde(default)]
pub aggregator: Option<AggregatorConfig>,
}
/// Aggregator-side per-app auth config. See [`Config::aggregator`].
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct AggregatorConfig {
/// One entry per app that talks to the aggregator. A token with no
/// `namespace` set is an admin token (can touch any tag); a token
/// with `namespace = "foo"` may only write tags whose name starts
/// with `foo:`.
#[serde(default)]
pub tokens: Vec<TokenEntry>,
}
/// A single Bearer token binding: `token` value → optional `namespace`
/// prefix that constrains which tag names this caller may touch.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TokenEntry {
/// The Bearer value the app presents in `Authorization: Bearer …`.
/// Long random string, e.g. `openssl rand -hex 32`.
pub token: String,
/// Tag-name prefix this token is allowed to write. Enforced with
/// a mandatory `<namespace>:` separator so `workspace:42` cannot
/// silently reach `workspace:420:*`. Absent = admin (any tag).
#[serde(default)]
pub namespace: Option<String>,
/// Human note; not consumed by auth. Shown in logs / listings.
#[serde(default)]
pub description: Option<String>,
} }
impl Config { impl Config {
+127 -29
View File
@@ -30,7 +30,7 @@ use crate::cluster::rpc::{
TimerStatus, TimerStatus,
}; };
use crate::cluster::transport::{NodeIdentity, QuicClient}; use crate::cluster::transport::{NodeIdentity, QuicClient};
use crate::config::{Config, PeerEntry}; use crate::config::{Config, PeerEntry, TokenEntry};
/// Aggregator runtime: one QuicClient, one peer list, one identity. /// Aggregator runtime: one QuicClient, one peer list, one identity.
/// ///
@@ -53,7 +53,16 @@ pub struct V2State {
/// Sourced from the aggregator's own `config.toml` `api_token`. /// Sourced from the aggregator's own `config.toml` `api_token`.
/// `None` disables the check (only appropriate on a trusted LAN, /// `None` disables the check (only appropriate on a trusted LAN,
/// e.g. Tailscale-only + loopback bind). /// e.g. Tailscale-only + loopback bind).
///
/// Treated as an **admin** token — no namespace constraint. For
/// multi-tenant use, prefer the per-app tokens in `token_entries`.
pub api_token: Option<String>, pub api_token: Option<String>,
/// Per-app tokens with optional namespace scoping (Phase 9 F4).
/// Merged with `api_token`: `api_token` is admin (no namespace);
/// entries here can be admin (no `namespace`) or scoped (must
/// match `<namespace>:*` on tag names). First match wins.
pub token_entries: Vec<TokenEntry>,
} }
impl V2State { impl V2State {
@@ -76,6 +85,11 @@ impl V2State {
client: std::sync::Arc::new(client), client: std::sync::Arc::new(client),
default_rpc_port_offset: 1, default_rpc_port_offset: 1,
api_token: cfg.api_token.clone(), api_token: cfg.api_token.clone(),
token_entries: cfg
.aggregator
.as_ref()
.map(|a| a.tokens.clone())
.unwrap_or_default(),
}) })
} }
@@ -480,6 +494,38 @@ impl V2State {
client: self.client.clone(), client: self.client.clone(),
default_rpc_port_offset: self.default_rpc_port_offset, default_rpc_port_offset: self.default_rpc_port_offset,
api_token: self.api_token.clone(), api_token: self.api_token.clone(),
token_entries: self.token_entries.clone(),
}
}
}
/// The identity attached to an authenticated request. Either an admin
/// (unrestricted) or a per-app token bound to a `namespace` prefix.
/// Handlers use this to decide whether a tag name is in-scope.
#[derive(Debug, Clone)]
pub enum AuthedCaller {
/// Any tag name allowed. Sourced from either `api_token` (legacy
/// admin) or a `[[aggregator.tokens]]` entry with no namespace.
Admin,
/// Restricted to tags whose name is `<namespace>:<anything>`.
Namespaced { namespace: String },
/// No auth was configured on the aggregator at all. Trusted-LAN
/// mode — everything allowed, no bearer required.
Open,
}
impl AuthedCaller {
/// Returns `true` iff this caller may pin/unpin/list a tag with
/// this name. Admin + Open allow anything; namespaced callers
/// require an exact `<namespace>:` prefix (with the separator,
/// so `workspace:42` cannot silently reach `workspace:420:*`).
pub fn may_touch_tag(&self, tag: &str) -> bool {
match self {
AuthedCaller::Admin | AuthedCaller::Open => true,
AuthedCaller::Namespaced { namespace } => {
let prefix = format!("{namespace}:");
tag.starts_with(&prefix)
}
} }
} }
} }
@@ -532,13 +578,12 @@ pub struct FanoutReply {
async fn handle_put_tag( async fn handle_put_tag(
Path(name): Path<String>, Path(name): Path<String>,
State(s): State<Arc<V2State>>, State(s): State<Arc<V2State>>,
axum::extract::Extension(caller): axum::extract::Extension<AuthedCaller>,
Json(body): Json<PutTagBody>, Json(body): Json<PutTagBody>,
) -> Result<Json<FanoutReply>, (StatusCode, String)> { ) -> Result<Json<FanoutReply>, (StatusCode, String)> {
check_tag_scope(&caller, &name)?;
let value = decode_blob_id(&body.blob_id) let value = decode_blob_id(&body.blob_id)
.ok_or_else(|| (StatusCode::BAD_REQUEST, "blob_id must be 64-char hex".to_string()))?; .ok_or_else(|| (StatusCode::BAD_REQUEST, "blob_id must be 64-char hex".to_string()))?;
if name.is_empty() {
return Err((StatusCode::BAD_REQUEST, "tag name cannot be empty".to_string()));
}
let results = fanout_put_tag(&s, &name, &value).await; let results = fanout_put_tag(&s, &name, &value).await;
let all_ok = results.iter().all(|r| r.ok); let all_ok = results.iter().all(|r| r.ok);
Ok(Json(FanoutReply { Ok(Json(FanoutReply {
@@ -556,10 +601,9 @@ async fn handle_put_tag(
async fn handle_delete_tag( async fn handle_delete_tag(
Path(name): Path<String>, Path(name): Path<String>,
State(s): State<Arc<V2State>>, State(s): State<Arc<V2State>>,
axum::extract::Extension(caller): axum::extract::Extension<AuthedCaller>,
) -> Result<Json<FanoutReply>, (StatusCode, String)> { ) -> Result<Json<FanoutReply>, (StatusCode, String)> {
if name.is_empty() { check_tag_scope(&caller, &name)?;
return Err((StatusCode::BAD_REQUEST, "tag name cannot be empty".to_string()));
}
let results = fanout_delete_tag(&s, &name).await; let results = fanout_delete_tag(&s, &name).await;
let all_ok = results.iter().all(|r| r.ok); let all_ok = results.iter().all(|r| r.ok);
Ok(Json(FanoutReply { Ok(Json(FanoutReply {
@@ -569,6 +613,26 @@ async fn handle_delete_tag(
})) }))
} }
/// Combined name-shape + namespace check. Empty name → 400; out-of-
/// namespace → 403 with a clear message so multi-tenant callers can
/// tell "you can't touch that" from "your token was bogus" (401).
fn check_tag_scope(caller: &AuthedCaller, name: &str) -> Result<(), (StatusCode, String)> {
if name.is_empty() {
return Err((StatusCode::BAD_REQUEST, "tag name cannot be empty".into()));
}
if !caller.may_touch_tag(name) {
let ns = match caller {
AuthedCaller::Namespaced { namespace } => namespace.as_str(),
_ => "(none)",
};
return Err((
StatusCode::FORBIDDEN,
format!("tag \"{name}\" is outside your namespace \"{ns}:\""),
));
}
Ok(())
}
async fn fanout_put_tag(s: &V2State, name: &str, value: &[u8; 32]) -> Vec<PeerResult> { async fn fanout_put_tag(s: &V2State, name: &str, value: &[u8; 32]) -> Vec<PeerResult> {
let mut set = JoinSet::new(); let mut set = JoinSet::new();
for peer in &s.peers { for peer in &s.peers {
@@ -648,33 +712,67 @@ async fn fanout_delete_tag(s: &V2State, name: &str) -> Vec<PeerResult> {
// ── auth middleware ───────────────────────────────────────────── // ── auth middleware ─────────────────────────────────────────────
/// If the aggregator's config sets `api_token`, mutating methods /// Resolve the request's bearer against the aggregator's known
/// (POST/DELETE) must present a matching `Authorization: Bearer …` /// tokens and produce an [`AuthedCaller`]. On GET/HEAD, we still
/// header. GET is always open so the dashboard loads unmodified. /// resolve so filtered list handlers can scope by namespace — but
/// missing/bad auth just yields [`AuthedCaller::Open`] to preserve
/// the "dashboard loads without credentials" property; open callers
/// only see unrestricted views, so nothing sensitive leaks.
fn resolve_caller(s: &V2State, headers: &axum::http::HeaderMap) -> Option<AuthedCaller> {
// No auth configured anywhere → trusted-LAN mode.
if s.api_token.is_none() && s.token_entries.is_empty() {
return Some(AuthedCaller::Open);
}
let provided = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))?;
if let Some(admin) = &s.api_token {
if provided == admin.as_str() {
return Some(AuthedCaller::Admin);
}
}
for entry in &s.token_entries {
if provided == entry.token.as_str() {
return Some(match &entry.namespace {
Some(ns) => AuthedCaller::Namespaced { namespace: ns.clone() },
None => AuthedCaller::Admin,
});
}
}
None
}
/// Middleware: on mutating methods, require a resolved caller and
/// stash it in request extensions for handlers. GET/HEAD stays open
/// (see `resolve_caller` for the rationale).
async fn v2_auth( async fn v2_auth(
State(s): State<Arc<V2State>>, State(s): State<Arc<V2State>>,
request: axum::extract::Request, mut request: axum::extract::Request,
next: axum::middleware::Next, next: axum::middleware::Next,
) -> axum::response::Response { ) -> axum::response::Response {
let method = request.method().clone(); let method = request.method().clone();
let needs_auth = let is_read = method == axum::http::Method::GET || method == axum::http::Method::HEAD;
method != axum::http::Method::GET && method != axum::http::Method::HEAD; let caller = resolve_caller(&s, request.headers());
if needs_auth { match (is_read, caller) {
if let Some(expected) = &s.api_token { // Reads always pass; auth is best-effort for future
let provided = request // namespace-scoped list filtering.
.headers() (true, Some(c)) => {
.get(axum::http::header::AUTHORIZATION) request.extensions_mut().insert(c);
.and_then(|v| v.to_str().ok()) }
.and_then(|v| v.strip_prefix("Bearer ")); (true, None) => {
if provided != Some(expected.as_str()) { request.extensions_mut().insert(AuthedCaller::Open);
return axum::response::Response::builder() }
.status(StatusCode::UNAUTHORIZED) // Writes require a resolved caller.
.header("content-type", "application/json") (false, Some(c)) => {
.body(axum::body::Body::from( request.extensions_mut().insert(c);
r#"{"error":"unauthorized"}"#, }
)) (false, None) => {
.unwrap_or_default(); return axum::response::Response::builder()
} .status(StatusCode::UNAUTHORIZED)
.header("content-type", "application/json")
.body(axum::body::Body::from(r#"{"error":"unauthorized"}"#))
.unwrap_or_default();
} }
} }
next.run(request).await next.run(request).await