s-m-r-t
Open the s-m-r-t source on GitHub Switch to dark color scheme
← Task guides

Task guide

Run a tenant from creation to a scoped request

Create tenants and their hierarchy. Give a user a membership and a role. Scope your models. Switch the active tenant safely. Identify the rules that the database enforces.

Verified against s-m-r-t 0.42.4

More in Build the foundation

This is the only guide in this family.

Tenant
Parent / childMembershipRole inheritanceProfile relationship
Organization, user access, and business relationships use different links.

Know which package holds what

The split between the two packages can cause an import error during a first attempt. smrt-users owns Tenant, User, Role, Membership, Permission, Session, and their collections. smrt-tenancy owns no models. smrt-tenancy is the context and enforcement layer. The package provides decorators, request context, the collection interceptor, and test helpers.

  • Tenant, TenantCollection, Membership, and MembershipCollection import from @happyvertical/smrt-users.
  • TenantScoped, tenantId, withTenant, and enableTenancy import from @happyvertical/smrt-tenancy.
  • The SvelteKit session handler lives at @happyvertical/smrt-users/sveltekit.
  • The SvelteKit tenant-context handle lives at @happyvertical/smrt-tenancy/adapters.

Create the schema and the system roles

Two things must exist before a membership can. A migration creates the tables. A seed call creates the built-in roles and populates the permission catalog. Both are ordinary application startup operations instead of CLI-only steps. Thus, scripts and tests can run them.

  • seedSystemRoles creates the owner, admin, member, and viewer roles.
  • syncPermissionCatalog is additive: it creates and updates permission rows, and never grants them to a role or deletes a stale one.
  • smrt db:migrate exists as a CLI equivalent; the programmatic call is used here because it works without CLI project discovery.
scripts/bootstrap.ts
typescript
import { ObjectRegistry, resolveDatabase } from '@happyvertical/smrt-core';
import { migrateSmrtSchemas } from '@happyvertical/smrt-core/migrations';
import { RoleCollection, syncPermissionCatalog } from '@happyvertical/smrt-users';

const dbConfig = { type: 'postgres', url: process.env.DATABASE_URL! } as const;
const db = await resolveDatabase(dbConfig, {
  schemas: ObjectRegistry.getAllSchemasAsDefinitions()
});

await migrateSmrtSchemas({ db, packageName: 'my-app' });

const roles = await RoleCollection.create({ db: dbConfig });
await roles.seedSystemRoles({ seedPermissions: true });

await syncPermissionCatalog({ db: dbConfig });

Create a tenant, then a child tenant

A tenant collection is created through the inherited static factory, and create() already writes the row — there is no separate save() step. createChild derives the hierarchy fields for you: the child receives the parent id, a hierarchy level one deeper, and its materialized path.

  • The parent field is parentTenantId, and the hierarchy is capped at ten levels.
  • cascadePermissions is the parent offering authority downward; inheritPermissions is the child accepting it. Both must be true for inheritance to happen.
  • findRoots, getAncestors, getDescendants, moveToParent, and getTree cover the rest of the hierarchy; a cycle raises TenantHierarchyError with code CIRCULAR_REFERENCE.
src/lib/server/tenants.ts
typescript
import { TenantCollection, TenantStatus } from '@happyvertical/smrt-users';

const tenants = await TenantCollection.create({ db: dbConfig });

const network = await tenants.create({
  name: 'Northern Network',
  slug: 'northern-network',
  status: TenantStatus.ACTIVE,
  cascadePermissions: true
});
// hierarchyLevel 0, parentTenantId null

const chapter = await tenants.createChild(network.id, {
  name: 'Edmonton Chapter',
  slug: 'edmonton-chapter',
  inheritPermissions: true
});
// hierarchyLevel 1, parentTenantId === network.id

await tenants.findChildren(network.id); // [ Edmonton Chapter ]

Give a user access through a membership

A membership joins one user, one tenant, and one role. There is no addMember helper. Create the row like any other row. This operation keeps the role decision explicit at the call site instead of hiding it in a convenience method.

  • TenantService.createTenantWithOwnership(userId, name) does the tenant and the owner membership together, and requires seedSystemRoles to have run.
  • Membership status is active, inactive, or pending; only an active membership authorizes a tenant switch.
  • PermissionResolver.hasPermission(userId, tenantId, slug) answers the authorization question these records exist to support.
src/lib/server/invite.ts
typescript
import {
  MembershipCollection, MembershipStatus, RoleCollection
} from '@happyvertical/smrt-users';

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

const admin = await roles.findBySlug('admin');

await memberships.create({
  userId: user.id,
  tenantId: chapter.id,
  roleId: admin.id,
  status: MembershipStatus.ACTIVE
});

await memberships.findByUserAndTenant(user.id, chapter.id);

Put your own models inside the boundary

Marking a model tenant-scoped connects it to the interceptor. Either spelling below registers the same configuration. The decorator form keeps the tenant field visible in the class. The core-option form avoids a smrt-tenancy import in the model file.

  • mode is required or optional. There is no global mode: a shared row is optional mode with a nullable tenant field holding null.
  • autoFilter and autoPopulate default to true; allowSuperAdminBypass defaults to false and must be opted into per class.
  • A required-mode class whose tenant field is non-nullable rejects a create that omits it, because model validation runs before the interceptor can populate the value. Declare the field nullable, or pass tenantId explicitly.
src/lib/objects/Document.ts
typescript
import { smrt, SmrtObject } from '@happyvertical/smrt-core';
import { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';

@smrt({ api: true })
@TenantScoped()
export class Document extends SmrtObject {
  @tenantId()
  tenantId = '';

  title = '';
}

// Equivalent, without importing smrt-tenancy here:
// @smrt({ api: true, tenantScoped: { mode: 'required' } })

Establish the context a request runs in

Nothing is scoped until something establishes the tenant context. In SvelteKit, two handles run in sequence. The tenancy handle opens the asynchronous request context. The session handler resolves the signed-in user, membership, permission snapshot, and active tenant. Outside a request, withTenant supplies the same context around a script, job, or test function.

  • The session handler populates locals.user, locals.membership, locals.permissions, locals.tenantId, and locals.sessionId — the SessionLocals interface your app.d.ts should extend.
  • The tenancy handle populates locals.tenantContext and locals.tenantId. Type that local as TenantContextData; TenantContext itself is a value, not a type.
  • Both packages describe the event structurally to avoid depending on @sveltejs/kit, so the casts are expected rather than a smell.
  • For a job or a CLI entry point, use createCliContext or runTenantScopedEntryPoint so an unscoped run fails instead of quietly reading everything.
src/hooks.server.ts
typescript
import { sequence } from '@sveltejs/kit/hooks';
import { createSvelteKitHandle } from '@happyvertical/smrt-tenancy/adapters';
import { enableTenancy } from '@happyvertical/smrt-tenancy';
import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';

enableTenancy();

const tenancyHandle = createSvelteKitHandle({
  resolveTenantId: async (event) => resolveTenantFromHost(event)
}) as unknown as Handle;

const sessionHandle = createSessionHandler({
  ...getSmrtConfig('Session'),
  enterTenantContext: true,
  postgresRls: true
}) as unknown as Handle;

export const handle = sequence(tenancyHandle, sessionHandle);

Switch the active tenant

Tenant switching is reachable from user input, so its controls are important. The helper verifies the caller has an active membership in the target tenant before it writes. Then, it creates a new session and revokes the old one. Thus, a stolen identifier cannot follow the user into the new tenant.

  • The membership check is fail-closed, so the tenant id may come from untrusted form data — but the Boolean result must be honored.
  • A successful switch into a tenant rotates the session id; the helper re-sets the cookie for you.
  • Clearing the tenant by passing null does not rotate.
  • If you call SessionService.switchTenant directly, persist the returned sessionId yourself.
src/routes/api/tenant/switch/+server.ts
typescript
import { error, json } from '@sveltejs/kit';
import { switchSessionTenant } from '@happyvertical/smrt-users/sveltekit';

export const POST = async (event) => {
  const { tenantId } = await event.request.json();

  const switched = await switchSessionTenant(event, tenantId, {
    ...getSmrtConfig('Session'),
    cookieName: 'sid',
    cookieSecure: true,
    cookieSameSite: 'lax'
  });

  if (!switched) error(409, 'No active membership in that tenant');
  return json({ ok: true });
};

What the data layer actually enforces

After interceptor enablement and class registration, list, get, count, save, and delete are covered. Reads and writes have different cross-tenant behavior. A read for the wrong tenant returns no result. A write that names the wrong tenant throws an error.

  • list() and count() receive the tenant predicate, so rows from another tenant are absent.
  • get() with a bare id becomes a lookup on id and tenant together. It returns null across a boundary instead of throwing an error.
  • An explicit filter naming a different tenant throws TenantIsolationError, code TENANT_ISOLATION_VIOLATION.
  • A save whose tenant field disagrees with the context throws the same error; an empty field is populated from the context instead.
  • Any covered operation with no context at all, on a required-mode class, throws TenantContextError, code TENANT_CONTEXT_REQUIRED.
boundary.ts
typescript
await withTenant({ tenantId: 'acme' }, async () => {
  await documents.create({ title: 'Acme plan', tenantId: 'acme' });
});

await withTenant({ tenantId: 'globex' }, async () => {
  await documents.list();          // only globex rows
  await documents.get(acmeDocId);  // null

  await documents.create({ title: 'x', tenantId: 'acme' });
  // TenantIsolationError: cannot save Document with tenantId 'acme'
  // in context of tenant 'globex'
});

await documents.list();
// TenantContextError: Tenant context required for listing Document.
// Use withTenant() or configure TenantContext middleware.

What it does not enforce

Isolation is a property of the paths that go through a scoped collection. Four gaps are deliberate, and an application that assumes otherwise will have holes that no test on the happy path will find.

  • Optional-mode reads with no context pass through unfiltered at the interceptor. Generated REST routes compensate by asking for global rows only; a hand-written route does not.
  • Raw SQL is gated, not filtered. The policy can throw, warn, or allow, but no tenant predicate is ever added, and a database handle obtained outside a collection bypasses interceptors entirely.
  • Context does not survive an asynchronous boundary, such as a timer, emitter, or queue consumer. Wrap the callback with TenantContext.bind or runWithJobContext.

Add row-level security where the database supports it

On PostgreSQL, the database can enforce the same rules. Thus, a query that skips the collection layer is still constrained. Generated policies read request-scoped settings that the session layer publishes inside the transaction. On SQLite, the same permission set is resolved and carried. However, the database does not enforce each operation.

  • Policies are generated only for required-mode tenant-scoped objects backed by a table no other object shares; anything else is reported in skipped, with the reason.
  • The read and write policies map to the collection’s read, create, update, and delete permissions.
  • generatePostgresPermissionSql returns the same statements without executing them, which is what you want in a reviewed migration.
  • Set postgresRls on the session handler so each request runs in a transaction carrying its own tenant and permission settings.
scripts/apply-rls.ts
typescript
import { applyPostgresPermissionPolicies } from '@happyvertical/smrt-users';

const { statements, targets, skipped } =
  await applyPostgresPermissionPolicies({ db: dbConfig });

// ALTER TABLE "public"."documents" ENABLE ROW LEVEL SECURITY
// ALTER TABLE "public"."documents" FORCE ROW LEVEL SECURITY

Prove it with a test

The boundary is worth a test that fails loudly when it regresses, and the tenancy package ships assertions for exactly the two error codes above. The testing guide covers the database harness these tests run on.

src/lib/objects/__tests__/isolation.test.ts
typescript
import { assertTenantContextRequired, assertTenantIsolationViolation, withTenant }
  from '@happyvertical/smrt-tenancy';

it('requires a context', async () => {
  await assertTenantContextRequired(() => documents.list());
});

it('refuses a foreign filter', async () => {
  await withTenant({ tenantId: 'acme' }, async () => {
    await assertTenantIsolationViolation(() =>
      documents.list({ where: { tenantId: 'globex' } }));
  });
});