Authorization
April 25, 2026 · View on GitHub
This project supports two composable authorization layers on top of JWT authentication:
| Layer | Module | Purpose |
|---|---|---|
| RBAC | common/compiled/node/auth/rbac.js | Tenant-scoped roles and permissions stored in the DB |
| FGA | common/compiled/node/auth/openfga.js | Fine-grained per-object checks via OpenFGA |
Both are optional. They can be used independently or together.
How it works
JWT payload
{
"iss": "https://api.example.com",
"sub": 3,
"aud": "https://app.example.com",
"scope": "openid profile email",
"roles": ["admin", "member"],
"tenant_id": 1,
"tenant_plan": "pro"
}
iss— issuer; configured viaJWT_ISSin.env.json, defaults to empty string.sub— user ID.aud— audience; configured viaJWT_AUDin.env.json, defaults to empty string.scope— space-separated scopes; configured viaJWT_SCOPEin.env.json.roles— coarse-grained flat array; source depends on the active tier (see below).tenant_id— the user's active tenant from RBAC; absent when RBAC is not configured.tenant_plan— the tenant's plan/tier for feature gating without a per-request DB call.- Fine-grained permissions are not embedded — resolved at request time via
rbac.getUserTenantsData.
Roles fallback chain (createToken)
getActiveTenant is always called first. The JWT roles array is populated via a three-tier fallback — each tier only consulted if the previous yields nothing:
- RBAC — roles from
user_tenant_rolesfor the active tenant. - FGA — roles from OpenFGA
ListObjects(only if RBAC yields no roles). - Legacy — flat
users.rolesDB column (only if FGA also yields nothing).
The roles format is identical regardless of source — consumers are unaware which tier was used.
The legacy users.roles column is populated by your app's user management (registration, admin tools). It is the fallback for deployments where neither RBAC nor FGA is configured — once RBAC is active it is bypassed entirely.
authUser middleware
After JWT verification, authUser attaches helpers to every authenticated request:
req.user // decoded JWT payload { sub, roles, tenant_id, tenant_plan, iat, exp }
req.rbac // { hasRole(...roles) } — checks flat JWT roles array
req.fga // { check(relation, object) } — ad-hoc OpenFGA check
For fine-grained permission checks use getUserTenantsData with req.user.tenant_id:
import { getUserTenantsData } from '@common/node/auth/rbac.js';
const data = await getUserTenantsData(req.user.sub, req.user.tenant_id);
// data.tenants[req.user.tenant_id].permissions → string[]
Mode selection
| Mode | Config |
|---|---|
| RBAC only | RBAC_CONFIG.enabled: true, FGA storeId empty |
| FGA only | RBAC_CONFIG.enabled: false, FGA storeId set |
| Both | Both enabled — RBAC roles take precedence; FGA used for per-object checks |
| Neither (legacy) | Both disabled — uses flat users.roles column |
| Use case | Recommendation |
|---|---|
| Tenant isolation, coarse role checks | RBAC |
| Per-object ownership or sharing (e.g. "can edit document:42") | FGA |
| Both coarse and fine-grained control | RBAC + FGA together |
| No external service, simple deployments | RBAC only |
| Maximum flexibility, dynamic policies | FGA only or RBAC + FGA |
RBAC
Data model
tenants id, name, slug, plan, is_active, timestamps
roles id, tenant_id→tenants, name, description, timestamps
permissions id, name (e.g. "users:read"), description, timestamps
role_permissions (role_id, permission_id) composite PK
user_tenant_roles (user_id, tenant_id, role_id) composite PK
Roles are tenant-scoped. Permissions are global. A user can hold multiple roles per tenant.
tenants
└── roles (tenant_id FK)
└── role_permissions (role_id FK)
└── permissions
users
└── user_tenant_roles (user_id, tenant_id, role_id)
Setup
cd scripts/dbdeploy/db-sample
npx knex migrate:latest
npx knex seed:run --specific=initial_rbac.js
Enable in apps/sample-api/.env.json:
"RBAC_CONFIG": {
"enabled": true
}
Usage in routes
import { authUser } from '@common/node/auth';
import { requireRole, getUserTenantsData } from '@common/node/auth/rbac.js';
// Middleware — declarative role check
router.get('/admin', authUser, requireRole('admin'), handler);
// Inline role check
router.get('/dashboard', authUser, async (req, res) => {
if (!req.rbac.hasRole('admin')) return res.sendStatus(403);
res.json({ ok: true });
});
// Permission check at request time
router.get('/reports', authUser, async (req, res) => {
const data = await getUserTenantsData(req.user.sub, req.user.tenant_id);
if (!data?.tenants[req.user.tenant_id]?.permissions.includes('reports:read')) {
return res.sendStatus(403);
}
res.json({ ok: true });
});
Role and permission management
import { assignRole, revokeRole, grantPermission, revokePermission } from '@common/node/auth/rbac.js';
await assignRole(userId, tenantId, roleId);
await revokeRole(userId, tenantId, roleId);
await grantPermission(roleId, permissionId);
await revokePermission(roleId, permissionId);
Seed data (initial_rbac.js)
| Tenant | Role | Permissions |
|---|---|---|
| Default (id=1) | TestGroup | users:read, reports:read |
| Default (id=1) | TestGithub | users:read |
| Default (id=1) | TestGmail | users:read, reports:read, reports:export |
| User | Roles in tenant 1 |
|---|---|
| user:1 (test) | TestGroup |
| user:2 (ais-one) | TestGithub |
| user:3 (aaronjxz) | TestGmail, TestGroup |
FGA
Fine-grained authorization using OpenFGA — an open-source Zanzibar-based access control system. Enables per-object permission checks and audit-friendly tuple history.
Authorization model
type user
type role
relations
define assignee: [user]
A user is an "assignee" of a named role object. This mirrors the users.roles format so existing role names work unchanged.
Tuples assign users to roles:
| user | relation | object |
|---|---|---|
| user:1 | assignee | role:TestGroup |
| user:2 | assignee | role:TestGithub |
| user:3 | assignee | role:TestGmail |
| user:3 | assignee | role:TestGroup |
Setup
1. Start OpenFGA
docker run -p 8080:8080 openfga/openfga run
2. Run migration and seed
cd scripts/dbdeploy/db-sample
npx knex migrate:latest
npx knex seed:run --specific=initial_openfga.js
The seed prints the store_id and auth_model_id it created.
3. Configure the app
"FGA_CONFIG": {
"apiUrl": "http://127.0.0.1:8080",
"storeId": "<printed store_id>",
"authorizationModelId": "<printed auth_model_id>"
}
Usage in routes
import { authUser } from '@common/node/auth';
import { requireFga, writeTuple, deleteTuple } from '@common/node/auth/openfga.js';
// Declarative middleware — static object
router.delete('/users/:id', authUser, requireFga('assignee', 'role:admin'), handler);
// Declarative middleware — dynamic object derived from request
router.put('/docs/:id', authUser, requireFga('owner', req => `document:${req.params.id}`), handler);
// Ad-hoc check inside a handler
router.get('/resource', authUser, async (req, res) => {
const canEdit = await req.fga.check('writer', 'document:42');
res.json({ canEdit });
});
// Tuple management
await writeTuple('user:42', 'assignee', 'role:admin');
await deleteTuple('user:42', 'assignee', 'role:admin');
RBAC + FGA together
import { authUser } from '@common/node/auth';
import { requireFga } from '@common/node/auth/openfga.js';
// Coarse: user must be an admin (JWT roles check)
// Fine: user must own this specific document (FGA)
router.put(
'/docs/:id',
authUser,
(req, res, next) => req.rbac.hasRole('admin') ? next() : res.sendStatus(403),
requireFga('owner', req => `document:${req.params.id}`),
handler,
);
fga_config table
Stores the active FGA store and model IDs so they can be read by tooling or admin UIs.
| Column | Type | Description |
|---|---|---|
id | integer PK | |
store_id | string(64) | OpenFGA store ID |
auth_model_id | string(64) | Authorization model ID |
label | string(80) | Human-readable name, default "default" |
api_url | string(255) | FGA server URL |
is_active | boolean | Whether this config is in use |
created_at / updated_at | timestamp |
Seed data (initial_openfga.js)
Idempotent — checks for an existing "sample-app" store before creating. It:
- Creates (or reuses) the
"sample-app"store. - Writes the authorization model.
- Writes tuples mirroring
initial_users.js. - Saves
store_idandauth_model_idto thefga_configtable.
If the OpenFGA server is unreachable the seed exits gracefully with a warning.
Production
OpenFGA requires its own persistent store. See the OpenFGA deployment guide for options (PostgreSQL, MySQL, in-memory).
Store and model IDs should be managed via a secrets vault and injected as environment variables — do not commit them.
Files
New files
| File | Purpose |
|---|---|
common/compiled/node/auth/rbac.js | RBAC service — getActiveTenant, getUserTenantsData, requireRole, assignRole, revokeRole, grantPermission, revokePermission |
common/compiled/node/auth/openfga.js | FGA client wrapper — setup, listUserRoles, check, writeTuple, deleteTuple, requireFga |
scripts/dbdeploy/db-sample/migrations/20260416000000_fga_config.ts | Creates fga_config table |
scripts/dbdeploy/db-sample/migrations/20260416000001_rbac_tables.ts | Creates tenants, roles, permissions, role_permissions, user_tenant_roles |
scripts/dbdeploy/db-sample/seeds/initial_rbac.ts | Seeds tenant, roles, permissions, user assignments |
scripts/dbdeploy/db-sample/seeds/initial_openfga.ts | Creates FGA store, model, seed tuples |
Modified files
| File | Change |
|---|---|
common/compiled/node/auth/index.js | Extend setup(); three-tier roles chain in createToken; attach req.rbac and req.fga in authUser |
common/compiled/node/express/preRoute.js | Read RBAC_CONFIG and FGA_CONFIG; pass to authService.setup() |
apps/sample-api/.env.json | Added RBAC_CONFIG and FGA_CONFIG blocks |
common/compiled/node/package.json | Added @openfga/sdk ^0.9.0 |