diff --git a/DESIGN.md b/DESIGN.md index b0544f3..16634b0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -169,6 +169,15 @@ spec: adminGroup: "" sessionMaxAge: 12h passwordLogin: true + # Bring-your-own instance credential: a human mints this once, manually, with + # their own admin session (`POST /api/service-accounts {name: ..., scope: + # instance}`) and creates this Secret themselves, in the OPERATOR's own + # namespace (same namespace status.credentialsSecretRef below would otherwise + # point into). When set and the Secret exists, the controller adopts it + # directly and skips bootstrap entirely -- see §6 for why this is required, + # not optional polish, whenever terdut-server was already bootstrapped by its + # chart or a human before this CR existed (the normal case, not an edge one). + credentialsSecretRef: {name: "", key: token} # Consent for TerdutTeams in OTHER namespaces to set serverRef at this # TerdutServer. Same-namespace TerdutTeams never need this. Modeled on # Gateway API's Gateway.spec.allowedListeners.namespaces (the ListenerSet @@ -190,6 +199,17 @@ Field-for-field this is the chart's `values.yaml` reshaped as a spec — the operator absorbs the chart's Deployment/Service/bootstrap-job templates, so existing installs have a direct mapping when migrating (see §10). +`spec.credentialsSecretRef` is an *input* (bring-your-own), distinct from +`status.credentialsSecretRef`'s *output* (generated-by-the-controller) — +when the input is set, the controller treats it as the credential outright +and echoes its name back into `status.credentialsSecretRef` rather than +generating a second Secret alongside it. Left unset, the controller falls +back to the self-registration flow (§6) — which only actually completes if +this `TerdutServer`'s own first reconcile is the one that wins the +`/api/bootstrap` race against a genuinely empty install; see §6 for why +that fallback is the exception, not the common case, and why this field +exists at all rather than being deferred as nice-to-have. + `spec.database` fields are `+kubebuilder:validation:XValidation` guarded to be mutually exclusive (`dsn` xor `postgresClusterRef`); mirrors the chart's "chart provisions no database" stance — this operator provisions no database @@ -399,11 +419,30 @@ General rules for every controller: ## 6. Bootstrap & authentication to terdut-server's API -terdut-server has no first-class "service account" token type today — API -keys belong to a real user row (`api_keys.user_id`). This section depends on -a server-side feature request (`terdut-server`'s `SERVICE-ACCOUNTS.md`) that -adds one; treat this whole section as v1-blocking, not v1-shippable, until -that lands (§10, §13). +terdut-server's scoped service-account credential type +(`terdut-server`'s `SERVICE-ACCOUNTS.md`) has shipped — confirmed against +source: `internal/api/service_accounts.go`, migration +`014_service_accounts.sql`, and `internal/api/router.go` wiring it in under +`AuthMiddleware`. This section is no longer blocked on it; the "v1-blocking" +framing here was accurate when this section was first written and is stale +now. + +**`GET /api/service-accounts?name=` is not an unauthenticated lookup, unlike +an earlier draft of this section assumed — confirmed against +`internal/api/middleware.go`'s `AuthMiddleware`, which hard-rejects any +request carrying neither a Bearer token nor a session cookie with `401` +before any handler (including this one's own internal, more permissive +name-filter check) ever runs.** This matters beyond a technicality: it means +the self-registration flow below (point 1) only ever completes for the +`TerdutServer` whose own controller happens to win the `/api/bootstrap` race +on a genuinely empty install. Every other case — including Stage 1's own +setup (`ROADMAP.md`): terdut-server deployed by its existing chart, which +runs its own bootstrap Job, *before* the `TerdutServer` CR or its controller +ever exist — leaves the controller with no credential and no authenticated +way to get one. `spec.credentialsSecretRef` (§4.1) exists to make that the +normal path, not an unhandled edge case: a human mints an instance-scoped +service account once, manually, with their own admin session, and hands the +controller that Secret directly. **Every credential the operator holds — the one instance-scoped key per `TerdutServer`, and one team-scoped key per `TerdutTeam` — lives in a Secret @@ -425,22 +464,35 @@ when `allowedTeams` narrows, no "OwnerReferences can't cross namespaces so track it in status instead" workaround — none of that machinery is needed when nothing ever crosses into a tenant namespace in the first place. -1. **Bootstrap, confirmed against source** (`internal/api/users.go`'s +1. **First reconcile checks `spec.credentialsSecretRef` before anything + else.** Set and the Secret exists: adopt it as-is, set + `status.credentialsSecretRef` to the same reference, `Bootstrapped: True`, + done — no API call made at all. This is the path every Stage 1 install + actually takes (`ROADMAP.md`): terdut-server deployed by its existing + chart, bootstrapped by that chart's own Job, before this CR exists. + Unset (or the named Secret doesn't exist yet): fall through to + self-registration, confirmed against source (`internal/api/users.go`'s `handleBootstrap`): `/api/bootstrap` is single-shot *per install*, gated on `SELECT COUNT(*) FROM users` — once non-zero, every call 403s regardless of identity, exactly as `charts/terdut-server`'s own `bootstrap-job.yaml` already assumes (403 → "already bootstrapped, - nothing to do", exit 0). So the `TerdutServer` controller's first-reconcile - flow is: call `/api/bootstrap` **only if nothing has bootstrapped yet** - (a genuinely empty install, e.g. chart's bootstrap Job disabled in favor - of the operator owning this — see §10); on 403, look itself up instead — - `GET /api/service-accounts?name=terdut-operator` — and self-register via - `POST /api/service-accounts` (instance scope) if it isn't already - registered. This also resolves the race if both the chart's Job and this - controller call `/api/bootstrap` against the same fresh install: whichever - loses treats 403 as "go get my own credential the other way," not an - error. -2. The resulting instance-scoped key is written to a generated Secret in the + nothing to do", exit 0). Call `/api/bootstrap`; on `201`, its response + (`{"user": ..., "api_key": {"key": "", ...}}`) hands back a real, + usable admin key directly — use it for exactly one further call, + `POST /api/service-accounts {name: "terdut-operator", scope: "instance"}`, + and keep *that* key, not the raw admin one, as the lasting credential. + **On `403`, there is no further fallback**: per this section's opening + note, `GET /api/service-accounts?name=` needs a credential this + controller does not have, so self-lookup cannot run unauthenticated. Set + `Ready: False, reason: WaitingForCredential` with an event telling the + human to mint an instance-scoped service account with their own admin + session and set `spec.credentialsSecretRef`, and requeue with backoff — + this is the expected, steady-state outcome whenever the operator loses + (or never entered) the bootstrap race, not a transient error to retry + past. +2. On the self-registration path only (point 1's bring-your-own path + generates nothing — it adopts the human-provided Secret directly): the + resulting instance-scoped key is written to a generated Secret in the **operator's own namespace** (e.g. `.-instance-credentials`), referenced back from `TerdutServer.status.credentialsSecretRef: {name, namespace}` (§4.1) — `namespace` is part of the reference now, since it's