17. Authentication & authorization
Identity and access for a Loom system, in one place: the system-scope user claim shape, the auth { … } OIDC config that generates a real verifier, the per-subdomain permissions catalogue (with implies grant hierarchy), the requires gate (HTTP 403), named policy functions, the policy { … } read/write ladder and its deny carve-outs, mask unless field redaction, the magic currentUser, and sensitive(...) tagging. Reach for this chapter when a request must be checked against who the caller is — distinct from precondition, which checks what state the aggregate is in (403 vs 422). Loom owns no auth runtime: declaring this surface generates the typed principal, the middleware, the verifier, the gates and the query filters; the IdP owns credentials, MFA and consent.
Grammar:
UserBlock/UserField/UserFieldName,AuthBlock,OidcConfig,AuthConfigValue,ClaimsMap/ClaimEntry,SessionMode,Enforcement,AuthMode,PermissionsBlock/PermissionDecl,PolicyDecl/PolicyReadRule/PolicyVerb/ReadLevel,RequiresStmt,RequiresProp,SensitivityClause,Property'smask unless,TenancyDecl· Validators:loom.auth-without-user,loom.auth-no-user-block,loom.duplicate-auth-block,loom.duplicate-user-block,loom.user-duplicate-field,loom.auth-unknown-provider,loom.auth-missing-issuer,loom.auth-missing-client-id,loom.auth-unknown-claim-field,loom.duplicate-permission,loom.unknown-permission,loom.permission-implies-self,loom.permission-implies-unknown,loom.policy-*,loom.field-mask-*,loom.currentuser-not-in-request-scope,loom.default-deny-ungated,loom.audit-history-ungated,loom.policy-read-gate-not-current-user,loom.projection-gate-not-current-user,loom.workflow-gate-not-current-user,loom.workflow-currentuser-find,loom.guard-principal-without-auth,loom.stamp-principal-without-auth,loom.current-user-needs-auth-ui,loom.auth-ui-misplaced,loom.auth-ui-target-open,loom.auth-ui-unsupported-framework,loom.orgpath-without-tenancy· Docs:../auth.md,../tenancy.md
All five backends emit auth files. The middleware, verifier seam, requires→403 mapping, permissions.<name> lowering, the read/write ladder and mask unless redaction are structurally identical across them — the divergence is host-language syntax and, for Elixir, the topology (a plug plus context-function guards rather than route-level throws). The frontends consume only the session probe; the auth: ui page gate is covered in UI pages and ../auth.md.
The single example below threads every feature in this chapter; each section excerpts the lines it produces.
system Helpdesk {
user { id: string role: string permissions: string[] tenantId: string }
auth {
provider: keycloak
oidc { issuer: env("OIDC_ISSUER") clientId: "helpdesk-web" clientSecret: env("OIDC_CLIENT_SECRET") scopes: ["openid", "profile"] }
sessions: cookie
enforcement: opt
claims: { role: "realm_access.roles", permissions: "resource_access.helpdesk.roles" }
}
tenancy by user.tenantId of Organization
subdomain Support {
permissions {
ticketsRead,
ticketsClose implies ticketsRead,
ticketsAdmin implies [ticketsClose, ticketsReassign],
ticketsReassign,
salaryUnmask
}
context Support {
enum TicketStatus { Open, Pending, Closed }
policy IsAgent(): bool = currentUser.role == "agent"
policy CanClose(prio: int): bool =
currentUser.permissions.contains(permissions.ticketsClose) && prio < 10
aggregate Ticket with tenantOwned, crudish {
subject: string
ssn: string sensitive(pii)
salary: int mask unless currentUser.permissions.contains(permissions.salaryUnmask)
status: TicketStatus
assignee: string
priority: int
operation close() requires IsAgent() || CanClose(priority) {
precondition status != Closed
status := Closed
}
operation reassign(to: string) {
requires currentUser.permissions.contains(permissions.ticketsReassign)
assignee := to
}
}
aggregate Secret with tenantOwned, crudish { code: string }
aggregate Plan crossTenant with crudish { name: string }
aggregate Organization with crudish { name: string implements tenantRegistry }
repository Tickets for Ticket { }
criterion TicketsMine of Ticket = assignee == currentUser.id
criterion TicketsOpen of Ticket = status == Open
repository Secrets for Secret { }
repository Plans for Plan { }
repository Organizations for Organization { }
policy {
allow deep on Ticket
deny on Secret
read Ticket requires currentUser.role == "agent"
}
}
}
storage primary { type: postgres }
resource supportState { for: Support, kind: state, use: primary }
api SupportApi from Support
deployable apiNode { platform: node contexts: [Support] dataSources: [supportState] serves: SupportApi port: 3000 auth: required }
// … one deployable per backend in the real fixture
}
user — the principal claim shape
user { field: Type … } at system scope declares the strongly-typed claim record the verifier decodes per request. Exactly one per system (loom.duplicate-user-block); a repeated field is loom.user-duplicate-field. id and permissions are admissible field names (the grammar's UserFieldName re-admits them, plus migration). Every currentUser.<field> reference type-checks against this block.
user { id: string role: string permissions: string[] tenantId: string }
The declared fields are the claims; under a tenancy by declaration the principal additionally carries two derived members resolved once per request — orgPath (the caller org's materialized registry path) and rootOrg (its first segment). They are server-side scoping state and stay off the /api/auth/me wire.
// auth/user-types.ts
export interface UserClaims { id: string; role: string; permissions: string[]; tenantId: string; }
export interface User extends UserClaims {
orgPath: string; // caller's tenant materialized path (multi-tenancy)
rootOrg: string; // first segment of orgPath — anchors `allow global`
}
// Auth/User.cs
public sealed record User(string Id, string Role, List<string> Permissions, string TenantId)
{
private string? _orgPath; // resolved once per request by UserMiddleware, memoized
}
// auth/User.java
public record User(String id, String role, List<String> permissions, String tenantId) { /* + orgPath/rootOrg accessors */ }
# app/auth/user.py
@dataclass(frozen=True)
class User:
id: str
role: str
permissions: list[str]
tenant_id: str
org_path = "" # class attribute, NOT a field — never serialized by asdict()
# api_elixir_web/auth.ex — the principal is a plain map built from the claims
defp build_user(claims) do
%{id: get_claim(claims, "sub"), role: get_claim(claims, "realm_access.roles"),
permissions: get_claim(claims, "resource_access.helpdesk.roles") || [],
tenant_id: get_claim(claims, "tenant_id")}
end
Referencing
currentUser.orgPath/.rootOrgwithout atenancy byline isloom.orgpath-without-tenancy.
auth: required — per-deployable middleware + verifier seam
The user block declares the shape; auth: required on a deployable opts it into the decode middleware. Without it the deployable stays open. With it but no user block, loom.auth-no-user-block fires (the sibling loom.auth-without-user is the auth-block-without-user case). The middleware decodes the token via the verifier hook, stashes the principal on the request context, and 401s on failure. A shared bypass list (/health, /ready, /openapi.json, /swagger, the OIDC handshake paths) keeps framework endpoints anonymous so smoke tests and the OpenAPI parity check work without tokens.
AuthMode is a two-value union: required (backend — decode + enforce) and ui (frontend — the client-side session guard). auth: ui on a backend deployable is loom.auth-ui-misplaced (it replaced the older auth-ui-on-backend code); pointing it at a target that is not auth: required is loom.auth-ui-target-open; a framework outside react/vue/svelte/angular/feliz/flutter is loom.auth-ui-unsupported-framework.
// auth/middleware.ts — mounted in http/index.ts as app.use("*", authMiddleware)
export const authMiddleware = createMiddleware<{ Variables: { currentUser: User } }>(async (c, next) => {
const path = new URL(c.req.url).pathname;
for (const prefix of BYPASS_PREFIXES) if (path.startsWith(prefix)) { await next(); return; }
if (routeProbe && !routeProbe(c.req.method, path)) { await next(); return; }
// … verifyUserOrThrow(c.req.raw) → 401 on failure, then c.set("currentUser", user)
});
The verifier is a registry you fill at startup (auth/verifier.ts: registerUserVerifier(fn) / verifyUserOrThrow(req)). requireCurrentUser() reads the ambient principal from any layer — that is what lets an always-on tenant filter scope a repository read without threading a parameter.
// Auth/UserMiddleware.cs — UseMiddleware<UserMiddleware>() between UseSwagger() and MapControllers()
User? user;
try { user = await verifier.VerifyAsync(ctx, ctx.RequestAborted); }
catch { ctx.Response.StatusCode = 401; return; }
if (user is null) { ctx.Response.StatusCode = 401; return; }
if (RequestContext.Current is { } rc) rc.CurrentUser = user;
Program.cs registers ICurrentUserAccessor + IHttpContextAccessor and fails fast at startup if no IUserVerifier is registered.
// auth/UserFilter.java — a servlet filter; auth/UserVerifier.java is the seam,
// auth/CurrentUserAccessor.java exposes the principal (statically) to services and mappers.
# app/auth/middleware.py — Starlette middleware; app/auth/verifier.py is the seam.
# The principal also lands in a ContextVar (`current_user_var`); require_current_user() reads it
# from any layer, fail-closed.
# api_elixir_web/auth.ex — a Plug in the :api pipeline (and a BrowserAuth plug + LiveAuth.on_mount for LiveViews)
if bypass_path?(conn.request_path), do: conn, else:
case verify_token(extract_token(conn)) do
{:ok, claims} -> assign(conn, :current_user, build_user(claims))
_ -> send_unauthorized(conn)
end
A missing verifier registration surfaces at runtime startup, not generation.
auth { … } — OIDC config
auth { … } is a system-scope sibling of user: the user block declares the claim shape, this declares who issues the token and how its claims map onto that shape. It generates a real verifier (filling the per-backend seam) plus the redirect handshake. At most one per system (loom.duplicate-auth-block), and only alongside a user block (loom.auth-without-user).
provider:— a preset name. Hosted presets (google,microsoft,entra) carry their own issuer; the self-hosted ones (auth0,okta,zitadel,cognito,keycloak,custom) require an explicitoidc { issuer: … }orloom.auth-missing-issuerfires. An unknown name isloom.auth-unknown-provider. Omittingprovider:entirely and supplying a rawoidc { issuer }is also valid.oidc { issuer, clientId, clientSecret, audience, scopes: […] }— each value is a"literal"orenv("VAR"), so secrets never land in source. A missingclientIdisloom.auth-missing-client-id.audience:is optional, and its default is unsafe. The emitted verifier validates the signature (JWKS), theissuerandexp; theaudclaim is checked only when an audience actually resolves, so with none, any token minted by the same issuer — including one issued to a different client in the same realm — is accepted. Verified against a running generated API: a token carryingaud: some-other-appfrom the configured issuer returned 200. The generatedauth/oidc.tssays so in a comment above the call it actually makes, and omittingaudience:now also raisesloom.auth-oidc-no-audience(warning).Two ways to turn it on, and the env one needs no regeneration:
OIDC_AUDIENCEin the deploy environment (it supplies an audience when the model declares none, and overrides the declared value when it does — every backend reads it), oraudience: env("OIDC_AUDIENCE")in theoidc { … }block. An empty value (OIDC_AUDIENCE="") is the documented opt-out.It is not defaulted to
clientId, because the generator's own Keycloak realm mints access tokens withaud: accountunless an audience mapper is configured — the default would break the shipped demo. See../auth.md.
sessions: cookie | jwt(defaultcookie),enforcement: denyByDefault | opt(defaultdenyByDefaultsince M-T3.1 — anauthblock that names noenforcement:is deny-by-default; writeenforcement: optto keep the pre-flip opt-in posture, or runnode scripts/codemod-enforcement-opt.mjsover a project that relied on it — seerequiresand../migrations.md).claims: { field: "dotted.claim.path" }— maps an IdP claim path onto auserfield; an unknown target field isloom.auth-unknown-claim-field.
auth {
provider: keycloak
oidc { issuer: env("OIDC_ISSUER") clientId: "helpdesk-web" clientSecret: env("OIDC_CLIENT_SECRET") scopes: ["openid", "profile"] }
sessions: cookie
claims: { role: "realm_access.roles", permissions: "resource_access.helpdesk.roles" }
}
The claims: map drives the verifier's claim projection — the dotted paths are read off the verified payload onto the principal:
// auth/oidc.ts — validates signature (JWKS) + issuer, then projects claims
function toUser(payload: JWTPayload): UserClaims {
return {
id: claim(payload, "sub") as string,
role: claim(payload, "realm_access.roles") as string,
permissions: claim(payload, "resource_access.helpdesk.roles") as string[],
tenantId: claim(payload, "tenantId") as string,
};
}
JWKS is discovered lazily via /.well-known/openid-configuration and cached; registerOidcVerifier() wires it into the seam.
// Auth/OidcUserVerifier.cs
return new User(
Id: ClaimString(payload, "sub") ?? string.Empty,
Role: ClaimString(payload, "realm_access.roles") ?? string.Empty,
Permissions: ClaimStringList(payload, "resource_access.helpdesk.roles"),
TenantId: ClaimString(payload, "tenantId") ?? string.Empty);
# api_elixir_web/auth.ex
defp build_user(claims) do
%{id: get_claim(claims, "sub"), role: get_claim(claims, "realm_access.roles"),
permissions: get_claim(claims, "resource_access.helpdesk.roles") || [], tenant_id: get_claim(claims, "tenant_id")}
end
Handshake routes mount under /api/auth alongside the domain routes: login, callback, logout, me (the session probe the auth: ui guard reads) and POST /api/auth/refresh (silent renewal with refresh-token rotation). PKCE is unconditional. generate system also emits a keycloak/realm.json and a compose keycloak service as the local IdP — for every provider, hosted presets included (../auth.md). Full route table, session depth and bypass list: ../auth.md.
Dev-stub verifier (x-loom-dev-claims)
Until a real verifier is registered, every backend ships an accept-all dev stub so the stack boots. It reads an optional x-loom-dev-claims header — base64-encoded JSON — and overlays it on a built-in identity derived from the declared shape ("admin" for a string, 0 for a number, the empty list for an array, so permission-gated surfaces deny by default):
curl -H "x-loom-dev-claims: $(echo -n '{"id":"u-1","permissions":["support.ticketsClose"]}' | base64)" \
http://localhost:3000/api/tickets
// auth/dev-stub.ts (Hono) — the four typed backends emit an equivalent per-claim mapper
return { ...base, ...JSON.parse(Buffer.from(injected, "base64").toString("utf8")) };
Array-typed claims ride the header on all five backends (each typed backend emits a devClaimStringList-style array mapper), so a permission gate is drivable in dev everywhere. It is a dev convenience, not a production path.
permissions — a typed catalogue with implies
permissions { name, … } lives at subdomain scope (a Subdomain member — it does not parse inside a context). Each name becomes a typed identifier permissions.<name>, usable in any expression resolving through the enclosing subdomain, and lowers to a stable string literal <lowercase-subdomain>.<name> — permissions.ticketsClose inside subdomain Support is "support.ticketsClose" on both sides of the wire. Backends never see a Permission type; the runtime is a string[] membership check. Multiple blocks in one subdomain merge; a duplicate is loom.duplicate-permission, an undeclared (or cross-subdomain) reference loom.unknown-permission.
A permission may declare that holding it transitively grants others. The single form omits the brackets, the multi form requires them:
permissions {
ticketsRead,
ticketsClose implies ticketsRead,
ticketsAdmin implies [ticketsClose, ticketsReassign],
ticketsReassign
}
The transitive closure is precomputed at lowering (impliedBy on the permission IR); the runtime check stays flat membership. A contains(permissions.X) gate expands to an OR over X plus every permission that transitively implies it, so a caller holding ticketsAdmin satisfies a ticketsReassign gate without the token carrying it:
// http/ticket.routes.ts — the gate written as `contains(permissions.ticketsReassign)`
if (!((currentUser.permissions).includes("support.ticketsReassign")
|| (currentUser.permissions).includes("support.ticketsAdmin")))
throw new ForbiddenError("Forbidden: currentUser.permissions.contains(permissions.ticketsReassign)");
if not ("support.ticketsReassign" in current_user.permissions or "support.ticketsAdmin" in current_user.permissions):
raise ForbiddenError("Forbidden: currentUser.permissions.contains(permissions.ticketsReassign)")
Enum.member?(current_user.permissions, "support.ticketsReassign") or
Enum.member?(current_user.permissions, "support.ticketsAdmin")
loom.permission-implies-self rejects a implies a; loom.permission-implies-unknown rejects a target no permissions { … } block in the subdomain declares. A mutual cycle (a implies b, b implies a) is allowed — the closure computation is cycle-safe.
The expansion is a flat
||chain spliced into the surrounding expression, so an impliedcontains(...)inside a larger&&reads with&&'s tighter binding. Parenthesise the gate yourself when the surrounding operator matters.
.contains(x) is an ordinary collection op (joining count/sum/all/any/where/first/firstOrNull) admissible on any array — it renders as the host's idiomatic membership test (.includes / .Contains / in / Enum.member?).
requires — the authorization gate (HTTP 403)
requires <expr> is a declarative authorization gate. Its bool expression may reference currentUser, permissions.<name>, parameters, this.<field>, named policy functions and criteria. Failure maps to HTTP 403 — deliberately distinct from precondition's 422, so caller-authorization and aggregate-state-validity don't share an error class.
| Clause | HTTP | Failure means |
|---|---|---|
requires |
403 | The caller isn't authorized to invoke this. |
precondition |
422 | The request/aggregate state is invalid for this operation. |
It is admissible in five positions, all of them declaration headers or a leading body run:
operation close() requires IsAgent() || CanClose(priority) { … } // header (post-load: sees `this` + params)
operation reassign(to: string) { requires currentUser.permissions.contains(permissions.ticketsReassign) … }
policy { read Ticket requires currentUser.role == "agent" } // every read of Ticket (see below)
projection Backlog requires currentUser.role == "agent" { … }
workflow Fulfil requires currentUser.role == "ops" { create open(...) requires … { … } }
page Admin { requires currentUser.role == "admin" … } // client-side guard, see ch. 15
The gate is not domain logic and is not emitted into the aggregate: the leading run of requires is hoisted out of the entity and evaluated by whatever calls it — the HTTP route, the Mediator/service handler, or the Phoenix context function. The domain method itself keeps its declared parameters and no injected principal.
// http/ticket.routes.ts — post-load, pre-call; note the gate is here, not in domain/ticket.ts
const currentUser = c.get("currentUser") as User;
const aggregate = await repo.getById(Ids.TicketId(id));
if (!(currentUser.role === "agent" || (currentUser.permissions).includes("support.ticketsClose")
|| (currentUser.permissions).includes("support.ticketsAdmin") && aggregate.priority < 10))
throw new ForbiddenError("Forbidden: IsAgent() || CanClose(priority)");
aggregate.close();
// domain/ticket.ts — the entity method carries the precondition only
public close(): void {
if (!(this._status !== TicketStatus.Closed)) throw new DomainError("Precondition failed: status != Closed");
this._status = TicketStatus.Closed;
this._assertInvariants();
}
// http/ticket.routes.ts — the shared catch
if (err instanceof ForbiddenError) return problem(403, "Forbidden", err.message);
if (err instanceof DomainError) return problem(422, "Unprocessable Entity", err.message);
// Application/Tickets/Commands/CloseHandler.cs — the Mediator handler owns the gate
var aggregate = await _repo.GetByIdForWriteAsync(command.Id, cancellationToken) ?? throw new AggregateNotFoundException(…);
var currentUser = _currentUser.User;
if (!(currentUser.Role == "agent" || (currentUser.Permissions).Contains("support.ticketsClose")
|| (currentUser.Permissions).Contains("support.ticketsAdmin") && aggregate.Priority < 10))
throw new ForbiddenException("Forbidden: IsAgent() || CanClose(priority)");
aggregate.Close();
An exception filter maps ForbiddenException → 403, DomainException → 422.
// features/tickets/TicketService.java
if (!(Objects.equals(currentUser.role(), "agent") || currentUser.permissions().contains("support.ticketsClose")
|| currentUser.permissions().contains("support.ticketsAdmin") && aggregate.priority() < 10))
throw new ForbiddenException("Forbidden: IsAgent() || CanClose(priority)");
aggregate.close();
ApiExceptionAdvice maps ForbiddenException → 403 via ProblemDetail.
# app/http/ticket_routes.py
if not (current_user.role == "agent" or "support.ticketsClose" in current_user.permissions
or "support.ticketsAdmin" in current_user.permissions and found.priority < 10):
raise ForbiddenError("Forbidden: IsAgent() || CanClose(priority)")
found.close()
# lib/api_elixir/support.ex — the context function owns the gate; {:error, :forbidden} → 403
def close_ticket(%Ticket{} = record, params, current_user \\ nil) when is_map(params) do
with :ok <- ensure(current_user.role == "agent" or Enum.member?(current_user.permissions, "support.ticketsClose")
or Enum.member?(current_user.permissions, "support.ticketsAdmin") and record.priority < 10,
{:forbidden, "Forbidden: IsAgent() || CanClose(priority)"}),
:ok <- ensure(record.status != :Closed, {:precondition_failed, "Precondition failed: status != Closed"}) do
# … perform the update
end
end
Read-side gates are currentUser-only. An aggregate's policy read gate, a projection's gate and a workflow's header gate all run before any row is loaded, so referencing an aggregate field is a compile error — loom.policy-read-gate-not-current-user, loom.projection-gate-not-current-user, loom.workflow-gate-not-current-user respectively (each names the offending reference and points at the alternative). The policy read gate (see below) emits an in-handler 403 at the top of every read route of its aggregate:
// http/ticket.routes.ts — GET /open, under `policy { read Ticket requires currentUser.role == "agent" }`
if (!(currentUser.role === "agent")) throw new ForbiddenError("Forbidden: find open");
const result = await repo.open();
Default-deny. auth { enforcement: denyByDefault } makes every client-reachable surface without a requires gate an error — loom.default-deny-ungated, raised separately for operations, creates, destroys, finds, projections, workflows, workflow-instance reads and command/query handlers, plus loom.audit-history-ungated for an audited aggregate's GET /<agg>/{id}/history. requires true is the explicit "intentionally public" escape. This is the default for an auth { … } block that writes no enforcement: (M-T3.1); an explicit enforcement: opt leaves ungated surfaces open, and a system with no auth block has no enforcement posture at all.
The two compiler-derived reads — the list GET /api/<plural> and the by-id GET /api/<plural>/{id} — are warnings when ungated (loom.default-deny-list-ungated, loom.default-deny-by-id-ungated; refused at runtime with 403 either way), whose remedy is the policy line read T requires <expr> (or requires true). One more surface is a warning, on one rule: an arm is an error only where the author can write a requires to satisfy it. An event-sourced create (loom.default-deny-es-create-ungateable) has one that is refused on the four backends that render its body into a domain _init where no principal is in scope (loom.lifecycle-guard-event-sourced) — elixir hoists the gate to its context function and binds a principal, so there the gate is accepted and a missing one stays a hard error. Erroring there would make the model unbuildable with nothing the author could do — it made denyByDefault and persistedAs: eventLog mutually exclusive for any aggregate with a creation endpoint. Every warning names its remedy; see ../auth.md.
No principal, no gate. A requires gate (or a principal-reading lifecycle stamp such as with auditable's createdBy := currentUser) on a deployable without auth: required would emit an unbound identifier and fail to compile — so it is caught first: loom.guard-principal-without-auth / loom.stamp-principal-without-auth.
Named policy functions
policy <Name>(<params>): bool = <expr> (or { <expr> }) is a context member: a reusable, ambient boolean authorization predicate — the named twin of an inline requires. It sees currentUser, its own parameters, permissions.<name>, enum values and sibling policy functions; it has no candidate row, so pass row fields in as arguments. The parentheses are required even for zero parameters — that is what disambiguates the function form from the policy { … } block.
policy IsAgent(): bool = currentUser.role == "agent"
policy CanClose(prio: int): bool = currentUser.permissions.contains(permissions.ticketsClose) && prio < 10
operation close() requires IsAgent() || CanClose(priority) { … }
A policy function is context-local: calling one from another bounded context — bare (IsAdmin()) or qualified with its context (Orders.IsAdmin()) — is loom.policy-out-of-scope ("policy X is declared in context A; policies are context-local — redeclare it in B"). Sharing a policy across contexts (a subdomain- or system-level policy) is not supported yet.
The call is inlined at the gate — no function is emitted, and the ForbiddenError message keeps the author's source text:
if (!(currentUser.role === "agent" || (currentUser.permissions).includes("support.ticketsClose")
|| (currentUser.permissions).includes("support.ticketsAdmin") && aggregate.priority < 10))
throw new ForbiddenError("Forbidden: IsAgent() || CanClose(priority)");
| Situation | Diagnostic |
|---|---|
| Wrong argument count at the call site | loom.policy-fn-arity |
Return type is not bool |
loom.policy-fn-return-type |
| A policy function (transitively) references itself | loom.policy-fn-cycle |
policy { … } — the read/write ladder and deny
The block form of policy is a context member that selects a per-aggregate reachability level, refining the tenant floor with tenantOwned installs. allow carries a widening ReadLevel (local / deep / global); the optional write verb switches to the write ladder; deny is the all-or-nothing carve-out and wins over any allow.
policy {
allow deep on Ticket // caller's org + every descendant org
allow write local on Ticket // (the default floor, spelled explicitly)
deny on Secret // Secret is invisible: findAll → [], findById → 404
deny write on Plan // Plan is read-only: mutations 404
}
| Level | Read scope |
|---|---|
local |
tenantId == currentUser.tenantId — the caller's own org node. The default. |
deep |
descendant-or-self on the materialized path: dataKey == orgPath OR dataKey under `orgPath |
global |
the caller's root-org subtree — the same prefix scan anchored at currentUser.rootOrg. Under flat tenancy it stays the flat floor (fail-closed). |
Each rule rewrites the aggregate's capability filter and rides the query seams the flat floor already uses — no new backend plumbing:
// db/repositories/ticket-repository.ts — `allow deep on Ticket`
.where(and(inArray(schema.tickets.id, ids),
or(and(isNotNull(schema.tickets.dataKey),
or(eq(schema.tickets.dataKey, requireCurrentUser().orgPath),
sql`${schema.tickets.dataKey} like ${… + ".%"} escape '!'`)), /* … */)))
// db/repositories/secret-repository.ts — `deny on Secret` composes as an always-false predicate
.where(and(eq(schema.secrets.id, id), and(isNull(schema.secrets.id), isNotNull(schema.secrets.id))))
// Infrastructure/Persistence/AppDbContext.cs
modelBuilder.Entity<Ticket>().HasQueryFilter("Filter1", x => ((x.DataKey != null && (x.DataKey == _currentUser.User.OrgPath || …))));
modelBuilder.Entity<Organization>().HasQueryFilter("IdFilter", x => x.Id == __SelfScopeId_Organization_0); // registry self-scope
// Infrastructure/Persistence/Configurations/SecretConfiguration.cs — `deny on Secret`
builder.HasQueryFilter("Filter2", x => false);
# app/db/repositories/ticket_repository.py
select(TicketRow).where(or_(and_(TicketRow.data_key.isnot(None),
or_(TicketRow.data_key == require_current_user().org_path,
TicketRow.data_key.like(…))), …))
| Situation | Diagnostic |
|---|---|
| Target names no aggregate in this context | loom.policy-unknown-aggregate / loom.policy-deny-unknown-aggregate |
Target is not with tenantOwned (a level refines the tenant floor) |
loom.policy-target-not-tenant-owned |
| Two rules select a level for the same aggregate / access | loom.policy-duplicate-target / loom.policy-deny-duplicate |
deep/global without implements tenantRegistry (no hierarchy) |
loom.policy-level-requires-hierarchy |
write global (parses, always rejected — root-wide mutation is a footgun) |
loom.policy-write-global-invalid |
| Write scope wider than the read scope | loom.policy-write-wider-than-read |
A deny that shadows an allow for the same aggregate (the allow is dead) |
loom.policy-deny-shadows-allow |
Full semantics, the registry self-scope, and the per-backend seams: ../tenancy.md and ../auth.md.
read — who may read an aggregate
The same block holds each aggregate's read gate: one read <Aggregate> requires <expr> line that covers every read of it — the list GET /<plural>, the by-id GET /<plural>/{id}, each repository find's route, the audit history read, and the scaffolded List page. It is evaluated before the load (403, so /{id} answers the same for an existing and a missing id — no existence oracle) and may read only currentUser and constants. An exposure gate (scaffoldPaged(requires:), a route / handler requires) still ANDs on top. A repository find carries no requires of its own.
repository Orders for Order { }
policy {
read Order requires currentUser.role == "clerk"
read Product requires true // intentionally public
}
// http/order.routes.ts — GET /orders/{id}
if (!(currentUser.role === "clerk")) throw new ForbiddenError("Forbidden: find byId");
// Application/Orders/Queries/GetOrderByIdHandler.cs
if (!(currentUser.Role == "clerk")) throw new ForbiddenException("Forbidden: find byId");
# lib/<app>_web/controllers/order_controller.ex — show/2
if not (current_user.role == "clerk") do
# app/http/order_routes.py — GET /orders/{id}
if not (current_user.role == "clerk"):
// features/orders/OrdersController.java — getOrderById
if (!(Objects.equals(currentUser.role(), "clerk"))) throw new ForbiddenException("Forbidden: find byId");
| Situation | Diagnostic |
|---|---|
The gate reads a row field (or anything but currentUser / constants) |
loom.policy-read-gate-not-current-user |
Two read rules for one aggregate |
loom.policy-duplicate-target |
| The target is not an aggregate of this context | loom.policy-unknown-aggregate |
The gate is not bool |
loom.requires-not-bool |
A rule starts with a word other than allow / deny / read |
loom.policy-unknown-rule |
read is not a keyword: it heads a rule only inside policy { } and stays an ordinary identifier elsewhere (a permission, an operation, let read = api.orders.getById(o)). The find … requires clause it replaces is gone; node scripts/codemod-find-gate-to-policy.mjs migrates a project (see ../auth.md).
mask unless — field read redaction
A property may carry a trailing mask unless <expr>: the field is redacted (null on the wire) unless the predicate holds — "sensitive everywhere, shown only to the authorised". Like a read gate the predicate is param-free and currentUser-only (loom.field-mask-not-current-user); it is evaluated at read projection, never against the row.
aggregate Ticket {
salary: int mask unless currentUser.permissions.contains(permissions.salaryUnmask)
}
Redaction lands at the response boundary — every read route and explicit query handler routes the aggregate through a masked serializer — and is fail-closed: an unauthenticated request always redacts. Internal audit/provenance snapshots keep the real value.
// db/repositories/ticket-repository.ts
toWireMasked(root: Ticket, currentUser: User | null): unknown {
const wire = this.toWire(root) as Record<string, unknown>;
if (!(currentUser !== null && ((currentUser.permissions).includes("support.salaryUnmask")))) wire.salary = null;
return wire;
}
// http/ticket.routes.ts — every read route projects through it
const __maskUser = c.get("currentUser") ?? null;
return c.json(result.map((r) => repo.toWireMasked(r, __maskUser)), 200);
# app/db/repositories/ticket_repository.py — reads the ambient principal, no caller-passed arg
def to_wire_masked(self, root: Ticket) -> dict[str, object]:
d = self.to_wire(root)
_mask_user = current_user()
if not (_mask_user is not None and ("support.salaryUnmask" in _mask_user.permissions)):
d["salary"] = None
return d
# api_elixir_web/controllers/ticket_controller.ex — the principal comes off the process dictionary
wire = serialize_unmasked(record)
wire = if current_user != nil and (Enum.member?(current_user.permissions, "support.salaryUnmask")),
do: wire, else: Map.put(wire, "salary", nil)
Two more gates guard the read paths a mask cannot reach: loom.field-mask-projection-source (a query-time projection sourcing from — or joining — a masked aggregate would expose the field; the #fold variant covers a folded event payload), and loom.field-mask-unsupported (a backend hosting the aggregate does not emit read redaction yet — it names the backends, so the gate is honest rather than silent).
currentUser — claim access in domain logic
currentUser is a magic identifier resolving to the typed principal, in scope wherever an expression evaluates per request: operation and workflow bodies, requires gates, mask unless predicates, aggregate test bodies, and query filters (a criterion body, a repository find's where). It is a compile error (loom.currentuser-not-in-request-scope) in an invariant, a derived property, or a function body — those can run outside a request.
repository Tickets for Ticket { }
criterion TicketsMine of Ticket = assignee == currentUser.id
// db/repositories/ticket-repository.ts — currentUser is a method param, parametrised into the bind
async mine(currentUser: User): Promise<Ticket[]> {
const rootRows = await this.db.select().from(schema.tickets)
.where(eq(schema.tickets.assignee, currentUser.id));
// …
}
// http/ticket.routes.ts — the route threads the verified principal in
const currentUser = c.get("currentUser") as User;
const result = await repo.mine(currentUser);
// Repo signature: Task<...> Mine(User currentUser, CancellationToken ct)
// The Mediator handler injects ICurrentUserAccessor and calls _repo.Mine(_currentUser.User, ct).
// The EF predicate lifts currentUser.Id via Linq-to-Entities — no string interpolation hits SQL.
def mine(current_user) do
Repo.all(from t in Ticket, where: t.assignee == ^current_user.id)
end
Two honest gaps: a workflow body may not call a currentUser-bound find (loom.workflow-currentuser-find points at getById or the route layer), and a page reading currentUser on a deployable that binds no verified session is loom.current-user-needs-auth-ui (the read would emit a dangling reference — react undefined.<claim>, invalid Dart on flutter, an unbound match on feliz).
Reactors — the system principal (currentUser.isSystem)
An event reactor — a workflow's event-triggered create(e) by … starter or an on(e) subscription — has no request of its own, so it has no request principal. It runs as the system principal (decision D-REACTOR-SYSTEM-PRINCIPAL):
| slot | value |
|---|---|
currentUser.isSystem |
true (it is false on every request principal) |
every user { } claim |
EMPTY — "", 0, false, [], null, the zero id — never a grant |
the tenancy claim / orgPath |
the triggering event's tenant — the raising request's in-process; across the outbox relay or a broker, the event origin the outbox row / envelope carried (tenantid / loomorgpath, channels.md § Event origin) |
causedBy |
the originating user's id, kept across hops (loomcausedby) — audit and logs only, not a language member, no gate can read it |
Gates are evaluated against it normally. A gate that should admit a reactor says so:
aggregate Order {
operation finish() {
requires currentUser.isSystem || currentUser.permissions.contains(permissions.close)
done := true
}
}
workflow closeOrder {
orderRef: Order id
create(e: Shipped) by e.order {
let o = Orders.getById(e.order)
o.finish() // gate evaluated against the system principal
}
}
// http/workflows.ts
export async function closeOrderStartShipped(db, events, e: Events.Shipped): Promise<void> {
const currentUser = systemPrincipal(); // auth/middleware.ts
// …
if (!((currentUser.isSystem === true) || (currentUser.permissions).includes("d.close")))
throw new ForbiddenError("Forbidden: currentUser.isSystem || currentUser.permissions.contains(permissions.close)");
o.finish();
}
// Application/Workflows/CloseOrderStartShippedHandler.cs
var currentUser = global::Api.Auth.User.SystemPrincipal(RequestContext.Current?.CurrentUser);
if (!(currentUser.IsSystem || (currentUser.Permissions).Contains("d.close"))) throw new ForbiddenException("…");
// application/workflows/OrdDispatcher.java — @EventListener, on the publishing thread
var currentUser = User.systemPrincipal(CurrentUserAccessor.currentOrNull());
if (!(currentUser.isSystem() || currentUser.permissions().contains("d.close"))) throw new ForbiddenException("…");
# app/dispatch.py
current_user = system_principal() # app/auth/user.py
if not (current_user.is_system or "d.close" in current_user.permissions):
raise ForbiddenError("…")
# lib/api/ord/workflows/close_order/start_shipped.ex
current_user = ApiWeb.Auth.system_principal()
with {:ok, o} <- Api.Ord.get_order(event.order),
{:ok, _} <- Api.Ord.finish_order(o, %{}, current_user) do
The request principal is unchanged on the wire: isSystem / causedBy are optional slots (node), serializer-skipped at their defaults (.NET), extra record components the verifiers never set (java), defaulted dataclass fields (python), and absent map keys (elixir), and every /auth/me projects the declared claims only. isSystem and causedBy are therefore reserved claim names (loom.user-reserved-field).
Two diagnostics follow from the rule:
loom.reactor-gate-unsatisfiable(warning) — a reactor reaches arequires(its own, or the hoisted gate of an operation it calls) that readscurrentUserand never mentionscurrentUser.isSystem. With every claim empty that gate refuses every event with 403. Add the disjunct, call an ungated operation, or move the check onto the command that emits the event.loom.timer-tenant-read(error) — a reactor on atimerSourcetick reads atenantOwnedaggregate. A tick has no originating request, so the system principal has no tenant and the read's tenant filter matches nothing. Make the cross-tenant read explicit — afind(or inlineRepo.run) withignoring tenantOwned.
Tenancy — tenancy by user.<claim> of <Registry>
Multi-tenancy is the auth layer's data-partitioning half and shares its principal. One system-level line names the claim and the registry; each aggregate then declares an explicit stance:
tenancy by user.tenantId of Organization
aggregate Ticket with tenantOwned { … } // stamped + filtered by the claim
aggregate Plan crossTenant { … } // shared reference data, opts out (header-region marker)
aggregate Organization { implements tenantRegistry } // the registry tree (parent + dataKey)
with tenantOwned splices tenantId: string internal, dataKey: string? internal, an onCreate stamp from currentUser.tenantId / currentUser.orgPath, and the filter this.tenantId == currentUser.tenantId. implements tenantRegistry adds parent: Self id? + the managed dataKey materialized path, turning currentUser.orgPath from a claim copy into a real registry lookup — and unlocking the deep/global levels above. crossTenant rides the aggregate header region (aggregate Plan crossTenant { … }), not a prefix.
Every persisted aggregate under a tenancy by system must take a stance (loom.tenancy-stance-unmarked; a subtype inherits its abstract base's stance). The other gates: loom.tenancy-duplicate, loom.tenancy-conflicting-stance, loom.tenancy-inherited-stance-conflict, loom.tenancy-claim-type-mismatch, loom.tenant-owned-claim-type, loom.tenant-owned-without-tenancy, loom.cross-tenant-without-tenancy (warning), loom.tenant-registry-without-tenancy, loom.tenancy-registry-duplicate, loom.tenancy-registry-not-target, loom.tenancy-registry-marked, loom.orgpath-without-tenancy, and loom.unique-missing-tenant-scope (a unique on a tenant-owned aggregate that omits the discriminator is a global unique — usually a bug). A seed row on a tenant-owned aggregate must use seed <dataset> raw { … }, because the domain create path stamps from a principal a first-boot seeder does not have (loom.seed-tenant-owned-needs-raw).
Declaration syntax also appears in Systems & deployables; the full design — registry bootstrap, per-backend filters, the hierarchy — is ../tenancy.md.
sensitive(...) — field tagging
A property carries sensitive(tag, …) (pii/phi/cred/audited by convention — any identifiers; multiple tags allowed) to declare it holds protected data. The load-bearing effect today is redaction in the auto-generated inspect form: the field prints as <redacted> in structural stringification, so it never lands in a log line or stack dump. The value still rides the wire normally — sensitive is a logging tag, not a wire-exclusion or read-mask (that is mask unless).
aggregate Ticket {
subject: string
ssn: string sensitive(pii)
}
// domain/ticket.ts — auto-generated inspect; ssn is redacted, salary is not
get inspect(): string { return "Ticket(" + "id: " + String(this._id) + ", " + "subject: " + "'" + this._subject + "'"
+ ", " + "ssn: " + "<redacted>" + ", " + "salary: " + String(this._salary) + /* … */ ")"; }
# lib/api_elixir/support/ticket.ex
string("Ticket(" <> "id: " <> to_string(record.id) <> ", " <> "subject: " <> "'" <> record.subject <> "'"
<> ", " <> "ssn: " <> "<redacted>" <> # …
)
All five backends emit the same redaction in their auto-
inspect; a user-suppliedinspectderived opts out and is rendered verbatim. Same rule as reservedinspect.
Errors
| Situation | Diagnostic |
|---|---|
auth { … } block without a user block |
loom.auth-without-user |
auth: required deployable but no user block |
loom.auth-no-user-block |
Two auth blocks / two user blocks / two fields same name |
loom.duplicate-auth-block / loom.duplicate-user-block / loom.user-duplicate-field |
Unknown provider: / self-hosted provider without oidc { issuer } / no clientId / unknown claims: target |
loom.auth-unknown-provider / loom.auth-missing-issuer / loom.auth-missing-client-id / loom.auth-unknown-claim-field |
oidc { … } with no audience: — the aud check is skipped unless OIDC_AUDIENCE is set at deploy time (warning) |
loom.auth-oidc-no-audience |
auth: ui on a backend / at an open target / on an unsupported framework |
loom.auth-ui-misplaced / loom.auth-ui-target-open / loom.auth-ui-unsupported-framework |
| Two permissions same name in one subdomain | loom.duplicate-permission |
permissions.X undeclared (or used outside any subdomain) |
loom.unknown-permission |
implies targets itself / an undeclared name |
loom.permission-implies-self / loom.permission-implies-unknown |
Policy function: bad arity / non-bool return / reference cycle |
loom.policy-fn-arity / loom.policy-fn-return-type / loom.policy-fn-cycle |
policy { … } rule problems (unknown target, non-tenant-owned, duplicate, missing hierarchy, write global, write wider than read, dead allow) |
loom.policy-unknown-aggregate, loom.policy-deny-unknown-aggregate, loom.policy-target-not-tenant-owned, loom.policy-duplicate-target, loom.policy-deny-duplicate, loom.policy-level-requires-hierarchy, loom.policy-write-global-invalid, loom.policy-write-wider-than-read, loom.policy-deny-shadows-allow |
mask unless references a row field / an unsupported backend / a projection source |
loom.field-mask-not-current-user / loom.field-mask-unsupported / loom.field-mask-projection-source |
currentUser in an invariant / derived / function body |
loom.currentuser-not-in-request-scope |
A read gate (policy read / projection / workflow header) references a row field |
loom.policy-read-gate-not-current-user / loom.projection-gate-not-current-user / loom.workflow-gate-not-current-user |
Workflow body calls a currentUser-bound find |
loom.workflow-currentuser-find |
Ungated client-reachable surface under denyByDefault / ungated audited history |
loom.default-deny-ungated / loom.audit-history-ungated |
| A gate or a principal stamp on a deployable without auth | loom.guard-principal-without-auth / loom.stamp-principal-without-auth |
A page reads currentUser on a deployable with no session binding |
loom.current-user-needs-auth-ui |
| Tenancy stance / registry / claim-type problems | loom.tenancy-*, loom.tenant-owned-*, loom.tenant-registry-without-tenancy, loom.cross-tenant-without-tenancy, loom.orgpath-without-tenancy, loom.unique-missing-tenant-scope |
See ../auth.md for the per-backend file layouts, the OIDC handshake route table, PKCE + refresh rotation, the bypass list, and the auth: ui frontend gate; ../tenancy.md for the full multi-tenancy design.