Skip to content

[RoadMap] ABAC: pluggable attribute-based authorization engine #203

@tsemachh

Description

@tsemachh

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.strategiesenrichJWT 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

  1. One extension point (AttributeProvider) — not five.
  2. where-first: providers compile to Payload where whenever possible → DB does the filtering, list views stay fast.
  3. No new permission DB / no parallel engine — everything is Payload-native access functions + relationships + JWT.
  4. Policies are optional sugar. v1 ships with implicit AND of all matching providers; declarative policies arrive only if users ask.
  5. Reuse existing conventionsexcludedCollections / includedCollections / admin.custom.<key> like every other plugin in this monorepo.
  6. 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.

Metadata

Metadata

Assignees

Labels

RoadMapRoadmap item open for community contributionpriority:P1High-value enhancement

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions