Link capabilities
A share link is a signed capability that holds roles on one resource, optionally narrowed to a few permissions; it resolves to a link principal, expires, can be revoked or used once, and reaches RLS through a short-lived Supabase token.
A guest quote page, a file share link, a read-only chat thread, a pre-boarding form: each lets someone without an account act on one record. PermDock models all of them as a capability, a signed token that holds roles on one resource instance. The resolver turns it into a subject whose only membership is on that resource, so can(), where(), snapshots and RLS treat the link holder exactly like a member of the resource and nothing more.
Declaring what a link may do
A link holds ordinary resource-scoped roles. Declare them next to the others:
export const policy = definePolicy(permissions, {
scopes: { organization: { key: "organization_id" } },
roles: [
role("staff", [allow(permissions.quote.read)], { on: "organization" }),
role(
"guest",
[
allow(permissions.quote.read, { where: { status: "sent" } }),
allow(permissions.quote.accept, { where: { status: "sent" } }),
],
{ on: permissions.quote },
),
role(
"commenter",
[allow(permissions.file.read), allow(permissions.file.comment)],
{ on: permissions.folder },
),
],
subject: subjectFromSupabase,
});A role held on a parent resource reaches its children through the child's parent field, so a commenter link on folder f_1 reads the files whose folder_id is f_1 and never a sibling folder's (tenancy).
Issuing a link
signCapability(input, signer, { audience }) from permdock signs a permdock-capability+jwt with any TokenSigner (JOSE):
import { signCapability } from "permdock";
import { joseTokenSigner } from "permdock/jwt";
const signer = joseTokenSigner({
key: privateJwk,
alg: "ES256",
kid: "2026-09",
issuer: "https://app.example.com",
});
export async function shareQuote(permdock: PermDock, quote: Quote) {
permdock.assert(permissions.quote.share, quote);
const link = await db.insert(links).values({ quoteId: quote.id }).returning();
const token = await signCapability(
{
id: link.id,
on: { resource: permissions.quote, id: quote.id },
roles: ["guest"],
expiresAt: Math.floor(Date.now() / 1000) + 30 * 86_400,
},
signer,
{ audience: "https://app.example.com" },
);
return `https://app.example.com/portal/quotes?token=${token}`;
}| Field | Meaning |
|---|---|
id | The link id: the token's sub and the handle the application revokes. Keep a row per link. |
on | The resource reference and instance id the roles are held on. |
roles | Resource-scoped role names. A scope or global role in the list grants nothing, because a link has no scope membership. |
permissions | Optional permission references that narrow the roles further, for example a read-only copy of an editor link. |
redeemer | Who may open it: 'anyone' (the default), 'signed-in', { user } or { scope, id }. |
once | One request per token. |
expiresAt | Required. It is the token's exp and the membership's expiresAt; a long-lived link pairs a long expiry with revocation. |
Issuing is application code. Guard it with its own permission (quote.share above) so only someone who may share the quote can mint a link for it.
Opening a link
subjectFromCapability(token, options) from permdock/jwt verifies the token and returns the subject it acts as. Like every subjectFrom* resolver it never throws: any failure is the anonymous subject, reported through onAuth.
import { subjectFromCapability } from "permdock/jwt";
const subject = await subjectFromCapability(url.searchParams.get("token"), {
jwks: "https://app.example.com/.well-known/jwks.json",
issuer: "https://app.example.com",
audience: "https://app.example.com",
revoked: async (id) =>
(await db.query.links.findFirst({ where: eq(links.id, id) }))?.revokedAt !=
null,
replay, // a ReplayStore, required for once: true
viewer: await session(), // the request's own verified subject, for redeemer checks
});
const permdock = createPermDock(policy, subject);
permdock.can(permissions.quote.read, quote); // true for the linked, sent quote onlyThe subject is:
{
"principal": {
"id": "lnk_1",
"kind": "link",
"issuer": "https://app.example.com",
"memberships": [
{
"on": { "resource": "quote", "id": "q_1" },
"roles": ["guest"],
"via": "link",
"expiresAt": 1791600000
}
],
"capability": {
"v": 1,
"id": "lnk_1",
"holder": "link",
"on": { "resource": "quote", "id": "q_1" },
"roles": ["guest"],
"expiresAt": 1791600000
}
},
"delegation": { "scopes": ["quote:read"] },
"context": {},
"expiresAt": 1791600000
}delegation appears only when the capability lists permissions; a check outside them is denied with not-delegated. The membership's via: 'link' tells audit and UI that the access came from a link. A link principal never satisfies another link's redeemer.
The resolver refuses, in this order: a token that fails verification (typ, iss, aud, signature, exp), a capability claim that is not a valid v1 object or whose id is not sub, a holder other than link, an expiry in the past, a redeemer the viewer does not satisfy (redeemer-mismatch), a revoked id (capability-revoked) and a one-time token whose jti was already claimed (capability-replayed). A one-time capability without a replay store is refused, and a revoked callback or store that throws denies with reason source-threw. A link its scope's link policy refuses is link-policy (below). The one-time claim runs last, so a request refused for another reason does not burn the link (JWT adapter).
Link policy per scope
A tenant can tighten what links on its resources may be, and resolution refuses a link that breaks the rules, including one issued before the tenant tightened them. A LinkPolicy has three optional rules, each of which only narrows:
| Rule | Refuses |
|---|---|
maxLifetime | A link whose expiresAt is more than this many seconds after it was issued (iat). A token without iat, or a value that is not a finite non-negative number, is refused. |
redeemers | A link whose redeemer kind (anyone, which also covers no redeemer, signed-in, user or scope) is not listed. An empty list refuses every link. |
once | A link that is not one-time. |
The policy lives with the scope instance that owns it, in the application's tables, so the resolver asks for it through a callback that receives the verified capability. Return the policy of every scope instance the resource sits in (the organization and the customer, say); all of them must hold:
const subject = await subjectFromCapability(token, {
jwks,
issuer,
audience,
revoked,
linkPolicy: async ({ on }) => {
const quote = await db.query.quotes.findFirst({
where: eq(quotes.id, on.id),
});
return quote === undefined
? { redeemers: [] }
: db.linkPoliciesFor([quote.organization_id, quote.customer_id]);
},
});A refused link is anonymous with cause link-policy, and a callback that throws denies with reason source-threw. The check runs before revocation and one-time use, so a refused one-time link is not burned. Pass the same policies to signCapability(input, signer, { linkPolicy }) and it rejects instead of signing a link resolution would refuse; linkPolicyViolation(capability, policy, issuedAt) returns the broken rule (lifetime, redeemer or once) for a UI that explains it.
Snapshots and clients
A link subject is an ordinary subject: permdock.snapshot() carries its membership and delegation, and fromSnapshot in the browser agrees with the server for every portable grant (snapshots). A capability in searchParams is request data: verify it at request time or in a private cache entry, never inside a shared 'use cache' function.
RLS
The database cannot verify a PermDock capability, so the server exchanges a verified link for a short-lived Supabase access token and queries with that:
import { exchangeCapability } from "permdock/supabase";
const accessToken = await exchangeCapability(subject, {
key: supabaseSigningJwk, // a private key imported into the project's JWT signing keys
alg: "ES256",
kid: "permdock-links",
ttl: 300,
});
const supabase = createClient(url, publishableKey, {
global: { headers: { Authorization: `Bearer ${accessToken}` } },
});The token carries role: 'anon', the capability under a capability claim, iat and exp (the ttl, default 300 seconds and at most an hour, never past the capability's own expiry), and no sub, because auth.uid() casts sub to a uuid and a link is not a user. A legacy project passes { key: { secret }, alg: 'HS256' } with its JWT secret. exchangeCapability returns undefined for anything but a live link subject.
permdock rls generate --capabilities (or rls.capabilities: true) adds permdock_capability_ids(p_resource, p_role, p_permission), which returns the resource id the claim names when its roles include p_role, its permissions (when present) include p_permission and it has not expired. Every resource-scoped grant gets an anon policy that calls it once per statement:
create policy quote_select_anon on public.quote for select to anon using (
("id"::text in (select permdock.permdock_capability_ids('quote', 'guest', 'quote.read'))) and ("status" = 'sent')
);
create policy file_select_anon on public.file for select to anon using (
"folder_id"::text in (select permdock.permdock_capability_ids('folder', 'commenter', 'file.read'))
);A resource role with no memberships table (a role only links hold, such as guest) gets only the anon policy, with a warning, instead of failing generation. Revocation reaches the database within the exchanged token's ttl. RLS has the flags.
Reserved: user-bound keys
holder: 'key' stays reserved and the resolver refuses it. User-bound and service API keys shipped as opaque pdk_ keys that the application looks up on every use, so revocation, a tightened tenant rule and the owner's current rights always apply (API keys); they are not capabilities and add no token format. key remains free for a stateless key, such as a calendar feed URL, should one ever be needed.
Why
Share links are where most SaaS products leak: an unguessable URL with no expiry, no revocation and a hand-written RPC that bypasses row security. Modelling a link as a membership on one resource keeps it inside the same evaluation as everyone else, so a condition such as "only sent quotes" applies to guests without a second code path, and inheritance to child resources is the same parent walk a member gets. A link can never exercise a scope or global role, because it has no scope membership to match.
The capability is a signed token rather than a database lookup so a link works where the application has no session, and so the same object feeds can(), the snapshot and RLS. Signing reuses the TokenSigner and JWK Set every other PermDock output uses, and the dedicated typ means a capability is never accepted as an access token and an access token is never accepted as a capability. The link policy is read at resolution rather than trusted from the token, because a tenant that tightens its rules means the links already out there too, and it is a callback because only the application knows which scope instances a resource sits in. Revocation keys on the link id rather than the token's jti because a product revokes the link, whichever token it was sent as. One-time use keys on the jti, so reissuing a one-time link produces a fresh use.
For RLS the server exchanges the capability for a short-lived anon token signed with the project's key instead of calling a security-definer RPC per resource. The generated policies then read one claim through one helper, stay uncorrelated, and let the same where conditions that apply in memory narrow the rows; the database never has to trust anything PermDock did not sign through the project's own key. Keeping holder in the v1 format now lets user-bound keys arrive without another wire change.
Last updated on
Elevated access
Time-bound, attributed, justified access. Just-in-time role activation makes a role eligible-only and mints a short-lived membership, break-glass is the only deny override and carries obligations, and support access lets a vendor act inside a tenant with consent and an actor. Purpose of use is a decision input.
API keys and service accounts
Restricted credentials are opaque pdk_ keys stored as SHA-256 hashes; a user-bound key is its owner's live rights narrowed to the key, a service key is a service principal bounded by its creator, and tenant settings, revocation and events apply on every use.