PermDock
Security

Threat model

The assets PermDock protects, the trust boundaries it sits on, the invariants every implementation must hold, and a table of threats with their mitigations.

PermDock is an authorization library embedded in application code. It does not authenticate users, issue tokens or store data. Its threat model is therefore about one question: can a decision be wrong in the attacker's favour, or can the machinery around decisions leak or be bypassed. Everything below is a design constraint and a test-suite checklist.

Assets

  • Decision correctness. A granted outcome for a subject that should be denied is the primary failure.
  • The policy. Roles, grants and conditions are server-only. They describe the shape of the business and often include tenant and ownership rules.
  • Snapshots. Serialised grants sent to clients. They reveal what a subject may do.
  • Approval tokens. A Decision.token that authorises a specific action once a human approves it.
  • Audit trail. on('decision') events and OTel spans. Tampering or gaps hide abuse.
  • Generated artefacts. RLS policies, OpenAPI documents and catalogs are consumed by other systems; a wrong artefact enforces the wrong thing elsewhere.

Trust boundaries

tool args, claimed identity refresh, resource data snapshot where / RLS decision events, approval records token, CAEP events SCIM 2.0 (direct or via the Cloud relay) memberships LLM / agent (untrusted) Agent adapter: mcp, ai-sdk, claude-agent, a2a, webmcp Browser / RN client (untrusted) Decision endpoint (AuthZEN) PermDock core: policy + createPermDock Server code (trusted) Database ApprovalStore / DecisionSink (in-memory, your DB, or PermDock Cloud) Identity provider scimHandler -> DirectoryStore (yours)
  • Model to adapter. Everything a model produces (tool arguments, claimed user ids, "the user approved") is untrusted input.
  • Client to server. Resource data and refresh requests from the browser are untrusted; the session or token that identifies the caller is trusted only after the application's real authentication has validated it.
  • Server to core. Every row is validated unless the caller marks it trusted: true because the application loaded it from its own database (validate: 'boundary').
  • Core to database. Generated where clauses and RLS are trusted by the database; they must be correct and never widen access.
  • IdP to adapter. Tokens and CAEP events are trusted only after signature and issuer verification.
  • IdP or Cloud relay to scimHandler. Provisioning writes are accepted only with the tenant's static bearer or an RFC 7523 bearer verified against the Cloud JWKS; the tenant is the credential's, never the body's. What lands in the store becomes memberships bounded by declared assignable roles.
  • Core to store and sink. The approval store and decision sink are trusted server components (the same standing as the database). They never influence a decision; a resume re-runs decide and recomputes the token before trusting a stored approved.

Invariants

  1. Fail closed. No matching grant, an unknown role, a validation failure, a thrown condition, a rejected sub-policy, a network error to a PDP: all are denied. can never throws and never returns true by accident. @ai-sdk/policy-opa fails open on unrecognised decisions (vercel/ai#19978); PermDock's adapters never emit not-applicable.
  2. Deny overrides allow. A deny grant in any applicable role wins over every allow. Allows OR together; denies AND against them.
  3. Unknown reference is a type error. Permissions are typed references; a grant for a permission outside permissions, or a check with the wrong arity, does not compile. At runtime, a key that findPermission cannot resolve is denied.
  4. Prototype-safe paths. Condition field paths and subject.* references are resolved with own-property lookups; __proto__, constructor and prototype segments are rejected at definition time and never traversed.
  5. No eval. Portable conditions are data interpreted by a fixed evaluator. Closures are user code, branded non-portable, and never reconstructed from a string. Nothing in a snapshot, catalog or RLS import is executed.
  6. Boundary validation. Data is untrusted by default and validated against the resource's Standard Schema before a rule runs. A schema that cannot validate synchronously raises PermDockValidationError rather than skipping validation.
  7. Never trust model-supplied subjects. principal, actor, delegation, memberships and the active tenant come from the adapter's verified authInfo, session, signature, or a MembershipSource / RoleSource the server owns, never from tool arguments, prompt content, request bodies or unsigned headers. A tool argument named userId or orgId is data about the resource, not the subject. There is never a default tenant: an absent or unmatched tenant means tenant-scoped grants do not apply (tenancy).
  8. service_role is never emitted. RLS generation never writes policies for bypass roles and never suggests them. The one exception is opt-in: when rls.trustedReaders lists service_role, the execute grant on permdock_trusted_role_permissions names it, and the generator still refuses it on every other line.
  9. The decision endpoint sits behind real authentication. The AuthZEN endpoint answers for the authenticated caller only. A shared "public secret" in client code (the Kilpi endpoint pattern) is obfuscation, not authentication, and is not supported as a configuration.
  10. Server-only imports never reach client entries. definePolicy, createPermDock and adapters live in server entry points; permdock/react exports only snapshot consumers. Bundle tests enforce it.
  11. Snapshots reveal grants, scoped snapshots limit it. A snapshot tells the client what its subject may do, including conditions. It never contains other subjects' grants, role definitions beyond names, closures (they serialise as { portable: false }) or opaque SQL. snapshot({ include: [...] }) limits a route to the features it renders.
  12. Approval tokens are replay-safe and single-use. Decision.token is a hash of the permission key, the resource id, the principal's id, tenant and issuer, the actor's id and kind, and the matched grant's condition fingerprint. An approval for one call cannot be replayed on another, and ApprovalStore.consume spends it once. See approvals.
  13. Quotas are server-side. limit grants are enforced by a LimitStore the server owns, never by the client snapshot. can never consumes. A LimitStore failure denies with limit-unavailable.
  14. Immutable, request-scoped instances. createPermDock returns a frozen object per request; nothing mutates shared state between requests. The subject is a frozen copy: the user object, roles and context the caller passed stay writable and later changes to them never reach the instance.
  15. No network call to decide. can, decide, assert, filter, where, simulate and snapshot run in-process; the pdp provider is the only opt-in exception. Approval stores, decision sinks and snapshot sources are interfaces with in-process defaults; PermDock Cloud is one implementation and is never required. The Cloud may relay directory provisioning into a DirectoryStore the application owns; it still implements neither MembershipSource nor RoleSource, and in that bring-your-own mode holds no authoritative copy. In Cloud-native directory mode the Cloud is the directory of record, and its facts reach decide only as claims on a token the application verifies locally, never through a request-time call.

Threat table

ThreatMitigationWhere documented
Model claims to be another user in tool argumentsSubject only from verified authInfo; arguments are resource dataMCP adapter, invariant 7
Prompt injection asks the agent to call a tool it lacksTool list filtered per caller; handler re-checks; denied with alternativesMCP authorization, OWASP mapping
MCP tool registered on the raw server, outside protectServer (an OpenAPI-to-MCP bridge wiring its own tools)Only tools registered through the guarded server carry a permission; a tool with none stays listed and callable, so bridges register on the server protectServer returns. mcp-oauth asserts per-token tool listsMCP adapter
A filtered tools/list served from a shared cache to another callerFiltered list results of 2026-07-28 requests are marked cacheScope: 'private' (2025-era results have no cache fields); the list is rebuilt per request from authInfoMCP adapter
A PEP spoofs a subject in an AuthZEN body (a browser or service claims to evaluate for another user)The body subject, actor and delegation are ignored unless trustedPep accepts the authenticated caller; otherwise the caller itself is evaluated. tests/runtimes asserts both on Node, Bun, Deno and workerdAuthZEN
Malformed or oversized tool argumentsBoundary validation against the resource schemaValidation
Agent exceeds the user it acts forDecision = principal grants ∩ delegation; chain attenuation is enforced by the token layer, and core evaluates only the final delegationDelegation
An agent inherits the user's full authority because its adapter found no scopes (an in-process agent, a signed Web Bot Auth request with no token)An actor with no delegation, or one with no scopes, authorizationDetails or access, is denied every check with no-delegation, and its snapshot carries empty scopes; agent adapters have no default delegation. A delegated entry with an identifier covers only that resource id. A policy delegations entry lifts this only for the actor kind (or id) and permission keys it names, for principals holding from, and is a ceiling the principal's grants still boundDelegation, invariant 1
A token or a policy delegation widens the other (a session token lists a scope the policy never delegated to that agent kind, or the policy lists a permission the token's consent did not)Both must cover: outside the policy ceiling a check is not-delegated; inside it coveredByDelegation still runs on the token. Removing the entry or validUntil is the revocation; the RevocationFeed ends sessions and never edits a delegationDelegation
A support admin acting as a user writes through a read-only support session, or a support or impersonation token is treated as the user alone (a better-supabase act.kind token)act.kind maps to actor kind support or impersonation with no delegation, so nothing is reached until a policy delegation names that kind; read_only: true narrows the ceiling to read-only permissions (not-delegated otherwise) on the server and in the snapshot; an unknown kind or a support level without session_id is the anonymous subject
Replayed approval on different argumentstoken hash bound to permission, resource id, principal id, tenant and issuer, actor; re-checked on resumeApprovals
Replayed approval on the same callApprovalStore.consume makes an approval single-use; a replay is approval-consumedapprovals
ABAC condition reads a user-editable claim (a region or clearance from Supabase user_metadata), or request context the database never seesClaim refs compile into RLS only as principal.claims.* from the verified token; the docs name server-set claims (the access token hook, app_metadata) as the only source and subjectFromSupabase never lifts user_metadata into roles; a context.* ref is refused by permdock rls generate and listed by doctor PD027, so it cannot silently fall out of the database policyRLS, Supabase
Row changed between approval and resume (a small refund approved, then the amount raised before the retry)With approval: { staleOn: 'resource-change' } the token hashes the resource's version field, so the resume recomputes a different token and denies with stale-approval; without staleOn, the approval binds the row id only, which the approvals page statesApprovals
Admin of another tenant approves or lists a requestEvery approval with a tenant needs an approver membership there; the inbox lists only the approver's tenantsApprovals adapter
One person satisfies a two-person rule by approving twice, or a stale ask is approved days laterapproval.quorum counts distinct principals and a repeat is approver-repeated; approval.ttl shortens expiresAt and the grant can never extend the store's windowApprovals
Client edits its snapshot to grant itself accessSnapshot is advisory for UI only; every mutation is re-checked server-sideSnapshots
Client calls the decision endpoint for another subjectEndpoint ignores request-body subject; uses the authenticated session. AuthZEN honours a body subject, actor or delegation only for a PEP the trustedPep allow-list accepts (off by default)AuthZEN, invariant 9
Forged row in a decision-endpoint body or an A2A taskresource.properties validated at the boundary; a throwing AuthZEN loader denies instead of evaluating the request's { id } stub; A2A never uses the task body as the row and requires a data loader for instance skillsAuthZEN, A2A
Tool runs after a denial because a boolean hook cannot denyAI SDK needsApproval throws on denial; the post-approval re-check denies without a recorded approvalAI SDK
Protected Nest handler reached over GraphQL, WebSocket or RPCGuard denies a protected non-HTTP route unless request maps the contextNest
Internal error details leak through tRPC error dataerrorFormatter merges only PermDock Problem DetailstRPC
Snapshot leaks tenant rules to the browserScoped snapshots; closures and opaque conditions never serialisedSnapshots, invariant 11
Policy imported into a client bundleServer-only entry points; bundle testsinvariant 10
Prototype pollution through condition pathsOwn-property lookups; forbidden segments rejectedinvariant 4
Code execution through a snapshot or importNo eval; conditions are datainvariant 5
Deny rule silently ignoredDeny overrides allow, tested in the policy matrix; a deny whose closure throws or whose condition is opaque or unanswerable denies instead of being skipped, and not cannot turn an opaque node into a matchPolicies, invariant 2
Async schema skips validationSync requirement; PermDockValidationErrorStandard Schema
Remote PDP unreachableDenied, never grantedpdp provider, invariant 1
A cached remote PDP answer reused for another tenant, agent or rowThe cache key holds the mapped request (subject, resource properties, action), the permission key, the principal's issuer, the tenant and the actor id and kind; the cache is bounded and drops expired entriespdp provider
A delegated permission read as granted from a pending Promise (if (permdock.can(...)) on an async instance)The request-scoped instance stays synchronous and denies delegated permissions with pdp-unavailable; only protect awaits the provider when the adapter has pdppdp provider, invariant 1
Stale snapshot after revocationCAEP receiver invalidates by subjectShared Signals
A demoted member keeps using a sensitive permission until their token expiresPermissions in the policy's fresh list deny with stale-credentials when token memberships are behind the source's authorization version (authz_ver, bumped by generated triggers on every membership change) or either version is missing; the snapshot drops those allows too; jwt_expiry = 900 bounds the restSupabase token hook
A user writes a membership column (user_id, customer_id, role, expires_at) on a row they may edit, such as their own contact record, and the hook and helpers read it as a membershipMembership sources name their deciding columns, including the id and value columns of a roles or profile table they read through; doctor PD028 warns while the migrations leave any of them insertable or updatable by anon or authenticated, and models a column-level revoke under a table-level grant as still writable, as Postgres doesSupabase token hook, doctor
A user sets their own attribute (region, clearance) through user_metadata or a writable profile column, and an attribute condition grants rowsThe hook's attrs come only from allow-listed server-owned columns and app_metadata; generate refuses user_metadata; the migration refuses to install while anon or authenticated can insert or update a listed column; incoming attrs are replaced; doctor PD028Supabase token hook, RLS
A token too large for the cookie silently drops membershipsThe generated hook keeps the active tenant's memberships first and sets memberships_truncated; claimsFirst then reads every membership from the sourceSupabase token hook
The application (or a client) edits a membership the identity provider provisioned, and the next sync reverts or divergesmanagedBy: 'idp' on SCIM memberships; decideRoleChange refuses with externally-managed; the generated permdock_protect_managed trigger raises 42501 for anon and authenticated writesSupabase token hook, SCIM
Generated RLS widens accessNever emits service_role; deny becomes RESTRICTIVE; verify parity testsPostgres RLS
A share on a parent folder leaks a restricted subfolder, or a deep or cyclic tree exhausts the evaluatorA restricted row stops the walk in can and in the closure triggers alike; walks read at most depth ancestors (default 16, at most 32); a cycle denies with relation-depth and the triggers refuse a write that creates one; rls verify --tree checks parity on a tree with restricted branchesRelationships, RLS closure table
A graph read fails and a relation deny is skipped, or a pending lookup reads as grantedAny RelationSource failure fails the grant; on a deny it denies the decision; a Promise is never awaited on the decision path and is relation-unavailable until loadRelations loads itRelationships, invariant 1
The closure table exposes another tenant's folder treeRLS is enabled on permdock_closure and authenticated reads only rows whose ancestor its own permitted_<resource>_ids returns; snapshots carry no graph factsRLS closure table
A forged snapshot or a newer policy document carries a grantee kind this build does not knowAn allow with an unknown kind matches nobody; a deny with one applies to everyone; a hosted grant naming one is dropped as unknown-grantee, and an approver by naming one is invalidPolicies
An access review trusts a partial holder listwhoCan sets complete: false whenever a grantee cannot be enumerated and confirms every listed holder with a full decision; it never grantsRelationships
SQL function twin drifts from the live helpersqlFunction twins are proven by rls verify --db; import fingerprints the deparsed AST; opaque SQL never runs app-sideconditions, RLS
RLS import executes attacker SQLImport parses to AST; opaque SQL is stored, never run app-sidePostgres RLS
A client reads a field-limited column straight from the table, or through a view that runs as its ownerfields redact in the application; rls generate --fields views adds a security_invoker <table>_visible whose restricted columns are case when <permitted> then col end over the row policy's own helper calls, and --revoke-columns leaves anon and authenticated only the unrestricted columns of the table. The owner-rights <table>_visible_fields companion masks every column with the whole field decision, denies included, keeps only rows with a readable column and is a security_barrier view, so reading it directly reveals nothing pick hides; with FORCE ROW LEVEL SECURITY and an owner without BYPASSRLS it returns no rows (fail-closed). Field-only read denies leave the row policy but stay in the masks. When a view anon reads calls the helpers, anon may execute them; they find no subject and return nothing. rls verify and rlsParity({ fieldViews }) compare the view with pick; doctor PD030 flags columns the table still returnsRLS field security
Supabase authorize() answers a tenant request from global roles (a member of Acme passes authorize('post.update', globex_id))Tenant-scoped grants pass the row's tenant; authorizeSql checks it against the membership table (database mode) or the memberships claim (JWT mode) and returns false when neither is configuredSupabase provider, rls
A revoked role keeps working in Postgres until the token expires--authorize database (the default) reads user_roles per statement; JWT mode is opt-in and doctor PD019 warns when jwt_expiry exceeds an hour with sensitive grantsSupabase provider, doctor
The access-token hook is callable by clients or resolves unqualified namesThe hook runs with set search_path = '', every name is schema-qualified, execute is revoked from authenticated, anon and public, and only supabase_auth_admin reads user_rolesSupabase provider
Denial body leaks other users' grantsProblem Details bounded to the subject's own view; denials and alternatives are included in every environment because they name only roles and permissions the subject already holdsProblem Details
Agent floods a paid actionlimit grants with server-side LimitStore; an exhausted limit answers 429 with Retry-After and RateLimit fields so a well-behaved client backs off, and a store that cannot answer is 503, never a grantPolicies, RateLimit header fields
A caller walks ids to learn which rows exist (a private repository, a patient record), from 403 versus 404disclosure: 'hide' on the resource: a denied check on a loaded row answers with the same 404 /not-found body and headers as a missing row, including for an anonymous caller; the reason stays on the decision event. Only denials that arise once the subject holds a matching grant (step-up, limits) still answer as themselvesPermissions
A route with an id in its path checks the permission without the row, so any holder of the permission reaches every id (BOLA, OWASP API1)protect(permission, loader) decides against the loaded row; doctor PD036 flags a protect(permission) with no loader on a route whose path names an iddoctor
A response returns columns the grant's fields leave out (OWASP API3)pick after the check returns only listed fields; writes check each field against the stored rowPolicies
Unsigned bot impersonates an agentWeb Bot Auth verification fails closed; unsigned requests have no actorWeb Bot Auth
Decisions not auditableon('decision') carries outcome, reasons, actor, delegation; permdock/otelAudit and observability
Token with alg: none, or an RSA public key replayed as an HMAC secret (key confusion)Explicit algorithms allow-list, none never accepted, keys only from the configured JWKS, jku / x5u / jwk headers ignoredJWT adapter, Authentication
Algorithm confusion within a family (an EdDSA token verified against a crv: Ed448 key, or a curve the allow-list did not intend)Fully-specified algorithm names per RFC 9864 (Ed25519, ES256, PS256); polymorphic EdDSA accepted only when the resolved key is crv: Ed25519; permdock doctor flags EdDSA in configurationJOSE, JWT adapter
Discovery document spoofed or redirected (a jwks_uri pointing at an attacker's keys, an issuer that differs from the one asked for)discovery is HTTPS only; the document's issuer must equal the configured issuer byte for byte (Discovery 4.3, RFC 8414 3.3) or the document is rejected and tokens stay anonymous; jwks_uri is taken only from a matching document; the token never names its own JWKSOpenID Connect, JWT adapter
ID token presented as an access token (a token meant for the RP's front end, with aud = client id and nonce, replayed at the API)typ checked (at+jwt or JWT, at+jwt only under FAPI 2.0); ID tokens are rejected with cause wrong-token-type unless accept: 'id-token' is an explicit opt-in, and then never carry delegationOpenID Connect, JWT adapter
Encrypted token without integrity, or a JWE used to smuggle an unsigned payload (alg: RSA1_5, enc without an inner signature)JWE accepted only when decryptionKeys is configured, only as a nested JWS with cty: JWT, never RSA1_5, never zip; anything else is encrypted-token and anonymousJOSE, JWT adapter
Signed snapshot or decision export accepted from the wrong signer, or after key rotation with a stale keyVerifier is configured with the signer's JWKS and the exact typ; header alg must be in the allow-list and kid must resolve; aud and exp are checked before the private claim is read; rotation keeps the old key published until every artefact signed with it has expired; a failed snapshot is treated as absent (server-only), never as a partial grant setWire formats, Cloud adapter, JOSE
Same sub from two issuers treated as one principal (grant or approval collision across identity providers)principal.issuer is set by every subjectFrom*; grants, memberships, approvals and audit key on issuer + idSubject, subject
Claimed act nest that is not an object with a string sub, or an unsigned GNAP access array used as a new inputsubjectFromJwt / subjectFromIntrospection yield anonymous with cause invalid-chain; delegation.access is only read from verified claims or an introspection body the RS already authenticated, then intersected with grants and never widenedJWT adapter, delegation
Unverified claims used for grants (decoded JWT, X-User-Id header, client-supplied userId)Only subjectFrom* outputs and framework session APIs feed the subject; verification failure yields anonymous, never a partial principalAuthentication, Server kernel
Access token in a query string (logged by proxies, leaked via Referer)Tokens read from Authorization / DPoP headers only; rejected outright under profile: 'fapi2'JWT adapter, FAPI 2.0
Stale token honoured after revocation or role changeexp and session_expiry bound expiresAt; CAEP session-revoked / credential-change invalidate by subject; role-from-context when freshness mattersAuthentication, Shared Signals
Share link that never dies, is replayed after revocation, or is forwarded to someone it was not meant forA capability is a permdock-capability+jwt with a required exp; subjectFromCapability requires iss and aud, binds sub to the capability id, calls revoked(id) and denies when it throws, claims the jti of a one-time link in a ReplayStore (and refuses one without a store), applies the linked scope instances' linkPolicy at resolution so a tenant's tightened rules reach links already issued, and checks redeemer against the request's own verified subject, never a link; every failure is the anonymous subjectLink capabilities, JWT adapter
A share link escalates beyond its record (an owner role named in the link, a guest link reading sibling rows or drafts)A link principal holds one { on } membership and no roles or scope memberships, so only resource-scoped grants on that resource (and its children through their parent field) can match, with their row conditions; permissions narrows further through delegation.scopes; issuing is application code guarded by its own permissionLink capabilities, Tenancy
Leaked or stolen API key (database dump, a key in a repository, a key that outlived its owner's role)Keys are opaque pdk_<id>_<secret><checksum> with 256-bit secrets and a CRC-32 checksum so secret scanners find leaked keys without false positives; only the SHA-256 hash is stored and compared in constant time; expiresAt is required unless the tenant's credentials settings set allowNoExpiry (doctor PD029); subjectFromApiKey re-reads expiry, revoked(id) and the tenant settings on every use, so a tightened rule reaches existing keys; a user-bound key is its owner's live rights intersected with the key through coveredByDelegation, so a demotion, suspension or deletion reaches it at once; a missing owner or any throwing store is the anonymous subjectAPI keys
A Supabase secret key used as an all-powerful admin (a leaked sb_secret_…, a second key reaching what one integration should, a publishable key treated as a service)withSupabase verifies the key against SUPABASE_SECRET_KEYS in constant time; only a key named in the code-declared secretKeys map becomes a principal, a service principal holding roles in one tenant and capped at the entry's permissions; any other key, a request that also carries a user token, and every publishable key stay anonymous; withSubject writes the key as the rls.apiKeys claim (sub: ''), so the database applies the same tenant and ceiling and never service_roleSupabase, RLS CLI
A key or service account that does more than its creator could (a developer minting an admin CI key, a key minting keys, an OAuth client widening itself)decideCredential bounds a service key by the creator's assignableRoles and assignablePermissions in its one tenant (exceeds-creator), refuses link and credential creators, keeps a delegated creator inside its delegation, and binds an approval token to the key's content and creator; a service principal holds one tenant membership from the credential and nothing elseAPI keys, Custom roles
A capability used as an access token, or an access token used as a capabilityDistinct typ: subjectFromJwt accepts only at+jwt / JWT, subjectFromCapability only permdock-capability+jwtJOSE
Database reached with a forged or stale capability claimPostgres never sees the link token: exchangeCapability mints a short-lived role: 'anon' token (default 300 seconds, never past the capability's expiry, never service_role, no sub) signed with the project's own key after verification; permdock_capability_ids also checks v, holder, the role, the permission and expiresAt; revocation reaches the database within the exchanged token's lifetimeSupabase adapter, RLS
Forged, replayed or indefinitely valid Security Event Token (a SET may omit exp, RFC 8417)joseTokenVerifier waives exp only for typ: secevent+jwt and then requires iat; the SSF receiver checks iss, aud, iat within tolerance and claims each jti once per issuer in its ReplayStore; a SET is an invalidation signal, never a decision input, so a replay can only revoke againSSF adapter, JOSE
Privilege escalation through user-editable claims (user_metadata.role = 'admin', Clerk unsafe metadata, Better Auth additionalFields)Providers read only server-set claims (app_metadata, hook-injected claims, backend-set metadata); user-editable metadata is never a grant source; subjectFromBetterAuth copies additionalFields into principal.claims only through options.schemaSupabase provider, Clerk provider, Better Auth provider
A session object from another library is trusted without its verification state ({ kind: 'anon' } or { kind: 'invalid' } carrying stale claims)subjectFromSupabaseSession maps only kind: 'user'; every other or unknown kind is the anonymous subject, whatever claims holdsSupabase provider
A token Supabase's OAuth server issued to a third-party app (client_id) is treated as the user, or plans for one tenant leak into anothersubjectFromSupabase maps client_id and act to an actor that needs a delegation covering each permission, from scope without the OpenID Connect identity scopes, or from a policy delegation that names the client; plans reads only the active tenant's entry of the claim. The exported actorOf reads only act and client_id, and its { ok: false } (a malformed act) must deny. Callers apply the role rule first: anon and service_role map to the anonymous subject and never carry an actorSupabase provider
Roles or tenant derived from SSO material that is not authoritative (the domain of email, a group display name, a missing hd or tid replaced by a default tenant)Tenant from a server-set tenant claim (hd, tid, org_id) compared against onboarded tenants, absent claim means no tenant; roles from app roles, a groups claim filter or group object ids mapped server-side, never display names; permdock doctor PD010 / PD011 flag the claim pathsAuthentication single sign-on, doctor
SAML assertion trusted without termination (a raw assertion posted to an API, or a permdock/saml entry)Not accepted. Only a session or JWT from the SSO layer that already terminated SAML reaches subjectFrom*Authentication Enterprise SSO, PermDock Cloud
Tenant switch to an organisation the user does not belong to (forged orgId in a URL, header or refresh({ tenant }) body)The active tenant is accepted only when a membership in the verified subject matches; otherwise every tenant-scoped check is denied with no-membership and the snapshot is empty for that tenant; the row's tenant key is compared against the membership, not against the requestTenancy, invariant 7
Cross-tenant row reached through a valid role (an Acme admin updates a Globex post by id)Scoped roles require the row's key for the role's scope (and each declared ancestor) to equal the membership's instance, through a membership of exactly that scope (no cascade), denied with tenant-mismatch; collection actions require the active tenant to hold the role; the same memberOf node compiles into RLS so the database enforces it tooTenancy, RLS
A portal contact passes an organization-level guard (can(permissions.customer.read, undefined) in the active tenant) through a role held on a nested scopeAn instance check without a row answers from memberships of the first scope only; a nested membership applies when the row carries its scope key or the caller selected that instance with team(id), otherwise the check is denied with scope; a snapshot denies every instance check without a row on a partitioned scopeScopes
A list query returns rows from every org the user belongs to (where() combined every allow with no tenant filter)where() ANDs each tenant or team grant with its membership and drops memberships outside the active tenant; with no active tenant, tenant grants contribute nothing. ormParity checks Drizzle, Kysely and Prisma 7 against filter() on Postgressnapshots
Previous user's permission UI still in the DOM after sign-out (Next.js App Router keeps visited routes as hidden trees; a client-side sign-out leaves them)Sign out through a document navigation: a form POST to a Route Handler that clears the session, expires the user's cache tag and answers 303Next.js Cache Components
Cross-tenant write through a proposed row (a member of Acme creates { orgId: 'globex' } under /acme/projects, or an update pair moves orgId from Acme to Globex)The tenant check runs on the proposed row of a collection write and on both rows of an update pair, server and client alike, denied with tenant-mismatch; testHttpAdapter sends the cross-tenant create through every HTTP adapterTenancy, testing
A global middleware caches a tenantless instance that a later tenant-scoped route reusesThe kernel caches one instance per (Request, tenant) and every adapter resolves tenant again on each protect, from its own framework context; RPC adapters read each procedure's own input, so a batch never shares a tenantServer kernel
Tenant admin defines a custom role wider than the policy allowsCustom roles are data resolved by one function (resolveCustomRole) and intersected with the ceiling: the code allows of declared assignable roles in the role's scope, never hosted grants; unknown keys, keys outside the ceiling and grants carrying a condition are dropped and reported (validateCustomRole, doctor PD023); every custom-role grant inherits the declared grant's condition, approval and limit, and the source role's declared denies; a declared role name always wins over a custom oneCustom roles, tenancy
A row written straight into the custom-role tables, or a forged grants claim entry, widens database accessThe generated helpers resolve custom roles only through the permdock_ceiling view of assignable declared roles; a permission outside it reaches no grant key; the tables have RLS on and no privileges for anon or authenticated; the claim is read only with --custom-roles and only from a token the database already trustsCustom roles, RLS
A tenant member saves a custom role that allows more than they may hand out, or rewrites a role that doespermdock_replace_custom_role_grants, permdock_rename_custom_role_grants and permdock_delete_custom_role_grants check membership of the tenant (or a meta.manageRoles permission held through a global role, the only way to write a platform custom role), the ceiling, and that the caller holds every permission and level the new and the stored definition allow (unless it holds a meta.manageRoles permission); the tables stay closed to anon and authenticated; rls.customRoleWrites.requires refuses a caller without the role-management permission before any hand-out check; the permdock_trusted_* variants skip the caller checks and are executable by no client role until a migration grants oneCustom roles, RLS
An operator stores a platform custom role that reaches what no assignable global role allows (privilege escalation through a stored global role)A scope: 'global' custom role is capped by the allows of declared global roles marked assignable; superadmin-style roles stay outside the ceiling unless code marks them. validateCustomRole reports every dropped key, the RLS helpers intersect with the same permdock_ceiling view, and check constraints refuse a global row that carries a tenant or scope id
A former key kept as an alias after it was meant to retire, so old SQL, tokens or stored roles keep reaching a renamed permissionrenamed aliases resolve only to the current leaf, never widen it, and appear in the catalog as renamedFrom. permdock doctor PD055 and PD056 list what still uses them, and permdock diff marks dropping an alias as the breaking alias-removed
Tenant admin hands out more than they holdassignableRoles() and assignablePermissions() intersect the ceiling, narrowed by RoleSource.assignable, with what the assigner holds in that tenant; only a held role or granted permission marked meta.manageRoles lifts it; a throwing assignable assigns nothing; the save action re-checks on the server with validateCustomRole; with an assigns graph, a role hands out exactly what it listsCustom roles
An identity-provider group, or a Clerk organization plan, grants more than the application allows (a group mapped to owner, a principal id read as SCIM filter syntax, an o: plan from one organization used in another)Core drops assignable: false roles from managedBy: 'idp' memberships; directoryMembershipSource looks users up with a structured eq node, never interpolated filter text; subjectFromClerk puts o: plans and features on the session organization's membership, so they match only while it is the active tenantSCIM, Clerk
An admin promotes themselves, demotes the last owner, or gives an external (guest, contact, partner) membership an admin roledecideRoleChange takes the actor from the instance, never from the change, and refuses self-changes (self-demotion), roles outside the actor's assigns (not-assignable-by), kinds outside the role's for (not-allowed-for-membership), and count breaks (last-holder, max-holders, transfer-only), failing closed when the holder count is unknown; a role held through a kind its for does not list grants nothing in decide, snapshots and generated RLS; the generated holder-count and transfer-only triggers refuse a write that skipped the check, and the rls.assignments triggers refuse a client write of a role the caller may not assign on the membership and global-roles tables, and with ownRole: 'refuse' any client write to the caller's own rowOwnership
A role change or activation names another organization's ancestors in within, or an expired membership still assigns roles or mints credentialsdecideRoleChange and activate read the tenant and ancestors from the subject's live membership and deny a disagreeing within with no-membership; a within with no held membership counts only with { trusted: true }, set by the application after loading it; the activation token covers within; expired memberships give no tenant, no assignableRoles and no credential ceilingOwnership, Elevated access
Team or group membership keyed on an editable display name, or inherited through an unbounded group graphMemberships key on provider or SCIM id / value, never display; nested groups are flattened by the directory before they reach PermDock, and resource role derivation follows only declared parent fields (no self-reference)JWT authorization claims, Tenancy
Expired or revoked membership still honoured (time-bound access, off-boarded team member)Membership.expiresAt is checked on every evaluation and compiled into RLS; membership rows are re-read per request through the MembershipSource or refreshed with the session; CAEP events invalidate snapshots by subjectTenancy, Shared Signals
A disabled organization or suspended user keeps database access until their token refreshes (jwt mode claims still name the membership)rls.suspension makes every generated helper, the inline membership exists and root-scope claim checks, the graph helpers, authorize() and the Supabase token hook check the users and scope-instance status tables live, in both modes; a disabled ancestor voids nested memberships; a missing status row counts as suspended; the in-process side reads the same status through the MembershipSourceScopes, RLS
Simulated ("view as") snapshot used to obtain a real approval token or mutate dataSnapshots carry simulated: true; the decision endpoint and approvalsHandler refuse them; only a subject holding a preview permission may request oneUI, Snapshots
Terminal token stored on disk read by another process or userShort-lived, scoped tokens in the OS credential store where available, file mode 0600 otherwise; refresh tokens never written in plain text; CAEP revocation where the issuer supports itTerminal adapter
Agent-run CLI claims an actor or principal via --actor or a bare PERMDOCK_ACTOR name (as opposed to a verified PERMDOCK_ACTOR_TOKEN JWT)Flags and environment variables never build a subject; actor and principal come from a verified token (device grant, client credentials, workload identity) or the process is anonymousTerminal adapter, Authentication
A CI job token accepted as a user, or a token minted for another audience or another provider reusedsubjectFromCiOidc pins the provider's issuer and JWKS, requires aud, returns only a workload principal (sub as the id, repository and ref as attributes, no roles from the token) and is anonymous on any failure, including a failing schemaTerminal adapter, Authentication
A destructive command run by accident or by an agent answering yA destructive permission needs the resource id typed at a terminal; without one the command exits 64 unless --yes is passed; --dry-run decides without running or consuming quotaTerminal adapter
Non-interactive approval bypass (--yes, piped stdin, agent answering its own prompt)--yes only waives the destructive confirmation; approval-required in a non-TTY resolves to denied; approval token is bound to permission, resource id, subject and actor, so a scripted answer cannot approve a different callTerminal adapter, Approvals
OpenAPI Overlay strips security before the document is publishedpermdock openapi --check applies the Overlay in CI and diffs the result against the generated security requirements; a removed or weakened requirement fails the buildOpenAPI adapter, OpenAPI Overlay
Approval store tampered with (a record flipped from pending to approved, or a forged record inserted)The store is a trusted server component like the database; resume still re-runs decide and recomputes token, so a forged record for a different call fails the comparison; approval events give an audit trail of every transitionApprovals adapter, approvals
Approver identity spoofed (agent approves its own call, or the approve request names an approver)Approver comes only from authentication (subject on approvalsHandler, Eve's responder, the session); an approver equal to the request's actor is refused; approval.by refuses an ineligible approver (approver-not-eligible)Approvals, Eve adapter, approval security
Requester approves their own request (a user asks their agent for a refund and approves it)The principal is refused as approver on every approval (approver-is-principal); only approval: { distinct: false } on the grant lifts it, doctor PD024 lists each opt-out, requireDistinctApprover on the handler overrides the opt-out, and a hosted grant cannot add one (weaker-approval)Approval security, approvals adapter, doctor
Approval answered from a chat platform by a spoofed or replayed interaction (forged Slack payload, a card forwarded to someone outside approvers)The Chat SDK verifies the platform's request signature before it returns the responder's user.id; the application maps that id to a Subject server-side, never from the message body; store.resolve applies the actor rule; the resumed call recomputes token, so a replayed interaction cannot approve a different callApprovals adapter Delivery, Approvals
Approval reused after the window (long-lived pending approval spent later)expiresAt on the request record; expire() marks stale records; resume against an expired record is deniedApprovals adapter
A data approval policy fails to load, and the call goes through without the approval it configuredApprovalPolicySource fails toward denial: a throw, a rejected promise or an entry that does not load denies every call an allow would grant (approval-policy-unavailable). Entries only add stages; none can grant, lower a quorum or drop a code requirement
An approver claims a relation they do not hold ("I am this expense's manager")Relation approvers are matched only through verdict.relations, which the approvals handler computes from the application's RelationSource for the request's resource id at verdict time; the request body never supplies them, and a store without the facts matches nobody
PERMDOCK_CLOUD_KEY leaked to a client bundle or logServer-only entry; tests/bundle asserts no client entry reaches permdock/cloud; permdock doctor flags NEXT_PUBLIC_* or client imports; keys are per environment and rotatable; the key never authorises a decisionCloud adapter
Unauthenticated or cross-environment caller on the hosted ADSVercel OIDC or client-credentials token verified with permdock/jwt; aud bound to the environment; no shared-secret mode; body subject ignored unless the caller is a registered trusted PEPCloud adapter, AuthZEN adapter, invariant 9
Hosted policy document replayed from another environment, or a snapshot served unsigned by a compromised proxycloud() verifies the permdock-policy+jwt with iss and aud both equal to the environment URL (<url>/v1/environments/<environment>) and the environment JWKS, so a document signed for another environment is rejected; snapshots.get() sends Accept: application/jwt and throws on anything but a compact JWS; hostable still bounds every hosted grantCloud adapter, Wire formats
Cloud unreachable or returning an unknown formatDecisions never depend on the Cloud (invariant 15); the sink drops to on('error'); an unknown v is rejected; a resume that cannot read its record is deniedCloud adapter, invariant 1
SCIM endpoint called with a leaked, guessed or another tenant's credential (forged POST /Users, a Group with roles for a tenant the caller does not own)scimHandler authenticates every request with a per-tenant static bearer compared against a stored hash, or an RFC 7523 JWT bearer through verifier (aud = the endpoint, tenant claim); the tenant is bound to the credential and never read from a body or path; an unauthenticated request is a SCIM 401 and writes nothing; every method on DirectoryStore takes the tenant firstSCIM adapter, SCIM, invariant 7
Compromised or malicious SCIM source or Cloud relay pushes groups that grant wide accessA group's roles extension can only name roles the policy declared assignable; unknown names are dropped; directoryMembershipSource produces memberships, never grants or conditions; the relay's bearer is verified against the Cloud JWKS and cannot reach the store any other way; every replayed operation is in the Cloud sync log and a directory event plus a membership event per affected user in the sink, so the change is visible and reversibleSCIM adapter, Audit and observability
Deprovisioned user keeps access (IdP sets active: false or deletes, but cached memberships or snapshots still hold)directoryMembershipSource yields no memberships for an inactive or deleted user on the next read; scimHandler publishes CAEP session-revoked into the revocations feed and onChange (kind: 'session-revoked') on deactivation or delete, so open streams and sockets end and the application invalidates cached snapshots for the affected principals; a CAEP transmitter at the same IdP sends the same signal through permdock/ssf; the residual window is the snapshot expiresAt and is stated in the runbookSCIM adapter, Authentication Lifecycle
Cloud relay outage or Cloud-side directory copy treated as authoritative (bring-your-own mode)The Cloud never implements MembershipSource or RoleSource and holds no authoritative copy of what it relays; the IdP retries against the relay and the relay retries against scimHandler; the store the application owns keeps its last state and decisions continue from it (invariant 15)Cloud adapter, PermDock Cloud
Governance action from the Cloud (rejecting an actor's pending approvals, invalidating snapshots) mistaken for a deny, or a "freeze" that leaves an agent runningGovernance actions operate on the store and the snapshot source only; a future decide is unaffected until the application's own RoleSource or context reads a kill-switch it owns; the Cloud UI states this and never shows a "deny" controlAudit and observability Evidence and governance, PermDock Cloud
Webhook receiver in another trust domain accepts a forged or replayed CloudEvents batchDeliveries are always signed; receivers verify the JWS batch (typ: permdock-decisions+jwt, Cloud JWKS, aud = the receiver, exp) with verifyWebhook, which has no unsigned mode, rejects event types outside the closed list and drops a replayed jti through a ReplayStore; a webhook delivers evidence and approval notifications, never an approval resolutionCloud integrations, Wire formats
Agent resolves an approval through the Cloud's MCP server, or reads another tenant's evidenceThe Cloud MCP server is read-only (evidence queries, pending approvals, catalog and drift); no tool resolves an approval; the OAuth token's subject and tenant scope the queries; approver identity comes only from the Cloud's own authenticationCloud integrations, Approvals
Trusted-issuer connector misconfigured on the Cloud (wrong JWKS, an issuer that mints tokens for many tenants)The ADS and snapshot edge verify with permdock/jwt under the same rules as the application (iss, aud, typ, algorithm allow-list); a tenant claim with no onboarded connection is no tenant; the connector can widen who is authenticated at the Cloud, never what the embedded engine decidesCloud integrations, Authentication
Relation grantee matches on a client-supplied field (authorId posted in a body)Boundary validation runs the resource schema first; only rows the caller marks trusted: true skip it; the relation still compiles to the same portable where the evaluator, filter and RLS usePolicies, RLS, invariant 9
An upgrade hint reveals a feature the subject could not reach on any plan, or a client grants on the hintnot-entitled is added only for grants whose roles the subject holds, so a plan name is shown only where buying it would grant; the snapshot's notEntitled entries are denials only, never read by can, filter or where; a mix with any other reason is a plain /deniedDecisions, snapshots
Plan or entitlement claim forged in user-editable metadataprincipal.plans comes from verified claims (entitlements, Clerk pla) through subjectFrom*; user_metadata never feeds grantsJWT adapter, Clerk adapter, invariant 13
Cloud identity gateway used as a second decision engine, or a device-flow token accepted without PKCECloud issues tokens; subjectFromJwt({ discovery }) verifies them; core still decides in-process; authorization code plus PKCE and the device flow are the only grants; Agent Auth capabilities map to permission references, never to a Cloud-side canCloud adapter, PermDock Cloud
Compromised Cloud-native directory or Cloud admin session mints wide roles into tokensMinted roles and entitlements are limited to declared assignable roles and plans in the uploaded catalog; names the policy never declared are dropped by subjectFromJwt and reported by on('auth'); an admin cannot hand out a role they do not hold in that tenant; every change is a membership event with source: 'cloud' and by, so it is visible and reversible; the Cloud still grants nothing a code policy does notCloud adapter, PermDock Cloud, audit and observability
Role removed in the Cloud-native directory keeps working on an issued tokenCloud-native access tokens default to a lifetime of minutes and the UI states the bound; the Cloud's CAEP transmitter sends session-revoked or credential-change for the subject and the snapshot edge invalidates signed snapshots; the app never looks the directory up live, so the bound is the token's expSSF adapter, PermDock Cloud
Cloud admin assigns a role that is not assignable, or a free-text roleThe assignment UI reads roles and plans from the catalog only and rejects free text; assignable: false roles and roles outside the tenant's assignable set are never offered; a name that slips through is dropped at decide time (fewer grants, never more)Tenancy, PermDock Cloud
Forged, replayed or rolled-back hosted policy documentDocuments are compact JWS with typ: permdock-policy+jwt verified against the environment JWKS with aud; an unverifiable document is absent and the last verified one stays; hosted grants apply only to hostable permissions, cannot override a code deny, and cannot drop a code approval; createPermDock reads the source's current() and never fetches; grantees are declared roles, plans or relations only and conditions portable only; every matched hosted grant records the document fingerprint and grant id, and the merged fingerprint rebinds approval tokens to the document; permdock doctor PD020 warns when a hostable permission is also compiled into RLSPermDock Cloud, invariants 2, 7 and 15
Standing admin rights left assigned long after they are neededA role with activation is eligible-only and never held directly; permdock.activate mints a via: 'elevated' membership with expiresAt, grantedBy and reason, optionally behind an approval and a fresh-authentication (assurance) check; expiry and the authz_ver counter end it; permdock doctor PD033 flags an activation without maxDuration or an activation role held standingElevated access
Break-glass used to read restricted data without a trace, or compiled into a database policyA breakGlass grant overrides only the deny grants it names, is engaged by context.purpose, and denies without the required purpose, reason or assurance; every use is granted with notify, review and justify obligations and a high-severity OCSF event carrying purpose and reason; the grant is non-portable, so RLS never compiles it and the server reads through a security definer function that checks a signed session and writes an audit row; permdock doctor PD034 flags a compile attemptElevated access
An adapter context hook copies a request header, query parameter or body field into subject.context, so a client sets the value a condition readscontext returns plain JSON merged under the policy's own context, whose keys win on a clash; it cannot set the subject, memberships, tenant or actor; a hook that throws or returns anything but a JSON object adds nothing and reports on('error'). The docs name server-derived values (a verified session, a geo lookup the edge did, a feature flag the server evaluated) as the only source. The value reaches the client in the snapshot's subject.context, so it never holds a secretextend PermDock
An adapter onDenied hook or wrap turns a denial into a grantonDenied runs only after a refusal and answers with a response; a status below 300, a throw or an invalid return keeps the default Problem Details and reports on('error'). MCP and AI SDK onDenied replace the text only; isError, structuredContent and the approval shape stay fixed. wrap sees the instance after the decision engine is built and can only change what the app calls, as any app code canextend PermDock
App data (meta.x, Membership.x, grant meta) holds a secret or steers a decisionPermDock never reads x in evaluation, conditions or RLS and leaves grant meta out of the policy fingerprint; it is plain, prototype-safe JSON at most eight levels deep; snapshot grants and memberships carry it to the client, which the docs stateextend PermDock
Vendor support reads or writes tenant data without consent or attributionsupportAccess access exists only through a consented, time-bound via: 'support' membership a tenant owner grants via an ApprovalRequest; actorRequired denies every decision under it with actor-required unless the subject carries an act, so impersonation is never the user's own session; forbid compiles to deny grants scoped to via: 'support'; access.started / .ended / .revoked events record the session, and revoking consent bumps the revocation counter; permdock doctor PD035 flags a support role without actorRequiredElevated access

Out of scope

  • Authentication, login, session management and token issuance (providers and frameworks own these; PermDock consumes their result). Token verification is in scope only for the optional permdock/jwt entry and the provider adapters, never for core. PermDock Cloud may issue tokens as an identity gateway; that path stays off the decision engine. See Authentication and PermDock.
  • Protecting the server process itself (secrets, dependency supply chain) beyond PermDock's own zero-runtime-dependency core.
  • Preventing an application from calling can with the wrong permission; types reduce this, tests and the permdock-audit skill catch the rest.

Last updated on

On this page