Skip to main content
Sintropix authenticates callers with Better Auth (ADR 0015), self-hosted against the same Postgres that stores the ledger. A browser signs in with email and password and receives an httpOnly session cookie; an integration or agent presents a long-lived API key in the 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.
Role gates which Entities a User can touch, not what they can do inside one. There is no per-operation permission system. A small set of routes require staff:
  • POST /api/entities and PATCH /api/entities/:entityId/parent and PATCH /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)
Client roles that hit a staff-only route receive 403.

Tenant Scoping

Any route whose path contains :entityId runs through TenantGuard (apps/erp-backend/src/auth/tenant.guard.ts):
  1. If the caller is staff, the guard passes.
  2. 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.
  3. Otherwise the guard requires an Entity Membership row for the caller’s User and the requested entityId, and returns 404 when it is absent.
The guard raises 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.com and api.sintropix.com). Two *.up.railway.app subdomains are different sites under the Public Suffix List and Safari drops the cookie.
  • Local development on localhost across ports is same-site and works out of the box.
Reset flows exist in Better Auth but Sintropix does not send email in v1: staff mint a set-password link and deliver it to the client through their existing channel (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/* carrying x-api-key is rejected with 403 before Better Auth sees it (apiKeyAuthSurfaceBar middleware, 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 apiKey plugin, sliding window).
Key management (mint, list, revoke) is self-serve in the Sintropix web app over the session; the API does not expose a mint endpoint to key holders. Any User may mint keys — including staff. A staff key therefore reaches every Entity of every client; treat it as a high-value secret.
Keys can live forever. Expiry is optional and unbounded by default (ADR 0026). Rotate keys on a schedule and revoke immediately on suspected leak.

Declared Actor

Every key-authenticated mutation must carry x-audit-actor:
The Declared Actor is recorded verbatim on the Audit Trail row that the mutation writes. It is a self-reported software label (“what wrote this row”), not proof of identity — the key’s owning User remains the verified Actor. Omitting the header on a mutation returns 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’s permissions 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 carries metadata.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:
Subsequent business calls read the cookie jar:
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.

API Key Example

Common Failure Modes