Olympus Docs
CookbookMulti-tenant

User belongs to multiple organizations

Org-switching UX patterns

A user might be in multiple tenants (e.g., consultant working for several customers; freelancer with personal + work accounts). Different from one-user-one-tenant.

Data model

-- Identity stays in Kratos (single sign-in identity)
-- Memberships are in your app DB

CREATE TABLE memberships (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  identity_id UUID NOT NULL,    -- Olympus identity
  org_id UUID NOT NULL,
  role TEXT NOT NULL DEFAULT 'member',
  joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (identity_id, org_id)
);

CREATE TABLE orgs (
  id UUID PRIMARY KEY,
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Session state

Beyond Olympus's identity, app session needs "current org":

CREATE TABLE app_sessions (
  id UUID PRIMARY KEY,
  identity_id UUID NOT NULL,
  active_org_id UUID,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Or store in cookie:

await setCookie("active_org", orgId, { httpOnly: true, secure: true, sameSite: "lax" });

Switching orgs

// app/components/OrgSwitcher.tsx
"use client";
import { useMemberships } from "@/lib/hooks";

export function OrgSwitcher() {
  const memberships = useMemberships();
  const active = useActiveOrg();
  
  return (
    <Select value={active.id} onChange={switchOrg}>
      {memberships.map(m => (
        <option key={m.org_id} value={m.org_id}>{m.org_name}</option>
      ))}
    </Select>
  );
}

async function switchOrg(orgId: string) {
  await fetch(`/api/active-org`, {
    method: "POST",
    body: JSON.stringify({ orgId }),
  });
  window.location.reload();  // refresh data scoped to new org
}

Data scoping

Every query in your app must be scoped to active_org:

// Good
const projects = await db`SELECT * FROM projects WHERE org_id = ${activeOrgId}`;

// Bad, leaks data across orgs
const projects = await db`SELECT * FROM projects`;

Linter / typecheck to enforce, or wrap your DB client:

class OrgScopedDb {
  constructor(private orgId: string) {}
  async query<T>(table: string, conditions: object) {
    return db.from(table).where({ org_id: this.orgId, ...conditions });
  }
}

const orgDb = new OrgScopedDb(activeOrgId);
const projects = await orgDb.query("projects", {});

Permissions per org

Role is per-membership:

function canEditProject(membership, project) {
  if (project.org_id !== membership.org_id) return false;  // wrong org
  return membership.role === "admin" || membership.role === "editor";
}

User might be admin in Org A and viewer in Org B.

Invitations

Existing user accepts invite → membership created, no new identity:

async function acceptInvitation(token: string) {
  const session = await getSession();
  const inv = await getInvitation(token);
  if (inv.email !== session.identity.traits.email) return error("wrong_email");
  
  await db.insert(memberships).values({
    identity_id: session.identity.id,
    org_id: inv.org_id,
    role: inv.role,
  });
  
  // Switch to new org
  await setActiveOrg(inv.org_id);
  return redirect("/");
}

If invitation is for a NEW email (user doesn't have Olympus identity yet):

  1. They sign up.
  2. After signup, redirect to invitation acceptance.
  3. Membership added.

Subdomain per org

Common UX: each org has a subdomain acme.your-app.com.

// middleware
const subdomain = req.hostname.split(".")[0];
const org = await db`SELECT * FROM orgs WHERE slug = ${subdomain}`.first();
if (!org) return notFound();
const membership = await db`SELECT * FROM memberships WHERE identity_id = ${session.identity.id} AND org_id = ${org.id}`.first();
if (!membership) {
  // User not in this org
  return redirect("/orgs");  // list of their orgs
}
req.org = org;
req.membership = membership;

User typing acme.your-app.com → if they're a member of Acme, they're in. If not, redirect.

Cross-org operations

Sometimes user does something that spans orgs (admin reporting, etc.). Special permission:

async function canViewCrossOrgReport(identity_id) {
  // Maybe only certain users can do this
  const isAdmin = await db`SELECT 1 FROM superusers WHERE identity_id = ${identity_id}`.first();
  return !!isAdmin;
}

Most apps: cross-org operations only by you / your team, not by customers.

Audit log

Audit per org. Member's action in Org A is in Org A's log:

INSERT INTO security_audit (identity_id, org_id, action, ...)
VALUES ($identity, $current_org, $action, ...);

When user switches to Org B and does X, that's in Org B's log.

Single sign-in but per-org session

User signs into Olympus once. But within app, each org is a sandbox.

Switching orgs doesn't require re-auth. Just updates active_org_id.

When to use this pattern

Best for B2B SaaS where:

  • Users genuinely operate in multiple orgs.
  • Orgs share an authentication system but separate data.

Not needed if:

  • Each user belongs to exactly one tenant.
  • Cross-org collaboration is rare.

Simpler: one-user-one-tenant model.

On this page