@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.
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
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
// Creates: owner, admin, member, viewer (idempotent)
await roles.seedSystemRoles();Step 3: Create User & Tenant
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
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);Architecture
Multi-Tenancy Model
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
- Base Role Permissions - User's membership role defines base capabilities
- Group Role Permissions - Additional roles from group membership (union)
- Permission Overrides - Grant adds permissions, Deny removes them
- Effective Permissions - Final resolved set (DENY takes precedence)
System vs Tenant Roles
| Type | Scope | Protected | Use Case |
|---|---|---|---|
| System | All tenants | Yes | Owner, Admin, Member, Viewer |
| Tenant | Single tenant | No | Custom roles (Editor, Moderator, etc.) |
Tenant Policies
| Mode | Auto-create | Min Tenants | Use Case |
|---|---|---|---|
| flexible | No | 0 | Multi-org SaaS |
| personal | Yes | 0 | Personal workspace apps |
| required | Yes | 1 | Single tenant per user |
Permission Resolution
Resolution Algorithm
- Find Membership - Get user's ACTIVE membership in tenant
- Base Permissions - Query RolePermission for membership's role
- Group Permissions - For each group, get all group roles and their permissions (union)
- Apply Overrides - Add GRANT overrides, remove DENY overrides (DENY wins)
PermissionResolver Methods
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
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
// 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
// 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)
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
// 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
// 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 membershipsTutorial 3: Permission Overrides
Grant Extra Permission
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'
);
// trueDeny Dangerous Permission
// 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
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
// 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
Best Practices
✅ DO
- Use
resource.actionformat 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
// 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);