@happyvertical/smrt-users
Multi-tenant user management with RBAC, hierarchical tenants, session handling, and SvelteKit integration.
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.DENYbeats any GRANT;TenantPermissionOverridesupportsINHERIT/GRANT/DENYat 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)
| Model | Key Pattern |
|---|---|
| User | Auth identity. profileId is a plain string (not FK) to smrt-profiles. Email
auto-lowercased. |
| Tenant | STI + hierarchical parent-child. Materialized hierarchyPath and hierarchyLevel. Max depth 10. |
| Session | Server-side. Secure UUID. TTL in seconds. Status auto-updates to EXPIRED on access. |
| MagicLinkToken | Single-use email login token. Backed by MagicLinkService. |
| Role | tenantId = null β system role (available to all tenants). isSystem: true blocks deletion. |
| Permission | Slug format: resource.action. Parsed by PermissionResolver. |
| Membership | User + Tenant + Role junction. UNIQUE(userId, tenantId). |
| Group | Team within a tenant. Multiple roles via GroupRole. |
| GroupMember, GroupRole, RolePermission | Join tables. |
| MembershipOverride | Per-user permission grant/deny. DENY always wins. |
| TenantPermissionOverride | Tenant-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
pnpm add @happyvertical/smrt-users
# or
npm install @happyvertical/smrt-usersDatabase Requirements
- SQLite (development):
{ type: 'sqlite', url: 'app.db' } - PostgreSQL (production):
{ type: 'postgres', url: 'postgresql://...' }
Quick Start (5 Minutes)
Step 1: Initialize Collections
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)
// 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
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
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
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.
- Tenant hierarchy β walk ancestors, applying
TenantPermissionOverrideat each level (INHERIT/GRANT/DENY) - Membership role β base permissions from the user's role in the tenant
- Group roles β permissions from all groups the user belongs to in that tenant (union)
- Membership overrides β final per-user GRANT/DENY.
MembershipOverride.DENYtakes 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
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:
- 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. - Config β extra slugs under
packages.users.permissions.custominsmrt.config.ts. - 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.
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 listPostgres 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.
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.
// 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:
smrt_rls_bypass()
OR (
tenant_id::text = smrt_current_tenant_id()
AND smrt_has_permission('articles.read')
)smrt_current_tenant_id()readssmrt.tenant_id;smrt_has_permission(slug)tests membership in the JSONsmrt.permissionsarray.smrt_rls_bypass()short-circuits totruewhensmrt.system_contextorsmrt.super_admin_bypassis 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-calculateshierarchyPathandhierarchyLevel, enforcing the max depth of 10moveToParent()updates the tenant and all descendants' paths and levelscascadePermissions(parent pushes down) +inheritPermissions(child accepts) β both must be true for permission cascadegetTree(rootId?)returns a nested structure for UI rendering
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); // 1SvelteKit Integration
Session Hooks
// 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
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).
// 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.
// 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_verifierper transaction; the SHA-256code_challengegoes 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
nonceclaim. - 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
returnTotargets 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
- 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.
- The user opens the verification URL in a browser, signs in normally, and approves the user
code from a page wired up with
mountTerminalLoginPage(). - 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 β¦. hooks.server.tsresolves those bearer tokens vialoadBearerSessionContext()alongside cookie sessions; logout revokes the token.
// 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.actionformat 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
tenantIdfor data isolation - Set session TTL based on security requirements β remember it's seconds
DON'T
- Don't use
getGroupIds()β it's cross-tenant; usegetGroupIdsForTenant() - Don't forget to check membership status is ACTIVE
- Don't trust user-supplied
tenantIdwithout verifying membership - Don't mix uppercase/lowercase in email comparisons (User collection auto-lowercases)