Problem
Today the authorization plugin is RBAC-only: permissions are keyed by role × collection × action, with an optional field allow-list. Real-world apps need access decisions that depend on attributes of the user and the document — e.g. "editors can only see content in their assigned geo area", "tenant-A users only see tenant-A docs", "users with clearance ≥ document classification can read". Building each of these as a bespoke plugin would explode the surface area.
Proposed solution — KISS
One small generic engine, one extension point. Attribute providers plug in; everything else (policies, where-clauses, JWT enrichment, list filtering) is derived.
The only contract users implement
type AttributeProvider = {
key: string // 'geo' | 'tenant' | 'clearance' | ...
fromUser: (user, req) => Value // read subject value
fromDoc?: Record<string, (doc) => Value> // per-collection: read resource value
match: (userVal, docVal) => boolean // boolean predicate
toWhere?: (userVal) => Where // Payload `where` for list/query access
enrichJWT?: (user) => object // bake resolved attrs into the token
}
That's it. Geo, tenant, language, department, clearance — all 30-line providers.
Per-collection wiring (one line)
custom: { abac: { geo: { docField: 'region' }, tenant: { docField: 'tenant' } } }
Policies (declarative, optional — can start with implicit "all providers must match")
abacPlugin({
attributes: [GeoAreaAttribute, TenantAttribute],
// optional, for combining/overriding:
policies: [{ collections: ['articles'], actions: ['read'], when: any([attr('geo'), attr('role').in(['admin'])]) }],
})
Where it plugs into Payload (no shadow engine)
access.read/create/update/delete → engine produces a where (∩ of provider toWheres); Payload applies it to REST, GraphQL, and admin list views automatically.
filterOptions on relationship fields → same provider constrains dropdowns (you can only pick areas you belong to).
beforeChange / validate → stamp/enforce subject attrs on create (user can only create in their tenant/area).
auth.strategies → enrichJWT bakes resolved attributes into the token; zero extra DB hits per request.
- Composes with
authorization → RBAC becomes the role provider; existing roadmap item "row-level where permissions" becomes the first consumer.
Concrete worked example (geo-area)
// 1) 30-line provider
export const GeoAttribute: AttributeProvider = {
key: 'geo',
fromUser: (u) => u.assignedAreas?.map(a => a.id) ?? [],
fromDoc: { articles: (d) => d.region },
match: (userAreas, docArea) => userAreas.includes(docArea),
toWhere: (userAreas) => ({ region: { in: userAreas } }),
enrichJWT:(u) => ({ assignedAreas: u.assignedAreas?.map(a => a.id) }),
}
// 2) Register
abacPlugin({ attributes: [GeoAttribute] })
// 3) Opt-in per collection
collections: [{
slug: 'articles',
custom: { abac: { geo: { docField: 'region' } } },
fields: [{ name: 'region', type: 'relationship', relationTo: 'areas' }, ...],
}]
Result, zero extra code:
- Editor assigned to
EU-North sees only EU-North articles in the admin list, REST, and GraphQL.
- The
region dropdown when creating an article is pre-filtered to EU-North.
- A
beforeChange hook stamps region = user.assignedAreas[0] if missing.
- Add a
TenantAttribute next week — same article list now also filters by tenant. No engine changes.
KISS principles we commit to
- One extension point (
AttributeProvider) — not five.
where-first: providers compile to Payload where whenever possible → DB does the filtering, list views stay fast.
- No new permission DB / no parallel engine — everything is Payload-native access functions + relationships + JWT.
- Policies are optional sugar. v1 ships with implicit AND of all matching providers; declarative policies arrive only if users ask.
- Reuse existing conventions —
excludedCollections / includedCollections / admin.custom.<key> like every other plugin in this monorepo.
- Start with two built-ins (
role bridge + tenant), ship geo as the first community-style provider in v0.2.
Acceptance criteria
- New package
@shefing/abac with the AttributeProvider contract documented in its README.
- Engine merges all matching providers'
toWhere into a single where and injects it into access.read/create/update/delete for opted-in collections.
- Built-in
tenant provider works end-to-end in test-app/ (users tagged with a tenant only see their tenant's docs in REST, GraphQL, and the admin list view).
GET /api/me/permissions?collection=<slug> returns { where, actions } for the current user — usable by QuickFilter "My scope" toggle and headless clients.
- Documented composition with the
authorization plugin (RBAC = the role provider).
- Integration test: a user without matching attribute cannot read/update/create the doc via REST, GraphQL, or admin.
How to claim
Comment on this issue saying you'd like to take it and a maintainer will assign it. Big design questions → discuss here or on the Payload Discord #plugins channel before opening a PR.
Problem
Today the
authorizationplugin is RBAC-only: permissions are keyed byrole×collection×action, with an optional field allow-list. Real-world apps need access decisions that depend on attributes of the user and the document — e.g. "editors can only see content in their assigned geo area", "tenant-A users only see tenant-A docs", "users with clearance ≥ document classification can read". Building each of these as a bespoke plugin would explode the surface area.Proposed solution — KISS
One small generic engine, one extension point. Attribute providers plug in; everything else (policies, where-clauses, JWT enrichment, list filtering) is derived.
The only contract users implement
That's it. Geo, tenant, language, department, clearance — all 30-line providers.
Per-collection wiring (one line)
Policies (declarative, optional — can start with implicit "all providers must match")
Where it plugs into Payload (no shadow engine)
access.read/create/update/delete→ engine produces awhere(∩ of providertoWheres); Payload applies it to REST, GraphQL, and admin list views automatically.filterOptionson relationship fields → same provider constrains dropdowns (you can only pick areas you belong to).beforeChange/validate→ stamp/enforce subject attrs on create (user can only create in their tenant/area).auth.strategies→enrichJWTbakes resolved attributes into the token; zero extra DB hits per request.authorization→ RBAC becomes theroleprovider; existing roadmap item "row-levelwherepermissions" becomes the first consumer.Concrete worked example (geo-area)
Result, zero extra code:
EU-Northsees only EU-North articles in the admin list, REST, and GraphQL.regiondropdown when creating an article is pre-filtered to EU-North.beforeChangehook stampsregion = user.assignedAreas[0]if missing.TenantAttributenext week — same article list now also filters by tenant. No engine changes.KISS principles we commit to
AttributeProvider) — not five.where-first: providers compile to Payloadwherewhenever possible → DB does the filtering, list views stay fast.excludedCollections/includedCollections/admin.custom.<key>like every other plugin in this monorepo.rolebridge +tenant), shipgeoas the first community-style provider in v0.2.Acceptance criteria
@shefing/abacwith theAttributeProvidercontract documented in its README.toWhereinto a singlewhereand injects it intoaccess.read/create/update/deletefor opted-in collections.tenantprovider works end-to-end intest-app/(users tagged with a tenant only see their tenant's docs in REST, GraphQL, and the admin list view).GET /api/me/permissions?collection=<slug>returns{ where, actions }for the current user — usable by QuickFilter "My scope" toggle and headless clients.authorizationplugin (RBAC = theroleprovider).How to claim
Comment on this issue saying you'd like to take it and a maintainer will assign it. Big design questions → discuss here or on the Payload Discord #plugins channel before opening a PR.