x-api-key header (ADR 0026). The two credentials share one authorization model: a global Role plus per-Entity Membership.
Roles
Every User has exactly one Role, stored on the User record:staff: Sintropix employees. Full read and write access to every Entity, including Entities created later, with no per-Entity grant.client: everyone else. Read and write access to exactly the Entities they hold an Entity Membership for.
staff:
POST /api/entitiesandPATCH /api/entities/:entityId/parentandPATCH /api/entities/:entityId/accounting-policies- Every route under
/api/onboarding/* POST /api/fx-rates/sync- Every route under
/api/agents/*and/api/entities/:entityId/agents/*(Erwin surfaces)
403.
Tenant Scoping
Any route whose path contains:entityId runs through TenantGuard (apps/erp-backend/src/auth/tenant.guard.ts):
- If the caller is
staff, the guard passes. - If the caller authenticated with an API key scoped to one Entity (see Erwin Key Leases below), the guard rejects requests to any other Entity with
404. - Otherwise the guard requires an Entity Membership row for the caller’s User and the requested
entityId, and returns404when it is absent.
404, never 403, on missing membership. A caller cannot use the API to discover whether an Entity id they do not own exists.
Session Cookies
The Sintropix web client signs in through Better Auth’s REST surface at/api/auth/* (email and password today; sign-up is disabled — Users are provisioned by staff, see Onboarding). The response sets an httpOnly, same-site session cookie that carries authentication on subsequent requests.
Two deployment consequences flow from the same-site cookie:
- The SPA and the API must share a registrable domain (for example
app.sintropix.comandapi.sintropix.com). Two*.up.railway.appsubdomains are different sites under the Public Suffix List and Safari drops the cookie. - Local development on
localhostacross ports is same-site and works out of the box.
POST /api/onboarding/users/:userId/set-password-link).
API Keys
API keys are stand-ins for their owning User. A key authenticates as that User, with that User’s Role and Memberships, with the following differences from a session:- The credential is a header (
x-api-key), not a cookie. - The key is barred from the auth surface. Any request to
/api/auth/*carryingx-api-keyis rejected with403before Better Auth sees it (apiKeyAuthSurfaceBarmiddleware, ADR 0026). A leaked key therefore cannot mint further keys, change passwords, or manage Users. - Every mutating request (
POST,PUT,PATCH,DELETE) must include a Declared Actor (see below). - Each key is rate-limited to 5,000 requests per hour (Better Auth
apiKeyplugin, sliding window).
Declared Actor
Every key-authenticated mutation must carryx-audit-actor:
400 with the required header name in the message.
Session-authenticated requests do not need x-audit-actor; the browser’s session already identifies the User.
GET and HEAD requests never require x-audit-actor, because the audit trail captures mutations only.Read-Only Keys
A key’spermissions column marks it read-only by carrying {"erp": ["read"]} (ADR 0030). The ReadOnlyKeyGuard (apps/erp-backend/src/auth/read-only-key.guard.ts) rejects any non-GET/HEAD request authenticated with such a key by throwing an HttpException with code API_KEY_READ_ONLY_ERROR.
The narrowing is verb-level only: a read-only key still reads everything its owner can read, across every Entity the owner is a member of, unless the key is also Entity-scoped.
Read-only key minting is currently exposed to staff-side flows (chiefly Erwin Key Leases, described next). Sintropix has not published a self-serve read-only key mint UI as of this writing; verify current availability in the settings screen.
Erwin Key Leases (Read-Only and Entity-Scoped)
Erwin, the AI assistant, does not hold a static credential. On every message a user sends, the agent gateway mints a fresh read-only key for that User, scoped to one Entity, with a 1-hour expiry, and pushes it to the flue-agent runtime for the duration of that submission (ADR 0030, amended by ADR 0032 and ADR 0039). Each key row carriesmetadata.entityId, which TenantGuard reads to reject requests to any other Entity.
The lease has no refresh protocol: an expired lease simply lapses, and the next user message mints a new one. Because the credential is per-submission and per-User, the Audit Trail records actorUserId = the human user and declaredActor = erwin:<conversationId>, tracing every write back to the exact chat transcript.
Sign-In Example (Better Auth)
The frontend uses Better Auth’s client library rather than raw HTTP, but the underlying request is a plain POST:This is the raw Better Auth transport. Refer to Better Auth’s REST docs for the full auth surface; Sintropix does not layer its own routes on top.