Changelog - 2026-07-20
Casbin Custom Grants
New Feature EnhancementIn one line. A grant row can now express an arbitrary subset of a subject's operations, not only a full tier. planGrant composes the smallest correct set of rows for any selection.
The problem it solves
A grant row used to mean one of four fixed tiers - read, write, execute, or manage. Granting a subject three operations out of a five-operation tier meant granting the whole tier, or hand-writing five per-operation rows:
typescript
const rows = planGrant({
subject: { type: 'Role', id: roleId },
resource: { type: 'Permission', id: orderResourceNodeId, subject: 'Order' },
intent: { ops: ['find', 'deleteById'] },
catalog, // [{ subject, method, code, action }, ...] resolved from your Permission table
});planGrant picks the smallest row set that grants exactly find and deleteById on Order. It uses a tier row wherever a tier fully collapses, and a custom row otherwise.
What changed
- New
action = 'custom'grant mode:AuthorizationActions.CUSTOM('custom') plusAuthorizationPolicyBuilder.customGrant(). - A custom row carries
metadata: { ops: [...] }against a subject-level resource node - the operations it grants, not a tier. ScopedCasbinAdapter.queryGrantsexpands each custom row into onepline per operation at read time.- The output is byte-identical to what equivalent per-operation rows produce.
- Expanding a custom row costs one extra batched catalog query (
queryOperationCatalog) per extraction. - Reading a custom row is opt-in. Map
entities.policyDefinition.metadata.columnName, or the adapter never selectsmetadata- it logs and skips any custom row it finds instead. - New
planGrant()planner incommon/grant-planner.ts- the supported way to build an operations-subset grant. planGrantcollapses a selection into tier rows wherever a tier is fully covered. What is left over becomes one custom row, or a per-operation row if only one operation remains.- Pass
exact: trueto turn collapsing off. - New nullable
metadatajsonb column inextraPolicyDefinitionColumns. Every app that builds itsPolicyDefinitiontable from this shared column set gets it automatically. queryGrants's SELECT now also readspermission.subjectandpermission.method. These resolve a custom row's target subject, and distinguish a resource node from an operation-level permission.
How planGrant collapses a selection
managecollapses only when the selection covers at least one operation in each ofread,write, andexecute. Otherwise a future operation added to an as-yet-empty tier would be silently pre-authorized.- Each narrower tier (
read,write,execute) collapses on its own whenever the selection fully covers it - even a tier with a single operation. A tier is never demoted to a custom row for being small. - What is left over after collapsing becomes one custom row, or a per-operation row if only one operation is left over.
- When the target's
supportsCustomMetadataisfalse, every leftover operation becomes its own per-operation row instead of one custom row. planGrantthrows (viagetError) on an unknown tier, an emptyops, or an operation missing from the resource's catalog slice. A write is a deliberate act, so the caller hears about a mistake immediately.- The adapter only logs and skips a malformed custom row on read. A read runs over data that may already be inconsistent - one bad row must not deny a user their remaining permissions.
Who is affected
- Every consumer of
ScopedCasbinAdapterandAuthorizationPolicyBuilder. No action needed. Themetadatacolumn is nullable, and reading it is opt-in viametadata.columnName. - Apps that want to grant a subject a handful of operations without a full tier. Call
planGrant({ subject, resource, intent: { ops: [...] }, catalog })instead of writing acustomGrantrow by hand.- It does the tier-collapse math and throws on unrecognized operations.
- Apps that provision
PolicyDefinitionviaextraPolicyDefinitionColumns. The newmetadatacolumn appears in their schema on the next migration. It is nullable, so no backfill is required.
Details
- Row shape. A subset grant is
action: 'custom', targeting a subject-level resource node (Permission.method === AuthorizationPermissionBuilder.RESOURCE_NODE_METHOD, the*sentinel), withmetadata: { ops: [...] }. opsholds method names, not full permission codes. The subject comes from the target node, soops: ['find']against nodeOrderresolves toOrder.find.- Expansion parity. A planned custom row and the equivalent per-operation rows expand to identical casbin lines. Nothing downstream, including the enforcer, can tell the two encodings apart.
| File | Package |
|---|---|
src/components/auth/authorize/common/grant-planner.ts | core |
src/components/auth/authorize/common/custom-grant.ts | core |
src/components/auth/authorize/common/policy-builder.ts | core |
src/components/auth/authorize/common/constants.ts | core |
src/components/auth/authorize/adapters/scoped-casbin.adapter.ts | core |
src/components/auth/models/entities/policy-definition.model.ts | core |