Fastify
The @crudy/pgmorbac plugin: hasPermission, org scopes, management routes.
The @crudy/pgmorbac npm package wraps the SQL engine for Node applications:
permission helpers for Fastify routes, an RLS session-context helper, and
ready-made management routes.
npm install @crudy/pgmorbac --registry https://npm.villains.frfastify and pg are optional peer dependencies: install them if you use
the Fastify or Pool-based helpers.
Authorizing a route
hasPermission resolves the acting user from the request, runs
morbac.is_allowed, and sends the error response itself. It returns a
boolean so the handler can bail out in one line:
import { hasPermission } from '@crudy/pgmorbac/permissions';
app.post('/prescriptions', async (req, reply) => {
const orgId = req.headers['x-org-id'] as string;
if (!await hasPermission(db, req, reply, orgId, 'prescribe', 'prescriptions')) {
return reply; // 401 or 403 already sent
}
// ... create the prescription for Clinique Valmont Nord
});A null org means a check with no organization: only
global rules can satisfy
it.
The same call also enforces API-key scopes: when the request was authenticated by a scoped key, an activity/view pair outside the scope is rejected before the database is consulted.
Request helpers
| Export | Purpose |
|---|---|
getUser(req) | acting user id, or null |
getOrgScope(req, reply, required) | validated org ids from the request |
orgScopeExpr(orgIds, column) | SQL fragment + params to filter by those orgs |
isGlobalAdmin(db, userId) | wildcard global rule holder |
requireGlobalAdmin(db, req, reply) | guard that answers 403 itself |
canActOnUser(...), canGrantRule(...) | guards for user and rule administration |
SYSTEM_ORG_ID | sentinel meaning "no org filter", never a real org |
SYSTEM_ORG_ID is not a row in morbac.orgs. It is a header value meaning
the caller is asking without an org filter, plus a write guard; never store
records against it.
Session context for RLS
Route handlers that read tables protected by
row-level security must tell the database
who is acting. withMorbacContext opens a transaction on a dedicated client,
sets morbac.user_id and morbac.org_id as transaction-local settings, and
rolls back on error:
import { withMorbacContext } from '@crudy/pgmorbac';
const rows = await withMorbacContext(pool, { userId: user.id, orgId }, async (client) => {
const { rows } = await client.query('SELECT * FROM app.prescriptions');
return rows; // RLS already filtered them
});Transaction-local settings are the safe choice behind a connection pool: the next borrower of the connection never inherits them.
If your runtime role has
BYPASSRLS, policies do not apply and this helper is only bookkeeping. Connect with a role withoutBYPASSRLSto make RLS a real second gate behindhasPermission.
Management routes
createMgmtRoutes registers CRUD endpoints for orgs, roles, assignments and
rules, each already gated by the corresponding morbac permission:
import { createMgmtRoutes } from '@crudy/pgmorbac/plugin';
app.register(createMgmtRoutes({
db,
checkLimit: async (kind, orgId) => { /* your quota policy */ },
onOrgEvent: (event) => auditLog(event),
onRoleEvent: (event) => auditLog(event),
}));The event sinks let you mirror organization and role changes into your own audit or search infrastructure without patching the plugin.
Layering
Authentication proves who is calling. hasPermission decides what they may
do. RLS enforces it per row even if a handler forgets. Use all three: the
database is the last line that cannot be bypassed by an application bug.