Skip to content

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, and org_app_access tables 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" }