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
- Zitadel is the only place passwords live. Nothing else stores or checks one.
- 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). - 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.
- CoreAPI owns everything else — orgs, workspaces, teams, roles, permissions — and enforces them per route with middleware.
- tellyid is just the shared login page; every console is a CoreAPI frontend that bounces to tellyid when
/identity/users/mereturns 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.
- 503 if license guard blocked.
company_coderequired unless headerPlayTelly-Platform: mobile(handlers/auth.go:104).- Zitadel: global user search by login name then email — must match exactly one user — then
POST /v2/sessionswith the password (services/zitadel.go:156-305). company_code→ localorgs(case-insensitive) →SearchOrg(name)→SearchUserInOrg. Not a member →INVALID_CREDENTIALS(deliberately indistinguishable from a wrong password).- Org
pending/disabled→ 403ORG_PENDING/ORG_DISABLED. Accountdisabled/pending/invited→ 403ACCOUNT_*. - JIT upsert of local
usersrow (updates email only on conflict). - 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 ifCOREAPI_AUTHAPI_PSKis unset. - 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_rolesexists in the struct but is never populated — don't build on it.- No
aud; CoreAPI checks neitherissnoraud(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 fromAUTHAPI_BASE_URL(theAUTHAPI_JWKS_URLsetting is ignored). org.roleis 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
disabledaccounts;pending/invitedpass. - Logout is
POST /api/v1/protected/logout. It's behindRequireAuth, 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/streampushessession_revokedon logout / revoke / delete — but the hub is in-memory, single-replica, prod runs 2–4 AuthAPI replicas, and every console onlyconsole.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.
4.4 Invites, magic links, password reset
| 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. ⚠️VIEWERis not read-only — it holdscatalogue: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_ADMINdo. /meand the middleware disagree:/mepicks 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)
ProtectedRS256first — permission middleware 401s without claims.- Put the scope in the path (
:orgId→RequireOrgPermission,:workspaceId→RequireWorkspacePermission, platform →RequirePlatformPermission). - 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.
- Never take org/workspace/user from headers or query params.
10.3 Adding a permission
- Add the constant and append it to the right
All*slice inpkg/permissions/permissions.go(feeds the catalog). grant(...)it inpkg/seed/roles.go.- 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
- 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. - 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 inPlayoutAdmin/TellyboardAdminsrc/utils/userManager.js:11. Rotate + move to real Secrets. - No auth on media/playback/distribution/scheduling/provisioning/spatial/flat catalogue/advertising (§7).
- Cross-org reads on ~13 tenancy GETs — users with emails, roles, members, workspaces, teams (§7). Add an org-membership check to the
orggroup. - Cross-org team takeover: team mutations never verify
:teamIdbelongs 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. - tellyid open redirect /
javascript:XSS via unvalidatedredirect_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. - Ticketing data leaks, unauthenticated:
GET /api/customer/profile?custId=(email, IC number, phone) andGET /api/orderTicketGroup?orderTicketGroupId=<sequential int>(audit).
P1
- Prod and staging share cookie domain
.castis.io(verified) — cross-environment sign-out, token sent to every*.castis.iohost, no CSRF defence between subdomains; CoreAPI accepts cookie auth on mutations with no CSRF token. - No rate limiting anywhere in AuthAPI — login, reset request (email bombing), company-code enumeration, self-signup 6-digit code (no attempt limit).
VIEWERis not read-only (§6.1). Ticket-group integration configs aren't workspace-scoped in the handlers (audit).- Role changes have no hierarchy / last-owner protection;
DeleteOrghas no platform-org guard. - 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.
/internal/users/:id/roleshas no org filter (§5).- CoreAPI-down-at-login → role
"member"→ zero permissions silently. - 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), ignorespassword_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); itsRequirePermissionreads fields/medoesn't return. - ticketcms retries non-idempotent POSTs (orders/payments) up to 3× (
src/lib/apiClient.ts:101-106). - Debug
fmt.Printf("xxxxxxxx…")inWorkspaceHasPermission(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).
13. Related docs and their status
| 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 |