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):
- They sign up.
- After signup, redirect to invitation acceptance.
- 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.