Skip to main content

Auth, actors and org scoping

Two different questions have two different answers here, and keeping them apart is the point of this page.

Authenticating

The backend GL API

Three ways, checked in order. If none matches, the request is rejected - locked by default.
The X-WorthState-* strings are legacy wire identifiers still required by the shipped backend, not the product name or a public URL. The docs retain the exact literals so an integration does not send an invented replacement; retiring them requires a compatible backend protocol migration.

The DevCenter API

One middleware gates every route. The service-token check goes through the same helper the actor derivation uses, so “is this the service token?” has one answer in both places. A failure is 401 with WWW-Authenticate: Bearer.
CORS is an explicit allowlist, never *, because these endpoints serve ledger data. The UI’s resolved origin is passed in as configuration - hardcoding a port once blinded every data page whenever the UI was served elsewhere: the request still returned 200, but the browser discarded the response and the page rendered empty. On a finance UI an empty page reads as “no data” rather than “blocked”.

The actor is derived, not sent

X-Actor used to be a browser-supplied header that nothing on the server re-derived, so the approver simply chose it. The submitter of a document could send X-Actor: <a colleague> and approve their own document: maker-checker passed, and the audit trail named someone who never touched it.
An audit trail the subject of the audit writes is not an audit trail.
The rule now: the actor comes from the identity the request was authenticated with, and X-Actor is ignored whenever such an identity exists. A service caller must still name someone. There is no default.
This is why the acting identity is not a convenience field. The derived actor becomes approved_by, reviewed_by, the ledger’s created_by, and the value the maker != checker comparison is made against. A client-chosen actor would defeat the posting gate, not just the intake one.

Two forms of the same person

Some tables key a person by a uuid rather than by the actor string, because that is what the row-level-security policies on those tables compare against - the Typewriter document’s creator and an approval step’s approver, for instance. Both facts are derived from the same verified session rather than one being looked up from the other later. A service-token caller has no browser session and therefore no uuid: it gets None, and a route that needs one refuses rather than inventing it.

Org scoping

Every read is scoped to that org by row-level security, so a cross-org caller sees zero rows. That is a property of the database, not a WHERE clause a future refactor can drop.
The DevCenter asserts at boot that CLERK_ORG_ID names exactly one existing org, and warns loudly if it is missing or ambiguous - so an empty review inbox is diagnosable from the logs rather than being mistaken for “no documents”. The assertion is best-effort and never breaks app import.

The error contract

The backend maps its typed ledger errors to RFC 9457 problem details, served as application/problem+json, so a client branches on a machine code rather than parsing prose.
The DevCenter’s own routes use plain HTTP status codes with a detail string, and the mapping is deliberate:
The Typewriter routes apply one asymmetry on purpose: an error the module raised itself carries a message written for the person reading it, and anything else is a 500 with no detail. A database driver error names columns and can quote values, and these responses reach a browser.

Where it is enforced

The test that would fail if it broke

langgraph_chat/devcenter/tests/test_auth.py drives the derivation table directly, including that a browser-sent X-Actor is ignored when a session identity exists and that a service caller with no X-Actor is refused.