Rendered from innerwall v0.4.1ADR-0021

Operator surface: a second listener and single-operator authentication

Status
Accepted
Amended
2026-10-06

Context

Everything the control plane serves today is served to agents: one TLS listener, client certificates verified against the authority bundle when presented, a per-service interceptor that requires a workload credential for everything but enrollment (ADR-0016). The operator console (ADR-0008) and automation consume a REST/JSON surface (ADR-0007), and until now that surface has had no listener, no authentication, and no endpoints.

The agent listener's design does not extend to a browser. A browser holds no client certificate, so a listener that requests one at the handshake degrades the experience of every human who reaches it; a browser does not trust the internal signing authority's root, and installing that root into a person's trust store to reach a console is a wider grant than the console deserves; and the identity model on that listener is a workload URI in a certificate, which is not what an operator is. The operator surface needs its own transport decisions, and they need recording before the read model (workloads, flows, the map) and the write model (policy authoring) are built on top of them.

The command line already authenticates: it holds database credentials and calls the domain functions directly. That is a second authentication plane, and this record has to say so rather than let the two drift into contradiction.

Decision

  1. A second listener in the same binary. The operator surface is a sibling listener beside the agent gateway, in the one control-plane binary (ADR-0017), with its own address and its own TLS configuration. It serves the REST/JSON surface under /api/v1 and, from the scaffold session onward, the embedded console at every other path; until the console lands, every non-/api path answers not found from a clearly marked mount point. The console uses only this public surface: anything the console can do, automation can do with the same endpoints and the same credentials, by construction, and no private endpoint, backchannel, or direct database access exists for it (ADR-0008). The agent listener is untouched: ADR-0016's decision stands unchanged, and the boundary it draws (workload identity from the connection credential, nothing else) is not stretched to cover operators. The two listeners share the process, the database, and the domain packages, and nothing else.

  2. TLS only, without exception. The operator listener serves TLS or it does not serve. The operator supplies a certificate and key through configuration; when neither is configured, the control plane generates a self-signed certificate on first start, persists it beside the other operator-provisioned configuration on the host (the authority directory, where the agent listener's certificate already lives), and logs its SHA-256 fingerprint so the operator can verify the first connection out of band. A generated certificate that has expired is regenerated the same way, with the new fingerprint logged. There is no plaintext listener and no flag that would create one. The rejection is explicit: a segmentation control plane that serves its own console in cleartext is an incoherent posture, since the credential that authorizes every policy change would cross the network unprotected, and a deployment that terminates TLS at a reverse proxy loses nothing by terminating it a second time on the last hop. The generated certificate is configuration, not replica state: it is written once, it is in the class ADR-0017 names ("the listener's TLS certificate"), and a deployment with more than one replica provisions the same pair on each, exactly as it provisions the authority directory.

  3. One operator, two credential forms, one principal, binary authorization. Version 1 has a single operator. The operator authenticates with a password from a browser and receives a session, or presents an operator token from automation; both forms resolve to the same principal, and a handler never learns which form authenticated the request. Authorization is binary: a request is authenticated or it is not. There are no roles, no scopes, and no per-resource permissions, and the schema does not pretend otherwise: the operator table is constrained to one row, the session and token tables carry no subject column because there is exactly one subject, and no table carries a permission. Multi-operator accounts, role-based authorization, and delegated sign-on are explicit non-goals of version 1; each is a design of its own and none is anticipated by a column here.

    • Password. Stored as an argon2id hash with its parameters in the stored string, so the parameters can be raised later without a migration. The password is set only from the command line on the host (innerwall operator set-password), which also sets the optional display name. There is no endpoint that sets or resets it: a fresh install that has no password refuses to log anyone in and says so with a distinct problem type, and the console's fresh-install state tells the operator to run the command on the host. A bootstrap endpoint would be an unauthenticated write to the credential that guards every other write, and the host's own access control is the stronger gate.
    • Session. Logging in creates a session: an opaque random identifier, held by the browser in a cookie and stored only as its SHA-256 digest, with a fixed seven-day lifetime and no sliding renewal, so the longest a stolen cookie is useful is bounded by the clock and not by activity. The cookie is marked so that scripts cannot read it, so that it travels only over TLS, and SameSite=Lax, which keeps it off cross-site subrequests and cross-site writes; a top-level cross-site navigation by a safe method does carry it, and that reaches only reads. The defense against a cross-site write is the origin guard (decision 5), not the cookie. (Amended 2026-10-06; see Amendments.) Logging out deletes the row. Expired rows are removed by the retention schedule the flow store already runs (ADR-0019); there is no separate housekeeping job.
    • Operator token. For automation. It mirrors the provisioning token (ADR-0016) in shape and semantics: a fixed prefix, iwo_, on 32 random bytes, so a secret scanner can recognize a leaked operator token and tell it from a provisioning token; only the SHA-256 digest at rest; the prefix and a few leading characters kept for listing; an optional expiry; a revocation timestamp; a use is recorded, never consumed. Tokens are minted, listed, and revoked from the command line and from this surface, through the same domain functions. (Amended 2026-10-06; see Amendments.) A token is presented as a bearer credential in the authorization header.
  4. Two authentication planes in version 1, stated honestly. The command line authenticates to Postgres with database credentials and calls domain functions directly; the operator surface authenticates with the operator credentials above and calls the same domain functions through the surface's handlers. Both are real and both stay, because the command line must work on a host whose control plane is not running (setting the first password is the proof) and because the surface must not depend on shell access. The domain functions are the one implementation; each transport is a thin caller. Consolidating the command line onto the surface, with the command line holding an operator token, is the direction for version 2 and is not implied to exist now.

  5. The transport rules of the surface. Endpoints are hand-shaped HTTP handlers over the shared domain layer (ADR-0007 as amended): the semantics a browser-facing surface needs (sessions and cookies, problem documents, and later conditional requests and blast-radius guards on writes) are properties of the transport, and the handlers carry them while holding no domain logic of their own. Every handler calls the same domain functions the command line calls, and that shared layer, not a shared schema, is what keeps the two transports from drifting; a handler that grows domain logic is the reviewable defect. Protobuf remains the source of truth for the agent contract and is not involved here. Errors are problem documents (application/problem+json) with a stable type per condition; timestamps are RFC 3339 in UTC; field names are snake_case. Authentication is a middleware in front of the handlers that resolves a session cookie or a bearer token to the principal and answers everything else with a 401 problem; the login endpoint is the one path reachable without a credential, named explicitly, so a new endpoint cannot land on the unauthenticated side by omission. Cross-site request forgery is refused at the middleware for every request that is not a read: a request whose Sec-Fetch-Site header says it is cross-site, or whose Origin header names a different origin from the one serving the console, is rejected. The console is served from the same origin as the surface and every request that is not a read passes this guard, so the guard is the defense against cross-site writes and no token scheme is needed; the Lax cookie travels on a top-level cross-site navigation by a safe method, which the guard does not need to stop because reads change nothing. (Amended 2026-10-06; see Amendments.) Login attempts are limited with a fixed window per source address, in process; that is a brake on online guessing, not a distributed rate limiter, and a deployment with several replicas or a reverse proxy that collapses sources into one address gets a proportionally looser or tighter brake and knows it.

Consequences

  • The console and automation have a surface to build on: the read model (M3.2) and the write model (M3.3) add endpoints under /api/v1 behind the same middleware, and nothing about authentication changes when they do.
  • Operators terminate TLS on the last hop whether or not something in front of them also does. A self-signed certificate makes the first connection a fingerprint check rather than a trust-store change; a real certificate is one configuration change away.
  • The password is set from the host and only from the host. Losing it means shell access to reset it, which is the intended recovery path and not a gap.
  • The single-row operator table, the subject-less session and token tables, and the absence of any permission column are the executable statement of the single-operator decision. Multi-operator support is a schema change with its own record, never a column quietly added.
  • Recording a token's use on every authenticated request is one write per request on the token path; at the scale of one operator's automation that is nothing, and it is the same choice the provisioning token made.
  • The login throttle protects one process. Estate-scale abuse protection, if ever needed, sits in front of the control plane, not inside it.
  • Two authentication planes are two places where credentials are handled; the command line's is the database's own, and the record says so rather than pretending there is one.

Amendments

Amendment2026-10-06

(PR #25, conformance-audit absorption). The core decision stands. Two sentences are corrected in place. The token management API that decision 3 said would follow has shipped: operator tokens, like provisioning tokens, are minted, listed, and revoked on the surface through the domain functions the command line calls. The session cookie was described as not travelling on cross-site navigations; it is SameSite=Lax, which does send it on a top-level cross-site navigation by a safe method. What stops a cross-site write is the origin guard every request that is not a read passes, and decision 5 now names it as that defense rather than leaning on the cookie.