Skip to content

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 catchTag for @app/authz/Forbidden.
  • The service loads first, then decides: it reads CurrentOrg, finds the resource with orgId first, resolves permissions with Permissions.resolve(actor, org.orgId), and calls the policy. The policy is a pure function of (actor, permissions, resource): no Effect, no I/O.
  • policy.ts holds 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 — pure
export const canDelete = (actor: Principal, permissions: ReadonlySet<Permission>, project: Project) =>
permissions.has("projects:delete") || (actor._tag === "User" && project.ownerId === actor.userId)
// projects/projects.ts
const 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 R is CurrentPrincipal | CurrentOrg. A job that forgets the actor does not compile, and the service never trusts an actor handed to it as an argument.
  • CurrentPrincipal and CurrentOrg are provided only at an entry point (per request, per job run, per message) with Effect.provideService, never in an application Layer. A layer-level principal would make every request share one actor. Tests are the exception.
  • Non-request callers provide a System principal 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 provideService placed 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 asks permissions.has("projects:delete"), never isAdmin(actor).
  • One table in authz/permissions.ts maps roles to permissions. The role itself comes from OrgMembers in the foundation feature orgs/, which owns org_members; authz depends on orgs, never the reverse. Each System.job literal 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.ownerId to a User principal’s userId, not projects: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 wire Forbidden (403). One tag means one catchTag per handler, and the client learns what is missing.
  • A resource in another org is always the feature’s NotFound: the repository’s org_id predicate 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:

apps/server/src/authz/errors.ts
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

  • A permission set for an ApiKey principal — trigger: the first machine client; day 1 has only User and System.
  • 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.