Skip to content

Commit 1f6fc52

Browse files
easelclaude
andcommitted
docs(auth): document Tailscale auth system end-to-end
Add module-level and inline docs covering the full auth stack: - axon-server lib.rs: auth modes table, ACL tag→role mapping, request-flow diagram, HTTP/gRPC failure codes - auth.rs: AuthContext caching semantics and config examples; LocalAPI Unix-socket protocol note; TailscaleWhoisProvider trait and method docs; AuthContext::verify startup-time semantics; preferred_node_name precedence - gateway.rs: authenticate_http_request middleware flow; auth_error_response HTTP status table; request_peer_address real-vs-mock explanation - service.rs: auth_to_status gRPC status mapping table - serve.rs: auth_context_from_serve_args priority table; DefaultRoleArg doc - axon-core auth.rs: four-layer access control overview (RBAC, field masking, write policies, database grants) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ebdd55b commit 1f6fc52

6 files changed

Lines changed: 176 additions & 6 deletions

File tree

crates/axon-core/src/auth.rs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,32 @@
1-
//! Role-based access control (RBAC) for Axon (US-044, FEAT-012).
1+
//! Role-based and attribute-based access control for Axon (FEAT-012).
22
//!
3-
//! Four built-in roles control access to Axon operations. Roles are derived
4-
//! from the identity provider (Tailscale ACL tags, OIDC claims, etc.) and
5-
//! checked at the API handler level before executing each operation.
3+
//! # Layers
4+
//!
5+
//! **Layer 1 — RBAC** ([`Role`], [`CallerIdentity`], [`Operation`])
6+
//!
7+
//! Four built-in roles (`Admin > Write > Read > None`) control access to
8+
//! Axon operations. Roles are derived from the identity provider (Tailscale
9+
//! ACL tags, OIDC claims, etc.) by the server layer and passed into handlers
10+
//! as a [`CallerIdentity`]. Handlers call [`CallerIdentity::check`] to
11+
//! enforce the minimum required role.
12+
//!
13+
//! **Layer 2 — Field-level masking** ([`MaskPolicy`])
14+
//!
15+
//! A `MaskPolicy` hides individual entity fields from callers whose role
16+
//! falls below a configured minimum. Applied by [`CallerIdentity::apply_masks`]
17+
//! before returning entity data to the caller. Admins always see all fields.
18+
//!
19+
//! **Layer 3 — Collection write control** ([`WritePolicy`])
20+
//!
21+
//! A `WritePolicy` sets a per-collection minimum write role and marks fields
22+
//! as immutable after creation. Checked by the handler before any mutation.
23+
//!
24+
//! **Layer 4 — Database-scoped grants** ([`DatabaseGrant`], [`GrantRegistry`])
25+
//!
26+
//! Grants scope a role to a specific database (or `"*"` for all databases).
27+
//! Without a matching grant a caller has `Role::None` on that database.
28+
//! Admins with a global grant bypass per-database checks. The grant registry
29+
//! is stored in `__axon_policies__` and enforced at the routing layer.
630
731
use serde::{Deserialize, Serialize};
832

crates/axon-server/src/auth.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,22 @@ struct CachedIdentity {
163163
expires_at: Instant,
164164
}
165165

166+
/// Abstraction over the Tailscale LocalAPI, enabling test doubles.
167+
///
168+
/// The production implementation ([`LocalApiWhoisProvider`]) contacts the
169+
/// real Tailscale daemon over its Unix socket. Tests use `FakeWhoisProvider`
170+
/// to inject pre-canned responses without needing a running Tailscale daemon.
166171
pub(crate) trait TailscaleWhoisProvider: Send + Sync {
172+
/// Verify that the provider is reachable.
173+
///
174+
/// Called once at server startup via [`AuthContext::verify`] to surface
175+
/// misconfigurations early (e.g., wrong socket path, daemon not running).
167176
fn verify(&self) -> BoxFuture<'_, Result<(), AuthError>>;
168177

178+
/// Resolve the identity of the given peer address.
179+
///
180+
/// Returns [`AuthError::Unauthorized`] for non-tailnet addresses and
181+
/// [`AuthError::ProviderUnavailable`] if the daemon cannot be reached.
169182
fn whois(
170183
&self,
171184
address: SocketAddr,
@@ -225,6 +238,12 @@ impl From<TsWhoisResponse> for TailscaleWhoisResponse {
225238
}
226239

227240
// ── Direct Unix-socket HTTP client for the Tailscale LocalAPI ────────
241+
//
242+
// Tailscale does not bind a TCP port for its LocalAPI — it only listens on
243+
// a Unix domain socket. This provider opens a new connection for every
244+
// request (no connection pooling) because whois calls are rare and caching
245+
// makes per-request cost negligible. Each call issues one HTTP/1.1 GET
246+
// and reads the full response body before closing the socket.
228247

229248
#[derive(Clone)]
230249
struct LocalApiWhoisProvider {
@@ -322,6 +341,31 @@ impl TailscaleWhoisProvider for LocalApiWhoisProvider {
322341
}
323342

324343
/// Request authentication state shared by the HTTP and gRPC frontends.
344+
///
345+
/// `AuthContext` is cheaply `Clone`d (it wraps `Arc` internally) and intended
346+
/// to be inserted as Axum router state and tonic service state.
347+
///
348+
/// Resolved identities are cached by peer IP for [`cache_ttl`] to avoid a
349+
/// Unix socket round-trip on every request. A cache hit requires only an
350+
/// `RwLock` read — no I/O. The cache is never actively evicted; stale entries
351+
/// are ignored on the next lookup if their TTL has expired.
352+
///
353+
/// # Configuration examples
354+
///
355+
/// ```rust,ignore
356+
/// // Local development — no auth required
357+
/// let auth = AuthContext::no_auth();
358+
///
359+
/// // Tailscale (production default)
360+
/// let auth = AuthContext::tailscale(
361+
/// Role::Read, // default role for untagged nodes
362+
/// "/run/tailscale/tailscaled.sock",
363+
/// Duration::from_secs(60), // identity cache TTL
364+
/// );
365+
///
366+
/// // Guest mode — unauthenticated callers get read access
367+
/// let auth = AuthContext::guest(Role::Read);
368+
/// ```
325369
#[derive(Clone)]
326370
pub struct AuthContext {
327371
mode: AuthMode,
@@ -373,6 +417,13 @@ impl AuthContext {
373417
&self.mode
374418
}
375419

420+
/// Verify that the auth provider is reachable.
421+
///
422+
/// Called once at server startup before accepting connections. In
423+
/// `NoAuth` and `Guest` modes this is a no-op. In `Tailscale` mode it
424+
/// issues a test request to the Tailscale daemon so a misconfigured
425+
/// socket path or a stopped daemon surfaces immediately at startup rather
426+
/// than on the first real request.
376427
pub async fn verify(&self) -> Result<(), AuthError> {
377428
match &self.provider {
378429
Some(provider) => provider.verify().await,
@@ -493,6 +544,10 @@ pub fn identity_from_tailscale(whois: &TailscaleWhoisResponse, default_role: &Ro
493544
}
494545
}
495546

547+
/// Pick the most human-readable name for a Tailscale node.
548+
///
549+
/// Preference order: `ComputedName` → `Hostinfo.Hostname` →
550+
/// `ComputedNameWithHost` → first label of the FQDN `Name`.
496551
fn preferred_node_name(node: &TsNode) -> String {
497552
if !node.computed_name.is_empty() {
498553
return node.computed_name.clone();

crates/axon-server/src/gateway.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ fn axon_error_response(err: AxonError) -> Response {
142142
}
143143
}
144144

145+
/// Convert an [`AuthError`] into an HTTP error response.
146+
///
147+
/// | `AuthError` variant | HTTP status | JSON code |
148+
/// |---------------------|-------------|-----------|
149+
/// | `MissingPeerAddress` / `Unauthorized` | 401 | `"unauthorized"` |
150+
/// | `Forbidden` | 403 | `"forbidden"` |
151+
/// | `ProviderUnavailable` | 503 | `"auth_unavailable"` |
145152
pub(crate) fn auth_error_response(err: AuthError) -> Response {
146153
match err {
147154
AuthError::MissingPeerAddress | AuthError::Unauthorized(_) => (
@@ -177,6 +184,11 @@ fn rate_limit_response(limited: &RateLimited) -> Response {
177184
.into_response()
178185
}
179186

187+
/// Extract the connecting peer's socket address from an axum request.
188+
///
189+
/// Checks both [`axum::extract::ConnectInfo`] (real TCP connections) and
190+
/// [`axum::extract::connect_info::MockConnectInfo`] (integration tests) so
191+
/// auth middleware works in both production and test contexts.
180192
fn request_peer_address(request: &axum::extract::Request) -> Option<SocketAddr> {
181193
request
182194
.extensions()
@@ -274,6 +286,22 @@ fn default_namespace_health<S: StorageAdapter>(
274286
))
275287
}
276288

289+
/// Axum middleware layer that resolves the caller's [`Identity`] and injects
290+
/// it as a typed request extension.
291+
///
292+
/// This middleware runs before every route handler. It:
293+
/// 1. Extracts the peer socket address via [`request_peer_address`].
294+
/// 2. Calls [`AuthContext::resolve_peer`] (cache-first, then Tailscale whois).
295+
/// 3. Inserts the resolved [`Identity`] into `request.extensions`.
296+
/// 4. On auth failure, short-circuits with an appropriate HTTP error response
297+
/// (401, 403, or 503) without reaching the route handler.
298+
///
299+
/// Route handlers extract identity with:
300+
/// ```rust,ignore
301+
/// async fn my_handler(Extension(identity): Extension<Identity>, ...) { ... }
302+
/// ```
303+
/// and then call `identity.require_read()` / `require_write()` / `require_admin()`
304+
/// to enforce the minimum required role for that operation.
277305
pub(crate) async fn authenticate_http_request(
278306
State(auth): State<AuthContext>,
279307
mut request: axum::extract::Request,

crates/axon-server/src/lib.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,46 @@
1-
// placeholder — filled in by service.rs and gateway.rs modules
1+
//! Axon server — HTTP gateway, gRPC service, MCP stdio, and authentication.
2+
//!
3+
//! # Authentication
4+
//!
5+
//! Identity is resolved once per request by [`auth::AuthContext`] and
6+
//! injected as a typed extension. Three modes are supported:
7+
//!
8+
//! | Mode | Flag | Actor | Role |
9+
//! |------|------|-------|------|
10+
//! | `NoAuth` | `--no-auth` | `"anonymous"` | Admin |
11+
//! | `Tailscale` | *(default)* | node name | from ACL tags |
12+
//! | `Guest` | `--guest-role <role>` | `"guest"` | configured role |
13+
//!
14+
//! In `Tailscale` mode the server contacts the local Tailscale daemon over its
15+
//! Unix socket (`/run/tailscale/tailscaled.sock` by default) and calls
16+
//! `/localapi/v0/whois?addr=<peer>` to resolve the connecting node's identity.
17+
//! Resolved identities are cached by peer IP for the duration of
18+
//! `--auth-cache-ttl-secs` (default 60 s).
19+
//!
20+
//! ACL tag → role mapping:
21+
//! - `tag:axon-admin` → [`Role::Admin`]
22+
//! - `tag:axon-write` / `tag:axon-agent` → [`Role::Write`]
23+
//! - `tag:axon-read` → [`Role::Read`]
24+
//! - no matching tag → `--tailscale-default-role` (default `read`)
25+
//!
26+
//! Connections that are not on the tailnet are rejected with HTTP 401 / gRPC
27+
//! `UNAUTHENTICATED`. If the Tailscale daemon is unreachable the server
28+
//! returns HTTP 503 / gRPC `UNAVAILABLE`.
29+
//!
30+
//! # Request flow
31+
//!
32+
//! ```text
33+
//! TCP accept
34+
//! └─ authenticate_http_request (gateway.rs middleware)
35+
//! ├─ AuthContext::resolve_peer → Identity
36+
//! └─ insert Identity into request extensions
37+
//! └─ route handler
38+
//! ├─ extract Extension<Identity>
39+
//! ├─ identity.require_read() / require_write() / require_admin()
40+
//! └─ handler logic
41+
//! ```
42+
//!
43+
//! gRPC follows the same pattern via [`service::AxonServiceImpl::authorize`].
244
pub mod actor_scope;
345
pub mod auth;
446
mod collection_listing;

crates/axon-server/src/serve.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ pub enum StorageBackend {
2323
Postgres,
2424
}
2525

26+
/// CLI-compatible role selection for `--tailscale-default-role` and `--guest-role`.
27+
///
28+
/// Converted to [`Role`] via `From<DefaultRoleArg>`.
2629
#[derive(Clone, Debug, clap::ValueEnum)]
2730
pub enum DefaultRoleArg {
2831
Admin,
@@ -135,7 +138,18 @@ pub fn init_tracing(mcp_stdio: bool) {
135138
let _ = result;
136139
}
137140

138-
/// Resolve the [`AuthContext`] from [`ServeArgs`].
141+
/// Build an [`AuthContext`] from the parsed CLI / env flags.
142+
///
143+
/// Priority: `--no-auth` > `--guest-role` > Tailscale (default).
144+
///
145+
/// | Condition | Mode | Notes |
146+
/// |-----------|------|-------|
147+
/// | `--no-auth` | `NoAuth` | All requests get `actor=anonymous, role=Admin`. |
148+
/// | `--guest-role <role>` | `Guest` | Tailscale daemon not required. |
149+
/// | *(default)* | `Tailscale` | Contacts `--tailscale-socket` on every cache miss. |
150+
///
151+
/// After construction, call [`AuthContext::verify`] (already done in [`serve`])
152+
/// to fail fast if the daemon is unreachable.
139153
pub fn auth_context_from_serve_args(args: &ServeArgs) -> AuthContext {
140154
if args.no_auth {
141155
AuthContext::no_auth()

crates/axon-server/src/service.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ fn entity_to_proto(e: axon_core::types::Entity) -> EntityProto {
108108
}
109109
}
110110

111+
/// Convert an [`AuthError`] into a gRPC [`Status`].
112+
///
113+
/// | `AuthError` variant | gRPC status |
114+
/// |---------------------|-------------|
115+
/// | `MissingPeerAddress` / `Unauthorized` | `UNAUTHENTICATED` |
116+
/// | `Forbidden` | `PERMISSION_DENIED` |
117+
/// | `ProviderUnavailable` | `UNAVAILABLE` |
111118
fn auth_to_status(error: AuthError) -> Status {
112119
match error {
113120
AuthError::MissingPeerAddress | AuthError::Unauthorized(_) => {

0 commit comments

Comments
 (0)