Skip to content

Authorization Admin Powers

Status: Adopted (Phase 234, 2026-05-10) Supersedes: the implicit silent-bypass pattern removed by quick task 260510-0vg Owners: authz kernel maintainers

The Phase 82 milestone audit caught that platform_admin carried resource-CRUD permissions in EFFECTIVE_GRANTS — concretely, project.update and project.delete were silently granted via DIRECT_GRANTS.platform_admin in src/lib/authz/policies.ts. That gave any platform admin cross-tenant write authority on user-owned data, with no audit row and no named code path.

Quick task 260510-0vg removed those grants from the role table. That fix is necessary but not sufficient: the next PR could re-add them in good faith (“admins need to delete spam projects”), and we would silently regress to the same posture. Phase 234 codifies the structural convention that prevents that — both as a CI guard (invariant test) and as a documented pattern for “how do admins legitimately moderate / GDPR-delete user data”.

Permissions split into two groups, treated differently:

Platform-level powers — granted directly via DIRECT_GRANTS.platform_admin

Section titled “Platform-level powers — granted directly via DIRECT_GRANTS.platform_admin”
PermissionWhy it’s platform-level
settings.readApp-wide configuration (LLM keys, MCP public base URL). Not per-tenant data.
settings.updateSame.
audit.readAudit table is global by definition.
registry.readPublic agent registry — global product surface (Phase 118).
registry.installSame — but also granted to org_admin and team_admin as of Phase 235; see scope-target rules below.
registry.updateSame.
registry.uninstallSame.

These operate on global app config, not on per-tenant user data. They are safe to grant via the role table.

Resource-CRUD powers — NEVER granted to platform_admin via DIRECT_GRANTS

Section titled “Resource-CRUD powers — NEVER granted to platform_admin via DIRECT_GRANTS”

Every other permission in the catalog (project.update, project.delete, object.delete, agent.update, skill.update, *.share, *.execute, *.manage*, *.promoteScope, *.cancel, *.resume, *.assign, *.approveHitl, *.respondToHitl, …) is resource-CRUD.

When platform admin intervention on user data is legitimate (moderation, GDPR, ownership transfer, incident response, compliance audit), it goes through a named, audited code path via withPlatformAdminBypass(...). There is no silent grant. The audit row is the authorization record.

Defined in src/lib/authz/admin-bypass.ts:

export type AdminBypassReason =
| "moderation"
| "gdpr_request"
| "ownership_transfer"
| "incident_response"
| "compliance_audit";
export async function withPlatformAdminBypass(
actor: ActorContext,
operation: string,
resource: ResourceRef & { ownerId: string },
reason: AdminBypassReason,
extraMetadata?: Record<string, unknown>,
): Promise<{ auditEventId: string }>;

Throw semantics: rejects with AuthzError({ statusCode: 403, reason: "forbidden" }) if actor.platformRole !== "platform_admin". Audit-write failure propagates (via logAuditEventStrict) and aborts the caller’s mutation — there is no “best effort” path.

Audit row shape (one row per successful call):

ColumnValue
actorPrincipalIdactor.principalId
actorPrincipalType"human" (literal — not "HumanUser"; common pitfall)
authSourceactor.authSource
organizationIdactor.organizationId
resourceTyperesource.resourceType (e.g. "project")
resourceIdresource.resourceId
operationcaller-supplied (e.g. "project.delete")
decision"allowed"
policyVersionactor.policyVersion
metadata.bypasstrue
metadata.reasonone of the 5 enum values
metadata.originalOwnerIdresource.ownerId (captured BEFORE the mutation)
metadata.<extras>merged from extraMetadata (caller-supplied keys merge FIRST and are overridden by the canonical keys above — a buggy or hostile caller cannot suppress bypass: true)

Return shape: { auditEventId: string }. Callers that surface the receipt to operators (UI toast, ticket comment) thread it through.

Five values, locked:

ReasonUse case
moderationRemoving spam / abuse content reported via in-app flow
gdpr_requestRight-to-be-forgotten deletion under GDPR Art. 17
ownership_transferReassigning resources after team / org membership changes
incident_responseContaining live security incidents (token leak, account takeover)
compliance_auditRead-then-mutate flows tied to SOC2 / HIPAA evidence collection

Adding a new reason requires editing AdminBypassReason in src/lib/authz/admin-bypass.ts AND landing a CR. Call sites narrow via Extract<AdminBypassReason, ...> to restrict which reasons their UI / API surface accepts — see Section 5.

5. First call site — moderationDeleteProject

Section titled “5. First call site — moderationDeleteProject”

In src/app/projects/admin-actions.ts:

export type ProjectModerationDeleteReason = Extract<
AdminBypassReason,
"gdpr_request" | "incident_response"
>;
const ALLOWED_REASONS: readonly ProjectModerationDeleteReason[] = [
"gdpr_request",
"incident_response",
] as const;
export async function moderationDeleteProject(
projectId: string,
opts: { reason: ProjectModerationDeleteReason; ticketRef: string },
): Promise<{ ok: true; auditEventId?: string }> {
// 1. Runtime allow-list check (mirrors the narrowed type — defends against
// a caller using `as any` to widen reason).
// 2. Runtime ticketRef non-empty / non-whitespace check.
// 3. Resolve session → actor.
// 4. Read project (idempotent: missing → return ok).
// 5. await withPlatformAdminBypass(actor, "project.delete", resource, reason,
// { ticketRef: opts.ticketRef })
// 6. await deleteProject(project.id)
}

Note the layered defenses: the type narrows the caller surface at compile time, the runtime allow-list narrows it at the server-action boundary against as any widening, and the helper itself is the trust boundary that produces the audit row. Error messages do not echo invalid input — log-injection defense, since these surface in Next.js server-action logs.

6. Adding a new bypass call site — recipe

Section titled “6. Adding a new bypass call site — recipe”
  1. Write a named server action. Don’t add the bypass inline next to a mutation; the named action is what auditors / on-call grep for.
  2. Narrow reason via Extract<>. Pick the smallest reason set the surface needs. UI / API consumers should not be able to pass compliance_audit to a moderation endpoint.
  3. Require any correlation IDs (e.g. ticketRef) and validate at runtime. Type-level required props are bypassable via as any; runtime check is the real gate.
  4. Call withPlatformAdminBypass BEFORE the mutation. Audit row is written first; if the audit DB is down, the mutation never runs.
  5. Thread caller-supplied metadata via the optional 5th arg. Only the canonical keys (bypass, reason, originalOwnerId) are guaranteed; any correlation field (ticket ref, incident ID, GDPR request UUID) goes here.
  6. Add a unit test asserting the order: bypass-then-mutate; audit-failure aborts the mutation; type-narrowed reasons fail at compile time; runtime-narrowed reasons fail at runtime when widened via as any.

src/lib/authz/__tests__/platform-admin-grants-invariant.test.ts walks EFFECTIVE_GRANTS.platform_admin and rejects any permission whose suffix matches the resource-CRUD regex /\.(update|delete|share|execute|manage[A-Z]|editOutput|promoteScope|cancel|resume|assign|approveHitl|respondToHitl)$/, except the four-entry allow-list:

  • settings.update — platform config (Section 2)
  • registry.update — platform registry (Section 2)
  • registry.install — platform registry
  • registry.uninstall — platform registry

*.create is intentionally excluded from the regex — Phase 82 deliberately allowed project.create for platform_admin since creation does not destroy existing user data and is needed for tenant-bootstrapping flows. If you find yourself wanting to add *.create to platform_admin for a new resource type, that is fine; the convention only locks down destructive / mutating powers on existing user data.

Failure message references this ADR + Phase 234 + quick task 260510-0vg.

Every bypass writes a row tagged metadata.bypass = true. To list all admin bypass actions:

SELECT id, created_at, actor_principal_id, resource_type, resource_id,
operation, metadata
FROM audit_events
WHERE metadata->>'bypass' = 'true'
ORDER BY created_at DESC;

Filter by reason:

SELECT * FROM audit_events
WHERE metadata->>'bypass' = 'true'
AND metadata->>'reason' = 'gdpr_request';

Filter by ticket ref (when call site threads it):

SELECT * FROM audit_events
WHERE metadata->>'ticketRef' = 'INC-12345';
  • Cross-org READ bypasses (e.g. inline actor.platformRole === "platform_admin" SQL filters in src/lib/derived-store-ownership.ts) — these are reads, not writes, and don’t require audit rows. The full grep result is in .planning/phases/234-authorization-bypass-convention/PHASE-234-MIGRATION-AUDIT.md (Plan 04).
  • Scope guards like src/lib/objects-store.ts write-scope checks — those are fail-closed enforcement points, not bypasses.
  • Phase 82 scope ratchet in src/app/projects/scope-ratchet.ts — the ratchet is a UX rule layered on top of the kernel; it cross-references this ADR but is not subject to it.
  • Phase 80 — kernel + audit table — .planning/phases/080-authorization-kernel/
  • Phase 82 — milestone audit (resource-CRUD removal) — .planning/phases/082-authorization-resources-projects/
  • Quick task 260510-0vg — landed on main; removed project.update / project.delete from DIRECT_GRANTS.platform_admin
  • Phase 234 migration audit.planning/phases/234-authorization-bypass-convention/PHASE-234-MIGRATION-AUDIT.md — full inventory of remaining inline platformRole === "platform_admin" references with their disposition (read-bypass / write-bypass / scope guard); conclusion: zero write-bypasses to migrate.
ConcernLocation
Helpersrc/lib/authz/admin-bypass.ts
Audit insertsrc/lib/authz/audit.ts (logAuditEventStrict)
First call sitesrc/app/projects/admin-actions.ts (moderationDeleteProject)
Invariant testsrc/lib/authz/__tests__/platform-admin-grants-invariant.test.ts
Permission catalogsrc/lib/authz/permissions.ts (with === Platform-level === / === Resource-CRUD === section markers)
Policy tablesrc/lib/authz/policies.ts (header references this ADR)
Scope ratchetsrc/app/projects/scope-ratchet.ts (cross-references this ADR)

9. registry.install scope-target rules (Phase 235)

Section titled “9. registry.install scope-target rules (Phase 235)”

registry.install is the one platform power that ships with target-scope granularity. Three roles can install, but each is bounded to specific target levels:

RoleAllowed target levels
platform_adminany (organization / team / project)
org_admin / org_ownerorganization only
team_adminteam (only the team they admin) — and project (when project owner OR co-owner OR team_admin of project’s owning team)
membernone — never granted

org_admin × team-target403. An org_admin who is NOT also team_admin of the target team must be denied. This is enforced by the product-specific helper assertCanInstallAtTarget inside installRegistryPackageAtScope (packages/agents/src/actions.ts), NOT by the kernel enforceResourceAccess alone — the kernel grants by permission, but install needs target-level constraints the kernel doesn’t model.

Cross-org forgery returns 403 with the same code as deny — no existence leakage. assertTargetBelongsToActiveOrg validates that target.id belongs to the active org BEFORE persistence.

Audit metadata carries targetScope: { level, id } on every install path (allowed + denied + back-compat-wrapper).

Production hand-off (Phase 236): Better Auth’s teamMember table currently has no role column, so production actor.teamRoles is empty. Until Phase 236 wires the loader, all team-target and team-owned-project installs by non-platform_admin actors deny — the picker correctly disables those rows with explainer tooltips.

References:

  • Phase 235 plans: .planning/phases/235-team-project-granularity-for-registry-install/
  • Server action: installRegistryPackageAtScope in packages/agents/src/actions.ts
  • UI: <InstallScopeDialog> in packages/agents/src/components/install-scope-dialog.tsx
  • Server-side picker target builder: buildInstallTargets in packages/agents/src/install-targets.ts