Authorization

April 25, 2026 · View on GitHub

This project supports two composable authorization layers on top of JWT authentication:

LayerModulePurpose
RBACcommon/compiled/node/auth/rbac.jsTenant-scoped roles and permissions stored in the DB
FGAcommon/compiled/node/auth/openfga.jsFine-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 via JWT_ISS in .env.json, defaults to empty string.
  • sub — user ID.
  • aud — audience; configured via JWT_AUD in .env.json, defaults to empty string.
  • scope — space-separated scopes; configured via JWT_SCOPE in .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:

  1. RBAC — roles from user_tenant_roles for the active tenant.
  2. FGA — roles from OpenFGA ListObjects (only if RBAC yields no roles).
  3. Legacy — flat users.roles DB 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

ModeConfig
RBAC onlyRBAC_CONFIG.enabled: true, FGA storeId empty
FGA onlyRBAC_CONFIG.enabled: false, FGA storeId set
BothBoth enabled — RBAC roles take precedence; FGA used for per-object checks
Neither (legacy)Both disabled — uses flat users.roles column
Use caseRecommendation
Tenant isolation, coarse role checksRBAC
Per-object ownership or sharing (e.g. "can edit document:42")FGA
Both coarse and fine-grained controlRBAC + FGA together
No external service, simple deploymentsRBAC only
Maximum flexibility, dynamic policiesFGA 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)

TenantRolePermissions
Default (id=1)TestGroupusers:read, reports:read
Default (id=1)TestGithubusers:read
Default (id=1)TestGmailusers:read, reports:read, reports:export
UserRoles 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:

userrelationobject
user:1assigneerole:TestGroup
user:2assigneerole:TestGithub
user:3assigneerole:TestGmail
user:3assigneerole: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.

ColumnTypeDescription
idinteger PK
store_idstring(64)OpenFGA store ID
auth_model_idstring(64)Authorization model ID
labelstring(80)Human-readable name, default "default"
api_urlstring(255)FGA server URL
is_activebooleanWhether this config is in use
created_at / updated_attimestamp

Seed data (initial_openfga.js)

Idempotent — checks for an existing "sample-app" store before creating. It:

  1. Creates (or reuses) the "sample-app" store.
  2. Writes the authorization model.
  3. Writes tuples mirroring initial_users.js.
  4. Saves store_id and auth_model_id to the fga_config table.

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

FilePurpose
common/compiled/node/auth/rbac.jsRBAC service — getActiveTenant, getUserTenantsData, requireRole, assignRole, revokeRole, grantPermission, revokePermission
common/compiled/node/auth/openfga.jsFGA client wrapper — setup, listUserRoles, check, writeTuple, deleteTuple, requireFga
scripts/dbdeploy/db-sample/migrations/20260416000000_fga_config.tsCreates fga_config table
scripts/dbdeploy/db-sample/migrations/20260416000001_rbac_tables.tsCreates tenants, roles, permissions, role_permissions, user_tenant_roles
scripts/dbdeploy/db-sample/seeds/initial_rbac.tsSeeds tenant, roles, permissions, user assignments
scripts/dbdeploy/db-sample/seeds/initial_openfga.tsCreates FGA store, model, seed tuples

Modified files

FileChange
common/compiled/node/auth/index.jsExtend setup(); three-tier roles chain in createToken; attach req.rbac and req.fga in authUser
common/compiled/node/express/preRoute.jsRead RBAC_CONFIG and FGA_CONFIG; pass to authService.setup()
apps/sample-api/.env.jsonAdded RBAC_CONFIG and FGA_CONFIG blocks
common/compiled/node/package.jsonAdded @openfga/sdk ^0.9.0