---
title: "Permissions and Roles"
url: "https://effect-auth.itsbroly.com/authorization/permissions/"
description: "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.

```mermaid title="Authentication selects a trusted principal before permission policy runs"
flowchart LR
  R[Request] --> A[Validate credential]
  A -->|Invalid| U[401 unauthenticated]
  A --> S[PermissionSubject]
  S --> C[CurrentPrincipal]
  C --> P[Policy.requirePermission]
  P --> E[Permissions service]
  E --> D[D1 or app policy]
  P -->|Denied| F[403 or deliberate 404]
  P -->|Allowed| W[Actor-constrained write]
```

## Choose the authorization model [#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 [#subjects-scopes-roles-and-grants]

```mermaid title="A subject can receive a permission directly or through a scoped role"
flowchart TB
  S[Permission subject] --> PG[Direct permission grant]
  PG --> P[Permission]
  S --> RG[Role grant]
  RG --> R[Role]
  R --> RP[Role-permission mapping]
  RP --> P
  SC[Global or exact scope] --> PG
  SC --> RG
```

```ts title="src/server/authorization.ts"
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                                    |

<CalloutContainer type="error">
  <CalloutTitle>
    Global is broad
  </CalloutTitle>

  <CalloutDescription>
    Never omit a scope because tenant or resource resolution failed. An omitted scope is global, not “unknown tenant.”
  </CalloutDescription>
</CalloutContainer>

## Provide a trusted CurrentPrincipal [#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.

```ts title="src/server/with-session-principal.ts"
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 [#use-the-maintained-d1-store]

Alchemy v2 provisions one D1 binding for auth and permission tables:

```ts title="alchemy.run.ts"
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:

```sh
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](/storage/cloudflare-d1/).

## Define roles and grant access [#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:

```ts title="src/server/bootstrap-document-role.ts"
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 [#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.

```ts title="src/server/edit-document.ts"
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 [#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:

```mermaid title="App-owned document membership resolves a product role before a conditional write"
flowchart LR
  S[Validated session userId] --> Q[D1 document and member lookup]
  Q -->|No membership| N[Safe 404]
  Q --> R[Owner, editor, or viewer]
  R --> M[App permission matrix]
  M -->|Denied| F[403 forbidden]
  M -->|Allowed| W[Actor-constrained mutation]
```

```ts title="src/server/documents/permissions.ts"
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:

```sql
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 [#custom-permission-backends]

Implement `Permissions` directly when exact grants are insufficient:

```ts title="src/server/app-permissions.live.ts"
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-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.

:::caution[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 [#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`, and `SessionCookie`;
* mandatory app-owned `AdminPermissionDefinitionAuthorization` with 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.

```ts title="src/auth/permission-definitions.ts"
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 [#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 [#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 [#test-the-boundary]

1. Reject missing/invalid credentials before selecting `CurrentPrincipal`.
2. Test subject-type isolation, exact scope IDs, global grants, expiry equality, revocation, and regrant.
3. Verify direct and role-derived paths independently and together.
4. Race definition CAS updates and assert one winner.
5. Disable/delete definitions and verify your explicit grant-retirement workflow.
6. Race membership changes against actor-constrained product writes.
7. Map denial to sanitized `403` or deliberate `404`; map backend failures to `500`.
8. Exclude cookies, credentials, raw subject/scope IDs, metadata, and request bodies from telemetry unless safely projected.

Continue with [App-owned Guards](/guides/app-owned-guards/), [Protect an API Endpoint](/recipes/protect-api-endpoint/), and [Set Up a Development Admin](/recipes/set-up-development-admin/).

