Authorization
Role-based access control
Role + PermissionGrant — roles are tenant-scoped; users assume a role per project (UserProjectRole). See Roles & Permissions for the full surface.
Default roles:
| Role | Grants |
|---|---|
| Admin | every action × every resource |
| Publisher | read + create + update + publish on contentType:all |
| Editor | read + create + update on contentType:all |
| Viewer | read on contentType:all |
requirePermission(action, resourceType?, resourceId?) runs at the top of every mutation route and throws 403 forbidden on failure.
Grant evaluation
function check(grants, action, resourceType, resourceId): "allow" | "deny" {
let allowed = false;
for (const g of grants) {
if (g.action !== action && g.action !== "*") continue;
if (g.resourceType !== resourceType && g.resourceType !== "all") continue;
if (g.resourceId !== null && g.resourceId !== resourceId) continue;
if (g.effect === "deny") return "deny"; // deny short-circuits
if (g.effect === "allow") allowed = true;
}
return allowed ? "allow" : "deny"; // default deny
}
Any matching deny wins. Otherwise an allow must exist for the (action × resource). Absent grants default to deny.
Field-level permissions (V3)
resourceType="field" + resourceId=fieldDefinitionId gates per-field read / update:
- No grant → field inherits the entry-level permission (back-compat).
readdenied → the entry editor hides the field; REST/GraphQL omit it.updatedenied → the editor disables the field with a 🔒 indicator; PUT requests touching it return403 field_permission_deniedwithdetails.restricted[].
API key scoping
Three orthogonal scopes:
- Type (
delivery/preview/management) — what kinds of operations the key can perform. siteId— restricts the key to operations against one site. Cross-site requests respond 404 (not 403) to avoid leaking which site slugs exist.environmentId(V2) — restricts the key to one environment. MismatchedX-Krios-Environmenthint returns 403environment_scope_mismatch.
Keys also carry a permissions: string[] array — additional action restrictions on top of the role-based gate.
Audit
Every grant change is recorded in the audit log: role.created, role.updated, role.deleted. Login attempts (success + lockout), password changes, key creation / revocation are also captured.
Enforcement points
| Layer | What it gates |
|---|---|
| Edge middleware | CORS, rate limit per IP, request-id propagation. Doesn't read DB. |
authenticateRequest | Resolves the caller (session or API key). |
resolveProjectBySlug | Project lookup + IP allowlist gate (V3). |
requirePermission | Per-action grant check. |
validateFieldValue | Field-level write validation (including custom:* types). |
assertReadyToPublish | Required-reference gate at publish. |
Each layer fails closed. A request that gets to the route handler has already passed every prior gate.
Errors
Production errors return { error: code, message, details? } with no stack traces, schema fragments, or cross-tenant data. Codes are machine-readable; messages are operator-friendly.
src/lib/auth/http.ts:ApiError is the shared error class — every route uses it via errorResponse(e).