Authorization and access
Authorization
api asks an HTTP callback of the front-end to decide each call of a protected procedure. The callback gets the procedure, the scope of the call (the deployments, the build, or the actor of a list of audit events) and the forwarded headers, and answers whether the call is allowed, optionally with the subject, the issuer of the subject, the deployments that the caller can see, and for a submit or an import the contact of the operator at the registrar account; a list then holds only those. An answer that denies can also name the subject, and can give a reason without values of the request. The procedures that read no deployment data are public. Centrifugo asks similar proxy callbacks. The normative rules are in 004-authorization.
In the default deployment, the panel hosts the callbacks; api can also host them in-process. Without a callback, api refuses to start, unless an explicit mode for development and tests allows all calls.
api does not find out who the caller is. It trusts the callback to map a credential to a subject. This keeps one identity authority, the front-end that owns the accounts. Front-end access model gives the credential of the browser.
A trusted backend can call api for its users without exposing api to them, with a static service token. On a match, api removes the token from the forwarded headers and marks the call as internal. The backend tells the callback on whose behalf the call runs, in a header that the callback reads only for an internal call; thus a browser cannot use it, and api does not read it.
Audit events
api records one audit event for each call that changes a deployment, also when it refuses the call, and for each automatic update (FR-010 of 001). It also records an event for a call that the callback denies, and for a request that does not agree with the published contract. A request whose body is not JSON gets its error before api calls the callback, so it has no event. The event names the actor that the callback returned, as a subject and its issuer, because a subject is unique only together with its issuer (api does not know the identity behind it), the action, the deployment and build, the result, and the request identifier that the ingress sets, so that a caller cannot set it. A domain contact has no deployment: its event has the ID of the contact in the place of the deployment.
A caller that is not logged in needs no account, thus anyone can cause many denials. Of the denials without an actor and without the service token, each process of api records only the first in each interval of a few minutes, and counts the others. The first such denial after the interval records the count as one event without an action and without a deployment, with the count in the reason, and then starts a new interval. A count that no later denial records is lost. The denials of a known actor each have their own event.
An event of a refusal has a short reason: the reason of the callback for a denied call, the location and type of each error of an invalid request, or the place in the configuration and the rule that fails for an invalid configuration. The reason never holds a value of the request, because a configuration can contain secrets. Other errors have no reason, because their messages can contain the log of a command. The last events of a deployment do not show the denied calls: all callers can cause these, and thus could push the actions of the owner out of the list.
The action is the operationId of the route. For deployment_action it is deployment_<action>, for example deployment_suspend, because a suspended deployment does not use its instances. This is also so when api refuses the call. An action that this revision of api does not know keeps deployment_action, so that the caller does not choose the stored value; this includes an operation that only a newer module source declares. A migration export also has an event, because it suspends the deployment and gives out its credentials. A migration import has one event, from the call that gives the result or the error. The calls before it, which only tell that the check continues, have no event.
A list of audit events gives the events of one actor on all deployments, newest first, also the denied calls and the events of deleted deployments, in pages. A page continues before the place in the chain of the last event of the page before, because no two events have the same place, but two events can have the same time; thus a page does not skip an event. A list without an issuer gives only the events that have no issuer, because an actor is unique only together with its issuer. The callback gets the actor and its issuer in the scope, and decides who can read the events of that actor.
api writes the event to the store, and then as a log line. A failed write to the store does not fail the call, and the log line still has the event. The store keeps the events after a delete, because the history of a deleted deployment is also necessary. The actor is personal data, so api removes events older than the retention, 365 days by default; a retention of 0 keeps all events.
The store holds a hash chain over the events, so that a changed or removed event is visible. Each event has a place in the chain, the hash of the event before it, and its own hash over its fields, its place and the hash before it. A lock makes sure that two calls do not get the same place. When api removes old events, the store keeps the place and the hash of the last removed event, thus the chain starts again at the first event that stays. Events without a place, from a store before the chain or from an import, get a place after the last event, in the order of time.
A verify command examines the chain in the store of the service. It reports an event that changed, an event that does not follow the event before it, missing events, and events without a place, gives the number of events and the head of the chain, and fails when there is a problem. It can also give the head as one structured object, with the number of events, the place and the hash of the head, and the time of the examination. On a node, a timer runs it each day, in a unit with the environment of the service and the credential of the store, but not the deploy tokens of the service. A new credential of the store does not restart the unit, because a restart would run the verification out of its schedule and clear a failure. A problem makes that unit fail, and its journal has the problems and the head.
The chain does not use a key, thus a writer of the database can calculate a new chain after a change. The chain also cannot show that the last events are gone. The log line has the identifier, the time, the place and the hash of the event, so compare the head of the chain with the last event in a copy of the log that the writers of the database cannot change. The audit shipper of the node sends these lines to the audit tenant of Loki, which refuses deletion, on a path that only the credential of api opens (telemetry).
When the provider sets a sink for the head of the chain, a timer runs the verify command at a regular interval, hourly by default, and gives the head to that sink, which keeps it in a store off the fleet. The unit gives the head also when the chain has a problem, and then fails. A head whose place is lower than an earlier head, or whose chain does not contain the hash of an earlier head at its place, shows a removed or changed event, also after a writer of the database calculated a new chain. No sink is set by default.
When services.fediversity-api.auditHead.publishCommand is set, the
fediversity-api-audit-head timer runs fediversity-api-audit-verify --json
each auditHead.interval (default hourly) and writes the head to the stdin
of that command, which keeps it in a store off the fleet. The unit sends the
head also when the chain has a problem, and then fails. A head whose seq is
lower than an earlier head, or whose chain does not contain the hash of an
earlier head at its seq, shows a removed or changed event, also after a
writer of the database calculated a new chain. No sink is set by default.
Front-end access model
A front-end (the panel, or a white-labelled equivalent) can call api directly from the browser, on another origin, and often on another registrable domain, than the front-end. This section gives how that is safe, and the trade-offs.
Credential: a bearer token, not a cookie
The browser authenticates to api with a short-lived bearer token in a header, not with an ambient session cookie.
- The front-end makes the token for the operator who is logged in, and the browser keeps it in memory.
- On each direct call to
api, the code of the front-end sets the header explicitly. apidoes not check the token. It forwards it to the callback. The front-end, which made and signed the token, checks it and finds the subject. Thus identity stays in the one authority that owns the accounts;apishares no signing secret and is no second source of identity.
The browser makes a new token with its session before the old one expires. A short lifetime limits the damage of a leaked token, at the cost of a periodic new token.
Why a token rather than a forwarded cookie
A browser sends a cookie ambiently: it attaches it to each request to its domain. This is what makes Cross-Site Request Forgery (CSRF) possible: a malicious page can make the browser of a victim send a request that changes state with the cookie of the victim, without reading it. External authorization does not prevent this. The callback answers "does this subject own this deployment?", and for a forged request the answer is truly "yes": it is the browser of the owner, but not by intent. The authorization design prevents impersonation, not forgery.
A bearer token in a header is not ambient. The browser attaches it only because the code of the front-end does so, and a cross-site attacker cannot read the token or run that code. Thus the direct browser surface has no CSRF category to defend: the protection is structural, not a middleware patch. This is the usual shape of an HTTP API that a browser can call directly.
Across different registrable domains (white-labelling), the token is also the simpler choice. A cookie would need its widest cross-site exposure plus a CSRF mechanism, and the usual double-submit defense fails, because the code of the front-end cannot read a cookie of the domain of api. A token in a header avoids cookie scope, same-site rules and the interaction of CORS with cookies.
CORS
The browser calls api across origins. Thus api has a CORS layer with a strict origin allowlist (the origins of the front-ends and tenants) that permits the authorization header. CORS controls which origins can read the responses of api and send the custom header, which causes a preflight that api controls. It is not the CSRF defense: CORS does not stop a forged request from being sent, only from being read. The token that is not ambient is what keeps a usable credential out of a forged request.
Each response has the CORS headers, also a failure (FR-005 of 004). The outermost error handler of the framework sits outside the CORS layer, so an unhandled error would reach the browser without the headers. The browser then reports a misleading "CORS header missing" and hides the real server error. Thus a catch-all inside the CORS layer answers an unhandled error with the usual error envelope.
The app can put a header only on a response that it makes. The reverse proxy in front of it answers some requests alone (while the service restarts, on a read timeout, over the body limit), and the app does not see them. Such a page has no CORS header, so the browser discards it and reports a bare network error, which hides the failures that an operator most needs to read. Thus the deployment also adds the header at the ingress, over the same allowlist, and only when the app set none: two values are as unusable as none.
Alternatives considered
- Forwarded session cookie plus a CSRF defense in
api(a custom request header plus the CORS allowlist, or a double-submit token). This works and needs the least new code. But it keepsapion an ambient credential that it must then guard, it needs a cross-site cookie, and across registrable domains only the custom-header variant works, because a double-submit cannot read a cookie of another domain. Rejected: remove the CSRF surface, do not guard it. - Shared-origin reverse proxy: serve
apion the origin of the front-end, so that a same-site cookie and the CSRF protection of the front-end cover it. This removes CORS and CSRF, butapithen has no origin of its own, which white-labelling needs. Kept as the fallback if separate origins ofapistop being a requirement.
The removal of the per-subscription Centrifugo token is not a precedent against tokens here. That token was redundant with the subscribe callback of Centrifugo, which checks the ownership of the channel again. For the HTTP surface the token is not redundant: it is what removes the CSRF surface of a cookie.