Remote PDP
The permdock/pdp provider is an AuthZEN policy enforcement point client that asks a remote decision point such as Cerbos, Topaz, Keycloak, Axiomatics, PlainID or OPA, maps requests and responses to PermDock decisions, fails closed on anything unknown, and bridges to OpenFGA or SpiceDB relation graphs.
permdock/pdp lets a PermDock instance defer some or all decisions to a remote Policy Decision Point that speaks the OpenID AuthZEN Authorization API. The same typed references, Decision outcomes, adapters and audit apply; only the source of truth moves. It is the mirror image of permdock/authzen, which makes PermDock the PDP.
Purpose
Organisations that already run a central PDP (Cerbos, Topaz, Keycloak, Axiomatics, PlainID, OPA behind an AuthZEN shim) want application code to stay typed and framework-integrated while policy lives elsewhere. Relation-graph systems (OpenFGA, SpiceDB) answer questions PermDock's condition model does not attempt to answer at scale; replacing them is a stated non-goal (roadmap). The provider gives both groups one integration: PermDock permissions map to AuthZEN action and resource fields, the remote answer becomes a Decision, and everything downstream (HTTP 403 bodies, MCP refusals, AI SDK approvals, snapshots) is unchanged.
API
import { createPermDock, remotePdp } from "permdock/pdp";
export const policy = definePolicy(permissions, {
roles: [member, admin], // local grants still apply
subject: (user) =>
user && { id: user.id, orgId: user.orgId, roles: user.roles },
providers: [
remotePdp({
url: "https://pdp.example.com", // discovers /.well-known/authzen-configuration
auth: { bearer: () => getServiceToken() },
permissions: [permissions.billing], // which permissions are delegated; others stay local
mapping: {
subject: (s) => ({
type: "user",
id: s.id,
properties: { orgId: s.orgId, roles: s.roles },
}),
resource: (permission, data) => ({
type: permission.resource,
id: data?.id,
properties: data,
}),
action: (permission) => ({ name: permission.action }),
},
timeout: 300,
cache: { ttl: "5s" },
}),
],
});OpenFGA and SpiceDB
import { openfga, spicedb } from "permdock/pdp";
const fga = openfga({
url: "https://fga.internal",
storeId: process.env.FGA_STORE_ID,
authorizationModelId: process.env.FGA_MODEL_ID, // optional
auth: { bearer: () => getFgaToken() }, // optional
map: [
[
permissions.doc.read,
(subject, doc) => ({
user: `user:${subject.principal.id}`,
relation: "viewer",
type: "document",
id: doc?.id,
}),
],
],
});
const spice = spicedb({
url: "https://spicedb.internal:8443", // the HTTP gateway (--http-enabled)
token: process.env.SPICEDB_KEY,
consistency: "minimize-latency", // or 'fully-consistent'
map: [
[
permissions.doc.read,
(subject, doc) => ({
subject: { type: "user", id: subject.principal.id },
permission: "view",
resource: { type: "document", id: doc?.id },
}),
],
],
});-
mapis one callback per delegated permission; permissions not in it stay local. The callback gets the row for a check andundefinedfor a listing. Returningnulldenies withpdp-denied; throwing denies withpdp-invalid-responseand nothing is sent. A check without anidispdp-invalid-response. -
decidecalls OpenFGAPOST /stores/<id>/check(allowed) or SpiceDBPOST /v1/permissions/check(permissionship).PERMISSIONSHIP_CONDITIONAL_PERMISSION, a caveat missing context, is a denial; an unknown permissionship ispdp-invalid-response. -
filterandwherecall OpenFGAlist-objects(ids are the objects of the mappedtypewith thetype:prefix removed; an object of another type fails the whole list) or SpiceDBLookupResources(the gateway's newline-delimited stream; onlyLOOKUP_PERMISSIONSHIP_HAS_PERMISSIONrows count, and anerrorline fails the whole list). The ids must match the PermDock resource's id field. -
timeout,cacheandfetchbehave as forremotePdp. Relation tuples are written by the application; PermDock never models or stores them. -
remotePdp(options)returns a provider that handlesdecidefor the listed permissions (or all whenpermissionsis omitted). Local roles and grants remain in force; the outcome is the intersection: localdeniedwins, localgrantedstill requires the remotetruewhen the permission is delegated. -
mappingdefaults totype = permission.resource,id = data[resource.id],action.name = permission.action,subject.type = 'user'; override for PDPs with their own naming. -
simulatedecides each check through the provider.filterdecides row by row unless the provider lists ids:remotePdpdoes when the PDP advertisessearch/resource(it followspage.next_token, up to 100 pages), and the relation presets always do. Thenfilterkeeps the rows whose id field is in the list and that local evaluation does not short-circuit, with one request per call instead of one per row. -
Discovery reads
.well-known/authzen-configurationto learn endpoints and supported features; a staticendpointsobject can be supplied when discovery is unavailable. -
whereis async on the PDP instance. For a delegated permission whose provider lists ids it returnsin(id, ids)over the resource's id field, ANDed with the local condition when local grants exist; without local grants it ispartial: true, because local denies are not in the condition and each row must still passdecide. A listing failure returns the always-false condition withpartial: false. A provider that cannot list returns{ condition: { op: 'or', conditions: [] }, partial: true }: filter the rows throughdecideinstead.
In an HTTP adapter
Every HTTP adapter and the server kernel take pdp: createPermDock from permdock/pdp. With it, protect decides delegated permissions through the provider and a provider failure denies with pdp-unavailable. On the PDP instance can, decide, assert, filter, simulate and where return Promises. The request-scoped instance on the context stays the synchronous one, so can there keeps denying delegated permissions with pdp-unavailable instead of returning a Promise that would read as truthy.
Request lifecycle
decide(permission, data)runs local evaluation first. A localdenied(explicit deny or no local grant for a non-delegated permission) short-circuits without a network call.- For delegated permissions, the provider builds the AuthZEN evaluation request from
mappingand addssubject.properties.actorandsubject.properties.delegationwhen the PermDock subject has an actor. - The request is sent with the configured auth and timeout; identical requests within
cache.ttlare served from cache. The TTL is capped at 30 seconds; a larger value is clamped, and zero, a negative or an unparseable value disables the cache. The key is the mapped request plus the permission key, the principal's issuer, the tenant and the actor id and kind, so a changed row, another tenant or another agent is a new request. The cache holds at most 1000 entries, drops the oldest first and deletes an expired entry when it is read. Amappingfunction that throws ispdp-invalid-responseand nothing is sent. Only answers are cached; unavailability never is. - The response is mapped:
| Remote response | PermDock Decision |
|---|---|
decision: true | granted (matched: provider: 'pdp') |
decision: false with context.permdock.outcome: 'approval-required' | approval-required with context.permdock.token when present |
decision: false | denied; context.permdock.denials copied when present, otherwise reason: 'pdp-denied' |
| timeout, network error, non-2xx, unparseable body, unknown shape | denied with reason: 'pdp-unavailable' or 'pdp-invalid-response' |
on('decision')fires with the provider name, latency, and whether the answer came from cache.
What it validates
- Responses are validated against the AuthZEN response schema before mapping; any deviation is
denied(fail closed). This is the explicit contrast with fail-open adapters such as@ai-sdk/policy-opaon unrecognised decisions (vercel/ai#19978). resource.propertiessent to the PDP are validated against the resource schema when they crossed a boundary (validate: 'boundary'), so untrusted data is never forwarded unchecked.- Discovery documents are validated; a PDP that advertises no
evaluationendpoint is a configuration error at startup. The provider relies on discovery and does not check the remote PDP's AuthZEN certification. - Delegation invariant: an agent can never exceed its user even when the remote PDP grants, because local delegation intersection runs before and after the remote call.
How denials surface
-
Identically to local denials:
Decisionwithdenialsandalternatives(alternatives are computed locally from the catalog and merged with remote ones), HTTP403Problem Details, MCPstructuredContent, AI SDKdenied. -
Unavailability is a denial, not an exception; the reason distinguishes it so operators can alert on
pdp-unavailablewithout conflating it with policy. -
The client snapshot marks delegated permissions as
server-only;usePermissionasks the decision endpoint, which asks the PDP. -
The relation presets map every failure the same way: unreachable is
pdp-unavailable, an unknown shape ispdp-invalid-response, and a failed listing denies every row.
Example app
None. The authzen-pdp example runs a second process that uses remotePdp against the PermDock PDP so both halves are exercised. tests/integration/src/pdp runs openfga against the openfga/openfga container and spicedb against the authzed/spicedb container with the same scenarios: a direct and a userset relation, filter and where from the listed ids, a local deny over a remote allow, a removed relation, and an unreachable server.
Why
- A listing member instead of a search DSL. Zanzibar engines and AuthZEN resource search all answer "which ids", and their own list-endpoint guides turn that into
WHERE id = ANY(...). An optionalpermittedonDecisionProvidercarries exactly that, sofiltermakes one request instead of one per row andwhereis a plainin. The list is ANDed with local evaluation, so a local deny still wins and a remote list never widens a local grant. - One tuple callback per permission. Relation models differ per application (
vieweron a document,memberon a team userset), and a mapping DSL would be a second policy language. A callback per delegated permission is typed, sees the verified subject, and keeps the delegated set explicit. - A 30 second cache cap, no event-driven purge. A cached
grantedoutlives a revocation by at most the TTL. Purging per subject on an SSF event would put a mutable, cross-request index in the provider and tiepermdock/pdptopermdock/ssf; a hard cap bounds the staleness for every source of change (SSF, a tuple write, a policy deploy) without either. Connections that must end at once use the revocation feed.
Related standards
- AuthZEN: request and response schemas, search, discovery.
- Delegation: actor and delegation forwarded to the PDP.
- Threat model: fail closed, never trust unknown responses.
- Research: landscape: Cerbos, Topaz, OpenFGA, SpiceDB, Oso and other hosted PDPs.
Last updated on
OpenTelemetry
permdock/otel records a span and a counter per permission check with decision attributes, behind a structural logger interface, with @opentelemetry/api as an optional dependency that is never required.
Drizzle
permdock/drizzle compiles portable conditions to Drizzle where clauses with toWhere, generates pgPolicy entries for RLS through drizzle-orm/supabase helpers, and reuses drizzle-zod schemas for generated definitions.