@happyvertical/smrt-users

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

v0.20.44Multi-tenant RBAC12 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.

Key Features

  • 4-level permission cascade - Tenant hierarchy, membership role, group roles, membership overrides
  • Hierarchical tenants - Parent-child trees (max depth 10) with cascading permissions
  • Group-based teams - Flexible team structure within tenants via GroupRole
  • DENY-takes-precedence - Security-first override system at both membership and tenant levels
  • System & tenant roles - System roles (owner/admin/member/viewer) available to all tenants, plus tenant-specific custom roles
  • Session management - Server-side sessions with secure UUID, TTL in seconds, automatic expiry
  • SvelteKit integration - Session hooks, cookie management, tenant context switching
  • Tenant policies - Flexible, personal, or required tenant modes via TenantService

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

typescript
// Creates: owner, admin, member, viewer (idempotent)
await roles.seedSystemRoles();

Step 3: Create User & Tenant

typescript
const user = await users.create({
  email: 'user@example.com',
  profileId: 'profile-123'
});
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);

Architecture

Multi-Tenancy Model

typescript
User (authenticated identity, email auto-lowercased)
  ↓
Membership (user + tenant + role, UNIQUE userId+tenantId)
  ├─→ Tenant (organizational boundary, STI, hierarchical)
  │    └─→ TenantPermissionOverride (INHERIT/GRANT/DENY)
  ├─→ Role (permission template, tenantId=null → system role)
  │    ├─→ RolePermission
  │    └─→ Permission (slug: resource.action)
  ├─→ MembershipOverride (per-user grant/deny, DENY wins)
  └─→ Group (teams within tenant)
       ├─→ GroupMember (user → group)
       └─→ GroupRole (group → role)

The Four Permission Layers (Cascade Order)

  1. Tenant hierarchy - Walk ancestors, apply TenantPermissionOverride at each level
  2. Membership role - Base permissions from user's role in the tenant
  3. Group roles - Permissions from all groups user belongs to in that tenant
  4. Membership overrides - Per-user GRANT/DENY (DENY always wins)

System vs Tenant Roles

TypeScopeProtectedUse Case
SystemAll tenantsYesOwner, Admin, Member, Viewer
TenantSingle tenantNoCustom roles (Editor, Moderator, etc.)

Tenant Policies

ModeAuto-createMin TenantsUse Case
flexibleNo0Multi-org SaaS
personalYes0Personal workspace apps
requiredYes1Single tenant per user

Permission Resolution

Resolution Algorithm

  1. Tenant hierarchy - Walk tenant ancestors, apply TenantPermissionOverride (INHERIT/GRANT/DENY) at each level
  2. Membership role - Query RolePermission for user's membership role in the tenant
  3. Group roles - For each group the user belongs to in that tenant, get all group roles and their permissions (union)
  4. Membership overrides - Apply per-user GRANT/DENY overrides (DENY takes absolute precedence)

PermissionResolver Methods

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

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

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

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

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

Tutorials

Tutorial 1: Multi-Tenant Setup from Scratch

Initialize System

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

// Initialize all collections
const db = { type: 'sqlite', url: 'app.db' };
const users = await UserCollection.create({ db });
const tenants = await TenantCollection.create({ db });
const roles = await RoleCollection.create({ db });
const memberships = await MembershipCollection.create({ db });
const permissions = await PermissionCollection.create({ db });

// Seed system roles (idempotent)
await roles.seedSystemRoles();

Create Tenant Owner

typescript
// Create user
const owner = await users.create({
  email: 'owner@company.com',
  profileId: 'profile-owner-123'
});
await owner.save();

// Create tenant
const company = await tenants.create({
  name: 'Acme Corporation',
  description: 'Our main tenant'
});
await company.save();

// Make user the owner
const ownerRole = await roles.findBySlug('owner');
const ownerMembership = await memberships.create({
  userId: owner.id,
  tenantId: company.id,
  roleId: ownerRole.id
});
await ownerMembership.save();

Invite Team Members

typescript
// Create team member
const member = await users.create({
  email: 'member@company.com',
  profileId: 'profile-member-456'
});
await member.save();

// Assign member role
const memberRole = await roles.findBySlug('member');
const memberMembership = await memberships.create({
  userId: member.id,
  tenantId: company.id,
  roleId: memberRole.id,
  status: 'PENDING' // Awaiting acceptance
});
await memberMembership.save();

// Later: accept invitation
memberMembership.status = 'ACTIVE';
await memberMembership.save();

Tutorial 2: Group-Based Team Access

Create Teams (Groups)

typescript
import { GroupCollection, GroupMemberCollection, GroupRoleCollection } from '@happyvertical/smrt-users';

const groups = await GroupCollection.create({ db });
const groupMembers = await GroupMemberCollection.create({ db });
const groupRoles = await GroupRoleCollection.create({ db });

// Create editorial team
const editorial = await groups.create({
  tenantId: company.id,
  name: 'Editorial Team',
  description: 'Content creators and editors'
});
await editorial.save();

// Create engineering team
const engineering = await groups.create({
  tenantId: company.id,
  name: 'Engineering Team',
  description: 'Developers and technical staff'
});
await engineering.save();

Assign Roles to Groups

typescript
// Create custom editor role
const editorRole = await roles.create({
  tenantId: company.id,
  name: 'Content Editor',
  description: 'Can create and edit content'
});
await editorRole.save();

// Add permissions to editor role
const createArticle = await permissions.findOrCreate('articles.create', {
  name: 'Create Articles',
  category: 'articles'
});
const editArticle = await permissions.findOrCreate('articles.update', {
  name: 'Edit Articles',
  category: 'articles'
});

await rolePermissions.addPermission(editorRole.id, createArticle.id);
await rolePermissions.addPermission(editorRole.id, editArticle.id);

// Assign role to editorial group
await groupRoles.addRole(editorial.id, editorRole.id);

Add Users to Groups

typescript
// Add users to editorial team
await groupMembers.addMember(editorial.id, user1.id);
await groupMembers.addMember(editorial.id, user2.id);
await groupMembers.addMember(editorial.id, user3.id);

// Users now have editor permissions through group membership
// No need to update individual memberships

Tutorial 3: Permission Overrides

Grant Extra Permission

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

const overrides = await MembershipOverrideCollection.create({ db });

// User is a viewer but needs to publish articles
const publishPerm = await permissions.findOrCreate('articles.publish', {
  name: 'Publish Articles',
  category: 'articles'
});

await overrides.grantPermission(membership.id, publishPerm.id);

// Verify permission granted
const resolver = await PermissionResolver.create({ db });
const canPublish = await resolver.hasPermission(
  user.id,
  tenant.id,
  'articles.publish'
);
// true

Deny Dangerous Permission

typescript
// User is an admin but shouldn't delete users
const deleteUserPerm = await permissions.findOrCreate('users.delete', {
  name: 'Delete Users',
  category: 'users'
});

await overrides.denyPermission(membership.id, deleteUserPerm.id);

// Permission resolution now excludes this permission
const canDelete = await resolver.hasPermission(
  user.id,
  tenant.id,
  'users.delete'
);
// false (DENY takes precedence)

Tutorial 4: 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
  skipPaths: ['/api/health'],
});
// Populates event.locals: { user, permissions, tenantId, sessionId }

Login / Logout

typescript
// +page.server.ts
import {
  createSessionCookie,
  destroySessionCookie,
  switchSessionTenant
} from '@happyvertical/smrt-users/sveltekit';

// Login
await createSessionCookie(event, userId, tenantId, { db });

// Logout
await destroySessionCookie(event, { db });

// Switch tenant context
await switchSessionTenant(event, newTenantId, { db });

Tenant Policy

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

// TenantService supports three modes:
// - flexible: no auto-create (multi-org SaaS)
// - personal: auto-create on first login, deletable
// - required: auto-create, must keep at least one

const tenantService = await TenantService.create(db, {
  mode: 'personal',
  maxTenants: 5,
  defaultName: 'My Workspace'
});

// Ensure user has a tenant
const { tenant, membership, created } =
  await tenantService.ensureTenantForUser(user.id, {
    email: user.email,
    name: profile.name
  });

Best Practices

✅ DO

  • Use resource.action format for permission slugs
  • Create minimal system roles, extend with tenant roles as needed
  • Use groups for team-based access instead of many tenant roles
  • Apply GRANT overrides sparingly, document why granted
  • Use DENY overrides for exceptions and security restrictions
  • Always filter queries by tenantId for data isolation
  • Normalize emails to lowercase automatically
  • Set appropriate session TTL based on security requirements

❌ DON'T

  • Don't create too many tenant-specific roles (use groups instead)
  • Don't forget to check membership status is ACTIVE
  • Don't rely on user input for tenantId (verify membership)
  • Don't expose permission IDs to client-side code
  • Don't mix uppercase/lowercase in email comparisons
  • Don't allow cross-tenant data access without verification

Troubleshooting

Permission denied for allowed user

Cause: Membership status is not ACTIVE.

Solution: Check membership status and update if needed.

User can't see tenant

Cause: No membership exists.

Solution: Create membership with appropriate role.

Group permissions not working

Cause: User not in group.

Solution: Verify GroupMember relationship exists.

DENY override not working

Cause: GRANT applied after DENY in code logic.

Solution: DENY is always applied last in PermissionResolver (should work automatically).

Debug Permission Resolution

typescript
// Get full resolution details
const result = await resolver.resolvePermissions(userId, tenantId);

console.log('Membership:', result.membershipId);
console.log('Base role:', result.roleId);
console.log('Groups:', result.groupIds);
console.log('Permissions:', Array.from(result.permissions));
console.log('Denied:', result.deniedPermissionIds);