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 for Phase 1 and a test-suite checklist thereafter.

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 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
  • 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. Trusted rows from the application's own database are not re-validated (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.
  • 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 that crossed a trust boundary is 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.
  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. Decision.token is a hash of permission key, resource id, subject and actor. An approval for one call cannot be replayed on another. See approvals.
  13. Quotas are server-side. limit grants (Phase 4) are enforced by a LimitStore the server owns, never by the client snapshot.
  14. Immutable, request-scoped instances. createPermDock returns a frozen object per request; nothing mutates shared state between requests.
  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 (ADR 0021).

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
Malformed or oversized tool argumentsBoundary validation against the resource schemaValidation
Agent exceeds the user it acts forDecision = principal grants ∩ delegation; widening chain hops rejectedDelegation
Replayed approval on different argumentstoken hash bound to permission, resource id, subject, actor; re-checked on resumeApprovals
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 sessionAuthZEN, invariant 9
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 matrixPolicies, invariant 2
Async schema skips validationSync requirement; PermDockValidationErrorStandard Schema
Remote PDP unreachableDenied, never grantedpdp provider, invariant 1
Stale snapshot after revocationCAEP receiver invalidates by subjectShared Signals
Generated RLS widens accessNever emits service_role; deny becomes RESTRICTIVE; verify parity testsPostgres RLS
RLS import executes attacker SQLImport parses to AST; opaque SQL is stored, never run app-sidePostgres RLS
Denial body leaks other users' grantsProblem Details bounded to the subject's own viewProblem Details
Agent floods a paid actionlimit grants with server-side LimitStore (Phase 4)Policies
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
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
Privilege escalation through user-editable claims (user_metadata.role = 'admin', Clerk unsafe metadata)Providers read only server-set claims (app_metadata, hook-injected claims, backend-set metadata); user-editable metadata is never a grant sourceSupabase provider, Clerk 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
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 scopes.tenant.key to equal the membership tenant, 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
Tenant admin defines a custom role wider than the policy allowsCustom roles are data that compose declared assignable roles only; an unknown or non-assignable name resolves to nothing; a custom role cannot carry conditions; assignable() intersects with what the assigner holdsTenancy, ADR 0024
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
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
Non-interactive approval bypass (--yes, piped stdin, agent answering its own prompt)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, ADR 0022
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; requireDistinctApprover also excludes the principalApprovals, Eve adapter
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
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
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

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; see Authentication and PermDock and ADR 0018.
  • 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 audit-permissions skill catch the rest.

Open questions

  • Whether denials and alternatives in 403 bodies should be off by default in production (see Problem Details).
  • The LimitStore interface and whether quotas belong in core at all.
  • Whether to verify delegation chains in core or trust the token layer (see delegation).
  • Whether approvals answered through a signed link (email, Slack action) without an approver session are acceptable, and how the link binds to an identity (see approvals).

On this page