Skip to content
Africa OS Knowledgebase
Esc
navigateopen⌘Jpreview
On this page

Multi-tenancy

How organizations act as the tenant boundary - the organization model, lifecycle, memberships, and the active-organization context.

Multi-tenancy is the backbone of Africa OS. An organization is the tenant: the unit that owns data, the unit that applications get enabled for, and the boundary every permission is scoped to. This page explains the model in code.

The core model

Two separate notions of “user” exist, and it is important to keep them straight:

  • public.user is the identity row that Better Auth owns (login, sessions, accounts). Its id is the identity id.
  • platform.users is the platform profile, keyed by identity_id. Its id is the platform user id that memberships, roles, and permissions reference.

The request runtime bridges the two: it authenticates the identity, then upserts the platform profile, and everything downstream works with the platform user id.

The organization lifecycle

Every organization moves through a state machine, defined in platform/organizations/src/lifecycle.ts:

The transitions table:

export const organizationTransitions = {
  pending: ["reviewing", "suspended"],
  reviewing: ["verified", "suspended"],
  verified: ["suspended"],
  suspended: ["reviewing"],
};

Verification is a manual administrative step performed in the admin console. Any transition not listed fails with IllegalOrganizationTransition. New organizations are created as pending, and the creator becomes an active member holding the owner role.

Memberships

A membership is the link between a user and an organization, and it has its own lifecycle (platform/organizations/src/membership.ts):

The transitions table:

export const membershipTransitions = {
  invited: ["active", "removed"],
  active: ["suspended", "removed"],
  suspended: ["active", "removed"],
  removed: [],
};

A removed member cannot come back through the state machine - they must be re-invited. MembershipService.invite looks the user up by email on public.user, refuses duplicate memberships with AlreadyMember, and inserts the membership as invited.

Creating an organization, transactionally

OrganizationService.create runs inside withTransaction, so the whole creation is atomic. Inside the transaction it:

  1. Slugifies the name, deduplicating until the slug is free (name, name-2, name-3, …).
  2. Inserts the organization with status pending.
  3. Inserts the creator as an active member.
  4. Creates an owner role scoped to the organization, wired to every registered permission.
  5. Grants the owner role to the creator’s membership.

The result: the founder can do anything inside their organization until it builds its own access model.

const organization = yield* OrganizationService.create({
  name: "Acme Retail",
  ownerUserId: platformUserId,
});

Listing a user’s organizations

OrganizationService.listForUser(userId) returns every organization where the user has an active membership, ordered by creation. This is what the web app’s organization switcher shows, and it is the input to active-organization resolution.

The active organization

A user can belong to many organizations, but a request happens in one. The request runtime resolves the active organization (platform/request-context/src/runtime.ts):

  1. A :organization slug in the URL wins, but only if it resolves to a real organization that is one of the user’s active memberships (NotOrganizationMember otherwise).
  2. Otherwise, the africaos.active_org cookie wins if it names an active membership.
  3. Otherwise, the first active membership.

The resolved organization is provided to programs as CurrentOrganization. A user with no memberships carries Option.none(), meaning no tenant context.

Tenancy rules that never change

  • The tenant id always comes from the request runtime, never from client input. Server functions receive CurrentOrganization and use its id; client-supplied organization ids are rejected or ignored.
  • Platform queries are scoped by the caller. The database layer provides orgWhere/orgScope helpers to keep queries tenant-scoped; see Database.
  • Application availability is an organization concern. Whether an org can use an application is decided by organization_applications, checked by RegistryService.getForOrganization.
  • Permissions are resolved per organization. The same user may hold different capabilities in different organizations.

Next steps

Last updated on August 18, 2026