Documentation/Learn/RLS Bridge
In this section

RLS Bridge

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. The defineRLSSchema, allow, deny, filter builders are the real @kysera/rls API, re-exported from @omnitron-dev/titan-database/rls. The condition function receives a PolicyEvaluationContext: ctx.auth for identity and ctx.row (read/update/delete) or ctx.data (create) for the record under evaluation:

// rls-schema.ts
import { defineRLSSchema, allow } from '@omnitron-dev/titan-database/rls';

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

ctx.auth (an RLSAuthContext) carries userId, roles, permissions, tenantId, attributes, and isSystem — the bridge populates it from the application's AuthContext so the DB layer sees the same identity the call-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.