Skip to content

Auth, Identity & RBAC — handover guide

Who this is for

You're taking ownership of login, users, roles, and the API surface that enforces them: AuthAPI, tellyid, OrgConsole (BizConsole), PlatformConsole, and the auth half of CoreAPI. Read §1–§3 on day one, keep §9 (runbook) and §11 (known issues) open while you work. Everything here was verified by reading source in full and against the live staging DB (counts only) on 2026-09-24 — AuthAPI staging@ba54b06, CoreAPI c5837de. file:line citations are relative to each repo root.


1. Mental model in five sentences

  1. Zitadel is the only place passwords live. Nothing else stores or checks one.
  2. AuthAPI turns "Zitadel says this password is right" into a PlayTelly RS256 JWT in an HttpOnly cookie, after checking which org the user is logging into (company_code).
  3. That JWT carries exactly one org and one org role, which AuthAPI fetches from CoreAPI at the moment of issue — so one login = one org, no org switcher.
  4. CoreAPI owns everything else — orgs, workspaces, teams, roles, permissions — and enforces them per route with middleware.
  5. tellyid is just the shared login page; every console is a CoreAPI frontend that bounces to tellyid when /identity/users/me returns 401.
sequenceDiagram
    participant B as Browser (console)
    participant T as tellyid
    participant A as AuthAPI
    participant Z as Zitadel
    participant C as CoreAPI
    B->>C: GET /api/v1/identity/users/me (no cookie)
    C-->>B: 401
    B->>A: POST /api/v1/refresh
    A-->>B: 401 no_refresh_token
    B->>T: redirect /auth?redirect_uri=...
    T->>A: POST /api/v1/login {username,password,company_code}
    A->>Z: POST /v2/sessions (password check)
    A->>A: company_code → org, membership, status checks
    A->>C: GET /api/v1/internal/users/{uid}/roles (PSK)
    A-->>T: Set-Cookie access_token (RS256), refresh_token
    T->>B: window.location = redirect_uri
    B->>C: GET /api/v1/identity/users/me (cookie)
    C-->>B: {user, platform_role, org{role, permissions, workspaces}}

2. Who owns what

System Owns Talks to Repo entry points
Zitadel credentials, Zitadel orgs & user objects, Zitadel sessions — playtelly-iac/services/infrastructure/zitadel/, Zitadel install
AuthAPI login, token signing (RS256 access / HS256 refresh), refresh sessions, magic links, password reset, license, local orgs/users mirror Zitadel (PAT), CoreAPI (PSK) internal/routes/routes.go, internal/services/auth.go, internal/services/zitadel.go
CoreAPI tenancy graph, roles & permissions, all enforcement, the only email sender AuthAPI (PSK, JWKS) pkg/middleware/auth_middleware.go, pkg/jwt/rs256.go, domain/tenancy/, domain/identity/
tellyid login / invite / reset / forced-password-change UI AuthAPI + CoreAPI src/components/Auth.tsx, src/utils/api.ts
OrgConsole org-admin UI: members, teams, roles, workspaces, email profiles CoreAPI (+ AuthAPI refresh & SSE) src/pages/console/directory/, src/api/api.ts
PlatformConsole tenant list + approve (that's all that's real) CoreAPI (+ AuthAPI refresh & SSE) src/pages/console/tenants/

Key design fact: AuthAPI's orgs.id and users.id UUIDs become CoreAPI's primary keys (CoreAPI/domain/tenancy/services/org_service.go:232,287,522). The two DBs are joined by ID, never by name.


3. From an empty database to a working platform

Order matters: Zitadel → AuthAPI → CoreAPI. Everything runs on every boot and self-gates after the first success.

# Step Where Skips when
0 Human, once: create the platform org + a sysadmin human user inside that org + a service (machine) user with a PAT and IAM Owner in Zitadel. Fresh instances can do this via start-from-init env (ZITADEL_FIRSTINSTANCE_*). No PlayTelly code creates these. Zitadel console —
1 AuthAPI AutoMigrate (8 models) AuthAPI/internal/migration/migrate.go:32 never (additive)
2 Seed platform org row, fixed UUID 00000000-0000-0000-0000-000000000001, name = AUTHAPI_ZITADEL_DEFAULT_ORG migrate.go:53-60 row exists
3 bootstrap.Run: find Zitadel org by name + sysadmin by login name, link zitadel_org_id, set company code, upsert user AuthAPI/internal/bootstrap/bootstrap.go:15-64 platform org has any user
4 License guard: starts blocked, verifies AUTHAPI_LICENSE_KEY signature, company code must equal AUTHAPI_DEFAULT_ORG_COMPANY_CODE, re-checks every 24h AuthAPI/internal/license/guard.go:22-76 —
5 CoreAPI AutoMigrate (native + johorzoo) CoreAPI/infra.go:100 never
6 seed.Run: 13 system roles + grants, apps, 6 demo orgs, 8 demo workspaces, demo team (no env gate — lands in prod too) CoreAPI/pkg/seed/00-seed.go:27 seed fingerprint unchanged
7 seed.Platform: list platform users from AuthAPI, take users[0], make them PLATFORM_OWNER + ORG_ADMIN, create Default workspace CoreAPI/pkg/seed/platform.go:19-107 platform_members non-empty. Failure is only a warning (infra.go:143-145)
8 seed.TicketingWorkspace: create pinned ticketing workspace, grant every platform member WORKSPACE_ADMIN CoreAPI/pkg/seed/ticketing_workspace.go:45 exists, or SPATIO_TICKETING_WORKSPACE_ID unset (it is unset in prod)

Gotchas:

  • If the sysadmin user isn't a member of the platform Zitadel org, bootstrap succeeds but web login fails with INVALID_CREDENTIALS (membership is checked at login, AuthAPI/internal/services/auth.go:214).
  • Changing the sysadmin later is a manual DB job — bootstrap never re-runs once a user exists.
  • A license whose company code doesn't match → every login returns 503 LICENSE_EXPIRED.

4. Login, tokens and sessions

4.1 POST /api/v1/login

Handler AuthAPI/internal/handlers/auth.go:89-141 → service internal/services/auth.go:179-265.

  1. 503 if license guard blocked.
  2. company_code required unless header PlayTelly-Platform: mobile (handlers/auth.go:104).
  3. Zitadel: global user search by login name then email — must match exactly one user — then POST /v2/sessions with the password (services/zitadel.go:156-305).
  4. company_code → local orgs (case-insensitive) → SearchOrg(name) → SearchUserInOrg. Not a member → INVALID_CREDENTIALS (deliberately indistinguishable from a wrong password).
  5. Org pending/disabled → 403 ORG_PENDING/ORG_DISABLED. Account disabled/pending/invited → 403 ACCOUNT_*.
  6. JIT upsert of local users row (updates email only on conflict).
  7. Role fetch from CoreAPI GET /api/v1/internal/users/{uid}/roles (5s timeout). On any failure the role becomes the string "member" (auth.go:820-829) — not a real role ID, so the user gets zero org permissions until next refresh. Also silently empty if COREAPI_AUTHAPI_PSK is unset.
  8. Sign tokens, set cookies (web) or return them in the body (mobile).

AuthAPI talks to Zitadel with a PAT as Authorization: Bearer (zitadel.go:66), loaded from ZITADEL_PAT_PATH or ZITADEL_PAT. Only Zitadel v2 APIs are used (users, sessions, organizations, password complexity). No OIDC client is involved anywhere — ignore any doc that tells you to create a Zitadel "project/app".

4.2 The tokens

Access Refresh
Alg RS256, kid = first 8 bytes SHA-256 of pubkey DER HS256 (AUTHAPI_JWT_REFRESH_SECRET)
TTL AUTHAPI_JWT_ACCESS_EXPIRY — integer seconds, default 900 AUTHAPI_JWT_REFRESH_EXPIRY — Go duration, default 168h
Cookie access_token, path / refresh_token, path /api/v1
Flags HttpOnly, SameSite=Lax, Domain=AUTHAPI_COOKIE_DOMAIN, Secure unless AUTHAPI_COOKIE_SECURE=false (forced off on localhost) same

Claims (services/auth.go:106-113, 329-343):

{
  "iss": "authapi", "sub": "<local user uuid>", "iat": 0, "exp": 0, "jti": "<uuid>",
  "uid": "<local user uuid>",
  "zid": "<zitadel user id>",
  "sid": "<stable session uuid>",
  "org": { "org_id": "<uuid>", "org_slug": "acme", "role": "ORG_ADMIN" }
}
  • app_roles exists in the struct but is never populated — don't build on it.
  • No aud; CoreAPI checks neither iss nor aud (CoreAPI/pkg/jwt/rs256.go:158-174), only signature + exp.
  • JWKS: GET /.well-known/jwks.json, one key, no rotation support. CoreAPI caches it 5 min and builds the URL from AUTHAPI_BASE_URL (the AUTHAPI_JWKS_URL setting is ignored).
  • org.role is either a system role ID (ORG_ADMIN) or a custom role UUID.

4.3 Refresh, logout, sessions

  • Refresh (POST /api/v1/refresh {client_type:"web"|"mobile"}) rotates both tokens and re-fetches the role from CoreAPI. That's how role changes reach a user without re-login. No reuse detection: two tabs refreshing at once → one gets 401.
  • Refresh rejects only disabled accounts; pending/invited pass.
  • Logout is POST /api/v1/protected/logout. It's behind RequireAuth, so once the 15-min access cookie expires logout 401s and the refresh cookie survives (up to 7 days). This is a known bug (§11).
  • SSE GET /api/v1/sessions/stream pushes session_revoked on logout / revoke / delete — but the hub is in-memory, single-replica, prod runs 2–4 AuthAPI replicas, and every console only console.logs the event. Treat it as non-functional.
  • Session management endpoints (GET /sessions, POST /sessions/:id/revoke, /sessions/revoke-others, /logout/all) work but nothing calls them — tellyid's Security page is a fixture.
Flow What happens Tables
Invite CoreAPI → AuthAPI POST /internal/orgs/:id/users/invite: Zitadel user created with random password, local user invited + password_change_required, magic link (32 random bytes, SHA-256 stored). CoreAPI adds users + organization_members(ORG_MEMBER) and emails {AUTHAPI_FRONTEND_URL}/auth/magic-link?token=… via the org's user_invite email profile. AuthAPI users, magic_link_tokens; CoreAPI users, organization_members
Redeem GET /auth/magic-link/status (peek) → POST /auth/magic-link/redeem (consume) → session issued, invited → active → tellyid forces /change-password (empty current_password allowed while the flag is set). same
Reset POST /auth/password-reset/request {email, company_code} — always 200. Link {AUTHAPI_FRONTEND_URL}/reset-password?token=… (1h), sent by CoreAPI via the password_reset email profile. confirm sets the password in Zitadel. Existing sessions are not revoked. password_reset_tokens

Magic-link TTL default is 10 minutes (PLAYTELLY_MAGIC_LINK_TOKEN_DURATION) — raise it for real invites. An invitee who redeems but abandons the password step is active with an unknown password; re-invite only works for invited, so their only way back is Forgot password.

password_change_required is enforced only by tellyid. SpatioViewAdmin and SpatioViewMobile ignore it.


5. Service-to-service (PSK lane)

Both directions use header X-Internal-Token: $COREAPI_AUTHAPI_PSK, routes under /api/v1/internal. Never browser-reachable (the header isn't in CORS allowed headers).

CoreAPI → AuthAPI (client CoreAPI/pkg/authapi/client.go, 11 live methods):

AuthAPI route Called from (CoreAPI)
POST /internal/platform/orgs org_service.go:215 CreateOrg
POST /internal/platform/orgs/self-signup org_service.go:353 SelfSignup
PUT /internal/platform/orgs/:id/status org_service.go:424 approve/disable
DELETE /internal/platform/orgs/:id org_service.go:673 DeleteOrg
GET /internal/platform/users/email-exists org_service.go:105
GET /internal/orgs/:id/users handler.go:721 list members, seed/platform.go:29
POST /internal/orgs/:id/users/invite org_service.go:504
POST /internal/orgs/:id/users/:uid/reinvite org_service.go:575
PUT /internal/orgs/:id/users/:uid/status handler.go:668 (local account_state only — does not touch Zitadel)
DELETE /internal/orgs/:id/users/:uid org_service.go:650
POST /internal/platform/reconcile?dry_run= CLI cmd/migrate-zitadel/main.go:27 only

POST /internal/orgs/:id/users (direct create) has a client method but no caller. 11 of AuthAPI's 26 /internal routes are 501 stubs.

AuthAPI → CoreAPI (AuthAPI/internal/services/coreapi_client.go):

CoreAPI route Called from (AuthAPI)
GET /internal/users/:id/roles every token issue/refresh (auth.go:820)
POST /internal/users (UpsertUserInCoreDB) invite.go:73, reconcile.go:107, auth.go:454
POST /internal/organizations (UpsertOrgInCoreDB) reconcile.go:174
POST /internal/email/password-reset password_reset.go:55

⚠️ The role endpoint runs SELECT role FROM organization_members WHERE user_id=$1 LIMIT 1 — no org filter (handler.go:687-707). A user in two orgs could carry org A's role into an org B token. Staging has 0 multi-org users today; don't create any until this is fixed.


6. RBAC

6.1 Roles (seeded, CoreAPI/pkg/seed/roles.go)

Scope Role IDs Stored in
Platform PLATFORM_OWNER, PLATFORM_ADMIN, PLATFORM_OPERATOR, PLATFORM_SUPPORT, PLATFORM_READONLY platform_members.role_id
Organization ORG_OWNER, ORG_ADMIN, ORG_MANAGER, ORG_OPERATOR, ORG_MEMBER organization_members.role + role_id (always equal)
Workspace WORKSPACE_ADMIN, WORKSPACE_MEMBER, VIEWER workspace_members.role, or team_workspace_roles.role_id via team_members
Custom UUID, is_system=false, organization_id set, scope organization or workspace roles + role_permissions

What the seeded roles can actually do:

  • Platform: only OWNER/ADMIN can manage/create orgs, only OWNER can delete; OPERATOR/SUPPORT/READONLY only get console access.
  • Org: OWNER/ADMIN get everything org-level; MANAGER gets workspaces + teams; OPERATOR gets console access only; MEMBER gets nothing.
  • Workspace: see roles.go:90-128. ⚠️ VIEWER is not read-only — it holds catalogue:manage, advertising:manage, notifications:manage, admin-profile:manage.

Staging reality (2026-09-24): 16 users; org roles ORG_ADMIN 11, ORG_MEMBER 5, ORG_OWNER 0 — no code path ever creates an ORG_OWNER, so org:transfer/org:account:cancel belong to nobody. 1 platform member (the bootstrap owner). 1 custom role.

6.2 How a check is resolved — the part that trips everyone up

Middleware Role source Tables Notes
RequirePlatformPermission(db, p) DB platform_members role_permissions
RequireOrgPermission(db, p) the JWT's org.role role_permissions Also requires token.org.org_id == :orgId — the only real cross-tenant check. Role change → stale until refresh.
RequireWorkspacePermission(db, p) DB, live workspaces, organization_members, workspace_members, team_*, role_permissions DB org role ORG_OWNER/ORG_ADMIN (literal strings) bypasses → admin of every workspace. Otherwise union of direct + team roles. Never checks the token's org.
RequireWorkspacePermissionFixed(db, id, p) same, pinned ID 2 call sites left, both domain/analytics/routes/routes.go:26-27
ProtectedRS256(validator) — — Bearer header, falls back to access_token cookie. Sets authClaims, uid, zid locals.
Protected(jwt) + HasCustomerRole() legacy HS256 — ticketing customer routes only
RequireInternalToken() PSK — not constant-time compare
CheckBlocklist(redis) Redis — global, see §6.3

Permission-check ≠ ownership-check: the middleware authorizes the container in the URL (:orgId, :workspaceId). Child IDs (:teamId, :ticketGroupId, :roleId) must be scoped by the handler/repository. Several aren't (§11).

Consequences a new owner must internalise:

  • A custom org role with every permission still doesn't get the workspace bypass — only the literal ORG_OWNER/ORG_ADMIN do.
  • /me and the middleware disagree: /me picks the bypass from the token role and shows permissions of one ranked role per workspace; the middleware uses the DB role and a union. When a user says "the UI says I can, the API says 403" (or vice versa), this is usually why.

6.3 Revocation (Redis blocklist)

PATCH …/users/:uid/role and PATCH …/users/:uid/status (disable only) call BlocklistUser → key blocklist:user:<uid> = now with TTL = access expiry (CoreAPI/pkg/redis/redis.go:70-88). CheckBlocklist rejects any token with iat < blockedAt → 401 → frontend refreshes → new token carries the new role.

  • Fails open: Redis down at boot → blocklisting silently disabled until pod restart; Redis error at runtime → request allowed.
  • Not called on remove user, org disable, org delete, workspace/team changes (workspace checks read the DB live anyway, so those take effect immediately).

6.4 Permission catalog

CoreAPI/pkg/permissions/permissions.go — 4 platform, 25 org, 33 workspace strings; served read-only at GET /api/v1/tenancy/permissions/catalog. Many are defined but enforced by no route (every org:*:view, billing, security, all media/playlist/display/device/campaign workspace perms) because the routes they were meant for have no auth (§7).


7. Which routes are protected (summary)

Full per-route table lives in the audit; this is what you need to remember.

Area Auth today
Tenancy writes (/api/v1/tenancy/organizations/:orgId/...) RS256 + org/workspace permission ✅
Tenancy reads — users, roles, roles-matrix, role members, workspaces, workspace members, teams, team members RS256 only — any logged-in user, any org ❌ (domain/tenancy/routes/routes.go:34,61,62,75,79,83,88,93,100,104,108,112)
/api/v1/identity/users/me, /ai/*, /chat/*, concierge /staff/* RS256 ✅
Ticketing admin (/api/*/:workspaceId/...) RS256 + workspace permission ✅ (analytics: pinned variant)
media, playback, distribution, scheduling, provisioning, spatial, flat catalogue, flat advertising, GET /api/v1/me None at all ❌ — not even RS256. Flat packages trust client headers X-Organization-ID / X-Workspace-ID / X-User-ID; media trusts ?tenant=/?workspaceId=.
Ticketing public (storefront GETs, /payment/*, /auth/*) none, by design — but see the two data leaks in §11
/api/v1/internal/* PSK ✅

There is no auth on the /api/v1 group itself (CoreAPI/routes_platform.go:57-61); each domain must opt in.


8. Tables

AuthAPI auth DB (AuthAPI/internal/migration/models.go; no FKs anywhere, no expired-row cleanup):

Table Purpose
orgs name, slug, zitadel_org_id, company_code (unique), hosting_type, status pending/active/disabled, on-prem URLs, applicant_* (self-signup, before a real user exists)
users unique zitadel_user_id; org_id; account_state active/disabled/pending/invited; password_change_required. Username/email not unique
refresh_tokens one row per live session (hash, session_id, Zitadel session, device, expiry)
magic_link_tokens, password_reset_tokens one per user
local_license on-prem only
org_invitations, auth_codes dead

Also present in staging but orphaned (no code references): organizations, organization_members, org_memberships, roles, role_permissions, invite_codes — leftovers of an older design. Safe to ignore; don't build on them.

CoreAPI coreapi DB (tenancy/auth subset; organization_id columns are never FK-enforced, by platform-wide convention):

Table Key columns
organizations id, name, status, tier_id
users id (= AuthAPI user id), organization_id, email, username, first_name, last_name
organization_members PK (organization_id, user_id), role, role_id
platform_members user_id, role_id
workspaces / workspace_members workspace_members PK (workspace_id, user_id), role
teams, team_members, team_workspace_roles team → workspace role grants
roles, role_permissions definitions + grant matrix
org_app_access, workspace_apps which capability apps an org/workspace can use
email_verification_codes self-signup
email_profiles, email_usage_mappings invite/reset email delivery (see Integrations)

Staging has ~100 legacy permission rows (media:read, org:manage, …) that seeding never deleted. They're inert — nothing checks them — but they show up in the roles matrix.


9. Runbook — managing users and the API

All org-level actions below need the caller's token role to hold the permission, so the actor must be logged into that org.

Task UI API (CoreAPI unless noted) Gotchas
Invite user OrgConsole → Directory → Members → Invite POST /tenancy/organizations/:orgId/invitations {username,given_name,family_name,email} — org:users:invite Fails 400 unless the org has an email profile mapped to user_invite (OrgConsole won't let you pick fallback). The role field in the form is ignored — everyone lands as ORG_MEMBER; edit afterwards.
Resend invite Members → row → Reinvite POST …/users/:uid/reinvite only while invited
Change org role Members → Edit PATCH …/users/:uid/role {role} — org:users:manage Can't change your own. No hierarchy: any users:manage holder can promote to ORG_OWNER. Takes effect on the user's next request (blocklist → refresh), unless Redis is down.
Disable / enable Members → Edit → status PATCH …/users/:uid/status {status:"active"\|"disabled"} Local account_state only; Zitadel untouched; existing refresh tokens not revoked. Blocks new logins and refreshes.
Remove from org Members → Remove DELETE …/users/:uid — org:users:remove Deletes the Zitadel user too. No blocklist → their token works up to 15 min.
Workspace access Workspaces → detail → Access, or Teams POST/PATCH/DELETE …/workspaces/:wid/members[/:uid]; teams …/teams/:tid/members, …/teams/:tid/workspaces Role string isn't validated — only pick from the dropdown. A user can't have both direct and team access to the same workspace (409).
Custom role Directory → Roles & Permissions POST …/roles {name,scope,description,permissions[]} — org:roles:manage Permission strings aren't validated server-side — use the catalog. Deleting a role doesn't check team grants.
User forgot password tellyid → Forgot password AuthAPI POST /api/v1/auth/password-reset/request {email,company_code} needs a password_reset email profile on that org
Approve a self-signup org PlatformConsole → Tenant Approvals → Approve PUT /tenancy/organizations/:orgId/status {status:"active"} — platform org:manage This is when the Zitadel org + admin get created; temp password emailed in plaintext. Reject/Request-info are toast-only.
Create an org directly none — Manual Register is fake POST /tenancy/organizations — platform org:create use curl (§10)
Disable / delete an org none in UI PUT …/:orgId/status {status:"disabled"} / DELETE …/:orgId disable doesn't revoke sessions; nothing stops you deleting the platform org
Grant a platform role none no API — insert into platform_members (user_id, role_id) by SQL only the bootstrap seed ever writes this table
Who has access? Members / Roles matrix GET …/users, GET …/roles-matrix, GET …/roles/:id/members
Fix Zitadel ↔ local drift — AuthAPI POST /api/v1/internal/platform/reconcile?dry_run=true (PSK) non-dry-run upserts every Zitadel human as active ORG_MEMBER

Before a new org can invite anyone: create an email profile → test-send → map user_invite and password_reset (Integrations can validate credentials first).


10. Developer recipes

10.1 Try it locally (Quickstart: AuthAPI :3002, CoreAPI :3000)

AUTH=http://localhost:3002; CORE=http://localhost:3000; JAR=/tmp/pt.jar
CODE='<COMPANY_CODE>'; U='<USERNAME_OR_EMAIL>'; P='<PASSWORD>'

# web login → cookies in $JAR
curl -s -c $JAR -H 'Content-Type: application/json' -H 'PlayTelly-Platform: web' \
  -d "{\"username\":\"$U\",\"password\":\"$P\",\"company_code\":\"$CODE\"}" $AUTH/api/v1/login

# who am I (CoreAPI reads the access_token cookie)
curl -s -b $JAR $CORE/api/v1/identity/users/me | jq

# refresh (rotates both cookies, re-fetches role)
curl -s -b $JAR -c $JAR -H 'Content-Type: application/json' -d '{"client_type":"web"}' $AUTH/api/v1/refresh

# decode the access token
grep access_token $JAR | awk '{print $7}' | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq

# logout (must be within 15 min of the last login/refresh — see §11)
curl -s -b $JAR -c $JAR -X POST $AUTH/api/v1/protected/logout

Mobile/bearer: send PlayTelly-Platform: mobile, read access_token/refresh_token from the JSON body, call CoreAPI with Authorization: Bearer $AT, refresh with {"client_type":"mobile","refresh_token":"$RT"}.

Create an org as a platform admin (the call Manual Register should make):

curl -s -b $JAR -H 'Content-Type: application/json' -X POST $CORE/api/v1/tenancy/organizations \
  -d '{"name":"Acme","company_code":"ACME","app_ids":["spatioview"],
       "admin":{"username":"acme-admin","email":"<email>","given_name":"A","family_name":"Admin","password":"<initial-password>"}}'

Body per CoreAPI/domain/tenancy/handlers/org_handler.go:27-67: name and company_code always required; for hosting_type cloud (default) also app_ids (≥1) and admin.{username,email,password}. Optional: legal_name, website, timezone, hosting_type: "on_prem" + license_duration_days (returns a license key, no admin). The admin gets ORG_ADMIN and a Default workspace; the password must satisfy Zitadel's complexity policy.

10.2 Protecting a new route

Follow CoreAPI/domain/catalogue/routes/routes.go:

auth := middleware.ProtectedRS256(rs256Validator)
g := app.Group("/api/v1/thing/:workspaceId")
g.Post("/", auth, middleware.RequireWorkspacePermission(coreDB, permissions.WorkspaceXxx), h.Create)
  1. ProtectedRS256 first — permission middleware 401s without claims.
  2. Put the scope in the path (:orgId → RequireOrgPermission, :workspaceId → RequireWorkspacePermission, platform → RequirePlatformPermission).
  3. In the handler/repo, filter every query by the path param you authorized, and join child IDs back to it. This is the step every current IDOR skipped.
  4. Never take org/workspace/user from headers or query params.

10.3 Adding a permission

  1. Add the constant and append it to the right All* slice in pkg/permissions/permissions.go (feeds the catalog).
  2. grant(...) it in pkg/seed/roles.go.
  3. Use it on a route.

Seeding is ON CONFLICT DO NOTHING, re-run when pkg/seed/*.go changes (Dockerfile hashes it into the fingerprint). So: new grants are added; nothing is ever updated or removed — revoking needs an explicit DELETE in the seed (example at roles.go:135-138). Custom roles never get new permissions automatically. Renaming only the string value of an existing constant doesn't change the seed hash → the new string is never granted → silent loss of access.


11. Known issues — prioritised

Verified in code unless marked (audit). These are the backlog you're inheriting.

P0 — fix now

  1. AuthAPI logs every Zitadel request/response in full — PAT bearer header, user passwords, session tokens (AuthAPI/internal/services/zitadel.go:68-84). Remove the dumps, then rotate the PAT and treat existing logs as sensitive.
  2. Committed secrets: AuthAPI playtelly-iac/services/access/authapi/overlays/{prod,staging}/secret.yml (PAT, RSA signing key, license private key, PSK, refresh secret, DB password) — anyone with repo access can mint tokens and licenses. Also CoreAPI ConfigMaps (Deployment), an OIDC client secret in PlayoutAdmin/TellyboardAdmin src/utils/userManager.js:11. Rotate + move to real Secrets.
  3. No auth on media/playback/distribution/scheduling/provisioning/spatial/flat catalogue/advertising (§7).
  4. Cross-org reads on ~13 tenancy GETs — users with emails, roles, members, workspaces, teams (§7). Add an org-membership check to the org group.
  5. Cross-org team takeover: team mutations never verify :teamId belongs to :orgId (CoreAPI/domain/tenancy/services/team_service.go:126-151) → an org-A admin can add their user to org B's team and inherit its workspace roles.
  6. tellyid open redirect / javascript: XSS via unvalidated redirect_uri (tellyid/src/components/Auth.tsx:27-29,45-47, Signout.tsx:28-30, ChangePassword.tsx:50-51, MagicLinkConfirm.tsx:58-60) — fires automatically for already-signed-in users.
  7. Ticketing data leaks, unauthenticated: GET /api/customer/profile?custId= (email, IC number, phone) and GET /api/orderTicketGroup?orderTicketGroupId=<sequential int> (audit).

P1

  1. Prod and staging share cookie domain .castis.io (verified) — cross-environment sign-out, token sent to every *.castis.io host, no CSRF defence between subdomains; CoreAPI accepts cookie auth on mutations with no CSRF token.
  2. No rate limiting anywhere in AuthAPI — login, reset request (email bombing), company-code enumeration, self-signup 6-digit code (no attempt limit).
  3. VIEWER is not read-only (§6.1). Ticket-group integration configs aren't workspace-scoped in the handlers (audit).
  4. Role changes have no hierarchy / last-owner protection; DeleteOrg has no platform-org guard.
  5. Revocation: blocklist fails open; remove/disable-org don't blocklist; disable doesn't revoke refresh tokens; SSE only works single-replica and no UI acts on it; logout breaks after 15 min idle.
  6. /internal/users/:id/roles has no org filter (§5).
  7. CoreAPI-down-at-login → role "member" → zero permissions silently.
  8. Invite form's role is dropped (verified: org_handler.go:285-290, org_service.go:533-537).

P2 — correctness / DX

  • SpatioViewAdmin: no SSO refresh (kicked out every ~15 min), VIEWER redirect loop, stale-localStorage legacy branch (authStore.ts:213-222), ignores password_change_required.
  • SpatioViewMobile logs passwords/tokens to device logs (src/api.ts:39-66); refreshes only at launch; sign-out doesn't revoke.
  • TellyboardAdmin refresh always 400s (no client_type); its RequirePermission reads fields /me doesn't return.
  • ticketcms retries non-idempotent POSTs (orders/payments) up to 3× (src/lib/apiClient.ts:101-106).
  • Debug fmt.Printf("xxxxxxxx…") in WorkspaceHasPermission (CoreAPI/pkg/middleware/auth_middleware.go:198-219).
  • WarnInsecureDefaults(nil) hides the fallback HS256 secret warning (CoreAPI/infra.go:102).
  • Mock RBAC (src/core/stores/rbacStore.ts, MOCK_USER_ROLE_ASSIGNMENTS) still drives parts of both consoles' sidebars; PlatformConsole shows 11 fake seed orgs when the API fails.
  • Magic-link default TTL 10 min; invite email has no redirect_uri, so new users land on tellyid's profile page.

12. Frontends at a glance

App Login Token storage Refresh Permissions from
tellyid itself (POST /api/v1/login) HttpOnly cookies on /me 401 —
OrgConsole redirect to tellyid cookies axios 401 interceptor, single-flight, BroadcastChannel "session expired" to all tabs /me for org:apps:bizconsole:access and org:email-profiles:manage only; rest of sidebar = mock store; backend enforces the real thing
PlatformConsole redirect to tellyid (loses deep link) cookies same as OrgConsole /me platform:apps:platformconsole:access
SpatioViewAdmin direct POST AuthAPI /api/v1/login form cookies (legacy mode: localStorage) none /me workspace role for VITE_SPATIO_WORKSPACE_ID → mapped to SYSADMIN/ADMIN/MEMBER
ticketcms legacy HS256 POST /auth/login localStorage legacy /auth/refresh-token local flag
SpatioViewMobile POST /api/v1/login + PlayTelly-Platform: mobile refresh in SecureStore, access in memory at launch only local fixtures
ConciergeGuestApp hotel PIN → CoreAPI concierge guest session localStorage — guest session
PlayoutAdmin / TellyboardAdmin redirect to tellyid cookies (+ user_data cache in localStorage) none / broken display only / broken

Real vs mock in the consoles:

  • OrgConsole real: Members, Teams, Roles & Permissions, Workspaces (list + Access tab), email Integrations.
  • OrgConsole mock: Invitations page (orphan), Access Policies, non-email integrations, workspace General/Apps/Resources tabs, SSO/MFA/SCIM/Sessions/Audit (placeholders).
  • PlatformConsole real: tenant list + Approve.
  • PlatformConsole mock: everything else, including Manual Register and Tenant Detail. PlatformConsole has no user management and no platform-role assignment.

Env vars that must be set at build time for each console: VITE_AUTH_URL (tellyid), VITE_AUTH_API_URL, VITE_CORE_API_URL. If the last two are unset, the guards fail open (UI shell renders; CoreAPI still requires the cookie, so no data leaks).


Doc Status (2026-09-24)
AuthAPI reference + authapi.yaml mostly accurate; invite_codes table and RequireOrgRole-style middleware mentions are stale; OpenAPI paths match the router
Identity domain native /me accurate; ticketing section predates /api/admin/:workspaceId/*
Tenancy: organization, workspace, team mostly accurate; mention of RequireWorkspaceRole is stale; read-routes gap not noted
Zitadel install the Zitadel ops page to use
Zitadel (legacy) ⚠️ describes an OIDC project/app setup nothing uses
AuthAPI install ⚠️ seed-SQL step is obsolete and harmful; lists dead env vars
tellyid ⚠️ wrong /me shape (orgs[]), missing routes
Middleware ⚠️ HasRole/HasAnyRole no longer exist — use §6.2 here
domain/tenant.md ⚠️ superseded by domain/tenancy/
Deployment staging/prod mechanics and the secrets-in-git hazard