s-m-r-t

@happyvertical/smrt-users

Multi-tenant user management with hierarchical RBAC, group-based permissions, and flexible tenant policies. Built for SaaS applications requiring sophisticated access control.

v0.19.0Multi-tenant RBACESM

Overview

The smrt-users package provides a complete multi-tenant user management system with role-based access control (RBAC), group-based permission inheritance, and per-user permission overrides.

Key Features

  • Multi-tenant architecture - Users belong to multiple tenants with different roles
  • Hierarchical RBAC - Base role → group roles → permission overrides
  • Group-based teams - Flexible team structure within tenants
  • DENY-takes-precedence - Security-first override system
  • System & tenant roles - Reusable roles across tenants or tenant-specific
  • Session management - Tenant context switching and session lifecycle
  • OIDC integration - Seamless authentication via smrt-profiles
  • Tenant policies - Flexible, personal, or required tenant modes

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)
  ↓
Membership (user + tenant + role)
  ├─→ Tenant (organizational boundary)
  ├─→ Role (permission template)
  │    ├─→ RolePermission
  │    └─→ Permission (named capabilities)
  ├─→ MembershipOverride (per-user grant/deny)
  └─→ Group (teams within tenant)
       ├─→ GroupMember (user → group)
       └─→ GroupRole (group → role)

The Four Permission Layers

  1. Base Role Permissions - User's membership role defines base capabilities
  2. Group Role Permissions - Additional roles from group membership (union)
  3. Permission Overrides - Grant adds permissions, Deny removes them
  4. Effective Permissions - Final resolved set (DENY takes precedence)

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. Find Membership - Get user's ACTIVE membership in tenant
  2. Base Permissions - Query RolePermission for membership's role
  3. Group Permissions - For each group, get all group roles and their permissions (union)
  4. Apply Overrides - Add GRANT overrides, remove DENY overrides (DENY wins)

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: OIDC Integration

Login with OIDC

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

// In your OIDC callback handler
const users = await UserCollection.create({ db });
const { user, profile, created } = await users.getOrCreateFromOidc(
  {
    sub: tokenClaims.sub,
    iss: tokenClaims.iss,
    email: tokenClaims.email,
    name: tokenClaims.name,
    picture: tokenClaims.picture
  },
  'google' // or 'kanidm', 'okta', etc.
);

console.log('User logged in:', user.email);
console.log('First login?', created);

Apply Tenant Policy

typescript
// Create tenant service with personal policy
const tenantService = await TenantService.create(db, {
  mode: 'personal',
  maxTenants: 5,
  defaultName: 'My Workspace'
});

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

if (tenantCreated) {
  console.log('Created personal workspace:', tenant.name);
}

// Create session with tenant context
const sessions = await SessionCollection.create({ db });
const session = await sessions.createSession({
  userId: user.id,
  tenantId: tenant.id,
  ttl: 7 * 24 * 60 * 60, // 7 days
  userAgent: req.headers['user-agent'],
  ipAddress: req.ip
});

// Store session ID in cookie
res.cookie('session', session.id, { httpOnly: true });

UI Components

smrt-users integrates with @happyvertical/smrt-svelte for UI components:

Available Components

  • User Components: UserCard, UserAvatar, UserList, UserForm, UserMenu, InviteUserModal
  • Tenant Components: TenantCard, TenantSwitcher
  • Role Components: RoleBadge, RoleSelector
  • Permission Components: PermissionCheck (conditional rendering)
  • Membership Components: MembershipCard, MembershipList

View component documentation →

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);