Compare commits

..

12 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
Niklas Ye 949d6595ba Set the chart's placeholder version to 0.31.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m48s
Release / test (push) Successful in 5s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 41s
Release / image (push) Successful in 1m3s
Release / scan-image (push) Successful in 5s
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 e5b4df7 and 97a4814, so a
tree heading for v0.31.0 doesn't say 0.30.0.
2026-09-27 18:23:43 +02:00
Niklas Ye 33356ca978 Add a global, colour-coded team selector to the nav
state.js's currentTeam() was hard-coded to teams[0] and never really meant
"the team currently selected" — team.js's settings page and queue.js's
filter chips each kept their own separate, unsynchronized notion of "which
team" instead, so picking one on one page had no effect on the other.

Replaces both with a single state.selectedTeamID, set only through the new
setSelectedTeam (persisted in localStorage, unlike the queue's old per-tab
sessionStorage filter) and broadcast to listeners via onTeamChange. A new
teamselector.js control — a coloured dot plus the team's name, or "All
teams" — sits at the top of both the desktop sidebar and the mobile topbar,
opening the existing bottom-sheet menu to switch. Shown only once someone is
in more than one team, matching every other team-aware control in this app.

Colours come from a new teamColorClass() in format.js, hashing a team's id
into the six-colour rc1..rc6 palette already used for the rota's per-person
chips, so no schema or API change is needed. The queue's team filter chips
pick up the same colours.
2026-09-27 18:14:54 +02:00
Niklas Ye e5b4df7c03 Set the chart's placeholder version to 0.30.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 25s
CI / test (push) Successful in 4m17s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 28s
Release / image (push) Successful in 1m9s
Release / scan-image (push) Successful in 27s
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 97a4814 and 155f27c, so a
tree heading for v0.30.0 doesn't say 0.29.1.
2026-09-27 11:44:16 +02:00
Niklas Ye 5b4683febf Let each team name its own OIDC group, not a global mapping
Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.

Each team now carries its own oidc_member_group and oidc_owner_group,
set by its owner (or an administrator) from the Members tab, or PUT
/api/teams/{teamID}/oidc-groups. The "highest role wins" rule
TERDUT_OIDC_GROUP_MAPPINGS used to apply across mappings now applies
across one team's own two fields: being in both makes somebody an
owner. The sync no longer creates a team by name; a group only ever
grants into a team that already exists.

This is a breaking change for anyone already using
TERDUT_OIDC_GROUP_MAPPINGS, deliberately not auto-migrated: an
OIDC-sourced membership is dropped at a user's next sign-in until its
team's owner re-sets the group. The README's OIDC section spells out
the migration and the risk of a visible access gap during it.

TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
2026-09-27 11:43:57 +02:00
Niklas Ye 97a4814c04 Set the chart's placeholder version to 0.29.1
CI / chart (push) Successful in 2s
CI / test (push) Successful in 12s
CI / security (push) Successful in 15s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 22s
Release / image (push) Successful in 1m3s
Release / scan-image (push) Successful in 5s
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 155f27c and c5be55d, so a
tree heading for v0.29.1 doesn't say 0.29.0.
2026-09-26 22:05:33 +02:00
Niklas Ye a2dc9e3b03 Ship a CA bundle in the image so single sign-on can reach the provider
The image is built FROM scratch and carried only the binary, so it had no
trust store, and every HTTPS call failed with "x509: certificate signed by
unknown authority". Nothing needed one until v0.29.0: OIDC discovery and the
token exchange are HTTPS calls to the identity provider, and the first
sign-in against Authentik died in discovery. The tests could not see it,
because they run on the host, whose trust store is fine.

The builder's ca-certificates.crt is copied in by name, so a missing file
fails the build instead of shipping an image that cannot sign anybody in.
Verified by fetching the provider's discovery URL from a scratch image with
and without the bundle: the same x509 error, then 200.

Password login and everything that talks only to Postgres were unaffected.
2026-09-26 22:05:33 +02:00
37 changed files with 2154 additions and 271 deletions
+6
View File
@@ -16,6 +16,12 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -ldflags="-w -s -X main.version=${VERSION}" -o /terdut ./cmd/terdut
FROM scratch
# scratch has no trust store, and a Go binary on it fails every HTTPS call with
# "x509: certificate signed by unknown authority". Nothing needed one until single
# sign-on: discovery and the token exchange are HTTPS calls to the identity provider.
# The bundle is the builder's, copied by name so a missing file fails the build
# rather than shipping an image that cannot sign anybody in.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /terdut /terdut
EXPOSE 8080
ENTRYPOINT ["/terdut"]
+91 -17
View File
@@ -152,9 +152,14 @@ TERDUT_OIDC_CLIENT_ID=terdut
TERDUT_OIDC_CLIENT_SECRET=...
TERDUT_OIDC_ALLOWED_GROUPS=terdut-users,terdut-admins
TERDUT_OIDC_ADMIN_GROUP=terdut-admins
TERDUT_OIDC_GROUP_MAPPINGS='[{"group":"sre","team":"SRE","role":"member"},{"group":"sre-leads","team":"SRE","role":"owner"}]'
```
Which team a group grants is not server-wide config: each team names its own
group(s), set by that team's own owner (or an administrator) from its Members
tab, or `PUT /api/teams/{teamID}/oidc-groups {"member_group":"sre","owner_group":"sre-leads"}`.
A team must already exist before a group can grant access to it — the sync
never creates one.
The web UI's sign-in page shows a "Sign in with <name>" button (a plain link to
`/api/oidc/login`) above the password form, or instead of it when
`TERDUT_PASSWORD_LOGIN=false`; it asks `GET /api/auth/config` what the server offers
@@ -175,8 +180,8 @@ Account page does not offer to set a password nobody could use.
2. *Whether.* With `TERDUT_OIDC_ALLOWED_GROUPS` set, somebody in none of them is
refused and nothing is created.
3. *What.* The administrator flag follows `TERDUT_OIDC_ADMIN_GROUP`. Team roles
follow the mappings; where several groups grant the same team the highest role
wins.
follow each team's own `oidc_member_group`/`oidc_owner_group`; where both of a
team's groups match, the owner group wins.
**Managed access.** What the sync grants is marked as managed by single sign-on,
and only that is ever changed by it. It is added at sign-in, and removed at the
@@ -184,10 +189,18 @@ next sign-in after the group is gone, even if that leaves a team without an owne
(an administrator can always repair a team) — the provider is the source of truth
for what it grants, so the last-owner and last-administrator guards do not apply.
Memberships and administrators added by hand are left alone; the exception is a
hand-added member whose mapping grants a *higher* role, who is raised and from then
on managed. Editing managed access by hand (`POST` or `DELETE` on a team's
members, revoking an SSO-granted administrator) is refused with `409`, since the
next sign-in would undo it.
hand-added member whose team's own group grants a *higher* role, who is raised and
from then on managed. Editing managed access by hand (`POST` or `DELETE` on a
team's members, revoking an SSO-granted administrator) is refused with `409`, since
the next sign-in would undo it.
> **Upgrading past migration 013: reconfigure every team's groups.**
> `TERDUT_OIDC_GROUP_MAPPINGS` is gone, and the sync no longer creates a team by
> name. Group-to-team-role mapping is now each team's own setting — an owner sets
> it from the Members tab, or `PUT /api/teams/{teamID}/oidc-groups`. Until a team's
> owner does that, an OIDC-sourced membership in it is dropped at that user's next
> SSO sign-in, the same as any other loss of group access. Set every team's groups
> before affected users next sign in, to avoid a visible gap in access.
**How fast changes arrive.** Groups are read only at sign-in. A session made by an
SSO sign-in has a hard ceiling (`TERDUT_OIDC_SESSION_MAX_AGE`, default 12h) that
@@ -321,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 |
@@ -329,7 +343,6 @@ over an administrator's edit.
| `TERDUT_OIDC_TRUST_EMAIL` | `false` | Link a first sign-in to an existing local user by email even if the provider does not mark the address verified |
| `TERDUT_OIDC_ALLOWED_GROUPS` | — | Comma-separated. Only people in one of these may sign in. Empty admits everybody the provider authenticates |
| `TERDUT_OIDC_ADMIN_GROUP` | — | Members are system administrators |
| `TERDUT_OIDC_GROUP_MAPPINGS` | — | JSON list of `{"group","team","role"}` (`role` is `owner` or `member`). Teams that do not exist are created |
| `TERDUT_OIDC_SESSION_MAX_AGE` | `12h` | Hard ceiling on a session made by an SSO sign-in |
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
@@ -338,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`.
---
@@ -693,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>
@@ -709,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:
@@ -752,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 |
@@ -798,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
@@ -815,20 +882,26 @@ 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 |
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}`. `409` when it would demote the last owner, or the membership is managed by single sign-on |
| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner, or a membership managed by single sign-on |
| `GET` | `/api/teams/{teamID}/oidc-groups` | member | Which groups control this team's membership: `{"member_group","owner_group"}`. An empty string means no group grants that role here |
| `PUT` | `/api/teams/{teamID}/oidc-groups` | **owner** | Set them. An empty string clears a binding |
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys. Each carries `status` (`active` if its key posted within 24h, `quiet` if it has but not lately, `never`), `last_used_at` (last webhook, usable or not), `last_alert_at` (when an alert last arrived on it) and `alerts_24h` (distinct alerts it refreshed in the last day). Alerts delivered before the source was recorded (migration 010) have none, so the last two fill in as Alertmanager re-sends them |
| `PATCH` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Rename `{"name"}`. The key does not change |
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
@@ -840,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.29.0
appVersion: "v0.29.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 }}
@@ -107,10 +109,6 @@ spec:
- name: TERDUT_OIDC_ADMIN_GROUP
value: {{ .Values.oidc.adminGroup | quote }}
{{- end }}
{{- if .Values.oidc.groupMappings }}
- name: TERDUT_OIDC_GROUP_MAPPINGS
value: {{ .Values.oidc.groupMappings | toJson | quote }}
{{- end }}
{{- end }}
livenessProbe:
httpGet:
+12 -6
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
@@ -148,12 +156,10 @@ oidc:
allowedGroups: []
# Members of this group are system administrators.
adminGroup: ""
# Team roles from groups. A team that does not exist is created. Where several
# groups grant the same team the highest role wins.
# - group: sre
# team: SRE
# role: member # member or owner
groupMappings: []
# Which group grants a team's membership and ownership is each team's own
# setting now, not chart config: an owner sets it from the Members tab, or
# PUT /api/teams/{teamID}/oidc-groups. A team must already exist for a group
# to grant access to it.
# Hard ceiling on a session made by a single sign-on login.
sessionMaxAge: 12h
+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
+51 -16
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
@@ -211,7 +220,15 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
return
}
userID, err := signInSSO(r.Context(), db, cfg, identity, grants)
teamGroups, err := loadTeamGroups(r.Context(), db)
if err != nil {
log.Printf("oidc: load team groups: %v", err)
ssoRedirect(w, r, ssoFailed)
return
}
teamGrants := oidc.ComputeTeamGrants(teamGroups, identity.Groups)
userID, err := signInSSO(r.Context(), db, cfg, identity, grants, teamGrants)
if err != nil {
var se ssoError
if errors.As(err, &se) {
@@ -253,7 +270,7 @@ func safeNext(next string) string {
// signInSSO resolves the identity to a user and applies its grants, in one
// transaction: a login that fails half way must not leave memberships changed.
func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identity, g oidc.Grants) (int64, error) {
func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identity, g oidc.Grants, teamRoles map[int64]string) (int64, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return 0, err
@@ -272,12 +289,34 @@ func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identi
if disabled {
return 0, ssoDisabled
}
if err := syncGrants(ctx, tx, userID, g); err != nil {
if err := syncGrants(ctx, tx, userID, g, teamRoles); err != nil {
return 0, err
}
return userID, tx.Commit()
}
// loadTeamGroups reads every team's own OIDC group binding, for the sync to
// evaluate against one user's groups at a time. Teams are few, so this reads
// the whole table rather than filtering it.
func loadTeamGroups(ctx context.Context, db *sql.DB) ([]oidc.TeamGroup, error) {
rows, err := db.QueryContext(ctx,
"SELECT id, COALESCE(oidc_member_group, ''), COALESCE(oidc_owner_group, '') FROM teams")
if err != nil {
return nil, err
}
defer rows.Close()
var out []oidc.TeamGroup
for rows.Next() {
var tg oidc.TeamGroup
if err := rows.Scan(&tg.TeamID, &tg.MemberGroup, &tg.OwnerGroup); err != nil {
return nil, err
}
out = append(out, tg)
}
return out, rows.Err()
}
// resolveSSOUser finds the user an identity belongs to, linking or creating one
// when this is its first sign-in.
//
@@ -407,7 +446,11 @@ func refreshProfile(ctx context.Context, tx *sql.Tx, userID int64, id *oidc.Iden
// administrator is a manual one. Rows added by hand are 'manual', and the sync
// only ever raises them (turning them into 'oidc' rows), never lowers or removes
// them.
func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants) error {
//
// teamRoles is keyed by team ID, not name: a team must already exist, with its
// own oidc_member_group/oidc_owner_group set by its owner, before a group can
// grant access to it. The sync never creates a team.
func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants, teamRoles map[int64]string) error {
// Administrator. A manual administrator stays one whatever the groups say.
if g.Admin {
if _, err := tx.ExecContext(ctx,
@@ -422,21 +465,13 @@ func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants) er
}
// Teams. The result of the loop is the set of teams the groups grant.
granted := make([]int64, 0, len(g.Teams))
for name, role := range g.Teams {
if _, err := tx.ExecContext(ctx,
"INSERT INTO teams (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", name); err != nil {
return err
}
var teamID int64
if err := tx.QueryRowContext(ctx, "SELECT id FROM teams WHERE name = $1", name).Scan(&teamID); err != nil {
return err
}
granted := make([]int64, 0, len(teamRoles))
for teamID, role := range teamRoles {
granted = append(granted, teamID)
// A row the sync owns follows the groups in both directions. One added by
// hand is only raised: a member the owner made an owner by hand is not
// demoted because the mapping says member.
// demoted because the group says member.
if _, err := tx.ExecContext(ctx, `
INSERT INTO team_members (team_id, user_id, role, source)
VALUES ($1, $2, $3, 'oidc')
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"database/sql"
"net/http"
)
// teamOIDCGroups is one team's own OIDC binding: which group, if any, grants
// member access and which grants owner access. The same shape answers GET and
// is accepted by PUT. An empty string means no group grants that role here.
type teamOIDCGroups struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
// handleGetTeamOIDCGroups answers which groups control a team's membership.
// Member-gated like the member list itself: this is part of "who is in the
// team and why", not a setting only an owner should be able to see.
func handleGetTeamOIDCGroups(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamMember(w, r, teamID) {
return
}
var g teamOIDCGroups
err := db.QueryRowContext(r.Context(),
"SELECT COALESCE(oidc_member_group, ''), COALESCE(oidc_owner_group, '') FROM teams WHERE id = $1",
teamID).Scan(&g.MemberGroup, &g.OwnerGroup)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, g)
}
}
// handleSetTeamOIDCGroups sets which groups control a team's membership.
//
// Owner-gated, the same as the schedule, the integrations and the escalation
// ladder: this decides who can end up in the team, which is exactly the kind
// of thing only the team's own owner (or an administrator repairing it) should
// be able to change. An empty string clears a binding.
func handleSetTeamOIDCGroups(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
}
var req teamOIDCGroups
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if _, err := db.ExecContext(r.Context(), `
UPDATE teams
SET oidc_member_group = NULLIF($1, ''),
oidc_owner_group = NULLIF($2, '')
WHERE id = $3`,
req.MemberGroup, req.OwnerGroup, teamID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+50 -10
View File
@@ -127,9 +127,10 @@ type idpUser struct {
badNonce bool
}
// ssoConfig is a terdut configuration wired to idp, with the mapping the tests
// share: terdut-users may sign in, terdut-admins administer, and the sre groups
// grant roles in the SRE team.
// ssoConfig is a terdut configuration wired to idp: terdut-users may sign in,
// terdut-admins administer. Which groups grant which team is not config
// anymore — it is each team's own oidc_member_group/oidc_owner_group, so a
// test that needs one seeds it with seedTeam.
func ssoConfig(idp *fakeIdP) config.Config {
c := testConfig()
c.OIDC = config.OIDC{
@@ -143,16 +144,28 @@ func ssoConfig(idp *fakeIdP) config.Config {
GroupsClaim: "groups",
AllowedGroups: []string{"terdut-users"},
AdminGroup: "terdut-admins",
GroupMappings: []config.GroupMapping{
{Group: "sre", Team: "SRE", Role: "member"},
{Group: "sre-leads", Team: "SRE", Role: "owner"},
{Group: "platform", Team: "Platform", Role: "member"},
},
SessionMaxAge: 12 * time.Hour,
}
return c
}
// seedTeam creates a team with an OIDC group binding, the way an owner would
// set one from the Members tab. Teams are no longer created by the sync
// itself, so a test whose groups should grant something needs the team to
// already exist. An empty group means that role is not granted by one.
func (s *ts) seedTeam(t *testing.T, name, memberGroup, ownerGroup string) int64 {
t.Helper()
var id int64
err := s.db.QueryRow(`
INSERT INTO teams (name, oidc_member_group, oidc_owner_group)
VALUES ($1, NULLIF($2, ''), NULLIF($3, '')) RETURNING id`,
name, memberGroup, ownerGroup).Scan(&id)
if err != nil {
t.Fatalf("seed team %q: %v", name, err)
}
return id
}
func newSSOTS(t *testing.T, idp *fakeIdP, tweak ...func(*config.Config)) *ts {
t.Helper()
c := ssoConfig(idp)
@@ -290,6 +303,8 @@ func sameMap(a, b map[string]string) bool {
func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "sre-leads")
s.seedTeam(t, "Platform", "platform", "")
b := ssoBrowser(t, s)
if loc := signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "platform")); loc != "/" {
@@ -305,6 +320,27 @@ func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) {
}
}
// A group matching no team's own binding grants nothing and creates nothing:
// unlike the old global mapping, the sync never creates a team by name.
func TestSSO_NoAutoCreateTeam(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
var before int
s.db.QueryRow("SELECT COUNT(*) FROM teams").Scan(&before)
signInSSO(t, idp, ssoBrowser(t, s), alice) // groups include "sre"; no team names it
if got := s.memberships(t, "alice"); len(got) != 0 {
t.Errorf("memberships %v, want none: no team's oidc_member_group/oidc_owner_group is set", got)
}
var after int
s.db.QueryRow("SELECT COUNT(*) FROM teams").Scan(&after)
if after != before {
t.Errorf("team count %d -> %d, want no team created", before, after)
}
}
func TestSSO_RefusedOutsideAllowedGroups(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
@@ -362,6 +398,7 @@ func TestSSO_ManualAdminIsNeverRevoked(t *testing.T) {
func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "sre-leads")
signInSSO(t, idp, ssoBrowser(t, s), alice)
// Somebody adds alice to another team by hand.
@@ -379,6 +416,7 @@ func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) {
func TestSSO_HighestRoleWinsAndRoleChangesFollow(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "sre-leads")
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "sre-leads"))
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/oidc"}) {
@@ -396,11 +434,11 @@ func TestSSO_ManualMemberIsRaisedNeverLowered(t *testing.T) {
// alice exists locally, is a manual owner of SRE, and is linked by email.
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'alice@example.com')")
s.exec(t, "INSERT INTO teams (name) VALUES ('SRE')")
s.seedTeam(t, "SRE", "sre", "")
s.exec(t, `INSERT INTO team_members (team_id, user_id, role)
VALUES ((SELECT id FROM teams WHERE name = 'SRE'), (SELECT id FROM users WHERE username = 'alice'), 'owner')`)
signInSSO(t, idp, ssoBrowser(t, s), alice) // the mapping only says member
signInSSO(t, idp, ssoBrowser(t, s), alice) // the group only grants member
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/manual"}) {
t.Errorf("%v: a hand-made owner must not be lowered by a member mapping", got)
}
@@ -614,6 +652,7 @@ func TestSSO_ProviderErrorGoesBackToTheUI(t *testing.T) {
func TestSSO_ManagedAccessCannotBeEditedByHand(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "")
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "terdut-admins"))
var aliceID, sreID int64
@@ -712,6 +751,7 @@ func TestSSO_UnreachableProviderRedirectsWithAnError(t *testing.T) {
func TestSSO_APIShowsWhereAccessCameFrom(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "")
b := ssoBrowser(t, s)
signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "terdut-admins"))
+42 -12
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,35 +158,58 @@ 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))
// Invite links into this team.
// 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.With(opMode).Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(db))
// 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))
}
}
+16 -4
View File
@@ -237,6 +237,14 @@ type adminTeam struct {
CreatedAt time.Time `json:"created_at"`
Members int64 `json:"members"`
OpenIncidents int64 `json:"open_incidents"`
// OIDCMemberGroup and OIDCOwnerGroup are the team's own group binding,
// read-only here: an administrator can see why a team's OIDC-sourced
// membership looks the way it does without being able to change it out
// from under the team's owner. Setting it is PUT
// /api/teams/{teamID}/oidc-groups, owner-only.
OIDCMemberGroup string `json:"oidc_member_group,omitempty"`
OIDCOwnerGroup string `json:"oidc_owner_group,omitempty"`
}
// handleAdminListTeams lists every team on the server, with its size. The
@@ -248,7 +256,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
SELECT t.id, t.name, t.created_at,
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
(SELECT COUNT(*) FROM incidents i
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
FROM teams t
ORDER BY t.name`)
if err != nil {
@@ -261,7 +270,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var t adminTeam
var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); err != nil {
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
&t.OIDCMemberGroup, &t.OIDCOwnerGroup); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -298,10 +308,12 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
SELECT t.id, t.name, t.created_at,
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
(SELECT COUNT(*) FROM incidents i
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
FROM teams t
WHERE t.id = $1`, teamID).
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents)
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
&t.OIDCMemberGroup, &t.OIDCOwnerGroup)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("not found"))
return
+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".
+106
View File
@@ -327,6 +327,112 @@ func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
}
}
// A team's own OIDC group binding follows the same rule as its schedule and
// its integrations: an owner sets it, a member may only read it, an outsider
// learns nothing, and an administrator can still reach it to repair a team
// whose owner has left.
func TestTeamOIDCGroups_OwnerOnlyToEdit(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "sre") // team.call authenticates as its owner
// A plain member of the same team.
var plain struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": "plain", "email": "plain@test.com"}), &plain)
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
map[string]any{"user_id": plain.ID, "role": "member"})
resp.Body.Close()
var key struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(plain.ID)+"/api-keys",
map[string]string{"name": "test"}), &key)
memberCall := func(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.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
}
// A member of a different team altogether.
_, outsiderCall := member(t, s, "outsider")
path := "/api/teams/" + id64(team.id) + "/oidc-groups"
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "sre", "owner_group": "sre-leads"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("owner PUT: %d, want 204", resp.StatusCode)
}
var got struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &got)
if got.MemberGroup != "sre" || got.OwnerGroup != "sre-leads" {
t.Errorf("owner GET after PUT: %+v", got)
}
resp = memberCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("member GET: %d, want 200", resp.StatusCode)
}
resp = memberCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("member PUT: %d, want 403", resp.StatusCode)
}
// 404, not 403: whether the team exists is itself something only its
// members should learn.
resp = outsiderCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider GET: %d, want 404", resp.StatusCode)
}
resp = outsiderCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider PUT: %d, want 404", resp.StatusCode)
}
// An administrator who is not a member may still set it, the same bypass
// that lets one repair a team whose owner has left.
resp = s.req(t, http.MethodPut, path, map[string]string{"member_group": "sre2"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("admin PUT: %d, want 204", resp.StatusCode)
}
// An empty string clears a binding, stored as NULL rather than the literal
// empty string, so an empty group claim can never accidentally match it.
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "", "owner_group": ""})
resp.Body.Close()
var cleared struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &cleared)
if cleared.MemberGroup != "" || cleared.OwnerGroup != "" {
t.Errorf("cleared: %+v", cleared)
}
}
// A team is not somewhere an outsider can look, whatever they know about it.
func TestTeams_OutsiderSeesNothing(t *testing.T) {
s := newTS(t)
+17 -35
View File
@@ -1,7 +1,6 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"net/url"
@@ -73,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
@@ -112,25 +119,10 @@ type OIDC struct {
// AdminGroup grants the system administrator flag while the user is in it.
AdminGroup string
// GroupMappings grants team roles. A user in Group gets Role in Team.
GroupMappings []GroupMapping
// SessionMaxAge is the hard ceiling on a session made by an SSO login. The
// login is the only moment groups are re-read, so this is how long a change
// in the provider may take to reach terdut.
SessionMaxAge time.Duration
// parseErr is a malformed TERDUT_OIDC_GROUP_MAPPINGS, reported by Validate:
// Load cannot fail, and a mapping that was silently dropped would grant
// less access than the operator wrote down.
parseErr error
}
// GroupMapping grants Role in Team to members of Group.
type GroupMapping struct {
Group string `json:"group"`
Team string `json:"team"`
Role string `json:"role"`
}
// Enabled reports whether single sign-on is configured.
@@ -167,6 +159,8 @@ func Load() Config {
DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true),
OIDC: loadOIDC(),
OperatorMode: boolean("TERDUT_OPERATOR_MODE", false),
}
}
@@ -185,11 +179,6 @@ func loadOIDC() OIDC {
AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"),
SessionMaxAge: duration("TERDUT_OIDC_SESSION_MAX_AGE", 12*time.Hour),
}
if raw := strings.TrimSpace(os.Getenv("TERDUT_OIDC_GROUP_MAPPINGS")); raw != "" {
if err := json.Unmarshal([]byte(raw), &o.GroupMappings); err != nil {
o.parseErr = fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS: %w", err)
}
}
return o
}
@@ -199,14 +188,11 @@ func loadOIDC() OIDC {
// than not starting.
func (c Config) Validate() error {
o := c.OIDC
if o.parseErr != nil {
return o.parseErr
}
if !o.Enabled() {
if c.DisablePasswordLogin {
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
}
if len(o.GroupMappings) > 0 || o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
if o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
return errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not")
}
return nil
@@ -223,16 +209,12 @@ func (c Config) Validate() error {
if o.SessionMaxAge <= 0 {
return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive")
}
for i, m := range o.GroupMappings {
if m.Group == "" || m.Team == "" {
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: group and team are required", i)
}
if m.Role != "owner" && m.Role != "member" {
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: role must be owner or member, got %q", i, m.Role)
}
}
if c.DisablePasswordLogin && len(o.GroupMappings) == 0 && o.AdminGroup == "" {
return errors.New("TERDUT_PASSWORD_LOGIN=false with no OIDC group grants leaves nobody able to do anything")
// Team grants are no longer visible here: they live on each team's own
// oidc_member_group/oidc_owner_group columns, set by that team's owner, not
// in config Validate can see at startup. The one thing left to guard against
// is an install nobody can administer at all.
if c.DisablePasswordLogin && o.AdminGroup == "" {
return errors.New("TERDUT_PASSWORD_LOGIN=false with no TERDUT_OIDC_ADMIN_GROUP leaves nobody able to administer the install")
}
return nil
}
+1 -11
View File
@@ -28,16 +28,6 @@ func TestValidate(t *testing.T) {
{"missing secret", func(m map[string]string) { delete(m, "TERDUT_OIDC_CLIENT_SECRET") }, "CLIENT_SECRET"},
{"missing public url", func(m map[string]string) { delete(m, "TERDUT_PUBLIC_URL") }, "PUBLIC_URL"},
{"bad issuer", func(m map[string]string) { m["TERDUT_OIDC_ISSUER"] = "not a url" }, "not a URL"},
{"bad mapping json", func(m map[string]string) { m["TERDUT_OIDC_GROUP_MAPPINGS"] = "{nope" }, "GROUP_MAPPINGS"},
{"bad mapping role", func(m map[string]string) {
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","team":"t","role":"admin"}]`
}, "role must be"},
{"mapping needs team", func(m map[string]string) {
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","role":"member"}]`
}, "group and team"},
{"good mapping", func(m map[string]string) {
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","team":"t","role":"owner"}]`
}, ""},
{"password off without sso", func(m map[string]string) {
clear(m)
m["TERDUT_PASSWORD_LOGIN"] = "false"
@@ -56,7 +46,7 @@ func TestValidate(t *testing.T) {
tt.env(env)
for _, k := range []string{
"TERDUT_PUBLIC_URL", "TERDUT_PASSWORD_LOGIN", "TERDUT_OIDC_ISSUER", "TERDUT_OIDC_CLIENT_ID",
"TERDUT_OIDC_CLIENT_SECRET", "TERDUT_OIDC_ADMIN_GROUP", "TERDUT_OIDC_GROUP_MAPPINGS",
"TERDUT_OIDC_CLIENT_SECRET", "TERDUT_OIDC_ADMIN_GROUP",
} {
t.Setenv(k, env[k])
}
@@ -0,0 +1,26 @@
-- Per-team OIDC group configuration, replacing the global
-- TERDUT_OIDC_GROUP_MAPPINGS env var.
--
-- Group -> team -> role used to be one global list an operator set for the
-- whole install, matched against a team by name, and the sync would create
-- the team if no team by that name existed yet. That put the decision of
-- which group controls a team in the server's environment rather than the
-- team's own hands, meant changing it needed an env var edit and a restart,
-- and let a typo in a team name silently create a stray team.
--
-- Each team now names, itself, which group grants membership and which
-- grants ownership. Nullable: most teams need neither. No uniqueness
-- constraint on either column — two teams may legitimately watch the same
-- provider group (a broad team and a narrower one both keyed off overlapping
-- groups is a choice for their owners to make, not one the schema should
-- refuse).
--
-- BREAKING CHANGE, deliberately not auto-migrated: TERDUT_OIDC_GROUP_MAPPINGS
-- stops being read as of this version, and the sync no longer creates a team
-- by name. Every team's group binding must be set again through
-- PUT /api/teams/{teamID}/oidc-groups. Until an owner does that, an
-- OIDC-sourced membership in that team is dropped at that user's next SSO
-- sign-in, the same way any other loss of group access is handled. See the
-- README's OIDC section.
ALTER TABLE teams ADD COLUMN oidc_member_group TEXT;
ALTER TABLE teams ADD COLUMN oidc_owner_group TEXT;
@@ -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
}
+40 -14
View File
@@ -18,7 +18,9 @@ const (
roleMember = "member"
)
// Grants is the access a set of groups confers.
// Grants is the account-wide access a set of groups confers. Team access is a
// separate question — see TeamGroup and ComputeTeamGrants — because it is
// configured per team in the database, not in this package's cfg.
type Grants struct {
// Admitted is false when AllowedGroups is set and the user is in none of
// them. Nothing else in the struct means anything then.
@@ -26,21 +28,16 @@ type Grants struct {
// Admin is whether the user is in the admin group.
Admin bool
// Teams maps team name to role. Where several groups grant the same team the
// highest role wins, so belonging to both a members group and an owners
// group makes somebody an owner rather than whichever mapping came last.
Teams map[string]string
}
// ComputeGrants evaluates the configured mappings against groups.
// ComputeGrants evaluates the account-wide configuration against groups.
func ComputeGrants(cfg config.OIDC, groups []string) Grants {
in := make(map[string]bool, len(groups))
for _, g := range groups {
in[g] = true
}
g := Grants{Teams: map[string]string{}}
var g Grants
g.Admitted = len(cfg.AllowedGroups) == 0
for _, allowed := range cfg.AllowedGroups {
@@ -54,16 +51,45 @@ func ComputeGrants(cfg config.OIDC, groups []string) Grants {
}
g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup]
return g
}
for _, m := range cfg.GroupMappings {
if !in[m.Group] {
continue
// TeamGroup is one team's own OIDC binding: which group, if any, grants
// member access to it and which grants owner access, as read from
// teams.oidc_member_group / teams.oidc_owner_group.
type TeamGroup struct {
TeamID int64
MemberGroup string // "" means no group grants member access here.
OwnerGroup string // "" means no group grants owner access here.
}
// ComputeTeamGrants evaluates every team's own group binding against groups,
// and returns the role each team grants, keyed by team ID. A team absent from
// the result is not granted at all. Where a team's member and owner groups
// both match, the owner group wins — the same "highest role wins" rule that
// applied across the old global mapping list applies here across one team's
// two fields, so belonging to both groups makes somebody an owner rather than
// whichever field happened to be checked last.
func ComputeTeamGrants(teamGroups []TeamGroup, groups []string) map[int64]string {
in := make(map[string]bool, len(groups))
for _, g := range groups {
in[g] = true
}
out := map[int64]string{}
for _, tg := range teamGroups {
role := ""
if tg.MemberGroup != "" && in[tg.MemberGroup] {
role = roleMember
}
if rank(m.Role) > rank(g.Teams[m.Team]) {
g.Teams[m.Team] = m.Role
if tg.OwnerGroup != "" && in[tg.OwnerGroup] && rank(roleOwner) > rank(role) {
role = roleOwner
}
if role != "" {
out[tg.TeamID] = role
}
}
return g
return out
}
// rank orders roles; an unknown or absent role ranks lowest.
+76 -18
View File
@@ -11,11 +11,6 @@ func testCfg() config.OIDC {
return config.OIDC{
AllowedGroups: []string{"terdut-users"},
AdminGroup: "terdut-admins",
GroupMappings: []config.GroupMapping{
{Group: "sre", Team: "SRE", Role: "member"},
{Group: "sre-leads", Team: "SRE", Role: "owner"},
{Group: "platform", Team: "Platform", Role: "member"},
},
}
}
@@ -28,27 +23,17 @@ func TestComputeGrants(t *testing.T) {
{
name: "not in an allowed group is refused",
groups: []string{"sre", "terdut-admins"},
want: Grants{Admitted: false, Teams: map[string]string{}},
want: Grants{Admitted: false},
},
{
name: "allowed but no grants",
groups: []string{"terdut-users"},
want: Grants{Admitted: true, Teams: map[string]string{}},
want: Grants{Admitted: true},
},
{
name: "admin group grants admin",
groups: []string{"terdut-users", "terdut-admins"},
want: Grants{Admitted: true, Admin: true, Teams: map[string]string{}},
},
{
name: "team roles from several groups",
groups: []string{"terdut-users", "sre", "platform"},
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "member", "Platform": "member"}},
},
{
name: "highest role wins whatever the order",
groups: []string{"sre-leads", "terdut-users", "sre"},
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "owner"}},
want: Grants{Admitted: true, Admin: true},
},
}
for _, tt := range tests {
@@ -69,6 +54,79 @@ func TestComputeGrants_NoAllowedGroupsAdmitsEveryone(t *testing.T) {
}
}
// testTeamGroups is one SRE team keyed off two groups (a member group and a
// higher owner group) and one Platform team keyed off a member group only —
// the same shape the old global TERDUT_OIDC_GROUP_MAPPINGS example used.
func testTeamGroups() []TeamGroup {
return []TeamGroup{
{TeamID: 1, MemberGroup: "sre", OwnerGroup: "sre-leads"},
{TeamID: 2, MemberGroup: "platform"},
}
}
func TestComputeTeamGrants(t *testing.T) {
tests := []struct {
name string
teamGroups []TeamGroup
groups []string
want map[int64]string
}{
{
name: "no matching group grants nothing",
teamGroups: testTeamGroups(),
groups: []string{"terdut-users"},
want: map[int64]string{},
},
{
name: "member group grants member",
teamGroups: testTeamGroups(),
groups: []string{"sre"},
want: map[int64]string{1: roleMember},
},
{
name: "owner group grants owner",
teamGroups: testTeamGroups(),
groups: []string{"sre-leads"},
want: map[int64]string{1: roleOwner},
},
{
name: "in both of a team's groups, owner wins",
teamGroups: testTeamGroups(),
groups: []string{"sre", "sre-leads"},
want: map[int64]string{1: roleOwner},
},
{
name: "several teams from several groups",
teamGroups: testTeamGroups(),
groups: []string{"sre", "platform"},
want: map[int64]string{1: roleMember, 2: roleMember},
},
{
name: "two teams may share a group",
teamGroups: []TeamGroup{
{TeamID: 1, MemberGroup: "sre"},
{TeamID: 2, MemberGroup: "sre"},
},
groups: []string{"sre"},
want: map[int64]string{1: roleMember, 2: roleMember},
},
{
name: "a team with neither field set is never granted",
teamGroups: []TeamGroup{{TeamID: 1}},
groups: []string{"sre", "sre-leads", "platform"},
want: map[int64]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ComputeTeamGrants(tt.teamGroups, tt.groups)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %+v, want %+v", got, tt.want)
}
})
}
}
func TestStringList(t *testing.T) {
if got := stringList([]any{"a", "", 3, "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Errorf("list: %v", got)
+31 -1
View File
@@ -261,6 +261,33 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center;
}
/* ---------- team selector ---------- */
/* The global control for which team the app is scoped to. Hidden (via the
`hidden` attribute, set from teamselector.js) for anybody in fewer than two
teams, the same rule every other team-aware control in this file follows. */
.nav-team-selector,
.team-selector-mobile {
display: inline-flex; align-items: center; gap: 8px;
border: 1px solid var(--border-strong); border-radius: 999px;
background: var(--surface); color: var(--text);
font-size: 13px; font-weight: 600; cursor: pointer;
padding: 4px 12px; max-width: 100%;
}
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A team's identity colour — not a status, so never the severity palette. Six
colours, then they repeat; teamColorClass() in format.js picks one by the
team's id, the same rcN convention the rota's per-person chips use. */
.team-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); }
.team-dot.rc1 { background: var(--accent); }
.team-dot.rc2 { background: var(--ok); }
.team-dot.rc3 { background: var(--snooze); }
.team-dot.rc4 { background: var(--warn); }
.team-dot.rc5 { background: var(--teal); }
.team-dot.rc6 { background: var(--pink); }
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
.view-page { padding-left: 16px; padding-right: 16px; }
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
@@ -282,6 +309,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
}
.chips::-webkit-scrollbar { display: none; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
flex: none;
min-height: 34px; padding: 0 12px;
border: 1px solid var(--border-strong); border-radius: 999px;
@@ -611,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);
@@ -645,6 +675,7 @@ kbd {
display: flex; align-items: center; gap: 10px;
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
}
.nav-team-selector { margin: -8px 10px 14px; width: calc(100% - 20px); }
.nav-link {
flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
@@ -784,7 +815,6 @@ kbd {
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
.stacked-form label.checkbox { gap: 8px; }
.stacked-form input.wide { min-width: min(420px, 100%); }
.team-picker { margin-top: 8px; max-width: 100%; }
/* The rota, a month at a time. A name is too wide to print thirty times and
too alike down a column to read, so a day carries an initial in that
+4
View File
@@ -86,6 +86,9 @@
<img src="/icon.svg" alt="" width="28" height="28">
<span>terdut</span>
</a>
<!-- Which team the app is scoped to. Hidden unless the signed-in user is
in more than one; teamselector.js fills it in and wires the click. -->
<button class="nav-team-selector" id="team-selector" type="button" hidden></button>
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span class="nav-label">Queue</span>
@@ -126,6 +129,7 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
</button>
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
<h1 class="topbar-title" id="topbar-title">Queue</h1>
</div>
<span class="open-pill" id="open-pill" hidden></span>
+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() {
+7
View File
@@ -146,6 +146,13 @@ function identityCard() {
fact('Created', when(t.created_at)),
fact('Members', String(t.members)),
fact('Open incidents', String(t.open_incidents)),
// Read-only here: an administrator can see why a team's OIDC-sourced
// membership looks the way it does, but setting it is the team's own
// owner's call, from the Team tab.
...(state.auth?.oidc?.enabled ? [
fact('OIDC member group', t.oidc_member_group || '—'),
fact('OIDC owner group', t.oidc_owner_group || '—'),
] : []),
),
form, err, ok,
);
+4
View File
@@ -135,6 +135,10 @@ export const addTeamMember = (id, userID, role) =>
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
// Which OIDC groups grant member and owner access to this team.
export const oidcGroups = (id) => call('GET', `/teams/${id}/oidc-groups`);
export const setOidcGroups = (id, body) => call('PUT', `/teams/${id}/oidc-groups`, { body });
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
export const createIntegration = (id, name) =>
call('POST', `/teams/${id}/integrations`, { body: { name } });
+4
View File
@@ -11,6 +11,7 @@ import * as alerts from './alerts.js';
import * as stats from './stats.js';
import * as account from './account.js';
import * as team from './team.js';
import * as teamselector from './teamselector.js';
import * as admin from './admin.js';
import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js';
@@ -230,6 +231,7 @@ async function boot() {
$('login-form').addEventListener('submit', onLogin);
$('signup-form').addEventListener('submit', onSignup);
$('menu-btn').addEventListener('click', openNavMenu);
teamselector.init();
ssoErrorCode = takeSSOError();
// /signup is the one route that works without a session.
@@ -245,6 +247,7 @@ async function boot() {
await loadAuthConfig();
state.me = await api.me();
await loadTeams();
teamselector.render();
// The Admin tab exists only for an administrator. Somebody who types /admin
// anyway gets the view's own "ask an administrator" card, not a blank page.
$('nav-admin').hidden = !state.me?.user?.is_admin;
@@ -328,6 +331,7 @@ async function onSignup(e) {
history.replaceState({ depth: 0 }, '', '/');
route = parseRoute('/');
await loadTeams();
teamselector.render();
$('nav-admin').hidden = !state.me?.user?.is_admin;
showApp();
} catch (ex) {
+8
View File
@@ -98,6 +98,14 @@ export function severityClass(sev) {
return '';
}
// A stable identity colour for a team, so the same team always reads the same
// colour without the server needing to store one. Teams have no colour field;
// this hashes the id into the six-colour rcN palette app.css already has for
// the rota's per-person chips (a team is not a status, so never severity).
export function teamColorClass(teamID) {
return `rc${(((teamID % 6) + 6) % 6) + 1}`;
}
// A one-line summary of the group labels, without the one the title already shows.
export function labelSummary(labels, skip = 'alertname') {
return Object.entries(labels || {})
+20 -32
View File
@@ -2,8 +2,8 @@
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
import { state, myID } from './state.js';
import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js';
@@ -27,34 +27,21 @@ const EMPTY = {
};
onboarding.onRerender(() => renderList());
// The queue used to keep its own team filter (a per-tab sessionStorage value,
// out of step with team.js's own picker); both now defer to the global
// selector's shared state, so re-render whenever it changes.
onTeamChange(() => {
renderChips();
refresh({ fresh: true });
});
let filter = loadFilter();
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
let items = null; // null while loading
let error = null;
let selected = null;
let cursor = -1; // keyboard position in the list
let built = false;
function loadTeamFilter() {
try {
return sessionStorage.getItem('terdut.queue.team') || '';
} catch {
return '';
}
}
function setTeamFilter(id) {
teamFilter = id;
try {
sessionStorage.setItem('terdut.queue.team', id);
} catch {
/* storage unavailable */
}
renderChips();
refresh({ fresh: true });
}
function loadFilter() {
try {
const f = sessionStorage.getItem('terdut.queue.filter');
@@ -89,8 +76,8 @@ export async function refresh({ fresh = false } = {}) {
// The open list is already fetched for the badges; no need to ask twice.
// The cached open queue covers every team, so it can only be reused when
// no team filter is applied.
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
const cached = filter === 'open' && !fresh && !teamFilter;
const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query;
const cached = filter === 'open' && !fresh && state.selectedTeamID == null;
const result = cached ? state.open : await api.incidents(query);
await onboarding.load();
if (requested !== filter) return;
@@ -136,19 +123,20 @@ function renderChips() {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(teamFilter === ''),
onclick: () => setTeamFilter(''),
text: 'All teams',
}));
'aria-selected': String(state.selectedTeamID == null),
onclick: () => setSelectedTeam(null),
}, h('span', { class: 'team-dot' }), ' All teams'));
for (const team of state.teams) {
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(teamFilter === String(team.id)),
onclick: () => setTeamFilter(String(team.id)),
text: team.name,
}));
'aria-selected': String(team.id === state.selectedTeamID),
onclick: () => setSelectedTeam(team.id),
},
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
' ' + team.name,
));
}
}
+50 -3
View File
@@ -10,12 +10,53 @@ export const state = {
auth: { password_login: true, oidc: { enabled: false, name: '' } },
open: [], // the default queue: open, not snoozed
teams: [], // the teams the viewer belongs to, each with their role
// Which team the whole app is scoped to right now; null means "All teams".
// Set only through setSelectedTeam below, never assigned directly, so every
// view stays in sync and the choice is remembered across reloads.
selectedTeamID: loadSelectedTeam(),
};
// The team whose schedule and settings the views act on. A viewer in one team —
// which is everybody until somebody makes a second — never has to choose.
const SELECTED_TEAM_KEY = 'terdut.selectedTeam';
function loadSelectedTeam() {
try {
const raw = localStorage.getItem(SELECTED_TEAM_KEY);
return raw ? Number(raw) : null;
} catch {
return null; // storage unavailable, or nothing saved yet
}
}
// Callbacks to run whenever the selected team changes, so every view that
// cares — the queue's filter, the Team settings page, the selector's own
// trigger — stays in sync without a general event bus, following the one
// precedent for this in the codebase: onboarding.js's onRerender.
const teamListeners = [];
export function onTeamChange(cb) {
teamListeners.push(cb);
}
// setSelectedTeam changes which team the app is scoped to (id, or null for
// "All teams"), persists it — a durable preference, unlike the per-tab
// sessionStorage filter this replaces — and tells every registered listener.
export function setSelectedTeam(id) {
state.selectedTeamID = id;
try {
if (id == null) localStorage.removeItem(SELECTED_TEAM_KEY);
else localStorage.setItem(SELECTED_TEAM_KEY, String(id));
} catch {
/* storage unavailable */
}
for (const cb of teamListeners) cb();
}
// The team whose schedule and settings the views act on: the selected team,
// falling back to the first one the viewer belongs to — which is everybody's
// only team until somebody makes a second, or the stored selection naming a
// team the account has since left.
export function currentTeam() {
return state.teams[0] || null;
const teams = state.teams || [];
return teams.find((t) => t.id === state.selectedTeamID) || teams[0] || null;
}
export function myID() {
@@ -36,6 +77,12 @@ export async function users() {
export async function loadTeams() {
state.teams = await api.teams();
// A stored id that no longer names one of the account's teams — left it, or
// this is simply a different account signed in on the same browser — is as
// good as unset.
if (state.selectedTeamID != null && !state.teams.some((t) => t.id === state.selectedTeamID)) {
state.selectedTeamID = null;
}
return state.teams;
}
+92 -28
View File
@@ -19,7 +19,7 @@
import * as api from './api.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js';
import { state, currentTeam, users as allUsers, myID } from './state.js';
import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
const view = () => document.getElementById('view-team');
@@ -41,6 +41,9 @@ export const TABS = [
{ tab: 'deadman', path: '/team/deadman', label: 'Switches', title: 'Dead man’s switches' },
];
// Cached from currentTeam() on each refresh(), for the many actions below
// (assignSchedule, addTeamMember, ...) that need a plain id rather than a
// round trip through state.
let teamID = null;
// Which sub-section is open. Remembered rather than passed, because the poll
// loop calls refresh() with no route.
@@ -49,6 +52,13 @@ let data = null; // { team, ... }; which fields are present varies by tab
let error = null;
let freshKey = null; // an integration key, shown once, until the view is left
// The global team selector is what changes which team this page shows now;
// re-fetch under whichever sub-section is open when it fires.
onTeamChange(() => {
data = null;
refresh();
});
export function show(route) {
const next = route?.tab ?? null;
// A different sub-section wants different data, so the old answer goes
@@ -61,13 +71,8 @@ export function show(route) {
refresh();
}
function selectedTeam() {
const teams = state.teams || [];
return teams.find((t) => t.id === teamID) || currentTeam();
}
export async function refresh() {
const team = selectedTeam();
const team = currentTeam();
if (!team) {
data = null;
render();
@@ -101,8 +106,12 @@ async function load(id) {
return { members, schedule };
}
if (tab === 'members') {
const [members, users] = await Promise.all([api.teamMembers(id), allUsers()]);
return { members, users };
const [members, users, oidcGroups] = await Promise.all([
api.teamMembers(id),
allUsers(),
state.auth?.oidc?.enabled ? api.oidcGroups(id) : null,
]);
return { members, users, oidcGroups };
}
if (tab === 'escalation') {
const [members, escalation] = await Promise.all([api.teamMembers(id), api.escalation(id)]);
@@ -168,24 +177,12 @@ function subnav() {
})));
}
// Only shown to somebody in more than one team, like the queue's filter chips.
// It is above the sections rather than inside one because it changes the
// subject of all six.
// Names which team's settings the six sections below belong to. It used to be
// a picker of its own for somebody in more than one team; that job now belongs
// to the global team selector in the nav, which is what onTeamChange above
// reacts to.
function teamPicker() {
if ((state.teams || []).length < 2) {
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
}
const select = h('select', { class: 'team-picker' },
...state.teams.map((t) => h('option', {
value: String(t.id), text: t.name, selected: t.id === teamID,
})));
select.addEventListener('change', () => {
teamID = Number(select.value);
data = null;
freshKey = null;
refresh();
});
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
}
// --- overview --------------------------------------------------------------
@@ -1046,6 +1043,73 @@ function shiftCell(m) {
: h('span', { text: 'not scheduled' });
}
// The team's own OIDC group binding, shown only on an SSO-enabled install:
// which group grants membership and which grants ownership. Read-only text
// for a member, an edit sheet for an owner — the server enforces the same
// split on the endpoint underneath.
function oidcGroupsCard() {
if (!state.auth?.oidc?.enabled) return null;
const g = data.oidcGroups || { member_group: '', owner_group: '' };
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Single sign-on' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
})),
h('p', { class: 'muted small' },
'Members of the group below are added to this team automatically at ',
'sign-in; members of the owner group become owners. Leave a field ',
'blank to grant nothing this way.'),
h('dl', { class: 'user-facts' },
fact('Member group', g.member_group || '—'),
fact('Owner group', g.owner_group || '—'),
),
);
}
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
}
function openOidcGroupsEditor() {
const g = data.oidcGroups || { member_group: '', owner_group: '' };
const memberGroup = h('input', {
type: 'text', value: g.member_group, placeholder: 'e.g. sre', autofocus: true,
});
const ownerGroup = h('input', { type: 'text', value: g.owner_group, placeholder: 'e.g. sre-leads' });
const problem = h('p', { class: 'load-error', hidden: true });
const form = h('form', { class: 'stacked-form' },
h('label', {}, 'Member group ', memberGroup),
h('label', {}, 'Owner group ', ownerGroup),
h('p', { class: 'muted small' },
'A person in both becomes an owner. Whoever the group lists is kept in ',
'sync at their next sign-in — a member added by hand can still be made ',
'an owner, but not the other way round.'),
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save' })));
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api.setOidcGroups(teamID, {
member_group: memberGroup.value.trim(),
owner_group: ownerGroup.value.trim(),
});
} catch (err) {
problem.textContent = err.message;
problem.hidden = false;
return;
}
closeSheet(true);
refresh();
});
openSheet(() => [h('h2', { class: 'sheet-title', text: 'Single sign-on groups' }), form]);
}
function membersCard() {
const members = data.members || [];
const owners = members.filter((m) => m.role === 'owner').length;
@@ -1090,7 +1154,7 @@ function membersCard() {
);
});
return h('div', { class: 'card' },
return [oidcGroupsCard(), h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Members' }),
isOwner() && h('button', {
@@ -1108,7 +1172,7 @@ function membersCard() {
h('th'))),
h('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
);
)];
}
// One sheet for both jobs a member's row has: who, and as what. Adding is
+63
View File
@@ -0,0 +1,63 @@
// The global team selector: a small control, once per layout (the desktop
// sidebar and the mobile topbar each have their own button in index.html),
// showing the current team's colour and name — or "All teams" — and opening a
// sheet to switch. Shown only once there is more than one team to choose
// between, the same rule every other team-aware control in this app follows;
// see state.js's currentTeam() for why nobody with just one ever has to.
import { h, openSheet, closeSheet } from './ui.js';
import { state, currentTeam, setSelectedTeam, onTeamChange } from './state.js';
import { teamColorClass } from './format.js';
// Not a real team id (ids are positive), so it can never collide with one —
// the value closeSheet resolves with for "All teams", distinct from the null
// a dismissed sheet resolves with.
const ALL_TEAMS = '__all__';
const buttons = () => [
document.getElementById('team-selector'),
document.getElementById('team-selector-mobile'),
].filter(Boolean);
// init wires the buttons once, at boot. render (below) is what actually fills
// them in and is called again by state.js whenever the selection changes.
export function init() {
for (const btn of buttons()) btn.addEventListener('click', open);
onTeamChange(render);
}
export function render() {
const multiTeam = (state.teams || []).length > 1;
const team = currentTeam();
const label = team ? team.name : 'All teams';
const dotClass = team ? `team-dot ${teamColorClass(team.id)}` : 'team-dot';
for (const btn of buttons()) {
btn.hidden = !multiTeam;
btn.replaceChildren(
h('span', { class: dotClass }),
h('span', { class: 'team-selector-label', text: label }),
);
}
}
function open() {
const teams = state.teams || [];
openSheet(() => [
h('h2', { class: 'sheet-title', text: 'Switch team' }),
h('ul', { class: 'menu', role: 'menu' },
h('li', {}, h('button', {
class: 'menu-item', type: 'button', role: 'menuitemradio',
'aria-checked': String(state.selectedTeamID == null),
onclick: () => closeSheet(ALL_TEAMS),
}, h('span', { class: 'team-dot' }), ' All teams')),
teams.map((t) => h('li', {}, h('button', {
class: 'menu-item', type: 'button', role: 'menuitemradio',
'aria-checked': String(t.id === state.selectedTeamID),
onclick: () => closeSheet(t.id),
}, h('span', { class: `team-dot ${teamColorClass(t.id)}` }), ' ' + t.name))),
),
]).then((choice) => {
if (choice == null) return; // dismissed: backdrop, escape, or cancel
setSelectedTeam(choice === ALL_TEAMS ? null : choice);
});
}