@shefing/abac adds a pluggable, Payload-native attribute-based access control layer.
- Keep the implementation simple and Payload-first.
- Compile attributes into Payload
whereclauses whenever possible. - Reuse JWT-enriched user data instead of introducing a parallel permission store.
pnpm add @shefing/abac
import type { AttributeProvider } from '@shefing/abac'
const tenantAttribute: AttributeProvider<string | null> = {
key: 'tenant',
fromUser: (user) => (typeof user.tenant === 'string' ? user.tenant : null),
match: (userValue, docValue) => userValue != null && userValue === docValue,
toWhere: (userValue) => ({ tenant: { equals: userValue } }),
enrichJWT: (user) => ({ tenant: user.tenant ?? null }),
}import { abacPlugin } from '@shefing/abac'
plugins: [
abacPlugin({
attributes: [
{
key: 'tenant',
fromUser: (user) => user.tenant,
match: (userValue, docValue) => userValue === docValue,
toWhere: (userValue) => ({ tenant: { equals: userValue } }),
enrichJWT: (user) => ({ tenant: user.tenant }),
},
],
}),
]Then opt collections in with custom.abac:
custom: {
abac: {
tenant: {
docField: 'tenant',
},
},
}geo is intentionally documented as an example for v1 rather than shipped as a built-in:
const geoAttribute = {
key: 'geo',
fromUser: (user) => user.region,
match: (userRegion, docRegion) => userRegion === docRegion,
toWhere: (userRegion) => ({ region: { equals: userRegion } }),
}Current limitation (v1): each attribute provider resolves to a single value per user (e.g.
user.tenant = 'tenant-a'). If a user belongs to multiple tenants the engine only sees the first one.
The v0.2 milestone will extend the engine and tenantAttribute to handle array-valued attributes natively:
// user.tenants = ['tenant-a', 'tenant-b'] ← multiple memberships
const multiTenantAttribute = {
key: 'tenant',
fromUser: (user) => Array.isArray(user.tenants) ? user.tenants : [],
match: (userTenants, docTenant) =>
Array.isArray(userTenants) && userTenants.includes(docTenant as string),
// toWhere will emit { tenant: { in: ['tenant-a', 'tenant-b'] } }
toWhere: (userTenants) => ({ tenant: { in: userTenants as string[] } }),
enrichJWT: (user) => ({ tenants: Array.isArray(user.tenants) ? user.tenants : [] }),
}This closes the main gap vs. Payload's official plugin-multi-tenant, which stores a tenants[] array per user. The same pattern applies to any set-valued attribute (multi-region, multi-clearance, etc.).
- v1 keeps policy composition simple: matching providers are combined with implicit
AND. - Built-in
tenantAttribute()androleAttribute()are exported from the package entry. roleAttribute()is additive with@shefing/authorization: it preserves JWT role context and can short-circuit admins, but it does not replace RBAC collection permissions.- See the integration coverage in
test-app/tests/e2e-plugins/abac.spec.tsonce the feature is fully wired.
| Version | Item | Status |
|---|---|---|
| v1 | Single-value attribute providers (tenant, role, custom) |
✅ Shipped |
| v1 | JWT enrichment — zero extra DB queries per request | ✅ Shipped |
| v1 | GET /api/me/permissions endpoint |
✅ Shipped |
| v1 | abacFilterOptions relationship field helper |
✅ Shipped |
| v1 | Fail-closed safety + startup config validation | ✅ Shipped |
| v0.2 | Multi-value attribute support (tenants[], { in: [...] } WHERE, array match) |
🔜 Next |
| v0.2 | readVersions / unlock action guards |
🔜 Next |
| v0.2 | pluginContext singleton → closure (test isolation) |
🔜 Next |
| v0.3 | Optional admin UI: tenant switcher component | 💡 Planned |
| v0.3 | isGlobal collection mode (per-tenant globals) |
💡 Planned |
| v0.3 | Per-tenant roles (tenantRoleAttribute) |
💡 Planned |
| v0.3 | i18n support | 💡 Planned |