Skip to main content

RLS Bridge

Design RFC

The RLS-bridge stack documented here (wrapper, permVer enforcement, generic policy projection) is a planned architecture, not what Omnitron ships today. Treat the code excerpts as reference shapes you'd implement in your own auth-utils package.

The auth layer above gates calls. @kysera/rls gates rows. They use the same identity — the user's AuthContext — bridged from the Netron invocation wrapper into the @kysera RLS AsyncLocalStorage scope.

This page covers the wrapper, the policy authoring shape, and the per-request permission-version (permVer) enforcement that shares the wrapper's hot path.

The shared wrapper

Every backend in the fleet (e.g. main, accounting, storage, messaging, analytics) wires the SAME createRlsInvocationWrapper from @yourorg/auth-utils. The wrapper does four things on every authenticated RPC:

// packages/auth-utils/src/rls-invocation-wrapper.ts
export function createRlsInvocationWrapper(options) {
return async (metadata, fn) => {
const authCtx = metadata.get('authContext');
if (!authCtx) return fn(); // 1. anon → no RLS scope

// 2. permVer check (stale-permissions defence)
if (options.permVerRedis) {
const tokenPv = authCtx.metadata?.pv ?? 0;
const currentPv = parseInt(await options.permVerRedis.get(`perm-v:${authCtx.userId}`) ?? '0', 10);
if (currentPv > tokenPv) {
throw Object.assign(new Error('Permission version stale'), {
code: 'PERMISSION_VERSION_STALE',
statusCode: 401,
});
}
}

// 3. User-activity touch — debounced Redis marker that
// the flusher worker in `main` batches into
// `users.last_active_at`. Fire-and-forget; a Redis
// hiccup must never delay the request.
if (options.activityRedis) {
void touchUserActivity(options.activityRedis, authCtx.userId).catch(() => undefined);
}

// 4. RLS context propagation
const rlsCtx = mapAuthToRLSContext(authCtx, { defaultTenantId: authCtx.metadata?.tenantId ?? 'default' });
return rlsContext.runAsync(rlsCtx, fn);
};
}

In bootstrap.ts:

auth: {
jwt: { enabled: true, tokenCacheTtl: 60_000 },
invocationWrapper: createRlsInvocationWrapper({
permVerRedis: deferredPermVerRedis.ref,
activityRedis: deferredPermVerRedis.activityRef,
}),
}

The deferredPermVerRedis ref is populated in afterCreate once Redis is resolvable from the DI container; the same underlying client doubles as both the permVer lookup and the activity-touch sink.

User activity

The touchUserActivity helper performs an atomic SET omni:activity:dirty:{userId} <iso-now> NX EX 60 — at most one Redis write per user per 60 seconds, regardless of request volume across all five backends. A dedicated UserActivityFlusherService in main runs on a 30-second interval to SCAN the dirty namespace, batch-update users.last_active_at with a GREATEST()-clamped UPDATE FROM VALUES (monotonic and idempotent), then UNLINK the keys.

Worst-case lag from "user did something" to "users.last_active_at advances" is windowSeconds + flushIntervalMs ≈ 90 seconds. No new inter-backend RPC traffic — each backend writes to the shared Redis namespace; only main reads.

RLS policies

A policy is a per-table allow/deny/filter that runs inside the plugin BEFORE every SELECT / INSERT / UPDATE / DELETE.

// rls-schema.ts
export const platformRLSSchema = defineRLSSchema<Database>({
users: {
policies: [
allow('read', () => true, { name: 'publicProfiles' }),
allow('update', (ctx) => ctx.auth.userId === row(ctx)?.['id'], { name: 'ownUpdate' }),
allow('update', (ctx) => {
const targetRole = (row(ctx)?.['platformRole'] as string) ?? 'user';
return canModerateUserWithRole(ctx.auth.roles, targetRole);
}, { name: 'tieredAdminUpdate' }),
],
skipFor: ['system'],
defaultDeny: false,
},
// …
});

ctx.auth carries userId, roles, permissions — the same shape the application-side gates check.

STRICT vs PERMISSIVE plugin tiers

A single plugin instance with allowUnfilteredQueries: true historically reduces RLS to an advisory hint. The schema is split into two tiers via two plugin instances:

// modules/rbac/rls-plugins.ts
const strict = rlsPlugin({
schema: platformRLSSchema,
bypassRoles: ['superadmin'],
requireContext: true, // fail closed on missing context
allowUnfilteredQueries: false, // fail closed on missing filters
});

const permissive = rlsPlugin({
schema: platformRLSSchema,
bypassRoles: ['superadmin'],
requireContext: false, // anonymous reads OK
allowUnfilteredQueries: true,
});

for (const t of STRICT_RLS_TABLES) registerTablePlugins(t, […existing, strict]);
for (const t of PERMISSIVE_RLS_TABLES) registerTablePlugins(t, […existing, permissive]);
TierTablesBehaviour
STRICTaudit_logs, platform_roles, users, user_sessions, organization_rolesQueries without auth context → 0 rows / refused. 'system' escape hatch.
PERMISSIVEposts, comments, organizations, employees, shops, products, orders, notifications, disputesAnonymous public reads work. Policies apply to authenticated callers.

A 'system' skipFor is built into every policy as the legitimate escape for migrations, background workers, and explicit service-layer transactions.

permVer counter

The perm-v:{userId} Redis key is bumped by any code path that mutates a user's effective permission set:

authService.bumpPermissionVersion(userId) // single bump (INCR + EXPIRE)
authService.bumpPermissionVersionMany(userIds) // batched pipeline

Bump sites (production):

MutationCaller
Platform role change (adminUpdateRole)UsersService.adminUpdateRole
Per-user platform override grant / revokeUserPermissionService.{grant,revoke,replaceAll}
Per-employee org override grant / revokeEmployeePermissionService.{grant,revoke,replaceAll}
Force-transfer org ownershipOrganizationsService.forceTransferOwnership

The JWT carries the snapshot in the pv claim. Every backend's invocation wrapper consults the live counter on every call — mismatch ⇒ PERMISSION_VERSION_STALE (401) ⇒ client refresh mints a fresh JWT with the up-to-date set.

Redis fail-open

The permVer check is best-effort: a Redis outage on GET falls open with an auth.permver.fail_open log tag, mirroring auth.session.fail_open (MED-2). The DB-side session-revoke path remains the authoritative kill switch.

See also

  • Per-user overrides — what triggers a bump.
  • Audit trail — every bump is audit-logged alongside the mutation that triggered it.
  • @kysera/rls upstream docs — schema authoring details beyond the bridge above.