Skip to main content
← Back to list
01Issue
FeatureShippedSwamp CLI
Assigneesstack72

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`
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 3 MOREFINDINGS+ 3 MOREPR_MERGED+ 1 MORENOTIFICATION_SKIPPED

Shipped

6/19/2026, 2:50:08 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack726/19/2026, 2:08:19 PM
stack72 linked parent of #6626/19/2026, 3:04:54 PM

Sign in to post a ripple.