Organizations
An organization is the top-level tenant boundary in PlayTelly. Every user, workspace, and app grant belongs to exactly one organization. Organizations are split across two systems:
- AuthAPI (auth slice) — owns the Zitadel org, the user's login identity, and invite codes.
- CoreAPI (business slice) — owns the
organizations,organization_members, andorg_app_accesstables that this domain manages.
A special platform organization (00000000-0000-0000-0000-000000000001) represents PlayTelly staff rather than a customer tenant, and is where platform-scope roles (PLATFORM_OWNER, PLATFORM_ADMIN, etc.) are assigned. Its app access is hardcoded to "all apps" and cannot be edited (PUT .../app-access returns 403 for this org).
Handles:
- Org creation — platform admin only, creates the org plus its first admin user in one call
- Org member invitations — invite, reinvite, list
- Org member role & account-status management
- Org app access — which apps (products) the org's users can use
- Role catalog — list system roles, view the permission matrix, define custom roles
- Internal sync endpoints — keep CoreAPI's copy of orgs/users in step with AuthAPI
- Org self-service settings (billing, tier) — not yet exposed via API
Architecture
graph LR
AdminPortal["Platform / Org Admin Portal"]
AuthAPI["AuthAPI (Zitadel + auth slice)"]
CoreAPI["CoreAPI (Tenancy — orgs)"]
CoreDB["CoreAPI DB\n(organizations, organization_members, org_app_access)"]
Redis["Redis\n(token blocklist)"]
AdminPortal -->|POST /tenancy/organizations| CoreAPI
AdminPortal -->|invitations, roles, app-access| CoreAPI
CoreAPI -->|create org + admin/invited user| AuthAPI
CoreAPI --> CoreDB
CoreAPI -->|blocklist on role/status change| Redis
AuthAPI -->|internal sync: unknown org/user| CoreAPI
CoreAPI never talks to Zitadel directly — every identity operation (creating the org, creating or inviting a user, issuing invite codes) is delegated to AuthAPI over an internal, PSK-signed HTTP call. CoreAPI then mirrors the result into its own tables so that org/workspace/app-access queries stay local.
The reverse sync exists too: when AuthAPI encounters an org or user it doesn't yet have a CoreAPI row for (e.g. during self-signup), it calls back into the /internal endpoints below to upsert it.
Data Model
erDiagram
Organization {
string id
string name
string legalName
string website
string timezone
string status
string tierId
}
OrganizationMember {
string organizationId
string userId
string roleId
string role
}
User {
string id
string organizationId
string email
string firstName
string lastName
string username
}
Role {
string id
string scope
string name
bool isSystem
}
RolePermission {
string roleId
string permission
}
OrgAppAccess {
string organizationId
string appId
}
Organization ||--o{ OrganizationMember : has
User ||--o{ OrganizationMember : "is a"
OrganizationMember }o--|| Role : "role_id references"
Role ||--o{ RolePermission : grants
Organization ||--o{ OrgAppAccess : "app access"
roles is a single global table shared across scopes (platform, organization, workspace) — a role's scope determines where it can be assigned, not which org it belongs to. is_system = true roles (ORG_OWNER, ORG_ADMIN, ORG_MANAGER, ORG_OPERATOR, ORG_MEMBER at organization scope) are the only ones PATCH .../users/:userId/role currently accepts.
Endpoints
Platform admin — requires platform permission org:manage
POST /api/v1/tenancy/organizations
Creates a new organization end-to-end: the Zitadel org and its first admin user via AuthAPI, then the local organizations/users/organization_members rows, then grants the requested app access. The admin user is assigned ORG_ADMIN.
Request
{
"name": "Acme Cinemas",
"legal_name": "Acme Cinemas Sdn Bhd",
"website": "https://acme.example.com",
"timezone": "Asia/Kuala_Lumpur",
"app_ids": ["ticketing", "signage"],
"admin": {
"username": "acme_admin",
"given_name": "Ada",
"family_name": "Lovelace",
"email": "ada@acme.example.com",
"password": "<initial-password>"
}
}
name, at least one app_ids, and all admin.* fields except given_name/family_name are required. Unknown app IDs are rejected with 400.
Response 201
{
"org": { "ID": "org_...", "Name": "Acme Cinemas", "Timezone": "Asia/Kuala_Lumpur", "Status": "active", "TierID": "free" },
"admin_user": { "ID": "usr_...", "Email": "ada@acme.example.com", "Username": "acme_admin" },
"app_ids": ["ticketing", "signage"]
}
Org-scoped — requires a valid session; some further require an org permission
GET /api/v1/tenancy/organizations/:orgId/users
Paginated list of the org's users (page, limit query params, default 1/20, max limit 100). Sourced from AuthAPI (identity, account state) and enriched with the CoreAPI role and platform role where available.
Response 200
{
"items": [
{ "userId": "usr_...", "name": "Ada Lovelace", "email": "ada@acme.example.com", "role": "ORG_ADMIN", "account_state": "active", "platform_role": null }
],
"total": 1,
"page": 1,
"limit": 20,
"hasNext": false
}
POST /api/v1/tenancy/organizations/:orgId/invitations — requires user:create
Invites a new user into the org. AuthAPI creates the user in an invited account state with an invite code; CoreAPI mirrors the user as ORG_MEMBER.
Request
{ "username": "bob", "given_name": "Bob", "family_name": "Ng", "email": "bob@acme.example.com", "password": "<temp-password>" }
Response 201
{ "user": { "ID": "usr_...", "Email": "bob@acme.example.com" }, "invite_code": "ABC123", "invite_expires_at": "2026-06-30T00:00:00Z" }
POST /api/v1/tenancy/organizations/:orgId/users/:userId/reinvite — requires user:create
Issues a fresh invite code for a user still in invited state. 404 if the user isn't in this org, 409 if they're no longer invited (already activated).
Response 200
{ "invite_code": "XYZ789", "invite_expires_at": "2026-07-07T00:00:00Z" }
PATCH /api/v1/tenancy/organizations/:orgId/users/:userId/role — requires user:manage
Changes a member's org role to one of the predefined system organization roles (ORG_OWNER, ORG_ADMIN, ORG_MANAGER, ORG_OPERATOR, ORG_MEMBER). Callers cannot change their own role (403). On success, the target user's tokens are blocklisted so their JWT picks up the new role on next use.
Request
{ "role": "ORG_MANAGER" }
Response 200
{ "success": true, "user_id": "usr_...", "role": "ORG_MANAGER" }
PATCH /api/v1/tenancy/organizations/:orgId/users/:userId/status — requires user:manage
Sets a member's account status to active or disabled via AuthAPI. Disabling blocklists the user's tokens immediately.
Request
{ "status": "disabled" }
GET /api/v1/tenancy/organizations/:orgId/app-access
Returns the app IDs the org has been granted. Accessible to any member of the org, or to a caller with platform org:manage.
Response 200
{ "org_id": "org_...", "app_ids": ["ticketing", "signage"] }
PUT /api/v1/tenancy/organizations/:orgId/app-access — requires platform permission org:manage
Replaces the org's full app access list. At least one app is required; unknown app IDs return 400. Returns 403 for the platform organization, whose access is fixed to every app.
Request
{ "app_ids": ["ticketing", "signage", "menu-board"] }
GET /api/v1/tenancy/organizations/:orgId/roles
Returns the full role catalog (all scopes), not just roles usable in this org — the :orgId segment is present for routing consistency but isn't used to filter. Intended for populating role-assignment dropdowns.
Response 200
[ { "id": "ORG_ADMIN", "scope": "organization", "name": "Org Admin" } ]
GET /api/v1/tenancy/organizations/:orgId/roles-matrix
Same role catalog as above, expanded with description, system flag, member count, and a permissions map of service -> highest level (admin > write > read, derived from each role's service:level permission strings).
Response 200
[
{
"id": "ORG_ADMIN", "scope": "organization", "name": "Org Admin", "description": "",
"is_system": true, "user_count": 3,
"permissions": { "user": "manage", "workspace": "manage", "media": "admin" }
}
]
POST /api/v1/tenancy/organizations/:orgId/roles — requires roles:manage
Defines a new custom role and its permission set.
Request
{ "name": "Content Reviewer", "scope": "organization", "permissions": ["media:read", "playlist:write"] }
Response 201
{ "id": "b3f1..." }
GET /api/v1/tenancy/apps
Lists the full app catalog (id + name) available on the platform, regardless of org.
Internal — PSK-protected, called by AuthAPI
Mounted at /api/v1/internal, gated by middleware.RequireInternalToken() (a pre-shared key in the X-Internal-Token header), never called by end-user clients.
POST /api/v1/internal/organizations
AuthAPI calls this when it discovers an org with no matching CoreAPI row (ON CONFLICT DO NOTHING, safe to retry).
{ "org_id": "org_...", "name": "Acme Cinemas" }
POST /api/v1/internal/users
Called during AuthAPI self-signup to create the local users row and an ORG_MEMBER organization_members row in the same transaction.
{ "user_id": "usr_...", "org_id": "org_...", "email": "...", "first_name": "...", "last_name": "...", "username": "..." }
GET /api/v1/internal/users/:userId/roles
Called during login so AuthAPI can embed the user's org role in the issued JWT (orgs[].role). Returns an empty role gracefully if the user has no CoreAPI row yet.
PUT /api/v1/internal/users/:userId/roles
Sets a user's org role directly (bypasses the self-role-change and system-role checks that the org-scoped PATCH .../role endpoint enforces — internal callers are trusted).
{ "org_id": "org_...", "role": "ORG_ADMIN" }