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.
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 getsNone, 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 asapplication/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:
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.
Related
- Maker-checker and separation of duties - what the derived actor is compared against
- Tenancy and RLS - how org scoping is enforced beneath the API
- The security boundary - the attacks this design was hardened against
- Intake and the posting gate - the routes where the actor matters most