Relationships
↑ child of #662#688 serve-auth: wire AccessDecisionService into serve chokepoints for authorization enforcement
Opened by stack72 · 6/19/2026· Shipped 6/19/2026
Parent
Sub-issue of #662 (serve authentication & authorization). Layer 5.
Dependencies
- ✅ #672 — AccessDecisionService
- ✅ #681 — Remote grant management + reload
- ✅ #682 — Auth config schema
- ✅ #683 — Admin materialization
- ✅ #685 — `mode: token` authentication (principal on connection)
Summary
With #685, the server knows WHO is connecting (a `Principal` on every authenticated connection). This issue wires in the check for what that principal is ALLOWED to do — every control plane request is checked against the AccessDecisionService before execution.
Currently the `_principal` parameter on `handleConnection()` is unused (prefixed with underscore). This issue makes it load-bearing: the principal is threaded through to every handler, and each handler checks the principal's grants before proceeding.
Enforcement points
Four chokepoints in `src/serve/connection.ts`, each requiring an authorization check:
| Handler | Request type | Resource | Action |
|---|---|---|---|
| `handleWorkflowRun()` | `workflow.run` | `workflow:` | `run` |
| `handleModelMethodRun()` | `model.method.run` | `model:` | `run` |
| `handleAccessGrantList()` | `access.grant.list` | `access:grant` | `read` |
| `handleAccessGroupList()` | `access.group.list` | `access:group` | `read` |
Additionally, grant/group mutations (create, revoke, add-member, etc.) flow through `model.method.run` with `typeArg` of `swamp/grant` or `swamp/group`. These need `admin` on `access:*`.
`access.check` and `access.reload` also need `admin` on `access:*`.
What to build
1. Thread principal through all handlers
Remove the underscore from `_principal` in `handleConnection()`. Pass it to every handler function. Each handler receives the principal alongside the existing parameters.
2. Build an authorization check helper
A function that takes the principal, action, resource, and the policy snapshot, and either returns (allowed) or throws/sends an error frame (denied):
```typescript function authorizeOrReject( socket: WebSocket, requestId: string, principal: Principal | null, action: Action, resource: AccessResource, ctx: ConnectionContext, ): boolean ```
When `authConfig.mode` is `none`, always returns true (no enforcement). When enforcing (`token` or `oauth`):
- If principal is null, send error frame and return false
- Build an `AccessPrincipal` from the principal (collectives come from the principal's claims — for `mode: token` phase 1, collectives are empty)
- Call `AccessDecisionService.decide()`
- If denied or no matching grant (null), send an `rpc.error` frame with a clear message and return false
- If allowed, return true
3. Insert checks at each chokepoint
`handleWorkflowRun()` — check before `executeWorkflowWithLocks()`: ``` authorize(principal, "run", { kind: "workflow", name: payload.workflowIdOrName }) ```
`handleModelMethodRun()` — check after definition lookup (we need the resolved model type):
- For `swamp/grant` and `swamp/group` model types: require `admin` on `access:*`
- For all other models: require `run` on `model:`
`handleAccessGrantList()` and `handleAccessGroupList()` — check at the top: ``` authorize(principal, "read", { kind: "access", name: "grant" }) authorize(principal, "read", { kind: "access", name: "group" }) ```
`handleAccessCheck()` and `handleAccessReload()` — check at the top: ``` authorize(principal, "admin", { kind: "access", name: "*" }) ```
4. Resource fields for CEL conditions
Where possible, populate the resource `fields` map so CEL conditions can evaluate:
- Workflow: `{ name, tags, collective }` from the workflow definition (if resolved)
- Model: `{ modelType, collective }` from the definition
- For access operations, fields can be empty (conditions on access grants are rare)
5. Error response format
When authorization fails, send an `rpc.error` frame: ```json { "type": "error", "id": "", "error": { "code": "unauthorized", "message": "Access denied: user:adam does not have 'run' on workflow:@acme/deploy" } } ```
The error message should include the principal, action, and resource so the user knows what to request access for.
Scope
- Thread principal through all handler functions in connection.ts
- Authorization check helper using AccessDecisionService
- Checks at all four chokepoints + access.check + access.reload
- Access model mutations require admin on access:*
- `mode: none` bypasses all checks (existing behavior preserved)
- Tests: authorized requests succeed, unauthorized requests get error frames, mode: none passes through, admin operations require admin grant
Out of scope
- `can-i` command — separate layer 5 issue
- Hard refusals — layer 6
- OAuth — layer 4 item 3
- Data plane authorization (already handled by dispatch-scoped writes)
References
- Connection handler: `src/serve/connection.ts`
- AccessDecisionService: `src/domain/access/grant_based_access_decision_service.ts`
- PolicySnapshotLoader: `src/domain/access/policy_snapshot_loader.ts`
- Auth config: `src/domain/access/serve_auth_config.ts`
- Access types: `src/domain/access/access_decision_service.ts`
Shipped
Click a lifecycle step above to view its details.