Permissions and Roles
Authorize trusted principals with app-owned ACLs or durable scoped grants.
Authentication proves who controls a credential. Authorization decides whether the resulting trusted principal may perform an action. A valid session, JWT, or API key grants no product permission by itself.
Choose the authorization model
| Model | Use when | Source of truth |
|---|---|---|
| App-owned membership / ACL | Access follows ownership, collaborators, mutable resources, or tenant membership | Product D1 tables and actor-constrained queries |
Maintained PermissionStore | Direct and role-derived grants with global or exact scopes fit | Maintained SQLite/D1/PostgreSQL tables |
Custom Permissions | Hierarchies, deny rules, ABAC, ReBAC, or an external policy engine are required | Application implementation |
These models can coexist. Use product tables for document collaboration and durable grants for cross-product capabilities such as support administration. Do not mirror every mutable product relationship into generic roles without a concrete need.
Subjects, scopes, roles, and grants
import {
PermissionId,
PermissionScope,
PermissionSubject,
RoleId,
} from "@effect-auth/core/Permission";
import { UserId } from "@effect-auth/core/Identifiers";
export const Documents = {
read: PermissionId("documents:read"),
edit: PermissionId("documents:edit"),
manageAccess: PermissionId("documents:manage_access"),
delete: PermissionId("documents:delete"),
};
export const DocumentEditor = RoleId("documents:editor");
export const AcmeTenant = PermissionScope.make("tenant", "acme");
export const Alice = PermissionSubject.user(UserId("user-alice"));
export const PackageBot = PermissionSubject.make("service-account", "pkg-bot");IDs are nominal brands, not validators; define and validate your own naming convention. Subject identity is exact (type, id). Scope identity is exact (type, id?).
| Scope rule | Effect |
|---|---|
Omitted scope or PermissionScope.global | Canonical global grant; satisfies unscoped and every scoped check |
PermissionScope.make("tenant", "acme") | Matches only that exact tenant scope |
| Scoped grant checked without scope | Does not authorize |
{ type: "global", id: "acme" } | Ordinary exact scope, not global |
Scope type "*" | Literal string, not a wildcard |
Global is broad
Never omit a scope because tenant or resource resolution failed. An omitted scope is global, not “unknown tenant.”
Provide a trusted CurrentPrincipal
CurrentPrincipal is authorization context, separate from CurrentSession and CurrentActor. Construct it only after validating a credential and mapping its identity server-side.
import {
CurrentPrincipal,
PermissionSubject,
} from "@effect-auth/core/Permission";
import type { ValidatedSession } from "@effect-auth/core/Sessions";
import { Effect } from "effect";
export const withSessionPrincipal =
(session: ValidatedSession) =>
<A, E, R>(effect: Effect.Effect<A, E, R | CurrentPrincipal>) =>
effect.pipe(
Effect.provideService(
CurrentPrincipal,
CurrentPrincipal.of(
PermissionSubject.user(session.currentSession.userId)
)
)
);JWT subjects must be issuer-qualified and schema-validated. For API keys, explicitly choose whether authority belongs to the key, a service account, or its owner. Never accept PermissionSubject, principal, role, or scope from browser JSON as trusted context.
Use the maintained D1 store
Alchemy v2 provisions one D1 binding for auth and permission tables:
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";
export const Database = Cloudflare.D1.Database("AuthDatabase", {
migrationsDir: "./migrations",
});
export const AuthWorker = Cloudflare.Worker("AuthWorker", {
main: "./src/workers/auth-backend.ts",
url: false,
compatibility: { date: "2026-03-17", flags: ["nodejs_compat"] },
env: {
DB: Database,
AUTH_SECRET: Config.redacted("AUTH_SECRET"),
},
});Use layer from @effect-auth/core/DrizzleD1PermissionStore with the shared
DrizzleD1Database service. For an application-owned compatible database, use
layerFromDatabase(database) or makeDrizzleD1PermissionStore(database). SQLite and
PostgreSQL expose the matching focused DrizzleSqlitePermissionStore and
DrizzlePostgresPermissionStore entrypoints. Provide the resulting
PermissionStore to PermissionAdministrationLive and
PermissionsFromStoreLive; do not use memory storage in production.
For a fresh D1 database, generate only the required modules:
curl "https://effect-auth.itsbroly.com/api/generator/v1/artifacts/migration-sql?database=sqlite&features=password,permissions&layout=module"Select permissions together with every authentication feature the new database needs; this example also selects password auth. An existing effect-auth migration ledger gains permission grants/mappings in 0024_auth_permission and definitions in 0025_auth_permission_definition. Never apply a fresh generated baseline as an upgrade. See Cloudflare D1.
Define roles and grant access
PermissionAdministration validates active definitions and compatible scope types before grant/assignment writes. Revocation and mapping removal remain available for cleanup after definitions become inactive. This one-time bootstrap creates a tenant-scoped editor role and grants it to Alice:
import {
PermissionAdministration,
PermissionScope,
} from "@effect-auth/core/Permission";
import { Effect } from "effect";
import { Alice, DocumentEditor, Documents } from "./authorization";
export const bootstrapDocumentEditor = Effect.gen(function* () {
const permissions = yield* PermissionAdministration;
yield* permissions.createPermissionDefinition({
id: Documents.edit,
description: "Edit tenant documents",
scopeType: "tenant",
});
yield* permissions.createRoleDefinition({
id: DocumentEditor,
description: "Document editor",
});
yield* permissions.assignRolePermission({
role: DocumentEditor,
permission: Documents.edit,
scopeType: "tenant",
});
yield* permissions.grantRole({
subject: Alice,
role: DocumentEditor,
scope: PermissionScope.make("tenant", "acme"),
metadata: { source: "provisioning" },
});
});Creation is not idempotent; provisioning code should get before create or treat already_exists explicitly. Granting the same subject/role/scope tuple upserts expiry and metadata and clears revocation. Revoking a missing grant is a successful no-op.
Direct permission grants and role-derived permissions are additive. Revoking a direct grant does not deny access still supplied by a role. There are no deny grants or role hierarchy.
Enforce permissions
Policy.requirePermission and requireRole read CurrentPrincipal and Permissions. A false check becomes AuthorizationError; PermissionCheckError remains an infrastructure failure and should normally become sanitized 500, not denial.
import * as Policy from "@effect-auth/core/Policy";
import type { ValidatedSession } from "@effect-auth/core/Sessions";
import { Effect } from "effect";
import { Documents } from "./authorization";
import { withSessionPrincipal } from "./with-session-principal";
export const editDocument = (input: {
session: ValidatedSession;
tenantId: string;
documentId: string;
title: string;
}) =>
Effect.gen(function* () {
yield* Policy.requirePermission(Documents.edit, {
scope: { type: "tenant", id: input.tenantId },
message: "Document edit denied",
});
return yield* documents.updateInTenant({
tenantId: input.tenantId,
documentId: input.documentId,
title: input.title,
});
}).pipe(withSessionPrincipal(input.session));updateInTenant is app-owned and must constrain the mutation by tenant/document in one database statement. The permission guard decides whether the request may attempt the operation; the write still enforces resource invariants transactionally. Map inaccessible membership to deliberate 404 when disclosure policy requires it.
App-owned document permissions
The maintained split Cloudflare example uses product-owned D1 membership, not PermissionStore. This is often the better model for collaborative resources:
type DocumentRole = "owner" | "editor" | "viewer";
type DocumentPermission =
| "documents:read"
| "documents:edit"
| "documents:manage_access"
| "documents:delete";
const permissionsByRole = {
owner: new Set<DocumentPermission>([
"documents:read",
"documents:edit",
"documents:manage_access",
"documents:delete",
]),
editor: new Set<DocumentPermission>(["documents:read", "documents:edit"]),
viewer: new Set<DocumentPermission>(["documents:read"]),
} satisfies Record<DocumentRole, ReadonlySet<DocumentPermission>>;
export const canDocument = (
role: DocumentRole,
permission: DocumentPermission
) => permissionsByRole[role].has(permission);For a mutation, avoid a role check followed by an unconstrained write:
UPDATE document
SET title = ?
WHERE id = ?
AND EXISTS (
SELECT 1 FROM document_member
WHERE document_id = document.id
AND user_id = ?
AND role IN ('owner', 'editor')
)
RETURNING *;UI role checks are presentation only. The Worker must repeat authorization for every read and write.
Custom permission backends
Implement Permissions directly when exact grants are insufficient:
import {
PermissionCheckError,
Permissions,
} from "@effect-auth/core/Permission";
import { Effect, Layer } from "effect";
export const AppPermissionsLive = Layer.succeed(
Permissions,
Permissions.of({
hasPermission: (input) =>
loadMembership(input.subject).pipe(
Effect.map((membership) => membershipAllows(membership, input)),
Effect.mapError(
(cause) =>
new PermissionCheckError({
operation: "has_permission",
message: "Permission backend unavailable",
cause,
})
)
),
hasRole: (input) =>
loadMembership(input.subject).pipe(
Effect.map((membership) => membershipHasRole(membership, input)),
Effect.mapError(
(cause) =>
new PermissionCheckError({
operation: "has_role",
message: "Permission backend unavailable",
cause,
})
)
),
})
);This is the extension point for tenant hierarchy, deny precedence, ownership, external policy engines, ABAC, and ReBAC. Keep deterministic product policy separate from provider transport and cache failures.
Definitions and CAS
Definitions are administrative catalog records. Updates, disable/enable, and soft delete require expectedUpdatedAt; stale writers fail with concurrent_modification. Deleted IDs remain reserved.
Definitions are not enforcement switches
hasPermission and hasRole evaluate active grants and role mappings, not definition state. Disabling or deleting a definition does not revoke existing grants. Revoke grants/mappings explicitly before or during retirement according to app policy.
| Definition behavior | Semantics |
|---|---|
description: undefined in update | Preserve current value |
description: null | Clear value |
| Permission list | UTF-8 order, exclusive after, limit clamped to 1–100 |
| Soft delete | Hidden by default; ID cannot be recreated |
Low-level PermissionStore write | Bypasses definition/activity/scope validation |
Permission-definition HTTP API
AdminPermissionDefinitionHttpApiLive is standalone and not mounted by CoreAuthHttpApiLive. It exposes create/get/list/update/disable/enable/delete under /auth/admin/permission-definitions and requires:
PermissionAdministration,Sessions, andSessionCookie;- mandatory app-owned
AdminPermissionDefinitionAuthorizationwith no allow-all default; - HTTP server services and strict origin policy.
It administers permission definitions only. Role definitions, grants, role mappings, and permission checks have no built-in HTTP admin API.
import { createAdminPermissionDefinitionClient } from "@effect-auth/core/Client";
const client = createAdminPermissionDefinitionClient();
const created = await client.definitions.create({
id: "documents:export",
description: "Export tenant documents",
scopeType: "tenant",
});
await client.definitions.update({
id: created.id,
expectedUpdatedAt: created.updatedAt,
description: "Export tenant documents as CSV or JSON",
});Authorization runs after session validation and before lookup/mutation. Definition mutations already emit best-effort audit events; do not wrap them with another identical decorator.
Audit administration
PermissionAdministrationAuditLive decorates successful definition, grant, revoke, and role-mapping mutations. Audit sink failures are ignored and events omit descriptions/metadata. Custom subject and scope IDs are omitted unless subjectReference / scopeReference return a safe projection.
Use a transactional outbox when audit delivery is a compliance requirement. A successful no-op revoke/removal can still emit an event.
Semantics and limits
| Behavior | Important consequence |
|---|---|
| Grant expiry | Active only while expiresAt > now; equality is expired |
activity.at list filter | Evaluates activity at a timestamp; not historical reconstruction |
| Grant upsert | No CAS or append-only history |
Role mapping without scopeType | Applies to every requested scope type |
Mapping with scopeType | Exact type match; role grant supplies scope ID |
| Grant metadata | Persisted but ignored by authorization |
| Memory store | Test/dev only; process-local |
The reference model has no deny grants, nested roles, membership hierarchy, wildcard permission strings, ownership policy, approvals, or Step-up. Compose those application concerns explicitly.
Test the boundary
- Reject missing/invalid credentials before selecting
CurrentPrincipal. - Test subject-type isolation, exact scope IDs, global grants, expiry equality, revocation, and regrant.
- Verify direct and role-derived paths independently and together.
- Race definition CAS updates and assert one winner.
- Disable/delete definitions and verify your explicit grant-retirement workflow.
- Race membership changes against actor-constrained product writes.
- Map denial to sanitized
403or deliberate404; map backend failures to500. - Exclude cookies, credentials, raw subject/scope IDs, metadata, and request bodies from telemetry unless safely projected.
Continue with App-owned Guards, Protect an API Endpoint, and Set Up a Development Admin.