Phase 9 F4: namespaced tokens for multi-tenant aggregator #104
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user