Compare commits

..

6 Commits

Author SHA1 Message Date
Niklas Ye 6f8499fa42 Set the chart's placeholder version to 0.33.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 18s
CI / test (push) Successful in 4m26s
Release / test (push) Successful in 9s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 29s
Release / image (push) Successful in 1m10s
Release / scan-image (push) Successful in 31s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Kept
in step anyway, the same as 0ee576f (0.32.0) and 949d659 (0.31.0) before
it, so a tree heading for v0.33.0 does not read as still being on 0.32.0.
2026-09-29 21:33:15 +02:00
Niklas Ye ef731e85c5 Document service accounts and operator mode in the README
The last commit (a4dd60f) shipped the code with no README update, against
this repo's own convention of documenting the whole API surface there.
Adds the Service accounts section and table, the Authentication and Teams
prose covering the new principal and TERDUT_OPERATOR_MODE, and the
/api/version row.

Corrects one thing along the way: a first draft claimed team-scoped
accounts are refused on team membership/invite endpoints. Checked against
teams.go and that is false — nothing server-side carves those two out,
only convention (no sane operator would call them) keeps them out of
automation's hands. README and SERVICE-ACCOUNTS.md now say that plainly
instead of the stronger, incorrect claim.
2026-09-29 21:33:04 +02:00
Niklas Ye a4dd60f6b8 Add service accounts and operator mode
Service accounts (SERVICE-ACCOUNTS.md) are a scoped, non-human credential:
not a users row, so they never touch OIDC sync, login or the is_admin flag.
Instance scope can create a team and mint a team-scoped account for it;
team scope is owner-equivalent for that one team and nothing else. This is
what unblocks terdut-operator's DESIGN.md §6 — no more impersonating a
human admin, and a real rotation story instead of the unworkable
delete-and-re-bootstrap /api/bootstrap can't actually do.

- migration 014: service_accounts + service_account_keys
- POST /api/service-accounts, POST/DELETE .../keys, GET ?name= self-lookup
- AuthMiddleware resolves a tdsa_-prefixed key to a distinct principal;
  a team-scoped account gets a synthetic single membership so
  requireTeamMember/requireTeamOwner work on it unmodified
- handleCreateTeam accepts an instance-scoped caller; the team it creates
  has no human owner, which is the expected shape for one an operator is
  about to hand a team-scoped credential to

Operator mode (TERDUT_OPERATOR_MODE / values.operatorMode) declares an
install gitops-managed: session and user-API-key writes to teams,
escalation policies, dead man's switches and integrations get 403
reason=operator_managed, while a service account's writes still go
through. Team membership/invites and the schedule are deliberately left
out — never gitops-managed by design, and still human day-to-day work.
/api/auth/config reports operator_mode so the web UI can grey these
sections out from the start rather than only after a write fails.

Also: GET /api/version (both terdut-tui and terdut-operator currently
detect server capability by route-probing; this gives them a real answer),
and a PUT for dead man's switches so a reconciler can update one in place
instead of deleting and recreating it.
2026-09-29 21:25:17 +02:00
Niklas Ye b5573fbca2 Add design note: scoped service-account/token type
Proposes a non-human credential type — service_accounts +
service_account_keys, instance- or team-scoped, distinct from both
user API keys (always tied to a human's full rights) and integration
keys (narrow, one-way webhook auth only). Directly unblocks
terdut-operator's DESIGN.md §6, whose bootstrap/rotation plan doesn't
work against /api/bootstrap's actual single-shot-per-install behavior.

Design note only, no implementation yet.
2026-09-29 20:35:28 +02:00
Niklas Ye 0ee576f793 Set the chart's placeholder version to 0.32.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 4m2s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 31s
Release / image (push) Successful in 1m10s
Release / scan-image (push) Successful in 3s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 949d659 and e5b4df7, so a
tree heading for v0.32.0 doesn't say 0.31.0.
2026-09-27 22:05:26 +02:00
Niklas Ye b610b1817a Fold the account page's ntfy and password forms behind disclosures
Both sat open by default, competing with the rest of the page for
attention on every visit even though most visits need neither. Team's
rota already has the same problem for its bulk-assign form and solves
it with a native <details>/<summary> disclosure, styled generically in
app.css; this reuses that idiom rather than inventing a JS toggle.

Each section now shows a one-line status (the topic, or whether a
password is set) with the actual form folded under a summary naming
the action ("Set a topic" / "Change topic", "Set a password" /
"Change password"). A successful save closes the fold and confirms
with a toast, since the point of folding is that a saved form goes
back to being just a status line; a validation or API error keeps the
fold open and shows inline, next to the field it's about.

The password section's heading no longer says "Change password" or
"Set a password" itself, since that verb now lives on the summary; it
just says "Password", matching the existing SSO-off case.
2026-09-27 22:04:59 +02:00
19 changed files with 1400 additions and 83 deletions
+69 -9
View File
@@ -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 `<issuer>/.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 <api-key>
@@ -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
+186
View File
@@ -0,0 +1,186 @@
# Service accounts: a scoped, non-human credential type
This is a design note for a feature, not an implementation plan — it exists to
propose the shape before writing code. It's raised directly by `terdut-operator`
(a separate repo, no shared code — see its `DESIGN.md` §6, §9, §13), which needs
a credential for unattended, repeatable API access and currently has no good one
available. Anything automating terdut-server long-term (this operator, CI, future
integrations) hits the same gap, so this is written as a general primitive, not
operator-specific.
## The problem
terdut-server has two credential types today, and neither fits "an unattended
process that manages teams/schedules/policies on someone's behalf":
- **User API keys** (`api_keys`, `internal/api/users.go`) are always tied to a
real `users` row and carry that user's full rights — every team they're a
member of, their admin flag if set. There's no `kind`/`service` marker
distinguishing "a human's personal automation key" from "a login session," and
no way to mint one scoped to less than the full user.
- **Integration keys** (`integrations`, `internal/api/*teams*.go`) are team-scoped,
but narrowly: they authenticate exactly one inbound Alertmanager webhook call
(`POST /api/integrations/{key}/alertmanager`) and nothing else. They're not a
general management-API credential and shouldn't become one — overloading a
narrow, one-way ingestion credential with broad read/write access would weaken
the one property that makes it safe to embed in an Alertmanager config today.
The result: any automation that needs to create teams, set escalation policies,
manage dead-man switches, or rotate integration keys has to hold a real human
admin's or team owner's API key. That key is exactly as powerful as that person
logging in — full team access, and full instance access if they're an admin.
`terdut-operator`'s design ran directly into this (its DESIGN.md §6): its
described bootstrap/rotation flow assumed a repeatable, identity-scoped way to
get a credential, and `/api/bootstrap`'s actual behavior (single-shot per
install, gated on `COUNT(*) FROM users`, confirmed via `internal/api/users.go`
and `charts/terdut-server/templates/bootstrap-job.yaml`) doesn't provide one —
it mints exactly one founding admin, once, ever.
## Goals
- A credential type that isn't a human: doesn't touch OIDC group sync, login,
session, or the `is_admin`/account-management semantics that come with a real
`users` row.
- Two scopes matching the two shapes automation actually needs: instance-wide
(create/list teams — what a server-owning controller needs) and team-scoped
(manage one team's escalation policy, dead-man switches, integrations,
schedule, OIDC group bindings — what a per-team controller or integration
needs).
- Repeatable issuance and rotation — unlike `/api/bootstrap`, callable more than
once, by anything that already holds admin rights, without destroying and
recreating state to get a fresh credential.
- Visibly distinct from a human in every place identity shows up (audit trails,
timeline entries, UI attribution) — a service account acting on a team should
never be indistinguishable from a person.
## Non-goals
- Not a general OAuth2/OIDC client-credentials flow — this is a bearer-token
primitive matching the shape `api_keys` already uses (SHA-256 hash stored,
raw key shown once at creation), not a new auth protocol.
- Not replacing integration keys — those stay as the narrow, one-way webhook
credential they are today.
- Not modeling per-endpoint or per-verb permissions within a scope — `instance`
and `team` are the only two scopes for now; finer-grained scoping is future
work if a real need shows up.
## Proposed shape
### Schema
```sql
CREATE TABLE service_accounts (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- e.g. "terdut-operator"
scope TEXT NOT NULL CHECK (scope IN ('instance', 'team')),
team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE,
-- team_id required iff scope = 'team'; NULL iff scope = 'instance'
created_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE service_account_keys (
id BIGSERIAL PRIMARY KEY,
service_account_id BIGINT NOT NULL REFERENCES service_accounts(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL, -- e.g. "initial", "2026-Q4-rotation"
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ
);
```
Deliberately not a `users` row: no `password_hash`, no `is_admin`, no
`user_identities` linkage, so it's structurally impossible for a service account
to be pulled into OIDC group sync or password login. Multiple keys per account
(mirroring `api_keys`' existing one-user-many-keys shape) so rotation is "mint a
new key, revoke the old one," not "recreate the account."
### Endpoints
- `POST /api/service-accounts` — instance-scope/admin-only. Body:
`{"name": ..., "scope": "instance"|"team", "teamID": ... }` (teamID required
iff scope=team, and caller must be that team's owner or a system admin).
Returns the account plus its first raw key (shown once, same pattern as
`POST /api/users/{id}/api-keys`). Safe to call again with the same `name` —
see "idempotent lookup" below — unlike `/api/bootstrap`, which is inherently
one-shot by design (it's answering "does any user exist yet," a question with
no analogue once one already does).
- `POST /api/service-accounts/{id}/keys` — mint an additional key on an existing
account (self-service-equivalent: instance admin for `instance` scope, team
owner or system admin for `team` scope). Enables rotation without recreating
the account or losing its identity/audit history.
- `DELETE /api/service-accounts/{id}/keys/{keyID}` — revoke one key, mirroring
`DELETE /api/users/{id}/api-keys/{keyID}`.
- `GET /api/service-accounts?name=` — look up an existing account by name.
This is what turns "I tried to create my account and got a conflict" into a
normal flow instead of an error: a controller that expects to have already
registered itself calls this first, and only falls through to `POST` if
nothing comes back.
### Auth middleware
`internal/api/middleware.go`'s existing dual resolution (`Authorization: Bearer`
→ `apiKeyUser()`, or session cookie → `sessionUser()`, both landing on the same
`models.User` + team-membership context) gains a third path: a bearer token that
hashes to a `service_account_keys.key_hash` resolves to a distinct principal
type, not a synthesized `models.User`. `requireTeamMember`/`requireTeamOwner`
treat a matching team-scoped service account as owner-equivalent for that one
team (satisfies the same checks a real team owner would), and an instance-scoped
one as satisfying `AdminOnly` for team-creation/listing purposes **and** for
minting a `team`-scoped service account against any team (`POST
/api/service-accounts {"scope":"team","teamID":...}`) — this second permission
is what lets an operator-style caller create a team, then immediately mint that
team its own narrower credential, without a human in the loop for every team.
Neither permission extends to user-management endpoints (`POST /api/users`,
`PUT /api/users/{id}/admin`, etc.), which stay human-admin-only. Anywhere
identity is recorded for a human (incident timeline
`acknowledged_by`/`assigned_to`, audit-relevant fields), a service-account
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:
1. **Bootstrap becomes single-purpose again.** `/api/bootstrap` mints exactly
the founding human admin, once. The operator's actual first-reconcile flow:
call `/api/bootstrap` only on a genuinely empty install; otherwise (or
immediately after, if it won the bootstrap race) call
`GET /api/service-accounts?name=terdut-operator`, and `POST` one if it
doesn't exist yet. From then on the operator never touches `/api/bootstrap`
again.
2. **Rotation becomes real.** `POST /api/service-accounts/{id}/keys` + revoke the
old one — no destructive DB-level workaround, no re-triggering a single-shot
endpoint that can't fire twice.
3. **Cross-namespace credential mirroring is no longer needed at all.**
`terdut-operator`'s current design holds every credential — instance- and
team-scoped alike — privately in the operator's own namespace, never in
the namespace of the CR each one authenticates for; reconciliation happens
entirely inside the operator's controller loop, so no CR owner ever needs
read access to a terdut-server credential regardless of same- or
cross-namespace `serverRef`. Team scoping is still what bounds the blast
radius of any individual credential: a leaked team-scoped key exposes
exactly one team's resources, never the whole server, which is what makes
holding many credentials in one place (the operator's namespace) an
acceptable trade rather than reintroducing the mirrored design's
server-admin-equivalent-everywhere problem.
## Suggested sequencing
Land this before `terdut-operator` implements any bootstrap/credential-handling
code — that code would otherwise be written against the current one-shot,
user-only credential model as a known-temporary workaround, which is wasted
effort on a repo that currently has zero implementation to begin with.
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.31.0
appVersion: "v0.31.0"
version: 0.33.0
appVersion: "v0.33.0"
@@ -75,6 +75,8 @@ spec:
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
- name: TERDUT_PASSWORD_LOGIN
value: {{ .Values.passwordLogin | quote }}
- name: TERDUT_OPERATOR_MODE
value: {{ .Values.operatorMode | quote }}
{{- if .Values.oidc.enabled }}
- name: TERDUT_OIDC_ISSUER
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
+8
View File
@@ -112,6 +112,14 @@ notify:
# redeploy) if the identity provider is down and somebody has to get in.
passwordLogin: true
# Declares this install gitops-managed: writes to teams, escalation policies,
# dead man's switches and integrations from a session or a user's own API key
# are refused, while a service account's (see SERVICE-ACCOUNTS.md) are not.
# Off by default — turning it on is a statement that something like
# terdut-operator, not a person in the web UI, owns this install's
# configuration from here on.
operatorMode: false
# Single sign-on through an OpenID Connect provider such as Authentik.
#
# At the provider, create an OAuth2/OpenID application whose redirect URI is
+1 -1
View File
@@ -54,7 +54,7 @@ func main() {
log.Fatalf("seed settings: %v", err)
}
router := api.NewRouter(database, notify, cfg)
router := api.NewRouter(database, notify, cfg, version)
srv := &http.Server{
Addr: cfg.Addr,
+1 -1
View File
@@ -60,7 +60,7 @@ func newTSWith(t *testing.T, deadman api.DeadmanConfig, cfg api.NotifyConfig, co
t.Helper()
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, cfg, conf))
srv := httptest.NewServer(api.NewRouter(database, cfg, conf, "test"))
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
+1 -1
View File
@@ -301,7 +301,7 @@ func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) {
func TestBootstrap_WithPassword(t *testing.T) {
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig()))
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig(), "test"))
t.Cleanup(srv.Close)
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
+122 -7
View File
@@ -9,15 +9,17 @@ import (
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/models"
)
type contextKey string
const (
ctxUser contextKey = "user"
ctxSession contextKey = "session"
ctxTeams contextKey = "teams"
ctxUser contextKey = "user"
ctxSession contextKey = "session"
ctxTeams contextKey = "teams"
ctxServiceAccount contextKey = "service_account"
)
// AuthMiddleware accepts either of the two credentials the server issues: an
@@ -39,12 +41,19 @@ func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
userID, ok := apiKeyUser(r.Context(), db, token)
if !ok {
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
if userID, ok := apiKeyUser(r.Context(), db, token); ok {
serveAs(w, r, next, db, userID, 0)
return
}
serveAs(w, r, next, db, userID, 0)
// Tried second, not first: a user API key is the common case,
// and a service-account key is visibly prefixed (tdsa_) so this
// second lookup is rarely reached on a request that was going
// to fail anyway.
if sa, ok := serviceAccountFor(r.Context(), db, token); ok {
serveAsServiceAccount(w, r, next, sa)
return
}
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
@@ -188,6 +197,112 @@ func userFromContext(ctx context.Context) (models.User, bool) {
return u, ok
}
// serviceAccountPrincipal is a service account as resolved from its key:
// enough to authorize requests, never the key itself.
type serviceAccountPrincipal struct {
id int64
name string
scope string
teamID int64 // meaningless (zero) for instance scope
}
// serviceAccountFor resolves a service-account key to its account and stamps
// its last use, the same shape apiKeyUser has for a user's own key.
func serviceAccountFor(ctx context.Context, db *sql.DB, token string) (serviceAccountPrincipal, bool) {
var sa serviceAccountPrincipal
var keyID int64
var teamID sql.NullInt64
err := db.QueryRowContext(ctx, `
SELECT k.id, a.id, a.name, a.scope, a.team_id
FROM service_account_keys k
JOIN service_accounts a ON a.id = k.service_account_id
WHERE k.key_hash = $1`, hashToken(token),
).Scan(&keyID, &sa.id, &sa.name, &sa.scope, &teamID)
if err != nil {
return serviceAccountPrincipal{}, false
}
if teamID.Valid {
sa.teamID = teamID.Int64
}
// best-effort; don't fail the request if this update fails
db.ExecContext(ctx,
"UPDATE service_account_keys SET last_used_at = $1 WHERE id = $2",
time.Now().Unix(), keyID)
return sa, true
}
// serveAsServiceAccount hands the request on with a service account's
// identity in context. A team-scoped account gets a single synthetic
// membership — owner of its own team, nothing else — which is what makes it
// satisfy requireTeamMember/requireTeamOwner exactly as a real owner would,
// without teaching either function about a second kind of caller. An
// instance-scoped account gets no memberships at all: it acts on teams by id,
// not by belonging to one.
//
// No CSRF check, for the same reason an API key needs none: a service-account
// key is only ever set by the client that holds it, never attached by a
// browser to a request another site makes.
func serveAsServiceAccount(w http.ResponseWriter, r *http.Request, next http.Handler, sa serviceAccountPrincipal) {
ctx := r.Context()
if sa.scope == models.ServiceAccountScopeTeam {
ctx = context.WithValue(ctx, ctxTeams, []membership{{teamID: sa.teamID, role: models.RoleOwner}})
}
ctx = context.WithValue(ctx, ctxServiceAccount, sa)
next.ServeHTTP(w, r.WithContext(ctx))
}
func serviceAccountFromContext(ctx context.Context) (serviceAccountPrincipal, bool) {
sa, ok := ctx.Value(ctxServiceAccount).(serviceAccountPrincipal)
return sa, ok
}
// isInstanceServiceAccount reports whether the caller is an instance-scoped
// service account — the one identity allowed to create a team and mint a
// team-scoped account against any of them, the two things system
// administration can already do that this extends to automation.
func isInstanceServiceAccount(ctx context.Context) bool {
sa, ok := serviceAccountFromContext(ctx)
return ok && sa.scope == models.ServiceAccountScopeInstance
}
// operatorReason marks a write that operator mode refused as such, distinct
// from every other 403 this server returns, so a client — the web UI or
// terdut-tui — can tell "you may not" from "this is managed elsewhere" and
// show the right message instead of a bare "forbidden".
const operatorReason = "operator_managed"
// OperatorModeBlock refuses a human write (session or a user's own API key)
// on a route it wraps, while letting a service account through. That is the
// whole point of operator mode: automation holding a service-account key
// (terdut-operator, most likely) keeps reconciling these resources, and a
// person in the web UI or terdut-tui gets a clear "edit this through your
// GitOps source instead" rather than a write that the next resync would only
// undo.
//
// Checked after AuthMiddleware, the same way AdminOnly is: by the time a
// request reaches here the caller is already known to be a service account
// or not. A router that never enables operator mode pays nothing for this —
// it hands back next unchanged rather than wrapping it in a check that would
// always pass.
func OperatorModeBlock(cfg config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
if !cfg.OperatorMode {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := serviceAccountFromContext(r.Context()); ok {
next.ServeHTTP(w, r)
return
}
respond(w, http.StatusForbidden, map[string]string{
"error": "this server is in operator mode; edit this through your GitOps source instead of the web UI or API",
"reason": operatorReason,
})
})
}
}
// membership is the caller's role in one team.
type membership struct {
teamID int64
+10 -1
View File
@@ -66,9 +66,18 @@ func handleAuthConfig(cfg config.Config) http.HandlerFunc {
// DeviceLogin is whether a client that cannot open a browser (the TUI)
// can sign in by showing a code, through /api/oidc/device.
DeviceLogin bool `json:"device_login"`
// OperatorMode is whether this install is gitops-managed: writes to
// teams, escalation policies, dead man's switches and integrations
// from a session or a user's own API key are refused (OperatorModeBlock),
// though a service account's are not. The web UI reads this before
// anybody signs in, the same way it reads PasswordLogin/OIDC, so it can
// show those sections read-only from the start rather than only after
// a write fails.
OperatorMode bool `json:"operator_mode"`
}
return func(w http.ResponseWriter, r *http.Request) {
resp := response{PasswordLogin: !cfg.DisablePasswordLogin}
resp := response{PasswordLogin: !cfg.DisablePasswordLogin, OperatorMode: cfg.OperatorMode}
if cfg.OIDC.Enabled() {
resp.OIDC = oidcInfo{Enabled: true, Name: cfg.OIDC.Name}
resp.DeviceLogin = true
+38 -13
View File
@@ -14,8 +14,12 @@ import (
// NewRouter builds the HTTP surface. notify is passed through to the webhook,
// the only handler that has to decide where a new incident's page goes; a zero
// notify disables notifications. Dead man's switches are per team and read from
// the database, so nothing about them is wired in here.
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
// the database, so nothing about them is wired in here. version is reported
// verbatim by GET /api/version, unauthenticated like /healthz: a client
// deciding whether it can talk to this server — terdut-tui, terdut-operator —
// needs to ask before it holds a credential for it, and the version is not a
// secret.
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version string) http.Handler {
// One limiter each, both process-wide for the life of the router: login
// counts failed passwords, sign-up counts account creation, and mixing the
// two would let a burst of sign-ups lock somebody out of logging in.
@@ -30,6 +34,9 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Get("/api/version", func(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, map[string]string{"version": version})
})
// Unauthenticated: bootstrap, the Alertmanager webhook receiver, and the
// Acknowledge button in a push notification. The last one is authorised by
@@ -151,11 +158,26 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Post("/api/incidents/{id}/notes", handleCreateNote(db))
r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db))
// Service accounts: a scoped, non-human credential for automation
// (terdut-operator, most likely) that needs to manage the resources
// below without impersonating a human user. See SERVICE-ACCOUNTS.md.
r.Get("/api/service-accounts", handleListServiceAccounts(db))
r.Post("/api/service-accounts", handleCreateServiceAccount(db))
r.Post("/api/service-accounts/{id}/keys", handleCreateServiceAccountKey(db))
r.Delete("/api/service-accounts/{id}/keys/{keyID}", handleDeleteServiceAccountKey(db))
// Operator mode (TERDUT_OPERATOR_MODE) makes every write below refuse a
// human caller (a session or a user's own API key) while still letting
// a service account through — see OperatorModeBlock. opMode is a no-op
// wrapper when the flag is off, so this costs nothing on a server that
// never sets it.
opMode := OperatorModeBlock(cfg)
// Teams. A user sees the teams they belong to; an owner configures one.
r.Get("/api/teams", handleListTeams(db))
r.Post("/api/teams", handleCreateTeam(db))
r.Put("/api/teams/{teamID}", handleRenameTeam(db))
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
r.With(opMode).Post("/api/teams", handleCreateTeam(db))
r.With(opMode).Put("/api/teams/{teamID}", handleRenameTeam(db))
r.With(opMode).Delete("/api/teams/{teamID}", handleDeleteTeam(db))
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
@@ -163,28 +185,31 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// A team's own OIDC group binding: which provider groups grant member
// and owner access to it.
r.Get("/api/teams/{teamID}/oidc-groups", handleGetTeamOIDCGroups(db))
r.Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(db))
r.With(opMode).Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(db))
// Invite links into this team.
// Invite links into this team. Not operator-mode-gated: membership is
// deliberately never gitops-managed (see terdut-operator's DESIGN.md
// §4.2), so it stays editable regardless of this flag.
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
// A team's escalation ladder: who is paged when nobody answers.
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
r.With(opMode).Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
// A team's own dead man's switches: which of its alerts are heartbeats,
// and how long a silence has to last before somebody is paged.
r.Get("/api/teams/{teamID}/deadman/switches", handleListTeamDeadman(db))
r.Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
r.Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
r.With(opMode).Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
r.With(opMode).Put("/api/teams/{teamID}/deadman/switches/{switchID}", handleUpdateTeamDeadman(db))
r.With(opMode).Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
// Integrations: where a team's alerts come in, and the key that says so.
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
r.Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
r.With(opMode).Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
r.With(opMode).Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
r.With(opMode).Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
// The rota is per team. /api/schedule/current is the exception: it
// answers across every team the caller is in, which is what somebody on
+327
View File
@@ -0,0 +1,327 @@
package api
import (
"context"
"database/sql"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
// serviceAccountKeyPrefix marks a service-account key visibly, in logs and at
// a glance, distinct from a user's own personal API key. It carries no
// meaning to the server itself — the hash is looked up the same way either
// kind of key is — it exists entirely for whoever is reading a log line or an
// audit trail.
const serviceAccountKeyPrefix = "tdsa_"
// randomServiceAccountToken is randomToken with serviceAccountKeyPrefix on the
// raw value, hashed as a whole: the prefix is not a fixed header stripped
// before hashing, it is part of the secret, the same as if it had been
// generated that long to begin with.
func randomServiceAccountToken() (raw, hash string, err error) {
body, _, err := randomToken()
if err != nil {
return "", "", err
}
raw = serviceAccountKeyPrefix + body
return raw, hashToken(raw), nil
}
// callerIsAdmin reports whether the caller is a signed-in human system
// administrator. A service account never is, by design (SERVICE-ACCOUNTS.md):
// account and user management stays human-only, service accounts included.
func callerIsAdmin(ctx context.Context) bool {
u, ok := userFromContext(ctx)
return ok && u.IsAdmin
}
// callerOwnsTeam reports whether the caller is a human owner of teamID. Built
// on callerRole/ctxTeams like requireTeamOwner, but without writing a
// response: callers here need to combine it with other ways of being
// allowed, not stop at the first no.
func callerOwnsTeam(ctx context.Context, teamID int64) bool {
role, ok := callerRole(ctx, teamID)
return ok && role == models.RoleOwner
}
// handleCreateServiceAccount creates a service account and mints its first
// key. Who may do this depends on scope: an instance-scoped account (which
// can in turn create a team and a team-scoped account for it) is system
// administration's own reach extended to automation, so only a human admin
// grants one. A team-scoped account is that team's owner's reach, so a human
// admin, the target team's own human owner, or an existing instance-scoped
// service account (minting itself a narrower credential for a team it just
// created) may create one.
func handleCreateServiceAccount(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Scope string `json:"scope"`
TeamID int64 `json:"team_id"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
respond(w, http.StatusBadRequest, errResp("name is required"))
return
}
if req.Scope != models.ServiceAccountScopeInstance && req.Scope != models.ServiceAccountScopeTeam {
respond(w, http.StatusBadRequest, errResp("scope must be instance or team"))
return
}
if req.Scope == models.ServiceAccountScopeTeam && req.TeamID == 0 {
respond(w, http.StatusBadRequest, errResp("team_id is required for a team-scoped account"))
return
}
if req.Scope == models.ServiceAccountScopeInstance && req.TeamID != 0 {
respond(w, http.StatusBadRequest, errResp("team_id must not be set for an instance-scoped account"))
return
}
allowed := callerIsAdmin(r.Context())
if !allowed && req.Scope == models.ServiceAccountScopeTeam {
allowed = callerOwnsTeam(r.Context(), req.TeamID) || isInstanceServiceAccount(r.Context())
}
if !allowed {
respond(w, http.StatusForbidden, errResp("team owner, system administrator, or instance-scoped service account access required"))
return
}
var callerUserID *int64
if u, ok := userFromContext(r.Context()); ok {
id := u.ID
callerUserID = &id
}
var teamID *int64
if req.Scope == models.ServiceAccountScopeTeam {
teamID = &req.TeamID
}
var sa models.ServiceAccount
var created int64
if err := db.QueryRowContext(r.Context(), `
INSERT INTO service_accounts (name, scope, team_id, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, name, scope, team_id, created_by, created_at`,
req.Name, req.Scope, teamID, callerUserID,
).Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("a service account with that name already exists"))
return
}
// The only foreign key that can fail here is team_id: an
// instance-scoped caller is not otherwise checked against it
// (callerOwnsTeam already proved it exists for a human owner).
respond(w, http.StatusBadRequest, errResp("unknown team_id"))
return
}
sa.CreatedAt = time.Unix(created, 0).UTC()
key, err := mintServiceAccountKey(r.Context(), db, sa.ID, "initial")
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusCreated, map[string]any{"service_account": sa, "key": key})
}
}
// mintServiceAccountKey inserts one key for an existing account and returns
// it with its raw value populated — the one moment that value exists outside
// the request that generated it.
func mintServiceAccountKey(ctx context.Context, db *sql.DB, serviceAccountID int64, name string) (models.ServiceAccountKey, error) {
raw, hash, err := randomServiceAccountToken()
if err != nil {
return models.ServiceAccountKey{}, err
}
var key models.ServiceAccountKey
var created int64
if err := db.QueryRowContext(ctx, `
INSERT INTO service_account_keys (service_account_id, key_hash, name)
VALUES ($1, $2, $3)
RETURNING id, service_account_id, name, created_at`,
serviceAccountID, hash, name,
).Scan(&key.ID, &key.ServiceAccountID, &key.Name, &created); err != nil {
return models.ServiceAccountKey{}, err
}
key.CreatedAt = time.Unix(created, 0).UTC()
key.Key = raw
return key, nil
}
func fetchServiceAccount(ctx context.Context, db *sql.DB, id int64) (models.ServiceAccount, error) {
var sa models.ServiceAccount
var created int64
err := db.QueryRowContext(ctx,
"SELECT id, name, scope, team_id, created_by, created_at FROM service_accounts WHERE id = $1", id,
).Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created)
if err != nil {
return sa, err
}
sa.CreatedAt = time.Unix(created, 0).UTC()
return sa, nil
}
// callerMayManageServiceAccount reports whether the caller may mint or revoke
// a key on sa: a system administrator, that team-scoped account's own human
// owner, or the account rotating its own credential — which is not a
// privilege escalation, the same reasoning requireSelfOrAdmin already rests
// on for a user's own API keys.
func callerMayManageServiceAccount(ctx context.Context, sa models.ServiceAccount) bool {
if callerIsAdmin(ctx) {
return true
}
if sa.TeamID != nil && callerOwnsTeam(ctx, *sa.TeamID) {
return true
}
if self, ok := serviceAccountFromContext(ctx); ok && self.id == sa.ID {
return true
}
return false
}
func serviceAccountParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid service account id"))
return 0, false
}
return id, true
}
func handleCreateServiceAccountKey(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := serviceAccountParam(w, r)
if !ok {
return
}
sa, err := fetchServiceAccount(r.Context(), db, id)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("service account not found"))
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if !callerMayManageServiceAccount(r.Context(), sa) {
respond(w, http.StatusForbidden, errResp("team owner, system administrator, or the account itself may rotate its key"))
return
}
var req struct {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Name == "" {
respond(w, http.StatusBadRequest, errResp("name is required"))
return
}
key, err := mintServiceAccountKey(r.Context(), db, sa.ID, req.Name)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusCreated, key)
}
}
func handleDeleteServiceAccountKey(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, ok := serviceAccountParam(w, r)
if !ok {
return
}
sa, err := fetchServiceAccount(r.Context(), db, id)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("service account not found"))
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if !callerMayManageServiceAccount(r.Context(), sa) {
respond(w, http.StatusForbidden, errResp("team owner, system administrator, or the account itself may revoke its key"))
return
}
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid key id"))
return
}
res, err := db.ExecContext(r.Context(),
"DELETE FROM service_account_keys WHERE id = $1 AND service_account_id = $2", keyID, sa.ID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("key not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleListServiceAccounts lists every service account, or looks one up by
// its exact name with ?name=. The name lookup is open to any authenticated
// caller, human or service account: it returns no key material, and it is
// what lets a service account find its own account on the 403 that follows a
// second POST — the self-registration pattern SERVICE-ACCOUNTS.md describes.
// Listing everything, with no filter, stays administrator-only.
func handleListServiceAccounts(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.URL.Query().Get("name"))
if name == "" && !callerIsAdmin(r.Context()) {
respond(w, http.StatusForbidden, errResp("administrator access required to list every service account; pass ?name= to look up one by name"))
return
}
query := "SELECT id, name, scope, team_id, created_by, created_at FROM service_accounts"
var args []any
if name != "" {
query += " WHERE name = $1"
args = append(args, name)
}
query += " ORDER BY id"
rows, err := db.QueryContext(r.Context(), query, args...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
accounts := []models.ServiceAccount{}
for rows.Next() {
var sa models.ServiceAccount
var created int64
if err := rows.Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
sa.CreatedAt = time.Unix(created, 0).UTC()
accounts = append(accounts, sa)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, accounts)
}
}
+366
View File
@@ -0,0 +1,366 @@
package api_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/models"
)
// reqAs is s.req with an arbitrary bearer credential in place of the admin's
// own key, for exercising a service account's or another user's key.
func (s *ts) reqAs(t *testing.T, key, method, path string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, s.URL+path, r)
req.Header.Set("Authorization", "Bearer "+key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
}
// createServiceAccount creates a service account as callerKey and returns its
// freshly minted raw key.
func createServiceAccount(t *testing.T, s *ts, callerKey, name, scope string, teamID int64) string {
t.Helper()
body := map[string]any{"name": name, "scope": scope}
if teamID != 0 {
body["team_id"] = teamID
}
resp := s.reqAs(t, callerKey, http.MethodPost, "/api/service-accounts", body)
if resp.StatusCode != http.StatusCreated {
resp.Body.Close()
t.Fatalf("create service account %s: %d", name, resp.StatusCode)
}
var result struct {
Key struct {
Key string `json:"key"`
} `json:"key"`
}
decode(t, resp, &result)
if result.Key.Key == "" {
t.Fatalf("create service account %s: no key returned", name)
}
return result.Key.Key
}
// createTeamAs creates a team as callerKey and returns its id.
func createTeamAs(t *testing.T, s *ts, callerKey, name string) int64 {
t.Helper()
resp := s.reqAs(t, callerKey, http.MethodPost, "/api/teams", map[string]string{"name": name})
if resp.StatusCode != http.StatusCreated {
resp.Body.Close()
t.Fatalf("create team %s: %d", name, resp.StatusCode)
}
var team struct {
ID int64 `json:"id"`
}
decode(t, resp, &team)
return team.ID
}
// ---------------------------------------------------------------------------
// Instance scope
// ---------------------------------------------------------------------------
func TestServiceAccount_InstanceScopeCreatesTeamWithNoHumanOwner(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
if !strings.HasPrefix(instanceKey, "tdsa_") {
t.Errorf("expected a service-account key to carry the tdsa_ prefix, got %q", instanceKey)
}
resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/teams", map[string]string{"name": "provisioned"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("instance-scoped account creating a team: %d", resp.StatusCode)
}
var team struct {
ID int64 `json:"id"`
Role string `json:"role"`
}
decode(t, resp, &team)
if team.Role != "" {
t.Errorf("expected no role on a team a service account created (no human owner), got %q", team.Role)
}
// It still exists, visible to an administrator, even with no member.
var admin []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/admin/teams", nil), &admin)
found := false
for _, tm := range admin {
if int64(tm["id"].(float64)) == team.ID {
found = true
}
}
if !found {
t.Errorf("expected the service-account-created team to appear in /api/admin/teams")
}
}
func TestServiceAccount_TeamScopeCannotCreateTeam(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
teamA := createTeamAs(t, s, instanceKey, "team-a")
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
resp := s.reqAs(t, keyA, http.MethodPost, "/api/teams", map[string]string{"name": "should-fail"})
if resp.StatusCode != http.StatusForbidden {
t.Errorf("expected 403, a team-scoped account creating a team, got %d", resp.StatusCode)
}
resp.Body.Close()
}
// ---------------------------------------------------------------------------
// Team scope
// ---------------------------------------------------------------------------
// The whole point of team scope: bound to its own team, refused everywhere
// else, the same as an instance-scoped account minting a key per TerdutTeam
// rather than sharing one server-admin-equivalent credential would need.
func TestServiceAccount_TeamScopeIsBoundToItsOwnTeam(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
teamA := createTeamAs(t, s, instanceKey, "team-a")
teamB := createTeamAs(t, s, instanceKey, "team-b")
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
policy := map[string]any{"repeat_count": 0, "fallback_topic": "", "levels": []any{}}
resp := s.reqAs(t, keyA, http.MethodPut, "/api/teams/"+id64(teamA)+"/escalation", policy)
if resp.StatusCode != http.StatusOK {
t.Fatalf("team-a's own key setting its escalation: %d", resp.StatusCode)
}
resp.Body.Close()
// 404, not 403: the same "does this exist" refusal a human non-member
// gets from requireTeamMember, not a distinguishable "you may not".
resp2 := s.reqAs(t, keyA, http.MethodPut, "/api/teams/"+id64(teamB)+"/escalation", policy)
if resp2.StatusCode != http.StatusNotFound {
t.Errorf("expected 404 reaching into another team, got %d", resp2.StatusCode)
}
resp2.Body.Close()
}
// Team scope is owner-equivalent broadly (SERVICE-ACCOUNTS.md), not limited to
// one endpoint: escalation, dead man's switches and integrations all work.
func TestServiceAccount_TeamScopeManagesItsResources(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
teamA := createTeamAs(t, s, instanceKey, "team-a")
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
resp := s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/deadman/switches",
map[string]any{"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"})
if resp.StatusCode != http.StatusCreated {
t.Errorf("team-scoped account creating a dead man's switch: %d", resp.StatusCode)
}
resp.Body.Close()
resp2 := s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
map[string]string{"name": "prod"})
if resp2.StatusCode != http.StatusCreated {
t.Errorf("team-scoped account creating an integration: %d", resp2.StatusCode)
}
resp2.Body.Close()
}
// ---------------------------------------------------------------------------
// Key rotation
// ---------------------------------------------------------------------------
func TestServiceAccount_SelfRotatesItsOwnKey(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
// Self-lookup by name, the pattern that turns /api/bootstrap's 403 into a
// normal flow instead of an unhandled error.
var accounts []map[string]any
decode(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/service-accounts?name=terdut-operator", nil), &accounts)
if len(accounts) != 1 {
t.Fatalf("expected exactly one match for ?name=terdut-operator, got %d", len(accounts))
}
id := int64(accounts[0]["id"].(float64))
resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/service-accounts/"+id64(id)+"/keys",
map[string]string{"name": "rotated"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("self-rotation: %d", resp.StatusCode)
}
var newKey struct {
Key string `json:"key"`
}
decode(t, resp, &newKey)
if resp := s.reqAs(t, newKey.Key, http.MethodPost, "/api/teams", map[string]string{"name": "after-rotation"}); resp.StatusCode != http.StatusCreated {
t.Errorf("expected the newly rotated key to work, got %d", resp.StatusCode)
} else {
resp.Body.Close()
}
// Rotation adds a key, it does not itself revoke the old one.
if resp := s.reqAs(t, instanceKey, http.MethodGet, "/api/service-accounts?name=terdut-operator", nil); resp.StatusCode != http.StatusOK {
t.Errorf("expected the original key to still work until explicitly revoked, got %d", resp.StatusCode)
} else {
resp.Body.Close()
}
}
// ---------------------------------------------------------------------------
// Operator mode
// ---------------------------------------------------------------------------
// Operator mode is exercised against a second router over an
// already-configured database, rather than turning it on for newTSWith's own
// setup: that setup creates the default integration with the admin's (human)
// key, which is precisely the write operator mode exists to refuse, and in
// the real deployment this flag targets that setup was never done by a human
// to begin with — the operator itself would have provisioned it.
func TestOperatorMode_BlocksHumanWritesButAllowsServiceAccounts(t *testing.T) {
s := newTS(t)
conf := testConfig()
conf.OperatorMode = true
opSrv := httptest.NewServer(api.NewRouter(s.db, s.notify, conf, "test"))
t.Cleanup(opSrv.Close)
do := func(key, method, path string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, opSrv.URL+path, r)
req.Header.Set("Authorization", "Bearer "+key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
}
// The bootstrap admin's own key is a human credential: refused.
resp := do(s.key, http.MethodPost, "/api/teams", map[string]string{"name": "human-team"})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403 for a human write under operator mode, got %d", resp.StatusCode)
}
var refusal map[string]string
decode(t, resp, &refusal)
if refusal["reason"] != "operator_managed" {
t.Errorf("expected reason=operator_managed, got %q", refusal["reason"])
}
// Creating the service account itself is not gated by operator mode —
// it is how an operator identifies itself, not one of the resources it
// manages.
resp2 := do(s.key, http.MethodPost, "/api/service-accounts",
map[string]any{"name": "terdut-operator", "scope": models.ServiceAccountScopeInstance})
if resp2.StatusCode != http.StatusCreated {
t.Fatalf("create service account under operator mode: %d", resp2.StatusCode)
}
var result struct {
Key struct {
Key string `json:"key"`
} `json:"key"`
}
decode(t, resp2, &result)
resp3 := do(result.Key.Key, http.MethodPost, "/api/teams", map[string]string{"name": "operator-team"})
if resp3.StatusCode != http.StatusCreated {
t.Fatalf("expected 201 for a service-account write under operator mode, got %d", resp3.StatusCode)
}
resp3.Body.Close()
// Reads are unaffected regardless of caller.
if resp := do(s.key, http.MethodGet, "/api/teams", nil); resp.StatusCode != http.StatusOK {
t.Errorf("expected reads to stay open under operator mode, got %d", resp.StatusCode)
} else {
resp.Body.Close()
}
}
func TestOperatorMode_OffLeavesHumanWritesAlone(t *testing.T) {
s := newTS(t) // testConfig(): OperatorMode false
resp := s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "still-fine"})
if resp.StatusCode != http.StatusCreated {
t.Errorf("expected a human write to succeed with operator mode off, got %d", resp.StatusCode)
}
resp.Body.Close()
}
// ---------------------------------------------------------------------------
// Version
// ---------------------------------------------------------------------------
func TestVersion(t *testing.T) {
s := newTS(t)
resp, err := http.Get(s.URL + "/api/version")
if err != nil {
t.Fatalf("get version: %v", err)
}
var v struct {
Version string `json:"version"`
}
decode(t, resp, &v)
if v.Version != "test" {
t.Errorf("expected version %q, got %q", "test", v.Version)
}
}
// ---------------------------------------------------------------------------
// Dead man's switch update-in-place
// ---------------------------------------------------------------------------
func TestDeadman_UpdateInPlacePreservesID(t *testing.T) {
s := newTS(t)
var created struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches",
map[string]any{"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"}), &created)
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(created.ID),
map[string]any{"name": "renamed", "matcher": "alertname=Watchdog", "timeout_seconds": 1200, "severity": "warning"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("update switch: %d", resp.StatusCode)
}
var updated struct {
ID int64 `json:"id"`
Name string `json:"name"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Severity string `json:"severity"`
}
decode(t, resp, &updated)
if updated.ID != created.ID {
t.Errorf("expected id to stay %d, got %d", created.ID, updated.ID)
}
if updated.Name != "renamed" || updated.TimeoutSeconds != 1200 || updated.Severity != "warning" {
t.Errorf("expected the update to apply, got %+v", updated)
}
var list []map[string]any
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/deadman/switches", nil), &list)
if len(list) != 1 {
t.Errorf("expected the update to replace in place, not add a row, got %d switches", len(list))
}
}
+122 -7
View File
@@ -116,6 +116,15 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
// handleCreateTeam creates a team and makes its creator the first owner. A team
// with no owner would need an administrator to repair before anybody could use
// it, so the two happen in one transaction.
//
// An instance-scoped service account may also create a team (SERVICE-ACCOUNTS.md:
// it acts with the same reach system administration has over teams), but it
// is not a users row and cannot become an owner the way a person does. The
// team it creates starts with no human owner at all — not a bug, the expected
// shape for one terdut-operator is about to provision: a system administrator
// can always act as owner to repair or hand it off (requireTeamOwner), and
// the account that created it mints itself a team-scoped credential for it
// next, via POST /api/service-accounts.
func handleCreateTeam(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
@@ -131,7 +140,14 @@ func handleCreateTeam(db *sql.DB) http.HandlerFunc {
return
}
caller, _ := userFromContext(r.Context())
caller, isUser := userFromContext(r.Context())
if !isUser && !isInstanceServiceAccount(r.Context()) {
// A team-scoped service account authenticates as owner of exactly
// one team already (see serveAsServiceAccount); letting it create
// another would reach outside that boundary.
respond(w, http.StatusForbidden, errResp("instance-scoped service account or user access required"))
return
}
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
@@ -152,11 +168,13 @@ func handleCreateTeam(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if _, err := tx.ExecContext(r.Context(),
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
team.ID, caller.ID, models.RoleOwner); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
if isUser {
if _, err := tx.ExecContext(r.Context(),
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
team.ID, caller.ID, models.RoleOwner); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
@@ -164,7 +182,9 @@ func handleCreateTeam(db *sql.DB) http.HandlerFunc {
}
team.CreatedAt = time.Unix(created, 0).UTC()
team.Role = models.RoleOwner
if isUser {
team.Role = models.RoleOwner
}
respond(w, http.StatusCreated, team)
}
}
@@ -861,6 +881,101 @@ func handleCreateTeamDeadman(db *sql.DB) http.HandlerFunc {
}
}
// handleUpdateTeamDeadman replaces one switch's configuration in place.
// Added alongside create/delete so an automated caller (terdut-operator) can
// reconcile a spec change without deleting and recreating the switch, which
// would otherwise be the only option and would needlessly rotate its id for
// no reason a reconciler's diff should ever manufacture.
func handleUpdateTeamDeadman(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
switchID, err := strconv.ParseInt(chi.URLParam(r, "switchID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid switch id"))
return
}
var req deadmanSwitchRequest
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Matcher = strings.TrimSpace(req.Matcher)
req.Name = strings.TrimSpace(req.Name)
if req.Severity == "" {
req.Severity = "critical"
}
if !deadmanSeverities[req.Severity] {
respond(w, http.StatusBadRequest, errResp("severity must be critical, error, warning or info"))
return
}
if req.TimeoutSeconds <= 0 {
respond(w, http.StatusBadRequest, errResp("timeout_seconds must be positive"))
return
}
if strings.Contains(req.Matcher, ";") {
respond(w, http.StatusBadRequest, errResp("one matcher per switch: add another switch instead of separating with ;"))
return
}
m, err := parseDeadmanMatcher(req.Matcher)
if err != nil {
respond(w, http.StatusBadRequest, errResp(
"unusable matcher ("+err.Error()+"): each must name an alertname, as in alertname=Watchdog,cluster=prod"))
return
}
if req.Name == "" {
req.Name = m.config()
}
if len(req.Name) > 100 {
respond(w, http.StatusBadRequest, errResp("name is too long"))
return
}
res, err := db.ExecContext(r.Context(), `
UPDATE deadman_switches
SET name = $1, matcher = $2, timeout_seconds = $3, severity = $4
WHERE id = $5 AND team_id = $6`,
req.Name, m.config(), req.TimeoutSeconds, req.Severity, switchID, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("switch not found"))
return
}
// The full status, not the bare request echoed back: an update can
// change whether the switch is dormant, alive or dead (a longer
// timeout can revive one that just went dead), and a caller
// reconciling against status deserves the same view
// handleListTeamDeadman would give it.
set, err := deadmanSetForTeam(r.Context(), db, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
statuses, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
for _, s := range statuses {
if s.ID == switchID {
respond(w, http.StatusOK, s)
return
}
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
}
}
// handleDeleteTeamDeadman removes a switch. An incident it already opened stays
// open until somebody resolves it: deleting the switch says "stop watching", not
// "the problem is gone".
+10
View File
@@ -72,6 +72,14 @@ type Config struct {
// OIDC configures single sign-on. The zero value, with no Issuer, is off.
OIDC OIDC
// OperatorMode declares this install gitops-managed: writes to teams,
// escalation policies, dead man's switches and integrations from a human
// (a session or a user's own API key) are refused, while a service
// account's are not. Deploy-time and restart-required, like the rest of
// "where this server is plugged in" — it is a statement about who owns
// this install's configuration, not a per-request toggle.
OperatorMode bool
}
// OIDC is the single sign-on configuration. Groups from the provider decide
@@ -151,6 +159,8 @@ func Load() Config {
DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true),
OIDC: loadOIDC(),
OperatorMode: boolean("TERDUT_OPERATOR_MODE", false),
}
}
@@ -0,0 +1,43 @@
-- Service accounts: a scoped, non-human credential for automation (e.g.
-- terdut-operator) that needs to manage teams, escalation policies, dead
-- man's switches, integrations and OIDC group bindings without impersonating
-- a human user. See SERVICE-ACCOUNTS.md for the design this implements.
--
-- Deliberately not a users row: no password_hash, no is_admin, no
-- user_identities linkage, so a service account can never be pulled into
-- OIDC group sync or password login, and is never mistaken for a human in an
-- audit trail.
--
-- scope is 'instance' (acts with the same reach system administration has
-- over teams: create one, list them, mint a 'team'-scoped account against
-- any of them) or 'team' (acts as that one team's owner, and nothing else).
-- The CHECK ties team_id's presence to scope directly, rather than leaving it
-- to application code to keep the two consistent.
CREATE TABLE service_accounts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK (scope IN ('instance', 'team')),
team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
CONSTRAINT service_accounts_scope_team_id_chk CHECK (
(scope = 'team' AND team_id IS NOT NULL) OR
(scope = 'instance' AND team_id IS NULL)
)
);
CREATE INDEX service_accounts_team_id_idx ON service_accounts(team_id);
-- One account, many keys: rotation is minting a new one and revoking the
-- old, the same shape api_keys already has, so an account's identity and
-- audit history survive a rotation instead of being recreated by it.
CREATE TABLE service_account_keys (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
service_account_id BIGINT NOT NULL REFERENCES service_accounts(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
last_used_at BIGINT
);
CREATE INDEX service_account_keys_service_account_id_idx ON service_account_keys(service_account_id);
+37
View File
@@ -0,0 +1,37 @@
package models
import "time"
// Service account scopes. Instance acts with the same reach system
// administration has over teams: create one, list them, mint a team-scoped
// account against any of them. Team acts as that one team's owner, and
// nothing else.
const (
ServiceAccountScopeInstance = "instance"
ServiceAccountScopeTeam = "team"
)
// ServiceAccount is a non-human credential: not a users row, so it never
// touches OIDC group sync, login, or the is_admin flag, and is never mistaken
// for a human in an audit trail (see api_keys' user_id, which every service
// account key deliberately does not have).
type ServiceAccount struct {
ID int64 `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
TeamID *int64 `json:"team_id,omitempty"`
CreatedBy *int64 `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ServiceAccountKey is one bearer credential on a ServiceAccount. Multiple
// keys per account, the same shape as APIKey, are what let rotation mint a
// new one and revoke the old without recreating the account.
type ServiceAccountKey struct {
ID int64 `json:"id"`
ServiceAccountID int64 `json:"service_account_id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
Key string `json:"key,omitempty"` // populated only on creation, never stored
}
+2
View File
@@ -639,6 +639,8 @@ details[open] > summary { margin-bottom: 8px; }
.account-name { font-size: 18px; font-weight: 750; }
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.pw-form { display: grid; gap: 12px; padding: 16px; }
.account-fold { border-top: 1px solid var(--border); padding-top: 12px; }
.account-fold .stacked-form { margin-top: 8px; }
.form-ok {
margin: 0; padding: 10px 12px;
background: var(--ok-soft); color: var(--ok);
+53 -41
View File
@@ -43,9 +43,12 @@ function render() {
//
// The topic is the whole address — the server it is published to is the
// install's one ntfy, set in the deployment and not something a user picks.
function notifyStatus(topic) {
return topic ? `Topic: ${topic}` : 'No topic set — pages go to the team’s fallback topic.';
}
function notifyForm(user) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const topic = h('input', {
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
autocapitalize: 'none', spellcheck: false,
@@ -54,29 +57,7 @@ function notifyForm(user) {
});
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
// Only offered once a topic is saved: the test publishes to whatever the
// server has stored, not to whatever is half-typed in the field.
const test = h('button', {
class: 'btn', type: 'button', text: 'Send a test push',
hidden: !user.ntfy_topic,
onclick: async () => {
err.hidden = true;
ok.hidden = true;
test.disabled = true;
try {
await api.testNotification();
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
ok.hidden = false;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
test.disabled = false;
}
},
});
const form = h('form', { class: 'card pw-form' },
const form = h('form', { class: 'stacked-form' },
h('label', {},
h('span', { text: 'ntfy topic' }),
topic),
@@ -89,25 +70,46 @@ function notifyForm(user) {
h('p', { class: 'muted small' },
'Anyone who knows the topic can read your pages and publish to it, so ',
'pick something unguessable rather than your name.'),
err, ok,
h('div', { class: 'row-actions' }, submit, test),
err,
submit,
);
const status = h('p', { class: 'muted', text: notifyStatus(user.ntfy_topic) });
const summary = h('summary', { text: user.ntfy_topic ? 'Change topic' : 'Set a topic' });
const details = h('details', { class: 'account-fold' }, summary, form);
// Only offered once a topic is saved: the test publishes to whatever the
// server has stored, not to whatever is half-typed in the field.
const test = h('button', {
class: 'btn', type: 'button', text: 'Send a test push',
hidden: !user.ntfy_topic,
onclick: async () => {
test.disabled = true;
try {
await api.testNotification();
toast('Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.');
} catch (ex) {
toast(ex.message, 'error');
} finally {
test.disabled = false;
}
},
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
ok.hidden = true;
submit.disabled = true;
try {
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
// Keep the cached user in step, so the onboarding checklist stops
// asking for this and the test button appears without a reload.
state.me.user = updated;
ok.textContent = updated.ntfy_topic
? 'Topic saved.'
: 'Topic cleared. Your pages go to the team’s fallback topic.';
ok.hidden = false;
status.textContent = notifyStatus(updated.ntfy_topic);
summary.textContent = updated.ntfy_topic ? 'Change topic' : 'Set a topic';
test.hidden = !updated.ntfy_topic;
details.open = false;
toast(updated.ntfy_topic ? 'Topic saved' : 'Topic cleared. Your pages go to the team’s fallback topic.');
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
@@ -115,7 +117,11 @@ function notifyForm(user) {
submit.disabled = false;
}
});
return form;
return h('div', { class: 'card pw-form' },
status,
h('div', { class: 'row-actions' }, test),
details);
}
// With password login switched off a password opens nothing, so somebody who
@@ -131,14 +137,13 @@ function passwordSection(user, hasPassword) {
];
}
return [
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
passwordForm(user, hasPassword),
];
}
function passwordForm(user, hasPassword) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const current = hasPassword
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
: null;
@@ -148,18 +153,24 @@ function passwordForm(user, hasPassword) {
// A hidden username field lets password managers file the new password
// under the right account.
const form = h('form', { class: 'card pw-form', autocomplete: 'on' },
const form = h('form', { class: 'stacked-form', autocomplete: 'on' },
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
current && h('label', {}, h('span', { text: 'Current password' }), current),
h('label', {}, h('span', { text: 'New password' }), next),
h('label', {}, h('span', { text: 'Repeat new password' }), again),
err, ok, submit,
err, submit,
);
const status = h('p', {
class: 'muted',
text: hasPassword ? 'Password set.' : 'No password set — sign-in needs one of the other methods.',
});
const summary = h('summary', { text: hasPassword ? 'Change password' : 'Set a password' });
const details = h('details', { class: 'account-fold' }, summary, form);
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
ok.hidden = true;
if (next.value !== again.value) {
err.textContent = 'The new passwords do not match.';
err.hidden = false;
@@ -171,13 +182,14 @@ function passwordForm(user, hasPassword) {
state.me.has_password = true;
form.reset();
if (!current) {
// From now on the form needs the current-password field.
// From now on the form needs the current-password field, and a fresh
// render already comes up with the fold closed.
render();
toast('Password saved');
return;
}
ok.textContent = 'Password saved. Other devices have been signed out.';
ok.hidden = false;
details.open = false;
toast('Password saved. Other devices have been signed out.');
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
@@ -185,7 +197,7 @@ function passwordForm(user, hasPassword) {
submit.disabled = false;
}
});
return form;
return h('div', { class: 'card pw-form' }, status, details);
}
function shortcuts() {