Skip to content

Commit 9a8c922

Browse files
easelclaude
andcommitted
docs(auth): align specs with implementation and add microsite auth page
Spec alignment: - FEAT-012: status Draft→Active; mark V1 ACs done (US-043/044/045); add implementation status table (V1 shipped / V2 scaffolded / V3 deferred); fix tag alias table to include tag:admin, tag:axon-agent, tag:write, tag:read; update identity flow diagram to show guest mode; replace pseudocode with actual implementation; note no OIDC / no tailscale-localapi crate; update traceability to real file paths - ADR-005: status Proposed→Accepted; document crate choice (direct hyper over tailscale-localapi); expand operational notes with guest mode, actor name resolution, cache behavior, and both-transport note Code alignment: - axon-core Role::from_tag: add tag:admin, tag:axon-agent, tag:write, tag:read aliases to match axon-server role_for_tag; extend tests Microsite: - New page: /docs/concepts/authentication — auth modes, role table, ACL tag mapping, Tailscale ACL example, /auth/me endpoint, all CLI flags, how it works under the hood, audit integration, troubleshooting - Concepts index: add Authentication card - CLI reference: add full auth flag table with env vars and examples; split serve flags into Storage and Authentication sections Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1f6fc52 commit 9a8c922

6 files changed

Lines changed: 372 additions & 95 deletions

File tree

crates/axon-core/src/auth.rs

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,22 @@ pub enum Role {
5252
impl Role {
5353
/// Parse a role from a Tailscale ACL tag or string name.
5454
///
55-
/// Accepted inputs:
56-
/// - `"tag:axon-admin"` or `"admin"` -> `Admin`
57-
/// - `"tag:axon-write"` or `"write"` -> `Write`
58-
/// - `"tag:axon-read"` or `"read"` -> `Read`
59-
/// - anything else -> `None`
55+
/// Accepted inputs (both full `tag:` prefix and bare name):
56+
///
57+
/// | Input | Role |
58+
/// |-------|------|
59+
/// | `"tag:axon-admin"` / `"tag:admin"` / `"admin"` | `Admin` |
60+
/// | `"tag:axon-write"` / `"tag:axon-agent"` / `"tag:write"` / `"write"` | `Write` |
61+
/// | `"tag:axon-read"` / `"tag:read"` / `"read"` | `Read` |
62+
/// | anything else | `None` |
63+
///
64+
/// `tag:axon-agent` is an alias for `write` — the conventional tag for
65+
/// automated agent workloads that need read/write but not admin access.
6066
pub fn from_tag(tag: &str) -> Self {
6167
match tag {
62-
"tag:axon-admin" | "admin" => Role::Admin,
63-
"tag:axon-write" | "write" => Role::Write,
64-
"tag:axon-read" | "read" => Role::Read,
68+
"tag:axon-admin" | "tag:admin" | "admin" => Role::Admin,
69+
"tag:axon-write" | "tag:axon-agent" | "tag:write" | "write" => Role::Write,
70+
"tag:axon-read" | "tag:read" | "read" => Role::Read,
6571
_ => Role::None,
6672
}
6773
}
@@ -328,9 +334,21 @@ mod tests {
328334

329335
#[test]
330336
fn role_from_tag() {
337+
// Primary tags
331338
assert_eq!(Role::from_tag("tag:axon-admin"), Role::Admin);
332339
assert_eq!(Role::from_tag("tag:axon-write"), Role::Write);
333340
assert_eq!(Role::from_tag("tag:axon-read"), Role::Read);
341+
// Short-form aliases
342+
assert_eq!(Role::from_tag("tag:admin"), Role::Admin);
343+
assert_eq!(Role::from_tag("tag:write"), Role::Write);
344+
assert_eq!(Role::from_tag("tag:read"), Role::Read);
345+
// Bare names (no tag: prefix)
346+
assert_eq!(Role::from_tag("admin"), Role::Admin);
347+
assert_eq!(Role::from_tag("write"), Role::Write);
348+
assert_eq!(Role::from_tag("read"), Role::Read);
349+
// Agent alias
350+
assert_eq!(Role::from_tag("tag:axon-agent"), Role::Write);
351+
// Unknown
334352
assert_eq!(Role::from_tag("unknown"), Role::None);
335353
}
336354

docs/helix/01-frame/features/FEAT-012-authorization.md

Lines changed: 110 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,38 @@ dun:
99
# Feature Specification: FEAT-012 - Authorization
1010

1111
**Feature ID**: FEAT-012
12-
**Status**: Draft
12+
**Status**: Active — V1 shipped; V2 scaffolded (not wired); V3 deferred
1313
**Priority**: P1
1414
**Owner**: Core Team
1515
**Created**: 2026-04-05
16-
**Updated**: 2026-04-05
16+
**Updated**: 2026-04-12
17+
18+
## Implementation Status
19+
20+
| Phase | Capability | Status |
21+
|-------|-----------|--------|
22+
| V1 | Global RBAC (admin/write/read/none) + `--no-auth` + `--guest-role` | **Shipped** |
23+
| V2 | `MaskPolicy` / `WritePolicy` structs and `GrantRegistry` defined | **Scaffolded** — types built and tested in `axon-core`, not yet wired into handlers |
24+
| V3 | Per-entity attribute conditions, policy inheritance, policy UI | **Deferred** |
25+
26+
Key V1 implementation choices that differ from this spec:
27+
- **No OIDC support** — Tailscale is the only external provider; OIDC deferred to V2+
28+
- **No `tailscale-localapi` crate** — identity is resolved via direct HTTP/1.1 over the
29+
Tailscale Unix socket using `hyper` + `tokio::net::UnixStream`
30+
- **`--guest-role` mode added** — unauthenticated requests receive a fixed role (not in
31+
original spec, added during implementation for edge deployments without Tailscale)
32+
- **Actor = node name, not email**`Identity.actor` is set to the Tailscale
33+
`ComputedName` (preferred) or `Hostinfo.Hostname`, not the user's email
1734

1835
## Overview
1936

2037
Axon requires an authentication and authorization layer to control who can
21-
access data and what operations they can perform. The auth model is built
22-
on **OIDC (OpenID Connect)** for identity, with **Tailscale** as the
23-
default (and first) identity provider via its LocalAPI whois mechanism.
38+
access data and what operations they can perform. The V1 auth model uses
39+
**Tailscale LocalAPI whois** for identity, with Tailscale ACL tags mapping
40+
to built-in RBAC roles.
2441

2542
The design separates identity (who you are) from authorization (what you
26-
can do), allowing other OIDC providers (Auth0, Okta, Google, etc.) to be
27-
added without changing the authorization model.
43+
can do). Future phases may add OIDC providers, but V1 is Tailscale-only.
2844

2945
## Problem Statement
3046

@@ -65,13 +81,21 @@ Audit log entries should carry real actor identities, not "anonymous".
6581
| `read` | Read entities, query, traverse, browse audit log |
6682
| `none` | No access (explicitly denied) |
6783

68-
- **Role assignment**: Roles are derived from the identity provider:
69-
- **Tailscale**: Mapped from ACL tags (`tag:axon-admin` → admin,
70-
`tag:axon-write` → write, `tag:axon-read` → read)
71-
- **Generic OIDC**: Mapped from JWT claims (configurable claim name,
72-
e.g., `axon_role` in the ID token)
73-
- **Default role**: Configurable. Default is `read` for authenticated
74-
users with no explicit role assignment
84+
- **Role assignment**: Roles are derived from Tailscale ACL tags. When a
85+
node carries multiple role-granting tags the highest-privilege role wins:
86+
87+
| Tag | Role |
88+
|-----|------|
89+
| `tag:axon-admin` / `tag:admin` | `admin` |
90+
| `tag:axon-write` / `tag:axon-agent` / `tag:write` | `write` |
91+
| `tag:axon-read` / `tag:read` | `read` |
92+
| *(no matching tag)* | `--tailscale-default-role` (default `read`) |
93+
94+
`tag:axon-agent` is the conventional tag for automated agent workloads
95+
that need read/write but not admin access.
96+
97+
- **Default role**: Configurable via `--tailscale-default-role` (default `read`)
98+
for authenticated nodes that carry no recognized ACL tag
7599

76100
#### Attribute-Based Access Control (ABAC)
77101

@@ -161,11 +185,11 @@ attributes of the **user**, the **resource** (entity/collection), and the
161185

162186
##### Implementation Phases
163187

164-
| Phase | Capability |
165-
|-------|-----------|
166-
| V1 | Global RBAC roles (admin/write/read/none) + --no-auth mode |
167-
| V2 | Per-collection policies, field masking, field immutability |
168-
| V3 | Per-entity attribute conditions, policy inheritance, policy UI |
188+
| Phase | Capability | Status |
189+
|-------|-----------|--------|
190+
| V1 | Global RBAC roles (admin/write/read/none) + `--no-auth` + `--guest-role` | **Shipped** |
191+
| V2 | Per-collection policies, field masking, field immutability | Scaffolded (structs + tests, not wired) |
192+
| V3 | Per-entity attribute conditions, policy inheritance, policy UI | Deferred |
169193

170194
#### Network-Layer Security (Tailscale-Specific)
171195

@@ -195,10 +219,10 @@ attributes of the **user**, the **resource** (entity/collection), and the
195219
**So that** my operations are attributed to me in the audit log
196220

197221
**Acceptance Criteria:**
198-
- [ ] Agent connects via Tailscale IP; Axon resolves its identity via whois
199-
- [ ] Audit entries show the agent's Tailscale node name as actor
200-
- [ ] Connections from outside the tailnet are rejected with 401
201-
- [ ] Agent with no recognized Tailscale tags receives the configured `default_role` (default: `read`)
222+
- [x] Agent connects via Tailscale IP; Axon resolves its identity via whois
223+
- [x] Audit entries show the agent's Tailscale node name as actor
224+
- [x] Connections from outside the tailnet are rejected with 401
225+
- [x] Agent with no recognized Tailscale tags receives the configured `default_role` (default: `read`)
202226

203227
### Story US-044: Role-Based Access Control [FEAT-012]
204228

@@ -207,11 +231,11 @@ attributes of the **user**, the **resource** (entity/collection), and the
207231
**So that** agents can't accidentally drop collections or modify schemas
208232

209233
**Acceptance Criteria:**
210-
- [ ] An agent with `tag:axon-write` can create/update/delete entities
211-
- [ ] An agent with `tag:axon-write` cannot drop collections or change schemas
212-
- [ ] An agent with `tag:axon-read` gets 403 on any write operation
213-
- [ ] An admin with `tag:axon-admin` can perform all operations
214-
- [ ] When a node has multiple role-granting tags, the highest-privilege role wins (admin > write > read)
234+
- [x] An agent with `tag:axon-write` can create/update/delete entities
235+
- [x] An agent with `tag:axon-write` cannot drop collections or change schemas
236+
- [x] An agent with `tag:axon-read` gets 403 on any write operation
237+
- [x] An admin with `tag:axon-admin` can perform all operations
238+
- [x] When a node has multiple role-granting tags, the highest-privilege role wins (admin > write > read)
215239

216240
### Story US-045: Development Without Auth [FEAT-012]
217241

@@ -220,9 +244,9 @@ attributes of the **user**, the **resource** (entity/collection), and the
220244
**So that** I don't need a Tailscale connection during development
221245

222246
**Acceptance Criteria:**
223-
- [ ] `axon-server --no-auth` starts without requiring tailscaled
224-
- [ ] All requests succeed as admin in no-auth mode
225-
- [ ] Audit entries show actor as `"anonymous"` in no-auth mode
247+
- [x] `axon-server --no-auth` starts without requiring tailscaled
248+
- [x] All requests succeed as admin in no-auth mode
249+
- [x] Audit entries show actor as `"anonymous"` in no-auth mode
226250

227251
## Technical Design
228252

@@ -233,75 +257,90 @@ Request arrives
233257
234258
235259
┌─────────────────┐
236-
│ --no-auth set? │──yes──▶ Identity = { actor: "anonymous", role: admin }
260+
│ --no-auth set? │──yes──▶ Identity = { actor: "anonymous", role: Admin }
237261
└────────┬────────┘
238262
│ no
239263
240264
┌─────────────────┐
241-
│ Extract peer IP │
242-
│ from connection │
265+
│ --guest-role? │──yes──▶ Identity = { actor: "guest", role: <configured> }
243266
└────────┬────────┘
267+
│ no (Tailscale mode)
244268
245269
┌─────────────────┐
246-
Check cache │──hit──▶ Use cached identity
247-
(IP → Identity)
270+
Extract peer IP │
271+
from connection
248272
└────────┬────────┘
249-
│ miss
250273
251274
┌─────────────────┐
252-
│ Call provider │
253-
│ (Tailscale whois │
254-
│ or OIDC verify) │
275+
│ Check IP cache │──hit──▶ Use cached identity (TTL: 60 s default)
276+
│ (RwLock map) │
255277
└────────┬────────┘
278+
│ miss
256279
280+
┌──────────────────────────────────┐
281+
│ GET /localapi/v0/whois?addr=peer │
282+
│ via Unix socket (tailscaled.sock) │
283+
└────────┬─────────────────────────┘
284+
│ 200 OK └─ 422 → 401 Unauthorized
285+
▼ └─ socket error → 503 Unavailable
257286
┌─────────────────┐
258-
│ Map to Role
259-
(tags → role)
287+
│ Map ACL tags
288+
to Role
260289
└────────┬────────┘
261290
262291
┌─────────────────┐
263292
│ Cache + inject │
264-
│ into request ctx │
293+
│ Identity into │
294+
│ request context │
265295
└─────────────────┘
266296
```
267297

268298
### Middleware Architecture
269299

300+
HTTP auth runs as an axum middleware layer (`authenticate_http_request` in
301+
`crates/axon-server/src/gateway.rs`). gRPC uses `AxonServiceImpl::authorize`
302+
in `crates/axon-server/src/service.rs`. Both delegate to `AuthContext::resolve_peer`.
303+
270304
```rust
271-
// axum middleware (HTTP)
272-
async fn auth_middleware(
273-
ConnectInfo(addr): ConnectInfo<SocketAddr>,
274-
State(auth): State<AuthProvider>,
305+
// Actual implementation (axon-server/src/gateway.rs)
306+
pub(crate) async fn authenticate_http_request(
307+
State(auth): State<AuthContext>,
275308
mut request: Request,
276309
next: Next,
277310
) -> Response {
278-
match auth.resolve_identity(addr).await {
311+
match auth.resolve_peer(request_peer_address(&request)).await {
279312
Ok(identity) => {
280313
request.extensions_mut().insert(identity);
281314
next.run(request).await
282315
}
283-
Err(AuthError::Unauthorized) => StatusCode::UNAUTHORIZED.into_response(),
284-
Err(AuthError::Forbidden) => StatusCode::FORBIDDEN.into_response(),
285-
Err(AuthError::Unavailable) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
316+
Err(error) => auth_error_response(error),
286317
}
287318
}
319+
```
320+
321+
Route handlers extract identity with `Extension<Identity>` and enforce
322+
role requirements:
288323

289-
// Same pattern for tonic interceptor (gRPC)
324+
```rust
325+
async fn my_handler(Extension(identity): Extension<Identity>, ...) {
326+
identity.require_write()?; // or require_read() / require_admin()
327+
// ... handler logic
328+
}
290329
```
291330

292-
### Provider Trait
331+
### Auth Types
293332

294333
```rust
295-
#[async_trait]
296-
trait IdentityProvider: Send + Sync {
297-
async fn resolve(&self, peer_addr: SocketAddr) -> Result<Identity, AuthError>;
334+
// crates/axon-server/src/auth.rs
335+
pub struct Identity {
336+
pub actor: String, // Tailscale ComputedName, "anonymous", or "guest"
337+
pub role: Role, // Admin | Write | Read
298338
}
299339

300-
struct Identity {
301-
actor: String, // email, node name, or "anonymous"
302-
role: Role, // admin, write, read, none
303-
provider: String, // "tailscale", "oidc", "none"
304-
raw_claims: Value, // provider-specific metadata
340+
pub enum AuthMode {
341+
NoAuth,
342+
Tailscale { default_role: Role },
343+
Guest { role: Role },
305344
}
306345
```
307346

@@ -351,8 +390,9 @@ role_claim = "axon_role"
351390

352391
- **FEAT-005** (API Surface): Auth middleware wraps HTTP and gRPC endpoints
353392
- **ADR-005**: Architecture decision for Tailscale LocalAPI whois
354-
- `tailscale-localapi` crate (v0.5.0) for whois calls
355-
- `jsonwebtoken` crate for generic OIDC JWT validation (Phase 2)
393+
- `hyper` + `tokio::net::UnixStream` — direct HTTP/1.1 over the Tailscale Unix socket
394+
(`tailscale-localapi` crate was evaluated but not used; direct socket calls chosen)
395+
- `jsonwebtoken` — deferred (OIDC not implemented in V1)
356396

357397
### Story US-046: Field-Level Masking [FEAT-012]
358398

@@ -391,9 +431,14 @@ role_claim = "axon_role"
391431

392432
### Related Artifacts
393433
- **Parent PRD Section**: Requirements Overview > P1 #6 (Authentication/authorization)
394-
- **User Stories**: US-043, US-044, US-045
434+
- **User Stories**: US-043, US-044, US-045 (shipped); US-046, US-047 (scaffolded)
395435
- **Architecture**: ADR-005 (Tailscale LocalAPI whois)
396-
- **Implementation**: `crates/axon-server/src/auth/` (planned)
436+
- **Implementation**:
437+
- `crates/axon-server/src/auth.rs``AuthMode`, `AuthContext`, `Identity`, `LocalApiWhoisProvider`
438+
- `crates/axon-core/src/auth.rs``Role`, `CallerIdentity`, `MaskPolicy`, `WritePolicy`, `GrantRegistry`
439+
- `crates/axon-server/src/gateway.rs``authenticate_http_request` middleware, `/auth/me` endpoint
440+
- `crates/axon-server/src/service.rs` — gRPC `authorize` + `auth_to_status`
441+
- `crates/axon-server/src/serve.rs``auth_context_from_serve_args`, CLI flags
397442

398443
### Feature Dependencies
399444
- **Depends On**: FEAT-005

0 commit comments

Comments
 (0)