Skip to content

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:


  1. Conventions
  2. Response envelope & errors
  3. Endpoints overview
  4. Public auth /auth — includes GET /auth/me/access (§4.7), password reset (§4.8), Invitations (§4.9)
  5. Internal /internal§5.1 RBAC, §5.1a Viewing users & details, §5.2 Full procedure, §5.2a Invoice / bill scope metadata, §5.3 Machine routes
  6. Discovery & health
  7. Tokens & sessions (includes §7.1 AWS SES email)
  8. JWT access token claims — header, standard & custom claims, example payload, per-platform notes
  9. Verifying JWTs in your backend
  10. Frontend integration checklist
  11. Backend (other services) checklist
  12. Operational notes

ItemValue
Content-Typeapplication/json for request bodies unless noted
Acceptapplication/json (recommended)
Access tokenAuthorization: Bearer <access_token>
Internal automationX-Internal-API-Key: <key> (only for machine routes)
JWT algorithmRS256
OpenAPI 3docs/openapi.yaml — import into Postman, Insomnia, or Swagger UI

Most JSON APIs return:

{ "success": true, "data": { } }

201 Created responses use the same shape with data populated.

{ "success": false, "error": { "code": "<string>", "message": "<string>" } }

Exception: GET /.well-known/jwks.json returns raw JWKS JSON (no success / data wrapper).

HTTPerror.codeTypical cause
400validation_errorBad body, missing fields, invalid UUID, etc.
401unauthorizedWrong email/password, missing/invalid Bearer token
401session_revokedSession invalidated
403forbiddenAuthenticated but not allowed (e.g. wrong role, bad internal key)
403pending_approvalSelf-serve user not yet approved
403registration_rejectedRegistration rejected
403account_inactiveUser deactivated
403registration_disabledPOST /auth/register disabled by server config
404not_foundResource missing
409conflictDuplicate email, etc.
429rate_limitedToo many login/register attempts (per IP, when Redis is available)
501not_implementede.g. accountType: "vendor" login
503finance_db_not_configuredCompany resolve without Finance DB
503(ready)Readiness failure — see /ready
5xxinternal_errorServer error

Treat error.code as stable for client logic; message may change.


MethodPath
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
MethodPath
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.

MethodPath
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)”
MethodPath
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).


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 to password. Allowed: password, google, microsoft, sso, other (same enum as admin user APIs).

Success: 201data includes email, status: "pending".

Rate limit: Same bucket as login when Redis is available (e.g. 30 requests / minute / IP — verify in deployment).


Auth: none

Body:

{
"email": "user@example.com",
"password": "string",
"accountType": ""
}

accountType — selects the login pipeline, not admin vs user:

ValueBehavior
"", "internal", "auto"Email/password against Auth DB (users).
"vendor"501 — not implemented in this service.
Anything else400 validation error.

Authorization (platform globalRole vs NONE, etc.) comes from the user record (global_role), not from accountType.

Success: 200data contains:

FieldDescription
accessTokenJWT (RS256), short-lived
refreshTokenOpaque string; store securely
expiresInAccess 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.


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).


Auth: none

Body:

{ "refreshToken": "<current refresh token>" }

Invalidates that session’s refresh token. Success: 200 — e.g. data.status: "ok".


Auth: Authorization: Bearer <access_token>

Revokes all sessions for the user in the token.

Body: none.


Auth: Authorization: Bearer <access_token>

Success: 200data 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.


Auth: Authorization: Bearer <access_token>


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

  1. Validate JWT
  2. Resolve active company context (from:
    • x-org header OR
    • default company OR
    • explicit selection in frontend)
  3. Load user + memberships (same as /auth/me)
  4. Load user grants (Auth DB)
  5. Call Platform Core to get company entitlements
GET /internal/companies/{companyId}/entitlements
  1. Merge:
effectiveAccess = entitlements ∩ userGrants

Note:

User must already have a valid company membership.

Membership is created via:

  • invitations (primary flow)
  • internal admin actions

Without membership, no access is computed.

  1. Return merged access model

Auth calls:

GET /internal/companies/{companyId}/entitlements

from Platform Core.


{
"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

  • Should use Redis caching per (userId, companyId)

  • Cache invalidation triggers:

    • membership change
    • permission change
    • entitlement change
    • token version change

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).


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

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

Invitations belong to Auth because they are part of:

  • identity lifecycle
  • onboarding
  • company membership creation
  • role assignment
  • access bootstrapping

An invitation should contain at minimum:

  • invitation id
  • email
  • company id
  • invited role
  • invited by
  • invitation token
  • status
  • expiration date

  • pending
  • accepted
  • expired
  • cancelled

  1. Authorized company/platform actor creates invitation
  2. Auth stores invitation
  3. Auth sends email (if email is enabled)
  4. Invitee accepts invitation
  5. Auth:
    • creates user if needed
    • or links existing user
    • creates company membership
    • assigns role
    • optionally assigns initial grants
  6. Invitation status becomes accepted

POST /auth/invitations
GET /auth/invitations
GET /auth/invitations/{id}
POST /auth/invitations/{id}/resend
POST /auth/invitations/accept
DELETE /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

Invitation creation must be restricted.

Allowed actors:

  • platform admins (globalRole)
  • company admins (tenant roles such as TENANT_SUPERADMIN, ADMIN)
  • optionally MANAGER depending on product policy

Not allowed:

  • normal users (SUBMITTER)
  • users without company admin privileges

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

Invitation endpoints must always enforce:

  • JWT validation
  • x-org company context
  • tenant role authorization

Invitations must never be created without company context.


Base path: /internal.


The Auth service integrates with Platform Core to resolve company entitlements.

Used by:

  • /auth/me/access

Auth calls Core:

GET /internal/companies/{companyId}/entitlements

Auth is responsible for:

  • merging entitlements with user grants
  • returning effective access to frontend

Core is responsible for:

  • packages
  • addons
  • modules enabled per company

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

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.

WhoList all usersCreate userPATCH userDELETE userList users in companyUpsert company membershipList users in BUUpsert BU membership
PLATFORM_ADMIN or PLATFORM_SUPERADMIN (legacy)YesYesFullYesYesYesYesYes
PLATFORM_MODERATORYesNoapprovalStatus onlyNoYesNoYesNo
Company TENANT_SUPERADMIN / FINANCE / ADMIN (shared company with target)NoNoYes (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 MANAGERNoNoapprovalStatus + isActive onlyNoYesNoYesNo
MethodPathDescription
GET/internal/usersList users. Query: approvalStatus, globalRole, limit, offset. Platform staff only.
POST/internal/usersCreate 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}/usersUsers with an active membership in that company. Platform staff or company MANAGER+.
POST/internal/companies/{companyId}/membershipsBody: 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}/usersUsers with an active BU membership for that companyId + businessUnitId. Platform staff or company MANAGER+.
POST/internal/companies/{companyId}/business-units/{businessUnitId}/membershipsBody: 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.

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

MechanismAuthWhat you get
GET /auth/meBearerCaller only — profile + companyMemberships + businessUnitMemberships.
GET /internal/users/{id}/contextX-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/usersBearer; 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}/usersBearer; platform or company role with rank ≥ MANAGER in that companyIdUsers 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}/usersSame rank ≥ MANAGER gate for that companyUsers with an active BU membership for that company + business unit.

Company role rank in the service is ordered (highest first): TENANT_SUPERADMINFINANCEADMINMANAGERSUBMITTER. 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, and FINANCE — can call GET /internal/companies/{companyId}/users for 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 per companyId (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 calling GET .../companies/{companyId}/users and GET .../business-units/{businessUnitId}/users with the correct ids. There is no extra server-side filter for “only BUs I belong to”; callers pass the businessUnitId they 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 right companyId / businessUnitId and 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)”
LayerWhere it livesJWT globalRole?Purpose
Platformusers.global_role in Auth DBYes — copied into access token at login/refreshCross-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 unitbusiness_unit_memberships (user_id, company_id, business_unit_id, role, …)NoScoped role per BU within a company (SUBMITTER, APPROVER, ADMIN).
  • POST /internal/users sets platform fields (including globalRole on the user row). It does not attach a company by itself.
  • POST /internal/companies/{companyId}/memberships creates or updates one company membership row (tenant role for that user in that company).
  • POST /internal/companies/{companyId}/business-units/{businessUnitId}/memberships creates 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)

KeyTypeValuesMeaning
versionnumbere.g. 1Schema version for readers.
invoiceViewScopestringOWN, BU, COMPANYHow widely this membership row allows seeing bills/invoices in that company: own only, one business unit, or whole company.
canEditOthersScopestringOWN, BU, COMPANYHow 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).
canEditOthersInvoicesbooleantrue / falseLegacy (Finance DB migration and older UIs). If present, readers may map it to canEditOthersScope (falseOWN, 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 membershipPOST /internal/companies/{companyId}/memberships with optional metadata. Use for defaults or org-wide hints for that user in that company (e.g. same scope on every BU).
  • Business unit membershipPOST /internal/companies/{companyId}/business-units/{businessUnitId}/memberships with optional metadata. Typical for SUBMITTER / per-BU overrides. For “company-wide submitter” visibility in a BU-grained model, many products repeat the same invoiceViewScope / canEditOthersScope on 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 optional approvalLimit on 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}/memberships
Content-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}/memberships
Content-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, and FINANCE on company_memberships are the primary role-based way to grant broad access inside a company; Finance stacks usually treat those as separate from submitter invoiceViewScope.
  • metadata is most relevant for SUBMITTER-style access and fine-grained view vs edit others when you are not granting full company FINANCE / ADMIN powers.

  1. Caller has a Bearer access token for an account allowed to perform the next steps (see §5.1).
  2. companyId is 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 with GET /internal/companies/resolve?raw=... using X-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.

  1. Authenticate as platform staff
    POST /auth/login with email/password → save accessToken (Bearer) and refreshToken.

  2. Create the identity (platform full admin only: PLATFORM_ADMIN or PLATFORM_SUPERADMIN on 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 is userId for later steps.
    • New admin-created users are APPROVED by default (approval_status), so login can work immediately unless you set isActive: false or change approval in a follow-up PATCH.
  3. Attach the user to the company (tenant permission)
    For each company the person should belong to, call once per company (same or different role):

    POST /internal/companies/{companyId}/memberships

    {
    "userId": "<uuid-from-step-2>",
    "role": "MANAGER",
    "isActive": true
    }
    • roleTENANT_SUPERADMIN | ADMIN | FINANCE | MANAGER | SUBMITTER (company-scoped; not the same enum as users.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) updates role / isActive.
    • Who may call: PLATFORM_ADMIN / PLATFORM_SUPERADMIN, or a company ADMIN / FINANCE / TENANT_SUPERADMIN in that companyId (see §5.1 table). PLATFORM_MODERATOR cannot call this route.
  4. User signs in
    POST /auth/login → access token includes globalRole (usually NONE) and does not embed membership lists in the JWT. Use GET /auth/me (Bearer) or GET /internal/users/{id}/context (internal API key) to read companyMemberships and businessUnitMemberships with the same JSON shapes, or query Auth DB / your BFF for authorization decisions.

  5. Verify (optional)

    • GET /internal/companies/{companyId}/users — lists users with an active membership in that company (platform staff or company MANAGER+); each user includes memberships (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/me returns all of the caller’s company and BU memberships in data for 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.

  1. RegistrantPOST /auth/register (requires PUBLIC_REGISTRATION_ENABLED=true on server)

    • User is created with approvalStatus: PENDING, isActive: false. No tokens returned.
  2. Find the pending user (platform staff)
    GET /internal/users?approvalStatus=PENDING&limit=50&offset=0

    • Requires PLATFORM_ADMIN, PLATFORM_MODERATOR, or PLATFORM_SUPERADMIN on the caller token.
  3. Approve (and activate) — login requires approvalStatus: APPROVED and isActive: true.
    PATCH /internal/users/{id}

    {
    "approvalStatus": "APPROVED",
    "isActive": true
    }
    • Use a caller with PLATFORM_ADMIN or PLATFORM_SUPERADMIN for this combined PATCH (recommended).
    • PLATFORM_MODERATOR may only send approvalStatus on PATCH; they cannot set isActive. If the registrant is still isActive: false, login stays blocked until a platform full admin performs a second PATCH with isActive: true (or you change product policy so moderators are granted broader rights — not the default here).
  4. Assign company role — same as Procedure A, step 3 (POST .../memberships) once you know userId and companyId.

  5. User logs inPOST /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.

  1. Upsert membershipPOST /internal/companies/{companyId}/memberships with userId, new role, and isActive as needed.
  2. 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).

IntentUse
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 companyPOST /internal/companies/{companyId}/memberships with new role / isActive.
Soft-delete user account (global)DELETE /internal/users/{id} — subject to §5.1 (not PLATFORM_MODERATOR).

  • If you change users.global_role or token_version, the user needs a new access token (POST /auth/refresh or login again) to see updated globalRole in /auth/me or 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)”
StepRequest
Resolve opaque id to canonical companyIdGET /internal/companies/resolve?raw=<string> with header X-Internal-API-Key — requires INTERNAL_API_KEY on server and Finance DB configured. See §5.3.

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.

MethodPathDescription
POST/internal/sessions/{id}/revokeRevoke one session (UUID in path). No body.
POST/internal/users/{id}/revoke-allRevoke 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}/contextLightweight user fields for support.

Liveness: process is up. 200 — JSON envelope with data.status: "ok".

No envelope. Raw JWKS document for RS256 signature verification. Keys include kid matching JWT header kid.

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.


ConceptRole
Access token (JWT)Send as Authorization: Bearer ... to protected routes. Short-lived (expiresIn seconds).
Refresh tokenOpaque; not a JWT. Used only with POST /auth/refresh and POST /auth/logout. Store securely (httpOnly cookie or secure storage per your threat model).
SessionServer-side row tied to sessionId in the JWT; can be revoked (logout, admin, password change, etc.).

Typical SPA flow

  1. Login → store refreshToken securely; keep accessToken in memory (or short-lived storage).
  2. Before calling APIs, ensure access token is valid; if expired or 401, call /auth/refresh then retry.
  3. On logout, call /auth/logout with 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.

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).

TriggerRecipientContent
POST /auth/registerRegistrantRegistration received; pending approval.
POST /internal/usersNew userWelcome + email and initial password.
PATCH /internal/users/{id}approvalStatus: APPROVEDUserAccount approved (first transition to approved).
PATCH /internal/users/{id}approvalStatus: REJECTEDUserRegistration not approved.
PATCH /internal/users/{id}isActive: false (deactivation)UserAccount deactivated — skipped if the same request already sent the rejection email.
DELETE /internal/users/{id} (soft-delete)UserAccount deactivated.
PATCH with new password (admin change)UserPassword changed.
POST /auth/password-reset/requestUserReset link: {FRONTEND_URL}/reset-password?token=...
POST /auth/password-reset/confirmUserPassword changed confirmation.

Set EMAIL_ENABLED=false to disable all outbound email (e.g. local dev without SES).


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).

Decode the header JSON to inspect:

FieldValue
algRS256
kidKey id — must match a JWK kid from GET /.well-known/jwks.json
typMay be JWT (depends on library)

Verifiers must use kid to pick the correct public key from JWKS.

These use the usual JWT names:

ClaimTypeMeaning
issstringIssuer — env JWT_ISSUER (default auth.primuse.dev)
substringSubject — same value as id (user UUID string)
audstring 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.
iatnumberIssued-at time (Unix seconds)
expnumberExpiration (Unix seconds)

There is no nbf claim in tokens issued today.

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 keyTypeMeaning
idstringUser UUID (canonical user id)
emailstringEmail address
namestringDisplay name (full_name in DB)
sessionIdstringSession UUID — ties the token to a server-side session (revocation, logout)
authTypestringLogin pipeline; internal email/password users → "internal"
globalRolestringCanonical platform role on users.global_role: NONE, PLATFORM_SUPERADMIN (legacy), PLATFORM_ADMIN, PLATFORM_MODERATOR
rolesstringLegacy display string derived from globalRole (see §8.4)
isVendorbooleanfalse for Auth-DB internal users today
vendorIdstring | nullnull when not a vendor session
tokenVersionnumber (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.

globalRole (in token)roles (in token)
PLATFORM_SUPERADMINAdmin
PLATFORM_ADMINPlatformAdmin
PLATFORM_MODERATORPlatformModerator
NONEUser

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
}
ClaimMeaning
idUser UUID
emailEmail
nameDisplay name
sessionIdSession UUID
authTypee.g. internal
globalRoleNONE | PLATFORM_SUPERADMIN | PLATFORM_ADMIN | PLATFORM_MODERATOR
rolesLegacy string derived from global role
isVendorBoolean
vendorIdNullable
tokenVersionInteger; must match DB for validation
issIssuer (config JWT_ISSUER, default auth.primuse.dev)
subSubject (user id)
audAudience (config JWT_AUDIENCE, default primuse-apps)
exp / iatExpiry and issued-at (Unix seconds)
PlatformWhat 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 / SwiftDecode 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 / KotlinSame keys with Gson/Kotlinx.serialization; vendorId nullable; aud may need a custom deserializer if either String or List<String>.
Backend servicesVerify 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.


  1. Fetch JWKS from GET https://<auth-host>/.well-known/jwks.json (cache with TTL).
  2. Verify RS256 signature using the JWK with matching kid.
  3. Validate iss and aud against your deployment’s issuer and audience.
  4. Validate exp (and optionally iat / clock skew).
  5. Enforce authorization using globalRole (and your app’s rules).
  6. Optionally call Auth GET /auth/me or validate session/tokenVersion against 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.


  • Base URL and HTTPS in production.
  • Login: POST /auth/login with accountType "" / "internal" / "auto".
  • Store refresh token securely; treat access token as short-lived.
  • Refresh: POST /auth/refresh before access expiry or on 401 from your API.
  • Attach Authorization: Bearer <accessToken> to protected calls.
  • Logout: POST /auth/logout with refresh token; clear client state.
  • Handle error.code (pending_approval, registration_rejected, rate_limited, etc.).
  • Register only if product enables PUBLIC_REGISTRATION_ENABLED on 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/me includes companyMemberships and businessUnitMemberships — use those or a BFF if you need a different aggregation (see §5.2). Optional per-row metadata may carry invoice scope flags (§5.2a).

  • Verify JWTs with JWKS (/.well-known/jwks.json), not shared symmetric secrets.
  • Align JWT_ISSUER / JWT_AUDIENCE with verification config in each service.
  • Cache JWKS; handle key rotation (kid changes).
  • 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, or company_memberships / business_unit_memberships via your data path — do not infer tenant access from globalRole alone (§5.2).
  • For Finance-style invoice list / draft edit rules, read optional metadata on company and BU membership rows (§5.2a); Auth stores JSON only.

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.

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:

VariableRelevance
JWT_ISSUERMust match what you validate as iss.
JWT_AUDIENCEMust match what you validate as aud.
ACCESS_TOKEN_TTLDrives expiresIn.
REFRESH_TOKEN_TTLHow long refresh remains valid (if unchanged from default).
PUBLIC_REGISTRATION_ENABLEDToggles POST /auth/register.
EMAIL_ENABLEDtrue / false — disables all SES emails when false.
FRONTEND_URLBase URL for reset links and email CTAs (e.g. https://app.kisum.io).
SES_AWS_*, EMAIL_FROM, EMAIL_APP_NAME, EMAIL_LOGO_URLAWS SES and sender branding; see main README §9.

Full list: main README.md §9.

  • GET /auth/me and GET /internal/users/{id}/context can 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 include metadata — 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 tokenVersion changes or after login/refresh.

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.


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


Defines what the user can do inside a module.

Examples:

  • finance.expense.view
  • finance.expense.create
  • market.contract.approve

Source of truth: Auth DB


effective access
=
company entitlements
membership module grants

Permissions are only valid if the module is enabled.


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.


Tenant roles define what a user can grant to others.

  • 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

  • Limited delegation
  • Can manage users below them
  • Can assign only allowed modules/permissions

  • No delegation
  • Only consumes access

No user can grant access they do not control.

Auth must compute access as follows:

  1. Load company entitlements from Core
  2. Load membership module grants
  3. Compute intersection
  4. Filter permissions by enabled modules
  5. Apply delegation limits