Auth Platform Backend Specification
This document is for frontend and backend engineers integrating with Backend Kisum Auth (RS256 JWT, sessions, JWKS). It complements the main README.md at the Auth respository, and the machine-readable contract in docs/openapi.yaml.
Default base URL (local): http://localhost:3097
Production example: https://auth.kisum.io
All paths below are relative to the base URL (no trailing slash required).
This is the API contract companion to:
- 2.-Backend-implementation` → master backend plan
- 2.2.-Backend-Core` → full engineering/backend specification
Table of contents
Section titled “Table of contents”- Conventions
- Response envelope & errors
- Endpoints overview
- Public auth
/auth— includesGET /auth/me/access(§4.7), password reset (§4.8), Invitations (§4.9) - Internal
/internal— §5.1 RBAC, §5.1a Viewing users & details, §5.2 Full procedure, §5.2a Invoice / bill scope metadata, §5.3 Machine routes - Discovery & health
- Tokens & sessions (includes §7.1 AWS SES email)
- JWT access token claims — header, standard & custom claims, example payload, per-platform notes
- Verifying JWTs in your backend
- Frontend integration checklist
- Backend (other services) checklist
- Operational notes
1. Conventions
Section titled “1. Conventions”| Item | Value |
|---|---|
| Content-Type | application/json for request bodies unless noted |
| Accept | application/json (recommended) |
| Access token | Authorization: Bearer <access_token> |
| Internal automation | X-Internal-API-Key: <key> (only for machine routes) |
| JWT algorithm | RS256 |
| OpenAPI 3 | docs/openapi.yaml — import into Postman, Insomnia, or Swagger UI |
2. Response envelope & errors
Section titled “2. Response envelope & errors”Success
Section titled “Success”Most JSON APIs return:
{ "success": true, "data": { } }201 Created responses use the same shape with data populated.
Errors
Section titled “Errors”{ "success": false, "error": { "code": "<string>", "message": "<string>" } }Exception: GET /.well-known/jwks.json returns raw JWKS JSON (no success / data wrapper).
HTTP status ↔ error.code (common)
Section titled “HTTP status ↔ error.code (common)”| HTTP | error.code | Typical cause |
|---|---|---|
| 400 | validation_error | Bad body, missing fields, invalid UUID, etc. |
| 401 | unauthorized | Wrong email/password, missing/invalid Bearer token |
| 401 | session_revoked | Session invalidated |
| 403 | forbidden | Authenticated but not allowed (e.g. wrong role, bad internal key) |
| 403 | pending_approval | Self-serve user not yet approved |
| 403 | registration_rejected | Registration rejected |
| 403 | account_inactive | User deactivated |
| 403 | registration_disabled | POST /auth/register disabled by server config |
| 404 | not_found | Resource missing |
| 409 | conflict | Duplicate email, etc. |
| 429 | rate_limited | Too many login/register attempts (per IP, when Redis is available) |
| 501 | not_implemented | e.g. accountType: "vendor" login |
| 503 | finance_db_not_configured | Company resolve without Finance DB |
| 503 | (ready) | Readiness failure — see /ready |
| 5xx | internal_error | Server error |
Treat error.code as stable for client logic; message may change.
3. Endpoints overview
Section titled “3. Endpoints overview”Public (no access token)
Section titled “Public (no access token)”| Method | Path |
|---|---|
| GET | /health |
| GET | /ready |
| GET | /.well-known/jwks.json |
| POST | /auth/register |
| POST | /auth/login |
| POST | /auth/refresh |
| POST | /auth/logout |
| POST | /auth/password-reset/request |
| POST | /auth/password-reset/confirm |
Bearer access token
Section titled “Bearer access token”| Method | Path |
|---|---|
| POST | /auth/logout-all |
| GET | /auth/me |
| GET | /auth/me/access |
| POST | /auth/invitations |
| GET | /auth/invitations |
| GET | /auth/invitations/{id} |
| POST | /auth/invitations/{id}/resend |
| POST | /auth/invitations/accept |
| DELETE | /auth/invitations/{id} |
Bearer — internal user & tenant management (RBAC in service)
Section titled “Bearer — internal user & tenant management (RBAC in service)”Requires a valid access token. Not every internal route needs a platform staff globalRole; see §5.1 and the step-by-step runbook §5.2.
| Method | Path |
|---|---|
| GET | /internal/users |
| POST | /internal/users |
| PATCH | /internal/users/{id} |
| DELETE | /internal/users/{id} |
| GET | /internal/companies/{companyId}/users |
| POST | /internal/companies/{companyId}/memberships |
| GET | /internal/companies/{companyId}/business-units/{businessUnitId}/users |
| POST | /internal/companies/{companyId}/business-units/{businessUnitId}/memberships |
X-Internal-API-Key (only if server has INTERNAL_API_KEY set)
Section titled “X-Internal-API-Key (only if server has INTERNAL_API_KEY set)”| Method | Path |
|---|---|
| POST | /internal/sessions/{id}/revoke |
| POST | /internal/users/{id}/revoke-all |
| GET | /internal/companies/resolve?raw=... |
| GET | /internal/users/{id}/context |
If INTERNAL_API_KEY is unset, these four routes are not registered (client receives 404).
4. Public auth /auth
Section titled “4. Public auth /auth”4.1 POST /auth/register
Section titled “4.1 POST /auth/register”Auth: none
Feature flag: PUBLIC_REGISTRATION_ENABLED=true on the server. If disabled → 403 registration_disabled.
Creates a user with approvalStatus: PENDING, isActive: false. No JWT is returned. Platform staff (PLATFORM_ADMIN, PLATFORM_SUPERADMIN, or PLATFORM_MODERATOR per route) must approve via PATCH /internal/users/{id} (approvalStatus: APPROVED, isActive: true) before login works. If SES is configured, the user receives a pending registration email (§7.1).
Body:
{ "email": "user@example.com", "password": "at-least-8-chars", "fullName": "Display Name", "phoneNumber": "", "profilePictureUrl": "", "authProvider": "password"}phoneNumber/profilePictureUrl: optional; empty strings are treated as omitted where applicable.authProvider: optional; defaults topassword. Allowed:password,google,microsoft,sso,other(same enum as admin user APIs).
Success: 201 — data includes email, status: "pending".
Rate limit: Same bucket as login when Redis is available (e.g. 30 requests / minute / IP — verify in deployment).
4.2 POST /auth/login
Section titled “4.2 POST /auth/login”Auth: none
Body:
{ "email": "user@example.com", "password": "string", "accountType": ""}accountType — selects the login pipeline, not admin vs user:
| Value | Behavior |
|---|---|
"", "internal", "auto" | Email/password against Auth DB (users). |
"vendor" | 501 — not implemented in this service. |
| Anything else | 400 validation error. |
Authorization (platform globalRole vs NONE, etc.) comes from the user record (global_role), not from accountType.
Success: 200 — data contains:
| Field | Description |
|---|---|
accessToken | JWT (RS256), short-lived |
refreshToken | Opaque string; store securely |
expiresIn | Access token lifetime in seconds (e.g. 900 = 15 minutes) |
tokenType | "Bearer" |
Failures: wrong password → 401; pending approval → 403 pending_approval; rejected → registration_rejected; deactivated → account_inactive.
4.3 POST /auth/refresh
Section titled “4.3 POST /auth/refresh”Auth: none
Body:
{ "refreshToken": "<opaque refresh token from login or previous refresh>" }Success: 200 — new accessToken, refreshToken (rotation), expiresIn, tokenType.
Use this before the access token expires to avoid forced re-login. Refresh token lifetime is configured on the server (ACCESS_TOKEN_TTL / REFRESH_TOKEN_TTL in main README).
4.4 POST /auth/logout
Section titled “4.4 POST /auth/logout”Auth: none
Body:
{ "refreshToken": "<current refresh token>" }Invalidates that session’s refresh token. Success: 200 — e.g. data.status: "ok".
4.5 POST /auth/logout-all
Section titled “4.5 POST /auth/logout-all”Auth: Authorization: Bearer <access_token>
Revokes all sessions for the user in the token.
Body: none.
4.6 GET /auth/me
Section titled “4.6 GET /auth/me”Auth: Authorization: Bearer <access_token>
Success: 200 — data includes user id, email, name, sessionId, globalRole, roles, tokenVersion, vendor fields, companyMemberships, and businessUnitMemberships (same entry shapes as GET /internal/users/{id}/context; see OpenAPI MeResponse).
Consistency: Membership arrays are loaded with ordinary SELECTs in the same request (read-your-writes within Auth Postgres). Downstream services may cache membership lists and refresh when the user’s tokenVersion changes or after login/refresh.
4.7 GET /auth/me/access
Section titled “4.7 GET /auth/me/access”Auth: Authorization: Bearer <access_token>
Company context
Section titled “Company context”This endpoint requires a company context.
Frontend must send:
x-org: <companyId>If not provided:
- backend may fallback to default company
- or return error depending on configuration
Returns the effective access model for the authenticated user.
This endpoint is used by the frontend to determine:
- which modules are visible
- which modules are enabled
- which permissions the user has
- which companies the user can access
- Validate JWT
- Resolve active company context (from:
x-orgheader OR- default company OR
- explicit selection in frontend)
- Load user + memberships (same as
/auth/me) - Load user grants (Auth DB)
- Call Platform Core to get company entitlements
GET /internal/companies/{companyId}/entitlements- Merge:
effectiveAccess = entitlements ∩ userGrantsNote:
User must already have a valid company membership.
Membership is created via:
- invitations (primary flow)
- internal admin actions
Without membership, no access is computed.
- Return merged access model
Internal dependency
Section titled “Internal dependency”Auth calls:
GET /internal/companies/{companyId}/entitlementsfrom Platform Core.
Response example
Section titled “Response example”{ "activeCompanyId": "cmp_001", "user": { "id": "uuid", "email": "user@example.com", "name": "User Name" }, "companies": [ { "companyId": "cmp_001", "role": "ADMIN", "modules": { "basic": true, "finance": true, "market": false, "touring": false, "venue": false, "ai": false }, "permissions": [ "finance.read", "finance.write", "basic.dashboard" ] } ], "global": { "platformRole": "NONE" }, "meta": { "generatedAt": "2026-04-16T05:00:00Z" }}-
This endpoint is the single source of truth for frontend access control
-
JWT does NOT include modules or permissions
-
Access is always resolved dynamically via this endpoint
-
Must be called:
- after login
- after refresh
- after company switch
Performance
Section titled “Performance”-
Should use Redis caching per
(userId, companyId) -
Cache invalidation triggers:
- membership change
- permission change
- entitlement change
- token version change
4.8 Password reset
Section titled “4.8 Password reset”POST /auth/password-reset/request
{ "email": "user@example.com", "accountType": "" }POST /auth/password-reset/confirm
{ "token": "<opaque token>", "newPassword": "..." }In production, the opaque reset token is not returned in JSON; the user receives it via email (link to {FRONTEND_URL}/reset-password?token=...) when SES is configured. In development, the token may also be returned in the response if EXPOSE_PASSWORD_RESET_TOKEN=true (never enable that in production).
4.9 Invitations
Section titled “4.9 Invitations”Auth: Authorization: Bearer <access_token>
Invitations are owned by the Auth Backend.
This flow is responsible for:
- inviting a user to join a company
- onboarding a new or existing user into a company
- creating or linking company membership
- assigning the initial tenant role
- optionally assigning initial module grants / permissions (if supported in the invitation flow)
Invitations are not owned by:
- Base backend
- Platform Core backend
Company context
Section titled “Company context”All invitation endpoints require:
x-org: <companyId>This defines the company the invitation belongs to.
Auth must validate:
- caller has access to this company
- invitation is created under this company
Ownership rule
Section titled “Ownership rule”Invitations belong to Auth because they are part of:
- identity lifecycle
- onboarding
- company membership creation
- role assignment
- access bootstrapping
Expected invitation model
Section titled “Expected invitation model”An invitation should contain at minimum:
- invitation id
- company id
- invited role
- invited by
- invitation token
- status
- expiration date
Suggested statuses
Section titled “Suggested statuses”pendingacceptedexpiredcancelled
Invitation flow
Section titled “Invitation flow”- Authorized company/platform actor creates invitation
- Auth stores invitation
- Auth sends email (if email is enabled)
- Invitee accepts invitation
- Auth:
- creates user if needed
- or links existing user
- creates company membership
- assigns role
- optionally assigns initial grants
- Invitation status becomes
accepted
Recommended routes
Section titled “Recommended routes”POST /auth/invitationsGET /auth/invitationsGET /auth/invitations/{id}POST /auth/invitations/{id}/resendPOST /auth/invitations/acceptDELETE /auth/invitations/{id}- Invitations are part of Auth domain
- Invitation acceptance must result in membership creation in Auth
- Base backend must not manage invitation lifecycle
- Platform Core must not manage invitation lifecycle
Authorization rules (IMPORTANT)
Section titled “Authorization rules (IMPORTANT)”Invitation creation must be restricted.
Allowed actors:
- platform admins (globalRole)
- company admins (tenant roles such as
TENANT_SUPERADMIN,ADMIN) - optionally
MANAGERdepending on product policy
Not allowed:
- normal users (
SUBMITTER) - users without company admin privileges
Required validation
Section titled “Required validation”When creating an invitation:
Auth must validate:
- caller belongs to the company
- caller has sufficient tenant role
- target email is valid
- invitation is not duplicated or already active
Security note
Section titled “Security note”Invitation endpoints must always enforce:
- JWT validation
x-orgcompany context- tenant role authorization
Invitations must never be created without company context.
5. Internal /internal
Section titled “5. Internal /internal”Base path: /internal.
Internal usage: Platform Core integration
Section titled “Internal usage: Platform Core integration”The Auth service integrates with Platform Core to resolve company entitlements.
Used by:
/auth/me/access
Auth calls Core:
GET /internal/companies/{companyId}/entitlementsAuth is responsible for:
- merging entitlements with user grants
- returning effective access to frontend
Core is responsible for:
- packages
- addons
- modules enabled per company
Internal usage: Invitation lifecycle
Section titled “Internal usage: Invitation lifecycle”The Auth service owns invitation lifecycle for company onboarding.
This includes:
- creating invitations
- resending invitations
- accepting invitations
- cancelling invitations
- creating membership after acceptance
Invitations must remain in Auth because they affect:
- user onboarding
- company membership
- tenant role assignment
- access bootstrap
They must not be handled by:
- Base backend
- Platform Core backend
5.1 User administration (Bearer + RBAC)
Section titled “5.1 User administration (Bearer + RBAC)”Auth: Authorization: Bearer <access_token> (session valid). Authorization is enforced in the service, not only by JWT role name.
Platform globalRole values (on the users row / JWT): NONE, PLATFORM_SUPERADMIN (legacy platform-wide full admin), PLATFORM_ADMIN (full platform control), PLATFORM_MODERATOR (change approvalStatus only on users). Company roles live in company_memberships (company_id + role: TENANT_SUPERADMIN, ADMIN, FINANCE, MANAGER, SUBMITTER). FINANCE is organization-scoped only (not users.global_role). The optional Finance database (FINANCE_DATABASE_URL) is only for resolving company identifiers — it is not a platform role.
| Who | List all users | Create user | PATCH user | DELETE user | List users in company | Upsert company membership | List users in BU | Upsert BU membership |
|---|---|---|---|---|---|---|---|---|
PLATFORM_ADMIN or PLATFORM_SUPERADMIN (legacy) | Yes | Yes | Full | Yes | Yes | Yes | Yes | Yes |
PLATFORM_MODERATOR | Yes | No | approvalStatus only | No | Yes | No | Yes | No |
Company TENANT_SUPERADMIN / FINANCE / ADMIN (shared company with target) | No | No | Yes (not globalRole) | Yes (shared) | Yes (if ≥ MANAGER in company) | Yes (if ≥ ADMIN in company) | Yes (if ≥ MANAGER in company) | Yes (if ≥ ADMIN in company) |
Company MANAGER | No | No | approvalStatus + isActive only | No | Yes | No | Yes | No |
| Method | Path | Description |
|---|---|---|
| GET | /internal/users | List users. Query: approvalStatus, globalRole, limit, offset. Platform staff only. |
| POST | /internal/users | Create user (201). PLATFORM_ADMIN or PLATFORM_SUPERADMIN only. Admin-created users are APPROVED by default. |
| PATCH | /internal/users/{id} | Partial update. Platform moderator: approvalStatus only. Platform admin: full. Company admins: see table. |
| DELETE | /internal/users/{id} | Soft-delete. Not available to PLATFORM_MODERATOR. |
| GET | /internal/companies/{companyId}/users | Users with an active membership in that company. Platform staff or company MANAGER+. |
| POST | /internal/companies/{companyId}/memberships | Body: userId, role, optional isActive, optional approvalLimit (decimal string), optional metadata (JSON — see §5.2a). Company role MANAGER requires at least one active BU membership for that user in that company (create BU membership first); FINANCE does not. Not for PLATFORM_MODERATOR. |
| GET | /internal/companies/{companyId}/business-units/{businessUnitId}/users | Users with an active BU membership for that companyId + businessUnitId. Platform staff or company MANAGER+. |
| POST | /internal/companies/{companyId}/business-units/{businessUnitId}/memberships | Body: userId, role (SUBMITTER | APPROVER | ADMIN), optional isActive, optional metadata (JSON — see §5.2a). Not for PLATFORM_MODERATOR. Auth does not validate that the BU exists in Finance for that company — callers must keep IDs aligned. |
5.1a Viewing users and user details
Section titled “5.1a Viewing users and user details”There is no GET /internal/users/{id} with a Bearer token. The service registers PATCH and DELETE on /internal/users/{id} only. To see another user’s profile and memberships with Bearer alone, you must use the list endpoints below (or call GET /auth/me for yourself). Arbitrary user by id for server-to-server use is GET /internal/users/{id}/context with X-Internal-API-Key (§5.3), not tenant RBAC on Bearer.
Ways to get user information today
| Mechanism | Auth | What you get |
|---|---|---|
GET /auth/me | Bearer | Caller only — profile + companyMemberships + businessUnitMemberships. |
GET /internal/users/{id}/context | X-Internal-API-Key (machine route) | Same user fields + membership arrays as /auth/me for that id. Server-to-server; not a substitute for Bearer tenant checks. |
GET /internal/users | Bearer; platform staff only (PLATFORM_ADMIN, PLATFORM_MODERATOR, PLATFORM_SUPERADMIN) | Global directory (filters + pagination). Not “all users in my tenant orgs” for a tenant admin — that is not this route. |
GET /internal/companies/{companyId}/users | Bearer; platform or company role with rank ≥ MANAGER in that companyId | Users with an active company_memberships row for that company (AdminUserOut list). Each item includes memberships for that company (nested company row + BU rows), same JSON shape as GET /internal/users. |
GET /internal/companies/{companyId}/business-units/{businessUnitId}/users | Same rank ≥ MANAGER gate for that company | Users with an active BU membership for that company + business unit. |
Company role rank in the service is ordered (highest first): TENANT_SUPERADMIN → FINANCE → ADMIN → MANAGER → SUBMITTER. List routes require rank ≥ MANAGER (i.e. MANAGER, ADMIN, FINANCE, or TENANT_SUPERADMIN in that company). SUBMITTER (rank below that threshold) receives 403 on those company/BU list routes.
Tenant operators (not platform staff)
TENANT_SUPERADMIN,ADMIN, andFINANCE— can callGET /internal/companies/{companyId}/usersfor each company where they hold an active membership at MANAGER+. There is no single Bearer endpoint that returns “all users across every company I belong to” in one response; use one request percompanyId(or use the global directory only if you are platform staff).MANAGER— same rank check; can list all users in the company and all users in a given BU by callingGET .../companies/{companyId}/usersandGET .../business-units/{businessUnitId}/userswith the correct ids. There is no extra server-side filter for “only BUs I belong to”; callers pass thebusinessUnitIdthey need (multiple calls if the manager has several BUs).SUBMITTER— cannot use those list endpoints (403).GET /internal/users(global directory) is platform-only anyway, so a company SUBMITTER does not get a tenant-wide or global user list through these routes. Other/internal/*routes remain governed by their own RBAC tables above.
Summary
- Bearer
GET /internal/users/{uuid}for “user X’s details” with tenant scoping is not implemented. - Bearer:
/auth/me(self), or company/BU user lists with the rightcompanyId/businessUnitIdand role. - Full row + memberships by arbitrary user id for backends:
GET /internal/users/{id}/context+ internal API key.
Request/response JSON shapes match docs/openapi.yaml (CreateUserRequest, PatchUserRequest, AdminUserOut, UpsertCompanyMembershipRequest, UpsertBusinessUnitMembershipRequest).
Transactional emails for create / approve / reject / deactivate / admin password change are described in §7.1.
5.2 End-to-end procedure: users and company permissions
Section titled “5.2 End-to-end procedure: users and company permissions”This section is the operational runbook: from creating a user to assigning tenant (company) roles and letting them sign in. Exact paths and bodies match docs/openapi.yaml.
Two different “permission” layers (do not confuse them)
Section titled “Two different “permission” layers (do not confuse them)”| Layer | Where it lives | JWT globalRole? | Purpose |
|---|---|---|---|
| Platform | users.global_role in Auth DB | Yes — copied into access token at login/refresh | Cross-tenant ops: PLATFORM_ADMIN, PLATFORM_SUPERADMIN, PLATFORM_MODERATOR, NONE, … |
| Company (tenant) | company_memberships in Auth DB (user_id, company_id, role, is_active, optional approval_limit) | No — memberships are not in the JWT (RFC §5.5) | What the user may do inside a given company (TENANT_SUPERADMIN, ADMIN, FINANCE, MANAGER, SUBMITTER per row). |
| Business unit | business_unit_memberships (user_id, company_id, business_unit_id, role, …) | No | Scoped role per BU within a company (SUBMITTER, APPROVER, ADMIN). |
POST /internal/userssets platform fields (includingglobalRoleon the user row). It does not attach a company by itself.POST /internal/companies/{companyId}/membershipscreates or updates one company membership row (tenant role for that user in that company).POST /internal/companies/{companyId}/business-units/{businessUnitId}/membershipscreates or updates one BU membership row for that user.
Until a user has approvalStatus: APPROVED, isActive: true, and a valid password, POST /auth/login will not issue tokens (see error codes pending_approval, registration_rejected, account_inactive).
Invitations are not a separate permission layer. They are an Auth-managed onboarding mechanism that results in creation of company membership and optional initial access assignment.
5.2a Invoice / bill scope metadata (optional JSON)
Section titled “5.2a Invoice / bill scope metadata (optional JSON)”Auth stores optional metadata JSONB on both company_memberships and business_unit_memberships. This service does not enforce Finance or AP/AR rules — it persists JSON and returns it on GET /auth/me, GET /internal/users/{id}/context, and upsert responses. Downstream services (e.g. Finance) interpret the keys for list filters and draft edit checks.
IAM vs invoice scope: Company membership objects always include tenant role (TENANT_SUPERADMIN, ADMIN, FINANCE, MANAGER, SUBMITTER) and optional approvalLimit (approval ceiling for workflows that use it). Each business unit membership includes role (SUBMITTER, APPROVER — manager/approver for that BU — or BU ADMIN). Those roles are separate from bill metadata scopes; use both when modeling users.
Response convenience: If metadata includes invoiceViewScope, canEditOthersScope, or canEditOthersInvoices, the API also returns those fields as top-level properties on the same membership object (in addition to the full metadata JSON) so UIs can show scope without parsing the blob.
Recommended keys (v1 contract)
| Key | Type | Values | Meaning |
|---|---|---|---|
version | number | e.g. 1 | Schema version for readers. |
invoiceViewScope | string | OWN, BU, COMPANY | How widely this membership row allows seeing bills/invoices in that company: own only, one business unit, or whole company. |
canEditOthersScope | string | OWN, BU, COMPANY | How widely the user may edit other people’s drafts (orthogonal to view — products should clamp edit to view, e.g. do not allow canEditOthersScope: COMPANY if invoiceViewScope is OWN). |
canEditOthersInvoices | boolean | true / false | Legacy (Finance DB migration and older UIs). If present, readers may map it to canEditOthersScope (false → OWN, true → match invoiceViewScope or BU per product rules). |
Use BU (not BUSINESS_UNIT) in JSON so values stay short and match existing migrations.
Where to set it
- Company membership —
POST /internal/companies/{companyId}/membershipswith optionalmetadata. Use for defaults or org-wide hints for that user in that company (e.g. same scope on every BU). - Business unit membership —
POST /internal/companies/{companyId}/business-units/{businessUnitId}/membershipswith optionalmetadata. Typical for SUBMITTER / per-BU overrides. For “company-wide submitter” visibility in a BU-grained model, many products repeat the sameinvoiceViewScope/canEditOthersScopeon each BU row in that company; Auth does not auto-expand rows.
Omit vs replace
- If the request body omits
metadata, the upsert keeps the existing JSON on that row (same pattern as optionalapprovalLimiton company upsert). - To replace metadata, send a full JSON object. To clear to “no custom flags”, send
metadata: {}(empty object).
Examples
Company membership — tenant role plus optional invoice scope defaults:
POST /internal/companies/{companyId}/membershipsContent-Type: application/json{ "userId": "550e8400-e29b-41d4-a716-446655440000", "role": "SUBMITTER", "isActive": true, "metadata": { "version": 1, "invoiceViewScope": "COMPANY", "canEditOthersScope": "BU" }}Business unit membership — submitter with BU-wide view and no edit of others’ drafts:
POST /internal/companies/{companyId}/business-units/{businessUnitId}/membershipsContent-Type: application/json{ "userId": "550e8400-e29b-41d4-a716-446655440000", "role": "SUBMITTER", "isActive": true, "metadata": { "version": 1, "invoiceViewScope": "BU", "canEditOthersScope": "OWN" }}Company roles vs metadata
TENANT_SUPERADMIN,ADMIN, andFINANCEoncompany_membershipsare the primary role-based way to grant broad access inside a company; Finance stacks usually treat those as separate from submitterinvoiceViewScope.metadatais most relevant for SUBMITTER-style access and fine-grained view vs edit others when you are not granting full company FINANCE / ADMIN powers.
Step 0 — Prerequisites
Section titled “Step 0 — Prerequisites”- Caller has a Bearer access token for an account allowed to perform the next steps (see §5.1).
companyIdis a UUID — the canonical tenant id your product uses (often aligned with Finance). If you only have a legacy code or slug, resolve it server-side withGET /internal/companies/resolve?raw=...usingX-Internal-API-Key(§5.3), not in a public browser (never ship the internal API key to clients).
Procedure A — Platform operator creates a user and grants company access (typical back-office)
Section titled “Procedure A — Platform operator creates a user and grants company access (typical back-office)”Goal: New employee user@company.com exists, can log in, and has e.g. MANAGER in company C.
-
Authenticate as platform staff
POST /auth/loginwith email/password → saveaccessToken(Bearer) andrefreshToken. -
Create the identity (platform full admin only:
PLATFORM_ADMINorPLATFORM_SUPERADMINon the caller token)
POST /internal/users{"email": "user@company.com","password": "at-least-8-chars","fullName": "Jane Doe","globalRole": "NONE","isActive": true,"authProvider": "password"}- 201 → read
data.id→ this isuserIdfor later steps. - New admin-created users are
APPROVEDby default (approval_status), so login can work immediately unless you setisActive: falseor change approval in a follow-upPATCH.
- 201 → read
-
Attach the user to the company (tenant permission)
For each company the person should belong to, call once per company (same or differentrole):POST /internal/companies/{companyId}/memberships{"userId": "<uuid-from-step-2>","role": "MANAGER","isActive": true}role∈TENANT_SUPERADMIN|ADMIN|FINANCE|MANAGER|SUBMITTER(company-scoped; not the same enum asusers.global_role).- Optional
metadata— JSON for product flags such as invoice view/edit scope (§5.2a); omit to leave any existing metadata unchanged. - Idempotent upsert: calling again with the same
(userId, company)updatesrole/isActive. - Who may call:
PLATFORM_ADMIN/PLATFORM_SUPERADMIN, or a companyADMIN/FINANCE/TENANT_SUPERADMINin thatcompanyId(see §5.1 table).PLATFORM_MODERATORcannot call this route.
-
User signs in
POST /auth/login→ access token includesglobalRole(usuallyNONE) and does not embed membership lists in the JWT. UseGET /auth/me(Bearer) orGET /internal/users/{id}/context(internal API key) to readcompanyMembershipsandbusinessUnitMembershipswith the same JSON shapes, or query Auth DB / your BFF for authorization decisions. -
Verify (optional)
GET /internal/companies/{companyId}/users— lists users with an active membership in that company (platform staff or company MANAGER+); each user includesmemberships(this company + its BU rows) like the platform user list.GET /internal/companies/{companyId}/business-units/{businessUnitId}/users— same idea for a business unit.GET /auth/mereturns all of the caller’s company and BU memberships indatafor client-side or gateway use.
Procedure B — Self-service registration, then approval, then company assignment
Section titled “Procedure B — Self-service registration, then approval, then company assignment”Goal: Someone registers on the public form; later an admin approves them and assigns a tenant role.
-
Registrant —
POST /auth/register(requiresPUBLIC_REGISTRATION_ENABLED=trueon server)- User is created with
approvalStatus: PENDING,isActive: false. No tokens returned.
- User is created with
-
Find the pending user (platform staff)
GET /internal/users?approvalStatus=PENDING&limit=50&offset=0- Requires
PLATFORM_ADMIN,PLATFORM_MODERATOR, orPLATFORM_SUPERADMINon the caller token.
- Requires
-
Approve (and activate) — login requires
approvalStatus: APPROVEDandisActive: true.
PATCH /internal/users/{id}{"approvalStatus": "APPROVED","isActive": true}- Use a caller with
PLATFORM_ADMINorPLATFORM_SUPERADMINfor this combined PATCH (recommended). PLATFORM_MODERATORmay only sendapprovalStatusonPATCH; they cannot setisActive. If the registrant is stillisActive: false, login stays blocked until a platform full admin performs a secondPATCHwithisActive: true(or you change product policy so moderators are granted broader rights — not the default here).
- Use a caller with
-
Assign company role — same as Procedure A, step 3 (
POST .../memberships) once you knowuserIdandcompanyId. -
User logs in —
POST /auth/login.
Procedure C — Existing user: only add or change company membership
Section titled “Procedure C — Existing user: only add or change company membership”Goal: User already exists and can log in; you only need tenant role changes.
- Upsert membership —
POST /internal/companies/{companyId}/membershipswithuserId, newrole, andisActiveas needed. - No new login required for membership-only changes in downstream services that read the DB — but the JWT will still not embed memberships; services must query
company_memberships(or your wrapper API).
Changing platform role vs company role
Section titled “Changing platform role vs company role”| Intent | Use |
|---|---|
Promote someone to platform staff (e.g. PLATFORM_MODERATOR) | PATCH /internal/users/{id} with "globalRole": "PLATFORM_MODERATOR" — platform admin only; company admins cannot change globalRole. |
| Change tenant role in one company | POST /internal/companies/{companyId}/memberships with new role / isActive. |
| Soft-delete user account (global) | DELETE /internal/users/{id} — subject to §5.1 (not PLATFORM_MODERATOR). |
Stale JWT after admin changes
Section titled “Stale JWT after admin changes”- If you change
users.global_roleortoken_version, the user needs a new access token (POST /auth/refreshor login again) to see updatedglobalRolein/auth/meor in decoded JWT. - Membership changes do not appear in the JWT at all; do not rely on the token for tenant permissions.
Machine-only helper (company UUID resolution)
Section titled “Machine-only helper (company UUID resolution)”| Step | Request |
|---|---|
Resolve opaque id to canonical companyId | GET /internal/companies/resolve?raw=<string> with header X-Internal-API-Key — requires INTERNAL_API_KEY on server and Finance DB configured. See §5.3. |
5.3 Internal machine / support routes
Section titled “5.3 Internal machine / support routes”Auth: X-Internal-API-Key: <INTERNAL_API_KEY>
Availability: Only if the server was started with INTERNAL_API_KEY set. Otherwise these paths return 404.
| Method | Path | Description |
|---|---|---|
| POST | /internal/sessions/{id}/revoke | Revoke one session (UUID in path). No body. |
| POST | /internal/users/{id}/revoke-all | Revoke all sessions; bump token_version. No body. |
| GET | /internal/companies/resolve?raw=<string> | Resolve company id (requires Finance DB on server). |
| GET | /internal/users/{id}/context | Lightweight user fields for support. |
6. Discovery & health
Section titled “6. Discovery & health”6.1 GET /health
Section titled “6.1 GET /health”Liveness: process is up. 200 — JSON envelope with data.status: "ok".
6.2 GET /.well-known/jwks.json
Section titled “6.2 GET /.well-known/jwks.json”No envelope. Raw JWKS document for RS256 signature verification. Keys include kid matching JWT header kid.
6.3 GET /ready
Section titled “6.3 GET /ready”Readiness: Auth Postgres, Redis, optional Finance DB. 200 if OK; 503 if a dependency fails (body includes error with not_ready).
Use for load balancers / orchestrators, not for end-user UI.
7. Tokens & sessions
Section titled “7. Tokens & sessions”| Concept | Role |
|---|---|
| Access token (JWT) | Send as Authorization: Bearer ... to protected routes. Short-lived (expiresIn seconds). |
| Refresh token | Opaque; not a JWT. Used only with POST /auth/refresh and POST /auth/logout. Store securely (httpOnly cookie or secure storage per your threat model). |
| Session | Server-side row tied to sessionId in the JWT; can be revoked (logout, admin, password change, etc.). |
Typical SPA flow
- Login → store
refreshTokensecurely; keepaccessTokenin memory (or short-lived storage). - Before calling APIs, ensure access token is valid; if expired or 401, call
/auth/refreshthen retry. - On logout, call
/auth/logoutwith refresh token (and optionally clear client state).
expiresIn: 900 means the access token lasts 15 minutes (if default TTL); it does not mean the user must use the login form every 15 minutes — use refresh until the refresh session expires or is revoked.
7.1 Email notifications (AWS SES)
Section titled “7.1 Email notifications (AWS SES)”When the Auth service is configured with AWS SES (see main README.md §9), it sends HTML email for lifecycle events. Sending is asynchronous; API responses still succeed if SES fails (failures are logged with a warning).
| Trigger | Recipient | Content |
|---|---|---|
POST /auth/register | Registrant | Registration received; pending approval. |
POST /internal/users | New user | Welcome + email and initial password. |
PATCH /internal/users/{id} → approvalStatus: APPROVED | User | Account approved (first transition to approved). |
PATCH /internal/users/{id} → approvalStatus: REJECTED | User | Registration not approved. |
PATCH /internal/users/{id} → isActive: false (deactivation) | User | Account deactivated — skipped if the same request already sent the rejection email. |
DELETE /internal/users/{id} (soft-delete) | User | Account deactivated. |
PATCH with new password (admin change) | User | Password changed. |
POST /auth/password-reset/request | User | Reset link: {FRONTEND_URL}/reset-password?token=... |
POST /auth/password-reset/confirm | User | Password changed confirmation. |
Set EMAIL_ENABLED=false to disable all outbound email (e.g. local dev without SES).
8. JWT access token claims
Section titled “8. JWT access token claims”Access tokens are RS256 JWTs. The wire format is header.payload.signature (Base64URL parts). After verification, read claims from the payload JSON object. Field names below are exact JSON keys (camelCase for custom claims).
Implementation reference: AccessClaims in internal/auth/jwt.go; internal login currently sets authType to "internal" and isVendor to false (vendor tokens are not issued by this path).
8.1 Header (first segment)
Section titled “8.1 Header (first segment)”Decode the header JSON to inspect:
| Field | Value |
|---|---|
alg | RS256 |
kid | Key id — must match a JWK kid from GET /.well-known/jwks.json |
typ | May be JWT (depends on library) |
Verifiers must use kid to pick the correct public key from JWKS.
8.2 Registered (standard) claims
Section titled “8.2 Registered (standard) claims”These use the usual JWT names:
| Claim | Type | Meaning |
|---|---|---|
iss | string | Issuer — env JWT_ISSUER (default auth.primuse.dev) |
sub | string | Subject — same value as id (user UUID string) |
aud | string or string[] | Audience — env JWT_AUDIENCE (default primuse-apps). Some libraries emit a single string; others an array with one element. Accept either when validating. |
iat | number | Issued-at time (Unix seconds) |
exp | number | Expiration (Unix seconds) |
There is no nbf claim in tokens issued today.
8.3 Custom application claims (payload)
Section titled “8.3 Custom application claims (payload)”These are private claims (still signed). Use them for identity and authorization hints after signature verification (servers) or for UI only if you only Base64-decode without verifying (clients — see below).
| JSON key | Type | Meaning |
|---|---|---|
id | string | User UUID (canonical user id) |
email | string | Email address |
name | string | Display name (full_name in DB) |
sessionId | string | Session UUID — ties the token to a server-side session (revocation, logout) |
authType | string | Login pipeline; internal email/password users → "internal" |
globalRole | string | Canonical platform role on users.global_role: NONE, PLATFORM_SUPERADMIN (legacy), PLATFORM_ADMIN, PLATFORM_MODERATOR |
roles | string | Legacy display string derived from globalRole (see §8.4) |
isVendor | boolean | false for Auth-DB internal users today |
vendorId | string | null | null when not a vendor session |
tokenVersion | number (integer) | Must match the user row in Auth DB; bumps invalidate old tokens |
Authorization: Prefer globalRole for platform-level checks. Company (tenant) roles are not in the token — load company_memberships server-side or via your BFF (§5.2). The roles string exists for older clients that expect a single label.
8.4 globalRole and legacy roles
Section titled “8.4 globalRole and legacy roles”globalRole (in token) | roles (in token) |
|---|---|
PLATFORM_SUPERADMIN | Admin |
PLATFORM_ADMIN | PlatformAdmin |
PLATFORM_MODERATOR | PlatformModerator |
NONE | User |
8.5 Example decoded payload (illustrative)
Section titled “8.5 Example decoded payload (illustrative)”Values are fake; only the shape and key names are normative:
{ "id": "550e8400-e29b-41d4-a716-446655440000", "email": "user@example.com", "name": "Example User", "sessionId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "authType": "internal", "globalRole": "NONE", "roles": "User", "isVendor": false, "vendorId": null, "tokenVersion": 1, "iss": "auth.primuse.dev", "sub": "550e8400-e29b-41d4-a716-446655440000", "aud": "primuse-apps", "iat": 1710000000, "exp": 1710000900}| Claim | Meaning |
|---|---|
id | User UUID |
email | |
name | Display name |
sessionId | Session UUID |
authType | e.g. internal |
globalRole | NONE | PLATFORM_SUPERADMIN | PLATFORM_ADMIN | PLATFORM_MODERATOR |
roles | Legacy string derived from global role |
isVendor | Boolean |
vendorId | Nullable |
tokenVersion | Integer; must match DB for validation |
iss | Issuer (config JWT_ISSUER, default auth.primuse.dev) |
sub | Subject (user id) |
aud | Audience (config JWT_AUDIENCE, default primuse-apps) |
exp / iat | Expiry and issued-at (Unix seconds) |
8.6 What each platform should read
Section titled “8.6 What each platform should read”| Platform | What to read |
|---|---|
| Web (browser) | After your backend verifies the JWT, or for display-only UI, decode the payload and read id, email, name, globalRole, sessionId. Do not put secrets in localStorage based only on unverified client-side decode. |
| iOS / Swift | Decode payload (e.g. JWTDecode, Auth0/JWT libraries) into a struct with id, email, name, sessionId, globalRole, roles, tokenVersion, iss, aud, exp, iat. Map JSON keys exactly (camelCase). |
| Android / Kotlin | Same keys with Gson/Kotlinx.serialization; vendorId nullable; aud may need a custom deserializer if either String or List<String>. |
| Backend services | Verify signature (JWKS), then iss, aud, exp, then use id / sub for user identity and globalRole for authorization. Optionally re-check tokenVersion or call Auth if you need stronger guarantees. |
Security note: Relying on unverified payload data in a server is unsafe. Other services must verify the JWT with JWKS (see §9). Clients may decode JWTs for UX (show name/role) but access control for APIs should remain on verified tokens or server-side sessions.
9. Verifying JWTs in your backend
Section titled “9. Verifying JWTs in your backend”- Fetch JWKS from
GET https://<auth-host>/.well-known/jwks.json(cache with TTL). - Verify RS256 signature using the JWK with matching
kid. - Validate
issandaudagainst your deployment’s issuer and audience. - Validate
exp(and optionallyiat/ clock skew). - Enforce authorization using
globalRole(and your app’s rules). - Optionally call Auth
GET /auth/meor validate session/tokenVersionagainst your architecture — many gateways only verify signature + claims.
Issuer and audience must match what Auth is configured to issue; mismatch is a common integration bug.
10. Frontend integration checklist
Section titled “10. Frontend integration checklist”- Base URL and HTTPS in production.
- Login:
POST /auth/loginwithaccountType""/"internal"/"auto". - Store refresh token securely; treat access token as short-lived.
- Refresh:
POST /auth/refreshbefore access expiry or on 401 from your API. - Attach
Authorization: Bearer <accessToken>to protected calls. - Logout:
POST /auth/logoutwith refresh token; clear client state. - Handle
error.code(pending_approval,registration_rejected,rate_limited, etc.). - Register only if product enables
PUBLIC_REGISTRATION_ENABLEDon server. - Do not embed platform-admin credentials or internal API key in public clients; admin tools only.
- Tenant UX: JWT does not list memberships;
GET /auth/meincludescompanyMembershipsandbusinessUnitMemberships— use those or a BFF if you need a different aggregation (see §5.2). Optional per-rowmetadatamay carry invoice scope flags (§5.2a).
11. Backend (other services) checklist
Section titled “11. Backend (other services) checklist”- Verify JWTs with JWKS (
/.well-known/jwks.json), not shared symmetric secrets. - Align
JWT_ISSUER/JWT_AUDIENCEwith verification config in each service. - Cache JWKS; handle key rotation (
kidchanges). - Use machine routes only server-to-server with
X-Internal-API-Key; never expose that key to browsers. - Respect
tokenVersion/ session invalidation if you mirror Auth checks. - For company- or BU-scoped authorization, use
GET /auth/me,GET /internal/users/{id}/context, orcompany_memberships/business_unit_membershipsvia your data path — do not infer tenant access fromglobalRolealone (§5.2). - For Finance-style invoice list / draft edit rules, read optional
metadataon company and BU membership rows (§5.2a); Auth stores JSON only.
12. Operational notes
Section titled “12. Operational notes”12.1 CORS
Section titled “12.1 CORS”If browsers call Auth directly, the deployment must allow your web origin (reverse proxy or app-level CORS). If a single API gateway fronts Auth and your BFF, CORS may be configured only on the gateway.
12.2 TLS
Section titled “12.2 TLS”Use HTTPS for production tokens and credentials.
12.3 Environment variables consumers care about
Section titled “12.3 Environment variables consumers care about”Deployed Auth service exposes behavior via config; integrators usually only need URLs. For self-hosted alignment:
| Variable | Relevance |
|---|---|
JWT_ISSUER | Must match what you validate as iss. |
JWT_AUDIENCE | Must match what you validate as aud. |
ACCESS_TOKEN_TTL | Drives expiresIn. |
REFRESH_TOKEN_TTL | How long refresh remains valid (if unchanged from default). |
PUBLIC_REGISTRATION_ENABLED | Toggles POST /auth/register. |
EMAIL_ENABLED | true / false — disables all SES emails when false. |
FRONTEND_URL | Base URL for reset links and email CTAs (e.g. https://app.kisum.io). |
SES_AWS_*, EMAIL_FROM, EMAIL_APP_NAME, EMAIL_LOGO_URL | AWS SES and sender branding; see main README §9. |
Full list: main README.md §9.
12.4 Source of truth
Section titled “12.4 Source of truth”- Exact request/response schemas:
docs/openapi.yaml. - Behavior details:
README.md,docs/RFC.md(if maintained).
12.5 Membership payloads and caching
Section titled “12.5 Membership payloads and caching”GET /auth/meandGET /internal/users/{id}/contextcan return arbitrarily many membership rows; there is no pagination in v1 — if a single user grows very large lists, treat it as an operational concern (split accounts, fewer roles, or a future paginated read model).- Each company membership may include optional
metadata(same JSON shape as upsert responses). Each BU membership may includemetadata— see §5.2a. - Responses are not cached inside Auth for these arrays: each call runs fresh reads (read-your-writes in Postgres). Consumers (e.g. Finance) may cache membership bundles with a TTL and invalidate when
tokenVersionchanges or after login/refresh.
Document scope
Section titled “Document scope”This file is for client developers (web/mobile) and maintainers of other backend services. For building and running the Auth service itself, start with the root README.md.
13. Access model layers (NEW)
Section titled “13. Access model layers (NEW)”This platform uses a 3-layer access model:
Level 1 — Company entitlements (Platform Core)
Section titled “Level 1 — Company entitlements (Platform Core)”Defines what the company bought:
- Basic
- Finance
- AI
- Touring
- Market
- Venue
Source of truth: Platform Core
Level 2 — Membership module grants (Auth)
Section titled “Level 2 — Membership module grants (Auth)”Defines which modules a user can access inside the company.
Source of truth: Auth DB
Level 3 — Permissions (Auth)
Section titled “Level 3 — Permissions (Auth)”Defines what the user can do inside a module.
Examples:
finance.expense.viewfinance.expense.createmarket.contract.approve
Source of truth: Auth DB
Final rule
Section titled “Final rule”effective access=company entitlements∩membership module grantsPermissions are only valid if the module is enabled.
14. Roles vs modules vs permissions (NEW)
Section titled “14. Roles vs modules vs permissions (NEW)”These concepts must never be confused:
- Role → administrative authority
- Module → product access
- Permission → action inside module
Example:
Two users can both be USER, but:
- User A → Finance only
- User B → Basic + Market
This is valid.
Roles DO NOT define modules.
15. Delegation model (NEW)
Section titled “15. Delegation model (NEW)”Tenant roles define what a user can grant to others.
TENANT_SUPERADMIN
Section titled “TENANT_SUPERADMIN”- Full control of company
- Can assign modules
- Can assign permissions
- Can define delegation limits
- Can buy add-ons
- Can manage users
- Can assign modules and permissions
- Cannot exceed delegation defined by Superadmin
- Cannot buy add-ons
MANAGER / APPROVAL
Section titled “MANAGER / APPROVAL”- Limited delegation
- Can manage users below them
- Can assign only allowed modules/permissions
USER / SUBMITTER
Section titled “USER / SUBMITTER”- No delegation
- Only consumes access
Critical rule
Section titled “Critical rule”No user can grant access they do not control.16. Effective access algorithm (NEW)
Section titled “16. Effective access algorithm (NEW)”Auth must compute access as follows:
- Load company entitlements from Core
- Load membership module grants
- Compute intersection
- Filter permissions by enabled modules
- Apply delegation limits