Organizations & tenancy
How tenancy, membership, roles, and the active-org contract work in Voke.
What is an organization?
An organization is the tenancy boundary in Voke. Most operational resources — plants, sub-devices, API keys, and trading-partner configs — are owned by exactly one organization. Data from one organization is never visible to another.
Device templates are the major exception: they are a global SuperAdmin-managed catalogue shared by all organizations.
Users can belong to multiple organizations simultaneously. Each membership carries an independent role scoped to that organization.
Roles
Organization membership is governed by OrgRole (from @cpi/shared/types/enums):
| Role | Description |
|---|---|
ORG_ADMIN | Full control: manage members, plants, templates, API keys, and trading config. |
OPERATOR | Read/write access to plants and telemetry; cannot manage members or org settings. |
VIEWER | Read-only access to plants and telemetry. |
SuperAdmin is a system-wide flag (isSuperAdmin: boolean on AuthenticatedUser), not an OrgRole value. SuperAdmins see every organization, receive a synthetic ORG_ADMIN role in all org lists, and bypass per-org RBAC checks. They are still required to pass a valid x-org-id header on org-scoped requests — see below.
Active organization resolution
Every auth response includes the user's organizations and a server-selected currentOrganization. The canonical TypeScript shape, from packages/shared/src/types/auth.ts:
export interface AuthOrganization {
id: string;
name: string;
slug: string;
role: OrgRole;
}
export interface AuthenticatedUser {
id: string;
email: string;
name: string;
role: UserRole;
isSuperAdmin: boolean;
defaultOrganizationId: string | null;
/**
currentOrganization: AuthOrganization | null;
organizations: AuthOrganization[];
}The admin SPA does not treat currentOrganization as the durable source of truth anymore. The active org flow is:
useOrgStorepersists the selected org inlocalStorageundervoke:current-org.OrgBootReconcilervalidates that stored org against every fresh/auth/meresponse.- If the stored org is still accessible, it wins, including after page refresh.
- If the stored org is no longer accessible, the client clears it, falls back to
user.currentOrganization ?? user.organizations[0] ?? null, and shows a toast. apiClientreads onlyvoke:current-orgwhen attachingx-org-id. It intentionally does not fall back tovoke:auth-user.currentOrganization.
defaultOrganizationId remains useful as cross-device preference, updated on org switch through PATCH /auth/default-org, but refresh durability in the admin UI is localStorage-first.
Mid-session 403 responses with ORG_MEMBERSHIP_REQUIRED, ORGANIZATION_NOT_FOUND, or ORG_CONTEXT_REQUIRED trigger the registered org-access-revoked handler: the client clears the stored org, refetches /auth/me, and lets the reconciler choose the fallback.
x-org-id header
All org-scoped API calls require the x-org-id request header set to the UUID of the target organization.
GET /api/v1/plants HTTP/1.1
x-org-id: 9f4e2a1b-3c5d-4e6f-8a9b-0c1d2e3f4a5b
Authorization: Bearer <token>The header is validated by OrgContextGuard before any service or database query runs:
| Condition | Error code |
|---|---|
| Header missing on an org-scoped endpoint | ORG_CONTEXT_REQUIRED |
| Header value is not a valid UUID | INVALID_UUID |
| Caller is not a member of the specified org | ORG_MEMBERSHIP_REQUIRED |
| Caller's role is insufficient for the operation | INSUFFICIENT_ORG_PERMISSIONS |
SuperAdmins must also supply a valid x-org-id; they are not exempt. The guard skips the membership check for SuperAdmins but still requires the header to be present and valid so downstream services always receive a non-null org context.
See Error codes for the full error schema.
SuperAdmin semantics
SuperAdmins are relevant to integrators in two ways:
- API key scope. A SuperAdmin can create API keys for any organization, but each key belongs to exactly one org. There is no organization-wildcard API key — every request still requires
x-org-id. - Organization list.
getUserOrganizations(userId, isSuperAdmin=true)returns every organization in the system, each with a syntheticORG_ADMINrole, ordered bycreatedAt ASC. This list drives the org switcher in the admin UI.
RBAC is otherwise unchanged: OrgRolesGuard (the guard protecting role-gated endpoints) short-circuits on isSuperAdmin and grants access without checking membership.
Implications for partners
An API key is tied to exactly one organization. VCP AMQP queues follow the naming scheme vcp.{queuePrefix}.*, where queuePrefix usually equals the organization slug. See Per-org AMQP queues for the full queue lifecycle and authentication contract.