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

Relationships

↑ child of #662

#672 serve-auth: AccessDecisionService with in-memory policy snapshot

Opened by stack72 · 6/17/2026· Shipped 6/17/2026

Parent

Sub-issue of #662 (serve authentication & authorization). Layer 1, item 4.

Dependencies

  • ✅ #667 — Access bounded context (Grant, Group, shared value objects) — merged
  • ✅ #670 — Sealed CEL grant-condition environment — merged

Summary

Implement the AccessDecisionService — the domain port that answers `(principal, action, resource) → allow | deny` with an explain variant. This is the core authorization engine. Everything downstream depends on it: enforcement at serve chokepoints (layer 5), the `swamp access check` and `can-i` CLI commands (layer 2).

What to build

AccessDecisionService (domain port)

A domain service in `src/domain/access/` with this interface:

```typescript interface AccessDecision { effect: "allow" | "deny"; grantId: string; // the grant that decided subject: Subject; // the subject on the deciding grant condition?: string; // the CEL condition, if one applied }

interface AccessDecisionService { decide( principal: Principal, action: Action, resource: { kind: ResourceKind; name: string; fields: Record<string, unknown> }, ): AccessDecision | null; // null = no matching grant = deny (default-deny)

explain( principal: Principal, action: Action, resource: { kind: ResourceKind; name: string; fields: Record<string, unknown> }, ): AccessDecision[]; // all matching grants, for the explain/can-i commands } ```

`decide` returns the single deciding grant (the first deny, or the first allow if no deny matched). `null` means no grant matched, which is a deny (default-deny). `explain` returns all matching grants so the CLI can show why access was allowed or denied.

In-memory policy snapshot

The service evaluates against an in-memory snapshot of all active grants and groups, NOT against the repository on every call. This is critical for performance — authorization checks happen on every request through the serve chokepoints.

Loading the snapshot:

Use `DataQueryService.query()` to load all active grants and group memberships. The worker gateway demonstrates this pattern — see how it queries enrollment tokens:

```typescript const records = await dataQueryService.query( 'modelType == "swamp/grant"', { loadAttributes: true }, ); ```

Parse each record's `attributes` through `GrantSchema`, filter to `state === "active"`, and index them for fast lookup.

Snapshot invalidation:

Subscribe to the `EventBus` for `ModelCreated` and `ModelUpdated` events. When a grant or group model mutation fires, invalidate and rebuild the snapshot. The orchestrator is single-process, so there are no race conditions on the snapshot — mutations are serialized.

The evaluation loop

  1. Resolve the principal's groups. Look up which local groups (from the Group model) the principal is a member of. Collect their IdP-asserted groups from the principal's claims (passed in on the principal object or a claims snapshot). This gives the full set of subjects to match: the principal's own `user:`, any `group:` they belong to, and any `idp-group:` from their claims.

  2. Select matching grants. From the snapshot, find all active grants whose subject matches one of the resolved subjects AND whose resource selector matches the target resource (using `resourceSelectorMatches()` from the value objects).

  3. Evaluate deny rules first. For each matching deny grant, evaluate its condition (if any) using `evaluateGrantCondition()` from #670. If any deny grant matches and its condition is satisfied (or it has no condition), return deny immediately. Deny wins and short-circuits.

  4. Evaluate allow rules. For each matching allow grant, evaluate its condition. If any allow grant matches, return allow with the grant's provenance.

  5. Default deny. If no grant matched, return null (deny).

Key invariants

  • Deny by default. No matching grant = denied.
  • Deny wins. A deny grant overrides any number of allow grants. Deny rules are evaluated first and short-circuit.
  • The decision path bypasses enforcement. The AccessDecisionService reads grants through an internal snapshot, not through the data plane. This avoids recursion (access checks gate data access, and grants are data) and lockout.
  • Snapshot invalidation is immediate. When a grant is created or revoked, the snapshot rebuilds before the next decision. The single-process orchestrator serializes this.
  • Conditions are evaluated via the sealed CEL environment from #670. No I/O, no side effects, pure function of resource fields + principal context.
  • The deciding grant's ID and condition are always available. This enables the explain mode and audit logging.

Scope

  • `AccessDecisionService` interface and implementation in `src/domain/access/`
  • Policy snapshot loading from `DataQueryService`
  • Snapshot invalidation via `EventBus` subscription
  • Subject resolution (local groups + IdP-asserted groups)
  • Deny-wins evaluation loop with CEL condition evaluation
  • Explain variant returning all matching grants
  • Comprehensive tests: default deny, deny wins, condition evaluation, subject resolution (user, group, idp-group), snapshot invalidation, explain mode

Out of scope

  • Enforcement wiring into serve chokepoints — layer 5
  • CLI commands (`access check`, `can-i`) — layer 2
  • Admin materialization from config — separate layer 2 item
  • Performance optimization (compiled CEL caching) — later if needed

References

  • Grant model: `src/domain/models/access/grant_model.ts`
  • Group model: `src/domain/models/access/group_model.ts`
  • Value objects: `src/domain/access/` (Principal, Subject, ResourceSelector, Action, etc.)
  • CEL grant environment: `src/infrastructure/cel/grant_condition_environment.ts`
  • DataQueryService: `src/domain/data/data_query_service.ts`
  • EventBus: `src/domain/events/event_bus.ts`
  • Worker gateway (snapshot pattern reference): `src/serve/worker_gateway.ts`
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 5 MOREREVIEW+ 3 MOREPR_MERGED+ 1 MORENOTIFICATION_SKIPPED

Shipped

6/17/2026, 10:48:03 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack726/17/2026, 9:22:50 PM
stack72 linked parent of #6626/17/2026, 10:21:02 PM

Sign in to post a ripple.