@happyvertical/smrt-users

Multi-tenant user management with RBAC, hierarchical tenants, session handling, and SvelteKit integration.

v0.29.34Multi-tenant RBAC13 Models

Overview

The smrt-users package provides a complete multi-tenant user management system with role-based access control (RBAC), hierarchical tenants, group-based permission inheritance, per-user permission overrides, session handling, and SvelteKit integration. It also ships the server-side login flows β€” OIDC (OAuth2 authorization-code + PKCE), magic links, and terminal device-code auth β€” while the stored identity records (OIDC/Nostr/API-key) live in smrt-profiles. In short: smrt-users handles authentication flows plus authorization and session/cookie plumbing; smrt-profiles stores the resolved identity.

Key Features

  • 4-level permission cascade β€” tenant hierarchy β†’ membership role β†’ group roles β†’ membership overrides
  • Hierarchical tenants β€” STI, materialized hierarchyPath, max depth 10
  • Group-based teams β€” flexible team structure within tenants via GroupRole
  • DENY always wins β€” MembershipOverride.DENY beats any GRANT; TenantPermissionOverride supports INHERIT/GRANT/DENY at each ancestor level
  • System & tenant roles β€” system roles (owner/admin/member/viewer, tenantId=null) are available to all tenants
  • Session management β€” server-side sessions with secure UUID, TTL in seconds, auto-expiry on access
  • SvelteKit integration β€” createSessionHandler, cookie helpers, tenant context switching
  • Tenant policies β€” flexible, personal, or required tenant modes via TenantService

Models (13)

ModelKey Pattern
UserAuth identity. profileId is a plain string (not FK) to smrt-profiles. Email auto-lowercased.
TenantSTI + hierarchical parent-child. Materialized hierarchyPath and hierarchyLevel. Max depth 10.
SessionServer-side. Secure UUID. TTL in seconds. Status auto-updates to EXPIRED on access.
MagicLinkTokenSingle-use email login token. Backed by MagicLinkService.
RoletenantId = null β†’ system role (available to all tenants). isSystem: true blocks deletion.
PermissionSlug format: resource.action. Parsed by PermissionResolver.
MembershipUser + Tenant + Role junction. UNIQUE(userId, tenantId).
GroupTeam within a tenant. Multiple roles via GroupRole.
GroupMember, GroupRole, RolePermissionJoin tables.
MembershipOverridePer-user permission grant/deny. DENY always wins.
TenantPermissionOverrideTenant-level cascade overrides. Effect: INHERIT / GRANT / DENY.

Tenancy

User is intentionally not tenant-scoped β€” emails are globally unique and a single user participates in many tenants via Membership. Tenant, Role, Permission, and the join tables use @TenantScoped({ mode: 'optional' }): a row with tenantId = null is a system-wide row (e.g. the built-in owner role), while a row with an explicit tenantId belongs to that tenant only. Sessions are scoped to a specific tenant context via switchSessionTenant().

Installation

typescript
pnpm add @happyvertical/smrt-users
# or
npm install @happyvertical/smrt-users

Database Requirements

  • SQLite (development): { type: 'sqlite', url: 'app.db' }
  • PostgreSQL (production): { type: 'postgres', url: 'postgresql://...' }

Quick Start (5 Minutes)

Step 1: Initialize Collections

typescript
import { UserCollection, TenantCollection, RoleCollection, MembershipCollection } from '@happyvertical/smrt-users';

const users = await UserCollection.create({ db: dbConfig });
const tenants = await TenantCollection.create({ db: dbConfig });
const roles = await RoleCollection.create({ db: dbConfig });
const memberships = await MembershipCollection.create({ db: dbConfig });

Step 2: Seed System Roles (Required)

typescript
// Creates: owner, admin, member, viewer (idempotent)
// Skipping this leads to "no role found" errors during PermissionResolver runs.
await roles.seedSystemRoles();

Step 3: Create User & Tenant

typescript
const user = await users.create({
  email: 'user@example.com',
  profileId: 'profile-123' // plain string FK to smrt-profiles
});
await user.save();

const tenant = await tenants.create({ name: 'My Company' });
await tenant.save();

Step 4: Create Membership

typescript
const adminRole = await roles.findBySlug('admin');
const membership = await memberships.create({
  userId: user.id,
  tenantId: tenant.id,
  roleId: adminRole.id
});
await membership.save();

Step 5: Check Permissions

typescript
import { PermissionResolver } from '@happyvertical/smrt-users';

const resolver = await PermissionResolver.create({ db: dbConfig });
const hasAccess = await resolver.hasPermission(
  user.id,
  tenant.id,
  'users.manage'
);
console.log('Can manage users:', hasAccess);

Permission Resolution -- 4-Level Cascade

PermissionResolver evaluates these levels in order. Each level can add or remove permissions; DENY always wins on the membership-override level.

  1. Tenant hierarchy β€” walk ancestors, applying TenantPermissionOverride at each level (INHERIT/GRANT/DENY)
  2. Membership role β€” base permissions from the user's role in the tenant
  3. Group roles β€” permissions from all groups the user belongs to in that tenant (union)
  4. Membership overrides β€” final per-user GRANT/DENY. MembershipOverride.DENY takes absolute precedence over every earlier GRANT.

Critical: use getGroupIdsForTenant(userId, tenantId) β€” it joins against the groups table to scope by tenant. Never use the cross-tenant getGroupIds() in resolution code.

PermissionResolver Methods

typescript
const resolver = await PermissionResolver.create({ db: dbConfig });

// Full resolution with metadata
const result = await resolver.resolvePermissions(userId, tenantId);
// { permissions: Set<string>, membershipId, roleId, groupIds, deniedPermissionIds }

// Single permission check
const canManage = await resolver.hasPermission(userId, tenantId, 'users.manage');

// Multiple (AND logic)
const hasAll = await resolver.hasAllPermissions(userId, tenantId, [
  'articles.create',
  'articles.publish'
]);

// Multiple (OR logic)
const hasAny = await resolver.hasAnyPermission(userId, tenantId, [
  'articles.update',
  'articles.delete'
]);

Permission Catalog & syncPermissionCatalog

The Permission rows the resolver checks don't have to be hand-maintained. PermissionCatalogService derives a catalog of resource.action slugs from three layers and reconciles them into the database:

  1. Manifest β€” every @smrt() object that exposes a REST/CLI/MCP operation contributes slugs: {collection}.read (when list/get is exposed) plus .create / .update / .delete, and one slug per exposed public custom method. Collection classes are skipped.
  2. Config β€” extra slugs under packages.users.permissions.custom in smrt.config.ts.
  3. Runtime β€” slugs registered programmatically via registerPermissionDefinitions().

syncPermissionCatalog(options) (also a method on the service) merges all three, creates missing Permission rows, updates changed name/description/category, and returns a PermissionCatalogSyncResult β€” { catalog, created, unchanged, updated }, where the last three are arrays of slugs. It is idempotent; run it at deploy/boot.

typescript
import { syncPermissionCatalog } from '@happyvertical/smrt-users';

const result = await syncPermissionCatalog({ db: dbConfig });
console.log(result.created);   // slugs inserted this run
console.log(result.updated);   // slugs whose metadata changed
console.log(result.unchanged); // slugs already in sync
// result.catalog.permissions is the full merged, sorted slug list

Postgres Row-Level Security (RLS)

On Postgres, smrt-users can push tenant isolation and permission checks down into the database as native Row-Level Security policies, so a query can't leak another tenant's rows even if app code forgets a WHERE tenantId = … clause. This is opt-in and Postgres-only.

1. Generate & apply policies

applyPostgresPermissionPolicies(options) (or generatePostgresPermissionSql() to inspect the SQL first) installs three STABLE helper functions and per-table policies. Policies are generated automatically for objects with @TenantScoped({ mode: 'required' }) (mapping SELECT→{collection}.read, INSERT→.create, UPDATE→.update, DELETE→.delete), and for any explicit postgres.bindings you declare. Tables shared by multiple objects, or non-required tenant modes, are skipped and listed in the result's skipped report.

typescript
import { applyPostgresPermissionPolicies } from '@happyvertical/smrt-users';

// Idempotent: drops + recreates the smrt_* policies and helper functions.
const { targets, skipped, statements } = await applyPostgresPermissionPolicies({
  db: { type: 'postgres', url: process.env.DATABASE_URL },
});
// Each target lists its per-action permission slugs; ALTER TABLE ... ENABLE +
// FORCE ROW LEVEL SECURITY is emitted for every covered table.

2. Enforce per request

Turn on postgresRls in the session handler. For every request it opens a transaction and sets Postgres session variables (smrt.tenant_id, smrt.permissions, smrt.user_id, …) from the loaded session via set_config(..., true) (transaction-local). The generated policies read those variables, so the database itself enforces the tenant + permission predicate. The transaction commits on success and rolls back on error.

typescript
// hooks.server.ts
import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';

export const handle = createSessionHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL },
  postgresRls: true,            // request-scoped RLS transaction
  enterTenantContext: true,     // also enter smrt-tenancy AsyncLocalStorage
});

How the policy predicate works

Each policy USING/WITH CHECK clause is, in effect:

sql
smrt_rls_bypass()
  OR (
    tenant_id::text = smrt_current_tenant_id()
    AND smrt_has_permission('articles.read')
  )
  • smrt_current_tenant_id() reads smrt.tenant_id; smrt_has_permission(slug) tests membership in the JSON smrt.permissions array.
  • smrt_rls_bypass() short-circuits to true when smrt.system_context or smrt.super_admin_bypass is set β€” used for system jobs and break-glass admin paths.
  • Tables are set FORCE ROW LEVEL SECURITY, so even the table owner is subject to the policies.

Hierarchical Tenants

  • TenantCollection.createChild() auto-calculates hierarchyPath and hierarchyLevel, enforcing the max depth of 10
  • moveToParent() updates the tenant and all descendants' paths and levels
  • cascadePermissions (parent pushes down) + inheritPermissions (child accepts) β€” both must be true for permission cascade
  • getTree(rootId?) returns a nested structure for UI rendering
typescript
const parent = await tenants.create({ name: 'Acme Corp' });
await parent.save();

const child = await tenants.createChild(parent.id, {
  name: 'Acme East',
  cascadePermissions: true,
  inheritPermissions: true,
});

// Materialized path -- O(1) ancestor checks
console.log(child.hierarchyPath);  // 'acme-corp.acme-east'
console.log(child.hierarchyLevel); // 1

SvelteKit Integration

Session Hooks

typescript
// hooks.server.ts
import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';

export const handle = createSessionHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL },
  ttl: 7 * 24 * 60 * 60, // 7 days in SECONDS (not ms)
  skipPaths: ['/api/health'],
});
// Populates event.locals: { user, permissions: string[], tenantId, sessionId }

Login / Logout / Tenant Switch

typescript
import {
  createSessionCookie,
  destroySessionCookie,
  switchSessionTenant
} from '@happyvertical/smrt-users/sveltekit';

await createSessionCookie(event, userId, tenantId, { db });
await destroySessionCookie(event, { db });
await switchSessionTenant(event, newTenantId, { db });

OIDC Login (OAuth2 Authorization Code + PKCE)

OidcLoginService implements standards-compliant OAuth2/OIDC authorization-code login with PKCE. It is provider-generic β€” it works against any compliant IdP (Kanidm, Dex, Keycloak, Authentik, Google, …) via discovery (/.well-known/openid-configuration), so the optional kind preset is just a documentation label and does not change protocol behaviour. On a successful callback it verifies the ID token and creates/links the SMRT User and the Profile identity.

Configure providers

Providers live under packages.users.auth.oidc in your SMRT config (defaultProvider + a providers map), or can be passed inline to the handlers. Each OidcProviderConfig needs an issuer and a clientId; confidential clients add a clientSecret. The token-endpoint auth method defaults to client_secret_basic when a secret is present, otherwise none (public client).

typescript
// smrt.config.ts (excerpt)
export default {
  packages: {
    users: {
      auth: {
        oidc: {
          defaultProvider: 'kanidm',
          providers: {
            kanidm: {
              kind: 'kanidm',              // optional preset label only
              issuer: 'https://id.example.com',
              clientId: process.env.OIDC_CLIENT_ID,
              clientSecret: process.env.OIDC_CLIENT_SECRET, // omit for public clients
              scopes: ['openid', 'profile', 'email'],       // the default
              // redirectUri is derived from the request when omitted
            },
          },
        },
      },
    },
  },
};

Login + callback handlers

Mount the two ready-made SvelteKit handlers. createOidcLoginHandler begins the flow: it generates a transaction holding state, nonce, and the PKCE code_verifier, stores it in a short-lived, HTTP-only, HMAC-signed cookie, and 303-redirects to the provider's authorization URL (with code_challenge_method=S256). createOidcCallbackHandler validates state, exchanges the code with the PKCE verifier, verifies the ID token signature, issuer, audience, and nonce, then mints the session cookie.

typescript
// src/routes/auth/[provider]/login/+server.ts
import { createOidcLoginHandler } from '@happyvertical/smrt-users/sveltekit';

export const GET = createOidcLoginHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL! },
});

// src/routes/auth/[provider]/callback/+server.ts
import { createOidcCallbackHandler } from '@happyvertical/smrt-users/sveltekit';

export const GET = createOidcCallbackHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL! },
  successRedirect: '/dashboard',
  // failureRedirect: '/login?error=oidc' (omit to return HTTP 401)
});

Need finer control? beginOidcLogin(event, options) and completeOidcLogin(event, options) expose the same steps for custom routes, and OidcLoginService.completeLogin() returns the verified claims, tokens, and the resolved user when you want to drive everything yourself.

Security properties (verified against source)

  • PKCE S256 β€” a 256-bit random code_verifier per transaction; the SHA-256 code_challenge goes on the authorization request.
  • state β€” random per-transaction value compared on callback; a mismatch is rejected.
  • nonce β€” random per-transaction value embedded in the request and checked against the ID token's nonce claim.
  • Signed transaction cookie β€” the verifier/state/nonce cookie is HMAC-SHA-256 signed (secret defaults to clientSecret) and verified with a constant-time compare; it expires (default 10 minutes) and is deleted after the callback.
  • Issuer + audience + azp β€” the ID token is verified against the discovered issuer and the configured clientId, with authorized-party (azp) validation for multi-audience tokens.
  • Open-redirect safe β€” post-login returnTo targets are kept local (must start with a single /).

Terminal Device-Code Auth (CLI login)

TerminalAuthService implements an OAuth-style device-code grant for terminal / CLI logins, where the device that starts the flow can't host a redirect URI. It is framework-agnostic (sets no cookies, parses no Request objects); the SvelteKit glue lives on the /sveltekit subpath.

The flow

  1. The CLI POSTs to the start handler β†’ gets a short user code (shown to the human), a long secret device code (kept by the CLI), and a verification URL.
  2. The user opens the verification URL in a browser, signs in normally, and approves the user code from a page wired up with mountTerminalLoginPage().
  3. The CLI polls the token handler with its device code; once approved it receives an opaque bearer token (a session id) usable as Authorization: Bearer ….
  4. hooks.server.ts resolves those bearer tokens via loadBearerSessionContext() alongside cookie sessions; logout revokes the token.
typescript
// src/routes/api/cli/auth/start/+server.ts
import { createTerminalAuthStartHandler } from '@happyvertical/smrt-users/sveltekit';
export const POST = createTerminalAuthStartHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL! },
  userCodePrefix: 'WG', // human-recognizable codes, e.g. WG-1A2B3C4D
});

// src/routes/api/cli/auth/token/+server.ts
import { createTerminalAuthTokenHandler } from '@happyvertical/smrt-users/sveltekit';
export const POST = createTerminalAuthTokenHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL! },
});

// src/routes/terminal-login/+page.server.ts -- the approval page
import { mountTerminalLoginPage } from '@happyvertical/smrt-users/sveltekit';
const handlers = mountTerminalLoginPage({
  db: { type: 'postgres', url: process.env.DATABASE_URL! },
  resolveUser: (event) => event.locals.user,
  resolveTenantId: (event) => event.locals.tenantId,
});
export const load = handlers.load;
export const actions = { approve: handlers.approve };

Best Practices

DO

  • Use resource.action format for permission slugs
  • Call seedSystemRoles() at app init β€” it's idempotent
  • Use groups for team-based access instead of many tenant roles
  • Apply GRANT overrides sparingly and document why
  • Use DENY overrides for exceptions and security restrictions (DENY always wins)
  • Always filter queries by tenantId for data isolation
  • Set session TTL based on security requirements β€” remember it's seconds

DON'T

  • Don't use getGroupIds() β€” it's cross-tenant; use getGroupIdsForTenant()
  • Don't forget to check membership status is ACTIVE
  • Don't trust user-supplied tenantId without verifying membership
  • Don't mix uppercase/lowercase in email comparisons (User collection auto-lowercases)