Authorization
Authentication says who is calling; authorization says what they may do. This page answers
where the decision lives, how the actor reaches it, how roles become permissions, and
what a denial looks like. Running example: Budi, a member of org Acme, calls
DELETE /api/v1/projects/42; a nightly job also deletes stale projects.
Enforce in the service; decide in a pure policy.ts
Section titled “Enforce in the service; decide in a pure policy.ts”Impact: HIGH one check covers HTTP, RPC, MCP and jobs alike
- Every entry point (HTTP and RPC handlers, MCP tools, jobs) calls the feature service, and none
of them checks. A check in front of the service protects only the callers that pass through
that front. The handler adds one
catchTagfor@app/authz/Forbidden. - The service loads first, then decides: it reads
CurrentOrg, finds the resource withorgIdfirst, resolves permissions withPermissions.resolve(actor, org.orgId), and calls the policy. The policy is a pure function of(actor, permissions, resource): no Effect, no I/O. policy.tsholds policies, rules that can change deliberately. Invariants are enforced where the value is made (a constructor or schema, plus a database constraint), and preferences are configuration.- Accepted cost: nothing forces a method to call its policy. Every method that reads or changes tenant data has a deny-case test, and a list operation keeps its query predicate next to the policy function.
❌ Incorrect — the handler checks, so the job and any other entry point skip it:
deleteProject: ({ params }) => Effect.gen(function* () { const actor = yield* CurrentPrincipal if (!isAdmin(actor)) return yield* new Http.Forbidden({ permission: "projects:delete" }) yield* projects.remove(params.id)}),✅ Correct — the service enforces; policy.ts decides:
// projects/policy.ts — pureexport const canDelete = (actor: Principal, permissions: ReadonlySet<Permission>, project: Project) => permissions.has("projects:delete") || (actor._tag === "User" && project.ownerId === actor.userId)
// projects/projects.tsconst remove = Effect.fn("Projects.remove")(function* (id: ProjectId) { const actor = yield* CurrentPrincipal const org = yield* CurrentOrg const project = yield* Effect.fromOption( yield* repo.findById(org.orgId, id), () => new ProjectNotFound({ projectId: id })) const permissions = yield* Permissions.resolve(actor, org.orgId) if (!Policy.canDelete(actor, permissions, project)) return yield* new Authz.Forbidden({ permission: "projects:delete" }) yield* repo.delete(org.orgId, id)})Source: notes/08-application-surfaces/authorization.md · Decision 1, amended
Require the actor in R; provide it only at an entry point
Section titled “Require the actor in R; provide it only at an entry point”Impact: HIGH the compiler asks every caller who is acting
- A method’s
RisCurrentPrincipal | CurrentOrg. A job that forgets the actor does not compile, and the service never trusts an actor handed to it as an argument. CurrentPrincipalandCurrentOrgare provided only at an entry point (per request, per job run, per message) withEffect.provideService, never in an applicationLayer. A layer-level principal would make every request share one actor. Tests are the exception.- Non-request callers provide a
Systemprincipal named for the job, a closed literal. It goes through the same policy as a user, and every system action is greppable and attributed. A job provides both per org iteration. - Accepted cost: the requirement is invisible at the call site without hovering, and a
provideServiceplaced too high is a silent escalation.
❌ Incorrect — an explicit actor argument, and a principal provided for the whole app:
readonly remove: (actor: Principal, id: ProjectId) => Effect.Effect<void, …> // trusts any caller
const AppLayer = Layer.succeed(CurrentPrincipal, adminPrincipal) // everyone is admin✅ Correct — the requirement is in the signature; the job names itself:
readonly remove: (id: ProjectId) => Effect.Effect<void, Authz.Forbidden | ProjectNotFound, CurrentPrincipal | CurrentOrg>
projects.remove(id).pipe( Effect.provideService( CurrentPrincipal, Principal.cases.System.make({ job: "projects/stale-cleanup" }), ),)Source: notes/08-application-surfaces/authorization.md · Decision 2, amended
Check permissions, not roles; map roles in one table
Section titled “Check permissions, not roles; map roles in one table”Impact: MEDIUM a restricted key or bot is a smaller set, not a second system
- Name permissions
<feature>:<action>. A policy askspermissions.has("projects:delete"), neverisAdmin(actor). - One table in
authz/permissions.tsmaps roles to permissions. The role itself comes fromOrgMembersin the foundation featureorgs/, which ownsorg_members;authzdepends onorgs, never the reverse. EachSystem.jobliteral maps to a permission set in the same table. - Ownership is never a permission. “The author may edit their own project” is a policy rule
comparing
project.ownerIdto aUserprincipal’suserId, notprojects:write-own. - Accepted cost: the table can drift from intent, and every feature that adds a permission edits the shared file.
❌ Incorrect — role checks and ownership-as-permission, scattered across call sites:
if (actor.role === "admin" || permissions.has("projects:write-own")) { … }✅ Correct — one closed set of permissions and one role table:
export const Permission = Schema.Literals([ "projects:read", "projects:write", "projects:delete", "labeling:rules:write",])
const table: Record<Role, ReadonlyArray<Permission>> = { owner: [/* … */], admin: [/* … */], member: ["projects:read", "projects:write"],}export const forRole = (role: Role): ReadonlySet<Permission> => new Set(table[role])Source: notes/08-application-surfaces/authorization.md · Decision 3, amended
Can’t see it → NotFound; can see it but can’t act → Forbidden
Section titled “Can’t see it → NotFound; can see it but can’t act → Forbidden”Impact: HIGH existence never leaks across tenants
- One shared domain error,
@app/authz/Forbidden { permission }, maps to the wireForbidden(403). One tag means onecatchTagper handler, and the client learns what is missing. - A resource in another org is always the feature’s NotFound: the repository’s
org_idpredicate returns no row, so the policy never sees it. A private resource the actor is not a member of is also NotFound. - Never collapse a denial into 401. That tells the client to log in again when it should ask an admin.
- Accepted cost: “can see” vs “can act” is a per-resource judgement, and a wrong 403 on another tenant’s id confirms the id exists.
| Situation | Error | Status |
|---|---|---|
| Project 42 belongs to another org | ProjectNotFound |
404 |
| A private resource the actor is not a member of | the feature’s NotFound |
404 |
The actor sees project 42 but lacks projects:delete |
Authz.Forbidden |
403 |
❌ Incorrect — one forbidden class per feature, and a 403 for another tenant’s row:
export class ProjectsForbiddenError extends Schema.TaggedError<ProjectsForbiddenError>()(…) {}if (project.orgId !== org.orgId) return yield* new ProjectsForbiddenError() // confirms it exists✅ Correct — one shared Forbidden in authz/, with no HTTP:
export class Forbidden extends Schema.TaggedError<Forbidden>()( "@app/authz/Forbidden", { permission: Permission },) { override get message(): string { return `Missing permission ${this.permission}` }}Source: notes/08-application-surfaces/authorization.md · Decision 4, amended
Deferred
Section titled “Deferred”- A permission set for an
ApiKeyprincipal — trigger: the first machine client; day 1 has onlyUserandSystem. - A declarative scope gate in front of the service check — trigger: a second audience, an API consumer that is not our web client and is not deployed from this repo.
- Where a membership write is authorized — trigger: the first endpoint that writes membership.