diff --git a/README.md b/README.md index 2e6c504..5e17b6c 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,7 @@ over an administrator's edit. | `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server: the notification's link into the web UI, its Acknowledge button, and whether the session cookie is `Secure` | | `TERDUT_NOTIFY_REPEAT` | `15m` | **seed.** How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats | | `TERDUT_PASSWORD_LOGIN` | `true` | `false` refuses password login and password sign-up (`403`), leaving single sign-on the only way in. Refused at startup unless SSO is configured | +| `TERDUT_OPERATOR_MODE` | `false` | Declares this install gitops-managed: a session's or a user's own API key's writes to teams, escalation policies, dead man's switches and integrations are refused (`403 reason:"operator_managed"`); a [service account](#service-accounts)'s are not. Team membership and the schedule stay editable regardless | | `TERDUT_OIDC_ISSUER` | — | Turns single sign-on on. The provider's issuer URL; discovery is read from `/.well-known/openid-configuration`. See [Single sign-on](#single-sign-on-oidc) | | `TERDUT_OIDC_CLIENT_ID` / `TERDUT_OIDC_CLIENT_SECRET` | — | **Required with an issuer.** The confidential client registered at the provider. Keep the secret in a Secret, not in values | | `TERDUT_OIDC_NAME` | `SSO` | What the sign-in button calls the provider | @@ -350,7 +351,7 @@ Note that `TERDUT_STALE_AFTER` and `TERDUT_DEADMAN_TIMEOUT` point in opposite di is a generous grace period around a `repeat_interval` you do not control; a dead man's switch is a deadline you set deliberately, and the heartbeat's route is configured to beat faster than it. -In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and `sweeper.archiveAfter`, dead man's switches via the `deadman.*` values, notifications via the `notify.*` values, and single sign-on via `oidc.*` and `passwordLogin`. +In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and `sweeper.archiveAfter`, dead man's switches via the `deadman.*` values, notifications via the `notify.*` values, single sign-on via `oidc.*` and `passwordLogin`, and operator mode via `operatorMode`. --- @@ -705,8 +706,8 @@ of the last heartbeat, and the heartbeat's labels are on the incident's All endpoints except `/api/bootstrap`, `/api/integrations/{key}/alertmanager`, `/api/notify/ack/{token}`, `/api/login`, `/api/logout`, `/api/auth/config`, -`/api/oidc/login`, `/api/oidc/callback`, `/api/oidc/device` and `/api/oidc/device/token` -require either an API key: +`/api/version`, `/api/oidc/login`, `/api/oidc/callback`, `/api/oidc/device` and +`/api/oidc/device/token` require either an API key: ``` Authorization: Bearer @@ -721,6 +722,13 @@ granting the flag itself. Everybody else works incidents — acknowledging, assigning, snoozing, resolving, noting — and manages their own account and nobody else's. An API key carries exactly the rights of the user it belongs to. +A third principal, the **service account**, exists for automation (a +Kubernetes operator, most likely) that needs to manage teams, escalation +policies, dead man's switches and integrations without impersonating a human. +It is not a user — it never signs in, never appears in a team's member list, +and never holds the administrator flag — and its key is prefixed `tdsa_` so it +reads as one at a glance in a log line. See [Service accounts](#service-accounts). + **Getting an account.** The first one comes from `/api/bootstrap`. After that it depends on `signup_mode`, an administrator setting: @@ -764,9 +772,21 @@ the shape of a team, not about reading other people's incidents. Anything belonging to a team you are not in answers `404`, not `403`: whether an incident exists is itself something only its team should learn. +**Operator mode** (`TERDUT_OPERATOR_MODE`, see [Configuration](#configuration)) +declares this install gitops-managed. When it is on, a session or a user's own +API key gets `403 {"error": "...", "reason": "operator_managed"}` on every +write this README marks **owner**-gated under Teams below (creating, renaming +or deleting a team; its OIDC group binding; its escalation ladder; its dead +man's switches; its integrations) — a service account's writes are unaffected. +Team membership and invites are deliberately excluded: they are never +gitops-managed, in operator mode or out of it. `GET /api/auth/config` reports +`operator_mode` so a client can grey those sections out before a write is ever +attempted. + | Method | Path | Description | |---|---|---| -| `GET` | `/api/auth/config` | How to sign in: `{"password_login", "oidc": {"enabled","name"}, "device_login"}`. No session needed | +| `GET` | `/api/auth/config` | How to sign in: `{"password_login", "oidc": {"enabled","name"}, "device_login", "operator_mode"}`. No session needed | +| `GET` | `/api/version` | `{"version"}` — this build's version string. No session needed, the same as `/healthz` | | `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures; `403` when `TERDUT_PASSWORD_LOGIN=false` | | `GET` | `/api/oidc/login` | Starts a single sign-on sign-in: redirects the browser to the provider. `?next=/path` is where to land afterwards; only a path on this server is honoured. Only exists when SSO is configured | | `POST` | `/api/oidc/device` | Starts a device login: returns `{device_code, user_code, verification_url, interval, expires_in}`. Only exists when SSO is configured | @@ -810,6 +830,41 @@ on anybody's. | `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials | | `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`, or `{"signup_mode": "open"\|"invite_only"}`. `400` for an unknown key or a value outside its bounds | +### Service accounts + +A service account is a scoped, non-human credential for automation — not a +`users` row, so it never signs in, is never a team member, and never carries +the administrator flag. Two scopes: + +- **instance** — the same reach system administration has over teams: create + one, and mint a **team**-scoped account against any of them. There is no + cap on how many instance-scoped accounts exist, but ordinarily there is one, + belonging to whatever is provisioning this install end to end. +- **team** — owner-equivalent for that one team, and nothing else: every + **owner**-gated endpoint under [Teams](#teams), membership and invites + included. Nothing narrower is enforced server-side; what actually keeps + membership out of automation's hands is that no operator built against this + scope should ever call those two endpoints — see + [operator mode](#authentication) and `SERVICE-ACCOUNTS.md`'s note on this. + +A key is shown once, at creation or rotation, and only its hash is stored — +the same handling as a user's API key. Losing it means minting a new one; +there is no way to recover a raw key from the server. + +| Method | Path | Who | Description | +|---|---|---|---| +| `GET` | `/api/service-accounts` | **admin** | Every service account. Pass `?name=` instead to look one up by its exact name — open to **any** authenticated caller (human or service account), since it returns no key material and is how an account finds its own id | +| `POST` | `/api/service-accounts` | owner\* | Create one and mint its first key `{"name","scope","team_id"?}` (`team_id` required for `scope:"team"`, absent for `scope:"instance"`). Returns `{"service_account", "key"}` — `key.key` shown once | +| `POST` | `/api/service-accounts/{id}/keys` | owner\* | Mint an additional key `{"name"}` — rotation without recreating the account. Shown once | +| `DELETE` | `/api/service-accounts/{id}/keys/{keyID}` | owner\* | Revoke one key | + +\* For an **instance**-scoped account: a system administrator only. For a +**team**-scoped account: a system administrator, that team's own human owner, +an instance-scoped service account (minting a narrower credential for a team +it just created), or — for the two key endpoints only — the account rotating +or revoking its own key, which is not a privilege escalation, the same +reasoning a user's own API keys rest on. + ### Alert ingestion Alerts arrive on a team's integration key. The key is both the credential and the @@ -827,15 +882,19 @@ and was removed in v0.13.0 once senders had moved onto keys. ### Teams -**owner** below means an owner of that team *or* a system administrator, who -passes every one of these without being a member — see -[Authentication](#authentication). **member** means membership and nothing else: an -administrator who is not in the team gets the same `404` as anybody else. +**owner** below means an owner of that team, a system administrator (who +passes every one of these without being a member), or that team's own +team-scoped [service account](#service-accounts) — including membership and +invites, technically, though no automation this scope was designed for +(a Kubernetes operator's CRDs, see `SERVICE-ACCOUNTS.md`) ever models team +membership or would call those two. See [Authentication](#authentication). +**member** means membership and nothing else: an administrator who is not in +the team gets the same `404` as anybody else. | Method | Path | Who | Description | |---|---|---|---| | `GET` | `/api/teams` | any | The caller's own teams, each with their role | -| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner | +| `POST` | `/api/teams` | any | Create a team `{"name"}`; a human creator becomes its first owner. An instance-scoped [service account](#service-accounts) may also create one, and it gets no owner at all — expected for a team an operator is about to hand a team-scoped credential to, not an orphaned team a human made | | `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken | | `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents | | `GET` | `/api/teams/{teamID}/members` | member | Who is in the team, with `status` (`oncall` if the rota has them today, `unpageable` when a page to them would go nowhere — even if they are on call — else `reachable`), `on_call`, `next_shift` (first rota day after today), `pageable` and `problem` (`has no ntfy topic` / `account is disabled`; never the topic itself) and `last_active_at` (their newest session or API-key use). Every member sees the same list | @@ -854,6 +913,7 @@ administrator who is not in the team gets the same `404` as anybody else. | `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it | | `GET` | `/api/teams/{teamID}/deadman/switches` | member | The team's [dead man's switches](#dead-mans-switch), each `{id, name, matcher, timeout_seconds, severity, status, last_heartbeat_at, last_triggered_at, open_incident_id, sources[]}`. `status` is `healthy`, `dead` or `dormant`; `sources` has one entry per heartbeat fingerprint. Empty when the team watches nothing | | `POST` | `/api/teams/{teamID}/deadman/switches` | **owner** | Add one: `{name?, matcher, timeout_seconds, severity?}`. `400` when the matcher names no `alertname` or holds several, or the timeout is not positive — a switch that silently watches nothing is the failure this feature exists to prevent | +| `PUT` | `/api/teams/{teamID}/deadman/switches/{switchID}` | **owner** | Replace one in place, same body and validation as create. Its id is unchanged — for an automated caller reconciling a spec change, unlike delete-and-recreate | | `DELETE` | `/api/teams/{teamID}/deadman/switches/{switchID}` | **owner** | Stop watching. An incident it opened stays open. `404` for a switch of another team | ### Notifications diff --git a/SERVICE-ACCOUNTS.md b/SERVICE-ACCOUNTS.md index 7d7404e..d34c2b7 100644 --- a/SERVICE-ACCOUNTS.md +++ b/SERVICE-ACCOUNTS.md @@ -138,6 +138,20 @@ identity is recorded for a human (incident timeline principal is stored and displayed distinctly, e.g. `service-account:terdut-operator`, never coerced into a `user_id` FK. +**Team scope, as implemented, is owner-equivalent for every `requireTeamOwner` +endpoint, membership and invites included — nothing server-side carves those +two out.** That's broader than what `terdut-operator`'s CRDs actually need +(escalation/deadman/integrations/OIDC-bindings only; membership is explicitly +never gitops-managed, see its DESIGN.md §4.2), a gap acknowledged rather than +closed here: narrowing this to exclude +`POST/DELETE /api/teams/{teamID}/members*` and +`.../invites*` specifically for a service-account caller is a small, isolated +follow-up (special-case those handlers rather than `requireTeamOwner` itself, +which every other owner-gated endpoint still wants shared). Until then, what +actually keeps membership out of automation's hands is that no operator built +against this scope should ever call those two endpoints — not a server-side +refusal. + ## What this unblocks Directly resolves `terdut-operator` DESIGN.md §6's two broken assumptions: