Compare commits
102 Commits
4e8c52c28c
...
v0.41.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a96b20cbe | |||
| 7b9d309d13 | |||
| 7412456c5a | |||
| 065b557860 | |||
| a8dc89e23d | |||
| ead5df1574 | |||
| 3ced069134 | |||
| 0f88574a41 | |||
| 3613fd5732 | |||
| dc62278788 | |||
| 0aaea8efb5 | |||
| 584d3441fc | |||
| 926aa2d3ec | |||
| a2ca9c25d0 | |||
| 92959cac38 | |||
| f15db0e20a | |||
| b82c10acf4 | |||
| 7cd6fbf571 | |||
| df83adfe47 | |||
| 9da913080f | |||
| 2b2609e98f | |||
| fa6d82d6e5 | |||
| 7efd1bbba7 | |||
| 3bf94a5d7f | |||
| 5c4e0bdd0e | |||
| 9e5b085d8b | |||
| 0050738ca0 | |||
| 42180948d1 | |||
| c83c7c2a8b | |||
| 43beda9a30 | |||
| 710521a73c | |||
| d9492913ed | |||
| 1770e5d945 | |||
| 497086cb51 | |||
| e3090d2779 | |||
| 91f03c21e8 | |||
| 4358e84b24 | |||
| f45dc2f925 | |||
| 774fdfcaa8 | |||
| fd26fef1ba | |||
| a9d788cc83 | |||
| 4b15079ac2 | |||
| fc9f47cc8d | |||
| bc9f793f1f | |||
| 871274a3a0 | |||
| 6f8499fa42 | |||
| ef731e85c5 | |||
| a4dd60f6b8 | |||
| b5573fbca2 | |||
| 0ee576f793 | |||
| b610b1817a | |||
| 949d6595ba | |||
| 33356ca978 | |||
| e5b4df7c03 | |||
| 5b4683febf | |||
| 97a4814c04 | |||
| a2dc9e3b03 | |||
| 155f27ca62 | |||
| a27ff49171 | |||
| c5be55dcbc | |||
| b2c3868619 | |||
| 36c00acf62 | |||
| 9d1df2b611 | |||
| 9bf4c92bfe | |||
| e616c82646 | |||
| 2b396d22d6 | |||
| 1f1faa437c | |||
| dc92f51cf8 | |||
| d675f8ec9b | |||
| e8d45f9d3d | |||
| f3918b863c | |||
| 3ee8583f6f | |||
| 591d5b8df0 | |||
| d2cdcc9776 | |||
| 8b2789b9b2 | |||
| 71d7e1853a | |||
| 60ebb75cd2 | |||
| 734cd9c5fd | |||
| 423ed9b3a3 | |||
| 43f004499b | |||
| 559be6de6e | |||
| e77f04b55e | |||
| e536fdd2c0 | |||
| 429d5fdda3 | |||
| 3cdd5aee1f | |||
| 6a03698f65 | |||
| 67d68ce058 | |||
| a6fa673e08 | |||
| ee22eb000c | |||
| 07914d5cdb | |||
| 7b9a337d25 | |||
| fc8b0c8d58 | |||
| 828cf87656 | |||
| ac9af8e4f5 | |||
| 8869ac864f | |||
| 0677e74cf8 | |||
| 56b8191a78 | |||
| 93761056eb | |||
| a92da7dcc0 | |||
| b39aac36b7 | |||
| 19f168ab7e | |||
| d827ceedff |
@@ -125,6 +125,9 @@ jobs:
|
||||
- name: Secret scan (gitleaks)
|
||||
run: make security-secrets
|
||||
|
||||
- name: Code security scan (gosec)
|
||||
run: make security-code
|
||||
|
||||
# Host mode, no `container:`: helm is baked into the runner image, and a container job
|
||||
# could not install it -- get.helm.sh is unreachable from the dind bridge. Same reason
|
||||
# release.yaml's chart job runs on the host.
|
||||
|
||||
+12
@@ -16,6 +16,18 @@ 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
|
||||
# Numeric, not a name: scratch has no /etc/passwd for one to resolve against,
|
||||
# and Docker's USER accepts a bare UID:GID without it. 65532 is the common
|
||||
# "nonroot" convention (distroless's own uid), chosen so the chart's pod
|
||||
# securityContext (runAsNonRoot, runAsUser: 65532) matches what the image
|
||||
# already runs as rather than fighting it.
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["/terdut"]
|
||||
|
||||
@@ -143,6 +143,7 @@ BUILDX_BUILDER ?= terdut
|
||||
TRIVY_VERSION := 0.73.0
|
||||
GOVULNCHECK_VERSION := v1.1.4
|
||||
GITLEAKS_VERSION := v8.30.0
|
||||
GOSEC_VERSION := v2.29.0
|
||||
|
||||
# --pull, not --no-cache: refresh the base image without discarding the layer cache.
|
||||
DOCKER_BUILD_FLAGS ?= --pull
|
||||
@@ -222,6 +223,26 @@ release: push helm-package helm-push ## Publish image + chart (the workflow's on
|
||||
security-go: ## Scan Go deps for known CVEs (govulncheck)
|
||||
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
|
||||
|
||||
# Code-level, not dependency- or secret-level: gosec reads this repo's own source for
|
||||
# known-dangerous patterns (weak crypto, SQL/command injection shapes, insecure file
|
||||
# permissions, …) rather than its module graph or working tree for leaked credentials,
|
||||
# which is what security-go and security-secrets above already cover.
|
||||
#
|
||||
# G104 (unchecked error) is excluded. Every hit it found here on first run was this
|
||||
# codebase's existing, deliberate idiom for a best-effort write or an already-reviewed
|
||||
# json.Unmarshal of this server's own JSONB (see the "best-effort" comments in
|
||||
# middleware.go and the //nolint:errcheck lines in alerts.go/deadman.go) -- a style that
|
||||
# predates gosec and that G104 cannot distinguish from a mistake. Reaching the same
|
||||
# green result by adding a dozens of individual #nosec comments would not add
|
||||
# information; it would just make a future *real* G104 regression one more suppressed
|
||||
# line instead of a visible one. Same reasoning as the chi-advisories note on
|
||||
# security-go above: what gosec reports here (nothing, beyond G104) is the useful
|
||||
# property, not a loophole. -exclude-generated skips web.go's embedded, build-time-only
|
||||
# assets.
|
||||
.PHONY: security-code
|
||||
security-code: ## Scan this repo's own source for risky patterns (gosec)
|
||||
go run github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION) -exclude-generated -exclude=G104 ./...
|
||||
|
||||
# --no-git scans the working tree rather than the history, so this catches a secret on the
|
||||
# way in. It is not a history audit and finding nothing here says nothing about what is
|
||||
# already committed. --redact because the finding is printed into a CI log.
|
||||
|
||||
@@ -47,12 +47,13 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
|
||||
|
||||
The server serves a web UI at `/`: the incident queue, each incident's alerts
|
||||
and timeline with every action (acknowledge, assign, snooze, note, resolve,
|
||||
archive), who is on call, the alert feed, and changing your own password. It is
|
||||
built for a phone first. On a phone it has a bottom tab bar and a sticky action
|
||||
bar, it follows the system's dark mode, and it can be added to the home screen.
|
||||
From 900px wide it switches to a sidebar with the queue and the incident side by
|
||||
side. Schedule editing, statistics and user management remain in
|
||||
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
||||
archive), who is on call, the alert feed, and an *Account* tab for your own
|
||||
password and the ntfy topic your pages go to. It is built for a phone first. On a phone
|
||||
it navigates through a hamburger menu and has a sticky action bar, it follows the
|
||||
system's dark mode, and it can be added to the home screen. From 900px wide it switches
|
||||
to a sidebar with the queue and the incident side by side. The Stats page shows
|
||||
incident counts, MTTA and MTTR, and alert frequency by name, hour and day over a
|
||||
chosen range.
|
||||
|
||||
You sign in with a username and password. Users have no password until one is
|
||||
set, and a user without one can only use API keys:
|
||||
@@ -85,15 +86,155 @@ How a browser stays signed in:
|
||||
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
|
||||
the web UI (`/incidents/{id}`).
|
||||
|
||||
A **Team** tab holds everything a team owns: the on-call rota, the escalation
|
||||
ladder, the alert sources with their keys, the dead man's switches and the
|
||||
membership. An owner edits it; a member sees the same page read-only, because
|
||||
the server refuses their writes anyway. Somebody in more than one team picks
|
||||
between them at the top.
|
||||
A **Team** tab holds everything a team owns, in five sub-sections with a URL
|
||||
each and a strip across the top to move between them: the on-call rota
|
||||
(`/team/rota`), the membership (`/team/members`), the escalation ladder
|
||||
(`/team/escalation`), the alert sources with their keys (`/team/sources`) and
|
||||
the dead man's switches (`/team/deadman`). `/team` itself is an overview — who
|
||||
is on call today, how many members and owners, how many ladder levels, how many
|
||||
keys and how many switches — so a page fetches only what it shows. An owner
|
||||
edits it; a member sees the same pages read-only, because the server refuses
|
||||
their writes anyway. Somebody in more than one team picks between them above
|
||||
the strip, since the choice changes the subject of all five.
|
||||
|
||||
The rota is a month at a time, one coloured initial per day with a legend
|
||||
underneath, and it says how many days are left uncovered — the question a rota
|
||||
is read for is who holds which stretch, and a run of one colour answers it
|
||||
where a list of dates does not. An owner taps a day to hand it to somebody or
|
||||
empty it, and fills a whole shift from the range form folded in below.
|
||||
|
||||
The **Admin** tab appears only for a system administrator, and holds what
|
||||
belongs to the whole server rather than to one team: every team, every user, and
|
||||
the settings that used to be environment variables.
|
||||
belongs to the whole server rather than to one team. It has three sub-sections,
|
||||
each with a URL of its own and a strip across the top to move between them:
|
||||
every team (`/admin/teams`), every user (`/admin/users`), and the settings that
|
||||
used to be environment variables (`/admin/settings`). `/admin` itself is an
|
||||
overview — how many of each, and what each section is for. Adding somebody is
|
||||
minting them an invite link into a team, rather than creating a bare account:
|
||||
the person who accepts it picks their own password, so one never passes through
|
||||
an administrator, and the link carries the team, so they land somewhere with a
|
||||
queue in it. That happens on the team's own page, since an invite is a fact
|
||||
about a team; the user list points there rather than asking which team beside a
|
||||
form.
|
||||
|
||||
A name in the team list opens **that team's page**, at `/admin/teams/{id}`: when it
|
||||
was created, how many are in it and how much is open, a field to rename it, the
|
||||
members with their roles, the invites into it, and deletion. The member list is the
|
||||
one thing there that needed a new endpoint — `GET /api/teams/{id}/members` is
|
||||
member-only and answers `404` to an administrator who is not in the team, which is
|
||||
the rule and not an oversight, so the page reads `GET /api/admin/teams/{id}` instead.
|
||||
An administrator still sees none of that team's incidents, alerts or rota.
|
||||
|
||||
A name in the user list opens **that person's page**, at `/admin/users/{id}`: their
|
||||
email and when they joined, where their notifications go, whether they are an
|
||||
administrator, whether the account is disabled, the teams they are in with their
|
||||
role in each, a password field for a first or forgotten one, and deletion. It is
|
||||
the one place membership is edited from the person's side — the Team tab answers
|
||||
"who is in this team", and answering "which teams is this person in" there means
|
||||
visiting each team in turn.
|
||||
|
||||
### Single sign-on (OIDC)
|
||||
|
||||
terdut can sign people in through any OpenID Connect provider; the examples use
|
||||
[Authentik](https://goauthentik.io/). Groups at the provider decide who may sign
|
||||
in, which teams they belong to and whether they administer the install, much as
|
||||
Grafana's OAuth role and org mapping does. Password login keeps working alongside
|
||||
it unless you turn it off.
|
||||
|
||||
**At the provider**, create an OAuth2/OpenID provider and an application for it:
|
||||
a *confidential* client, redirect URI `<TERDUT_PUBLIC_URL>/api/oidc/callback`, and
|
||||
the `openid`, `profile` and `email` scopes. The issuer is the application's, e.g.
|
||||
`https://auth.example.com/application/o/terdut/`. Then set:
|
||||
|
||||
```sh
|
||||
TERDUT_PUBLIC_URL=https://terdut.example.com
|
||||
TERDUT_OIDC_ISSUER=https://auth.example.com/application/o/terdut/
|
||||
TERDUT_OIDC_CLIENT_ID=terdut
|
||||
TERDUT_OIDC_CLIENT_SECRET=...
|
||||
TERDUT_OIDC_ALLOWED_GROUPS=terdut-users,terdut-admins
|
||||
TERDUT_OIDC_ADMIN_GROUP=terdut-admins
|
||||
```
|
||||
|
||||
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
|
||||
(`password_login`, `oidc.enabled`, `oidc.name`). A refused sign-in comes back to that
|
||||
page with the reason spelled out. Access the groups grant is badged **SSO** on the
|
||||
Team, Admin and per-user pages, with its edit and remove controls disabled, and the
|
||||
Account page does not offer to set a password nobody could use.
|
||||
|
||||
**What a sign-in does**
|
||||
|
||||
1. *Who.* The provider's `(issuer, subject)` is the identity. The first time, a
|
||||
user is found by email — only when the provider marks it verified, or
|
||||
`TERDUT_OIDC_TRUST_EMAIL` is set — or created with no password. A username taken
|
||||
by somebody else gets a numeric suffix (`alice-2`). Username and email follow the
|
||||
provider at each sign-in. Authentik reports `email_verified` as false unless
|
||||
configured otherwise, so linking existing users usually needs
|
||||
`TERDUT_OIDC_TRUST_EMAIL=true`.
|
||||
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 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
|
||||
next sign-in after the group is gone, even if that leaves a team without an owner
|
||||
(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 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
|
||||
sliding never extends, so a change at the provider reaches terdut within that time.
|
||||
Password sessions are unaffected.
|
||||
|
||||
> **API keys are not revoked when somebody is removed at the provider.** terdut
|
||||
> holds no refresh token and never asks the provider again, so a person removed
|
||||
> from every allowed group loses their sessions within `TERDUT_OIDC_SESSION_MAX_AGE`
|
||||
> and cannot sign in again, but keeps any API key they made (the TUI and scripts use
|
||||
> them) until an administrator disables the user in terdut.
|
||||
|
||||
**Signing in from a terminal.** A client with no browser of its own, such as the
|
||||
TUI over SSH, signs in with a device code, run by terdut itself so the terminal
|
||||
never talks to the provider:
|
||||
|
||||
1. The terminal calls `POST /api/oidc/device` and shows the person a link
|
||||
(`<TERDUT_PUBLIC_URL>/device?code=XXXX-XXXX`) and the code.
|
||||
2. On any device the person opens the link, signs in (by the provider or by
|
||||
password, whatever the login page offers), sees the code and the account, and
|
||||
presses **Approve**. Only a browser session can approve; an API key cannot.
|
||||
3. The terminal polls `POST /api/oidc/device/token` every 5 seconds and is given the
|
||||
ordinary `terdut_session` cookie once. A person who signs in through the provider
|
||||
gets the same `TERDUT_OIDC_SESSION_MAX_AGE` ceiling on the terminal's session as
|
||||
on their browser's.
|
||||
|
||||
A login expires after 10 minutes. `GET /api/auth/config` reports `device_login`.
|
||||
|
||||
**If the provider is down**, terdut still starts (discovery is fetched on first
|
||||
use) and password login is the way in. With `TERDUT_PASSWORD_LOGIN=false` that way
|
||||
is closed: set it back to `true`. The first administrator comes from the bootstrap
|
||||
endpoint, and stays a manual administrator that no group can revoke; on an SSO-only
|
||||
install set `bootstrap.enabled: false` in the chart if you don't want that account,
|
||||
or keep it and never give it a password.
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -192,6 +333,17 @@ over an administrator's edit.
|
||||
| `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call |
|
||||
| `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 |
|
||||
| `TERDUT_OIDC_SCOPES` | `openid profile email` | Scopes requested, comma or space separated. Authentik puts `groups` behind `profile` |
|
||||
| `TERDUT_OIDC_USERNAME_CLAIM` / `_EMAIL_CLAIM` / `_GROUPS_CLAIM` | `preferred_username` / `email` / `groups` | ID token claims read for the username, email and groups |
|
||||
| `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_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.
|
||||
|
||||
@@ -199,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, and notifications via the `notify.*` values.
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -339,10 +491,20 @@ exactly as it was rather than with a hole in it.
|
||||
### Push notifications
|
||||
|
||||
With `TERDUT_NTFY_URL` set, an incident that opens is pushed to the on-call
|
||||
person's phone through [ntfy](https://ntfy.sh). Set each user's topic with
|
||||
`PUT /api/users/{id}/notify`; a user with no topic falls back to
|
||||
`TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with nobody on call.
|
||||
If neither yields a topic, nothing is queued.
|
||||
person's phone through [ntfy](https://ntfy.sh). Everybody sets their own topic
|
||||
under *Account* in the web UI, where a **Send a test push** button proves it
|
||||
before an incident has to; `PUT /api/users/{id}/notify` is the same thing over
|
||||
the API, and an administrator may set somebody else's. A user with no topic
|
||||
falls back to `TERDUT_NTFY_FALLBACK_TOPIC`, as does an incident that opens with
|
||||
nobody on call. If neither yields a topic, nothing is queued.
|
||||
|
||||
The **server** is the install's one ntfy, from `TERDUT_NTFY_URL`, and is not
|
||||
something a user picks. Only the topic is per-person.
|
||||
|
||||
A topic is a shared secret with the ntfy server: anyone who knows it can both
|
||||
read the pages and publish to it, so an unguessable one is worth the trouble.
|
||||
That is also why the topic never appears in an incident's timeline, which every
|
||||
API key can read.
|
||||
|
||||
Three things get pushed:
|
||||
|
||||
@@ -466,19 +628,27 @@ nothing unless something downstream notices it stop. That is what
|
||||
`TERDUT_DEADMAN_MATCHERS` defaults to.
|
||||
|
||||
**Switches belong to a team**, which decides which of its own alerts are
|
||||
heartbeats and how long a silence has to last. An owner sets them through
|
||||
`PUT /api/teams/{teamID}/deadman`; a missed heartbeat opens an incident in the
|
||||
team whose integration received it.
|
||||
heartbeats and how long a silence has to last. Each **switch** is a row of its
|
||||
own — a name, one matcher, a timeout and a severity — so switches in one team
|
||||
can have different deadlines. An owner adds and removes them on **Team →
|
||||
Switches**, which lists each with a status (**healthy**, **dead**, or
|
||||
**dormant** until its first heartbeat), when it was last heard from, and when it
|
||||
last opened an incident; a matcher that several clusters satisfy is broken down
|
||||
per cluster. The API is `POST`/`DELETE /api/teams/{teamID}/deadman/switches`. A
|
||||
missed heartbeat opens an incident in the team whose integration received it.
|
||||
Removing a switch stops the watching; an incident it already opened stays open
|
||||
until somebody resolves it.
|
||||
|
||||
The environment variables are the starting point, not the setting: at startup
|
||||
every team **without** a configuration of its own is given one from them, and an
|
||||
owner's later edit is never overwritten by a redeploy. A team created after
|
||||
that starts watching nothing until its owner says otherwise — inheriting an
|
||||
install-wide heartbeat would page a new team about a source it has never heard
|
||||
of.
|
||||
The environment variables are the starting point, not the setting: the **first**
|
||||
time the server starts, every team is given a switch per default matcher from
|
||||
them, once. After that a team's switches are its own — an owner's edit or
|
||||
deletion is never put back by a redeploy. A team created later starts watching
|
||||
nothing until its owner says otherwise — inheriting an install-wide heartbeat
|
||||
would page a new team about a source it has never heard of.
|
||||
|
||||
A matcher is a set of exact label conditions, one of which must be the
|
||||
`alertname`, in the same format the environment variable uses:
|
||||
`alertname`, in the format the environment variable uses (one matcher per switch; the
|
||||
variable takes several, separated by `;`):
|
||||
|
||||
```
|
||||
alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat
|
||||
@@ -535,7 +705,9 @@ of the last heartbeat, and the heartbeat's labels are on the incident's
|
||||
### Authentication
|
||||
|
||||
All endpoints except `/api/bootstrap`, `/api/integrations/{key}/alertmanager`,
|
||||
`/api/notify/ack/{token}`, `/api/login` and `/api/logout` require either an API key:
|
||||
`/api/notify/ack/{token}`, `/api/login`, `/api/logout`, `/api/auth/config`,
|
||||
`/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>
|
||||
@@ -550,6 +722,32 @@ 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:
|
||||
|
||||
- `invite_only` (the default) — a team owner mints a link with
|
||||
`POST /api/teams/{teamID}/invites`, and the person who opens it picks a
|
||||
username and password and lands in that team with the role the link carries.
|
||||
Links are single-use unless told otherwise, expire after seven days, and can
|
||||
be revoked before that.
|
||||
- `open` — anybody who can reach the server can create an account, and must
|
||||
name a team, which they then own.
|
||||
|
||||
Invites are **links, not email**: this server has no SMTP, and adding it to send
|
||||
one message would be a subsystem to run, secure and monitor. Send the link
|
||||
however you already talk to the person.
|
||||
|
||||
A domain-restricted third mode was considered and dropped: with no email there
|
||||
is nothing to verify an address against, so it would only check the domain of a
|
||||
string somebody typed.
|
||||
|
||||
The first user, from `/api/bootstrap`, is an administrator. Users created
|
||||
afterwards are not, until an administrator says so. An install always keeps at
|
||||
least one: the last administrator can be neither deleted nor demoted, and
|
||||
@@ -560,17 +758,42 @@ Endpoints that require the flag answer `403` with
|
||||
|
||||
**Teams** are the unit of tenancy, and are a separate axis from the administrator
|
||||
flag. A team owns its incidents, alerts, schedule and integrations, and a user
|
||||
sees exactly the teams they belong to — an administrator is not implicitly in
|
||||
every team, because administration is about accounts, not about reading other
|
||||
people's incidents. Within a team an **owner** configures it (schedule,
|
||||
integrations, membership) and a **member** works its incidents.
|
||||
sees exactly the teams they belong to. Within a team an **owner** configures it
|
||||
(schedule, integrations, membership) and a **member** works its incidents.
|
||||
|
||||
An administrator crosses that line in one direction only. They **configure any
|
||||
team** without being in it — every owner-only endpoint accepts the flag, because
|
||||
otherwise a team whose last owner left could never be repaired. They do **not
|
||||
read any team**: the queue, the alerts and the incidents are filtered by real
|
||||
membership, so an administrator sees a team's work only by joining it, which is
|
||||
a membership change and shows up as one. Administration is about accounts and
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures |
|
||||
| `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 |
|
||||
| `POST` | `/api/oidc/device/token` | `{"device_code"}` → `202 {"status":"pending"}`, then `200` with the session cookie once approved (once only). `410` with `{"error":"expired"}` or `{"error":"denied"}`; `429 {"error":"slow_down"}` if polled faster than `interval` |
|
||||
| `POST` | `/api/oidc/device/approve` | **session** — `{"user_code"}`. Approves a pending device login as the caller. `403` for an API key; `404` for an unknown, expired or already decided code |
|
||||
| `POST` | `/api/oidc/device/deny` | **session** — `{"user_code"}`. Refuses it |
|
||||
| `GET` | `/api/oidc/callback` | Where the provider sends the browser back. Sets the session cookie and redirects to `/`, or to `/?sso_error=<code>` — one of `denied`, `expired`, `failed`, `unavailable`, `not_allowed`, `no_email`, `email_conflict`, `disabled`, `not_bootstrapped` (no user exists on this install yet — sign in again once something has called `/api/bootstrap`) |
|
||||
| `POST` | `/api/logout` | Ends the session and clears the cookie |
|
||||
| `GET` | `/api/me` | The caller: `{user, has_password}` |
|
||||
|
||||
@@ -584,11 +807,14 @@ on anybody's.
|
||||
|
||||
| Method | Path | Who | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/signup` | — | Whether sign-up is open, and whether `?invite=` is usable. No session needed: the caller has no account yet |
|
||||
| `POST` | `/api/signup` | — | Create an account `{"username","email","password","invite"?,"team_name"?}` and sign in. `403` without a usable invite when the mode is invite-only |
|
||||
| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
|
||||
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
|
||||
| `GET` | `/api/users/{id}/teams` | self or admin | The teams that user is in, each with their role. `/api/teams` is always about the caller; this one answers it about somebody else, for the admin page's per-user view. `404` for a user who does not exist, so "no teams" and "no such person" are distinguishable |
|
||||
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
|
||||
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
|
||||
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
|
||||
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself, the last administrator, or an administrator granted by single sign-on |
|
||||
| `PUT` | `/api/users/{id}/disabled` | **admin** | Take an account out of use, or put it back `{"disabled"}`. `409` for yourself or the last administrator |
|
||||
| `PUT` | `/api/users/{id}/notify` | self or admin | Set push notification target `{"ntfy_topic"}` — empty string clears it |
|
||||
| `PUT` | `/api/users/{id}/password` | self or admin | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions |
|
||||
@@ -600,8 +826,44 @@ on anybody's.
|
||||
| Method | Path | Who | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/admin/teams` | **admin** | Every team on the server, with its member and open-incident counts. `/api/teams` answers "what am I in"; this answers "what is there" |
|
||||
| `GET` | `/api/admin/teams/{teamID}` | **admin** | One team and who is in it: `{"team", "members"}`. `404` for a team that does not exist. `GET /api/teams/{teamID}/members` is **member**-only and still `404`s an administrator from outside the team — reading a team's shape and reading its work are different questions, so they are different endpoints |
|
||||
| `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}`. `400` for an unknown key or a value outside its bounds |
|
||||
| `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
|
||||
|
||||
@@ -620,22 +882,39 @@ and was removed in v0.13.0 once senders had moved onto keys.
|
||||
|
||||
### Teams
|
||||
|
||||
**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 |
|
||||
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` |
|
||||
| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner |
|
||||
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys |
|
||||
| `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 |
|
||||
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration |
|
||||
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none |
|
||||
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration. Alerts it delivered stay, unattributed |
|
||||
| `GET` | `/api/teams/{teamID}/invites` | **owner** | The team's invite links, with their uses and expiry. Never the tokens |
|
||||
| `POST` | `/api/teams/{teamID}/invites` | **owner** | Mint one `{"role","max_uses"}` — the full URL is returned once |
|
||||
| `DELETE` | `/api/teams/{teamID}/invites/{inviteID}` | **owner** | Revoke a link before it expires |
|
||||
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[], last_escalated_at?, last_escalated_incident_id?}`. Empty levels means the team has none. Each level also carries `status` (`ready`, `escalating` when an unanswered incident has climbed to it, `unreachable` when nobody on it could be woken), `waiting` (ids of the open incidents on it) and, per target, `username` (who it means today — the person on call, for a rota target), `reachable` and `problem`. The extra fields are output only; `PUT` takes the plain shape |
|
||||
| `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` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` |
|
||||
| `PUT` | `/api/teams/{teamID}/deadman` | **owner** | Replace it. `400` when no matcher names an `alertname`, because a switch that silently watches nothing is the failure this feature exists to prevent |
|
||||
| `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
|
||||
|
||||
@@ -705,9 +984,11 @@ name: degrade unknown values to "resolved, reason unknown".
|
||||
| `created_at` | timestamp | |
|
||||
|
||||
Types written today: `triggered`, `alert_added`, `alert_resolved`,
|
||||
`acknowledged`, `unacknowledged`, `assigned`, `snoozed`, `unsnoozed`, `resolved`,
|
||||
`note`, `notified`, `notify_failed`, `deadman_silent`. On an `assigned` event
|
||||
`user_id` is the **assignee**, not the actor. New types may be added; render
|
||||
`acknowledged`, `unacknowledged`, `assigned`, `archived`, `unarchived`, `snoozed`,
|
||||
`unsnoozed`, `resolved`, `note`, `notified`, `notify_failed`, `deadman_silent`. On an
|
||||
`assigned` event `user_id` is the **assignee**, not the actor; the actor is in
|
||||
`actor_user_id`/`actor_username` or `actor_service_account_id`/`actor_service_account_name`
|
||||
(absent on assignments made before they were recorded). New types may be added; render
|
||||
unknown ones generically rather than dropping them.
|
||||
|
||||
On `notified` and `notify_failed`, `detail` carries the notification kind
|
||||
@@ -880,8 +1161,8 @@ What changes, and will need attention:
|
||||
|
||||
**Dead man's switches moved too.** `TERDUT_DEADMAN_MATCHERS`, `_TIMEOUT` and
|
||||
`_SEVERITY` are no longer the setting; they are the default each existing team
|
||||
is seeded with at startup, after which an owner edits them per team through
|
||||
`PUT /api/teams/{teamID}/deadman` and a redeploy never overwrites that.
|
||||
is seeded with at startup, after which an owner manages them per team through
|
||||
`/api/teams/{teamID}/deadman/switches` and a redeploy never overwrites that.
|
||||
|
||||
Nothing else about an incident changes, and incidents never move between teams:
|
||||
an alert belongs to whichever team's key it arrived on.
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# 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
|
||||
|
||||
**Revised** (this section originally described an aspiration that didn't
|
||||
match what shipped — `TEAM-LOOKUP.md` already caught one instance of that,
|
||||
and a fuller audit found three more; this is the corrected, as-built
|
||||
description, not the original proposal).
|
||||
|
||||
`internal/api/middleware.go`'s dual resolution (`Authorization: Bearer` →
|
||||
`apiKeyUser()`, or session cookie → `sessionUser()`) and the service-account
|
||||
path (`serviceAccountFor()`) both resolve into one `Caller` type
|
||||
(`internal/api/caller.go`), not two parallel, un-unified context
|
||||
representations the way an earlier version of this server kept them. Every
|
||||
authorization predicate reads `Caller`'s methods:
|
||||
|
||||
- `Caller.IsAdmin()` — true **only** for a human system administrator, never
|
||||
for a service account of either scope, under any circumstance. `AdminOnly`
|
||||
and `requireSelfOrAdmin` key on this alone — user management
|
||||
(`POST /api/users`, `PUT /api/users/{id}/admin`, etc.) and
|
||||
`GET/PUT /api/admin/settings` stay human-only, forever. The original text
|
||||
here claimed an instance-scoped service account satisfies `AdminOnly` "for
|
||||
team-creation/listing purposes" — that was never true of the shipped code
|
||||
(`TEAM-LOOKUP.md` caught the listing half; the creation half was always a
|
||||
separate, bespoke check in `handleCreateTeam`, not `AdminOnly` itself) and
|
||||
is not being made true now. Don't widen `AdminOnly`: every time this has
|
||||
come up, the fix has been a narrower, purpose-built capability instead
|
||||
(`?name=` lookups for teams and service accounts; now
|
||||
`terdut-operator`'s own invite-minting feature for the one real gap this
|
||||
boundary left — how a human ever gets a first login on a no-OIDC,
|
||||
operator-managed install. See the bottom of "What this unblocks.")
|
||||
- `Caller.IsInstanceServiceAccount()` — true only for an instance-scoped
|
||||
service account, never for a human (including a human admin).
|
||||
`handleCreateTeam` uses exactly this: a human creates a team by being a
|
||||
human (and becomes its owner); an instance-scoped service account creates
|
||||
one with no human owner at all. The two paths are not interchangeable, so
|
||||
this predicate deliberately does not also admit a human admin.
|
||||
- `Caller.Role(teamID)`/`TeamIDs()` — a human's real `team_members` rows, or
|
||||
a team-scoped service account's single synthetic owner membership
|
||||
(`serveAsServiceAccount`). This is what makes `requireTeamMember`/
|
||||
`requireTeamOwner` treat a team-scoped service account as owner-equivalent
|
||||
for that one team, with no separate branch needed in either function.
|
||||
- `Caller.ServiceAccountID()` — used by `OperatorModeBlock` ("any service
|
||||
account passes") and by `callerMayManageServiceAccount`'s self-rotation
|
||||
check.
|
||||
- `Caller.AsHuman()` — the accessor every handler that needs a real
|
||||
`user_id` to act on behalf of must call and check, instead of reading a
|
||||
user off context unconditionally. Before the `Caller` type existed, four
|
||||
handlers did the latter and silently misbehaved for a service-account
|
||||
caller: `handleMe` and `handleTestNotification` 500'd (a zero-value user id
|
||||
that matches no row), `handleDismissOnboarding` silently no-op'd (`UPDATE
|
||||
... WHERE id = 0` affects nothing, still returns 204), and
|
||||
`handleCreateInvite` wrote that same zero value into `invites.created_by`
|
||||
— a real foreign-key violation, not just a wrong answer, since that column
|
||||
is nullable but was never passed as `nil`. All four now call `AsHuman()`
|
||||
and return an explicit 403 ("this endpoint is for human accounts only")
|
||||
or, for the invite case, leave `created_by` `NULL` the same way
|
||||
`handleCreateServiceAccount` already did for the analogous situation.
|
||||
|
||||
**Team scope is owner-equivalent for every `requireTeamOwner` endpoint,
|
||||
membership and invites included — by design, not by an unclosed gap.** An
|
||||
earlier version of this document flagged this as "acknowledged rather than
|
||||
closed," kept in check only by the social convention that nobody *builds*
|
||||
automation against those two routes. That convention is retired:
|
||||
`terdut-operator`'s `TerdutTeam` controller now mints and revokes its own
|
||||
team's invite link through exactly this capability (its existing
|
||||
team-scoped credential, `POST`/`DELETE /api/teams/{teamID}/invites`), which
|
||||
is the real fix for the human-onboarding gap below — not a narrower
|
||||
carve-out of this capability. `service_accounts_test.go`'s
|
||||
`TestServiceAccount_TeamScopeManagesItsOwnInvites` pins it.
|
||||
|
||||
**A team-scoped account can also mint another service account scoped to its
|
||||
own team** (`handleCreateServiceAccount`'s `callerOwnsTeam` branch, which a
|
||||
team-scoped caller already satisfies for its own team via the synthetic
|
||||
membership above). Kept, not restricted, for the same reason: a team-scoped
|
||||
credential is that team's owner's reach, full stop — carving this one
|
||||
capability out while leaving membership/invites alone would be an arbitrary
|
||||
asymmetry. Pinned by
|
||||
`TestServiceAccount_TeamScopeCanMintAnotherAccountForItsOwnTeam`.
|
||||
|
||||
**`callerMayManageServiceAccount` gained the one load-bearing fix this
|
||||
redesign exists for:** an instance-scoped service account may manage
|
||||
(mint/revoke a key on) *any* team-scoped account, not only one admin, that
|
||||
team's human owner, or the account itself. `handleCreateServiceAccount`
|
||||
already let an instance-scoped caller *create* a team-scoped account for
|
||||
any team; this closes the gap where adopting or rotating one it didn't just
|
||||
create in the same call — exactly `terdut-operator`'s documented
|
||||
adopt-on-409 crash-window recovery (its own `DESIGN.md` §5) — 403'd forever
|
||||
instead of succeeding (`terdut-operator#3`). Pinned by
|
||||
`TestServiceAccount_InstanceScopeAdoptsAnExistingTeamScopedAccountsKey`.
|
||||
|
||||
Anywhere identity is recorded for a human (incident timeline
|
||||
`acknowledged_by`/`assigned_to`, audit-relevant fields), a service-account
|
||||
caller is still coerced into a bare `user_id` of `0` today — `Caller`'s new
|
||||
`Identity()` accessor exists for exactly this follow-up, but wiring it in
|
||||
needs a schema migration (an actor-attribution column distinct from
|
||||
`user_id`) and is deliberately out of scope here. Tracked separately, not by
|
||||
this document.
|
||||
|
||||
## 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.
|
||||
4. **A human can get a first login on a no-OIDC, operator-managed install —
|
||||
without ever touching `AdminOnly` or `/api/admin/settings`.** This was
|
||||
filed as `terdut-server#23` ("no API path to create a human login after
|
||||
bootstrap") and diagnosed, at the time, as this server needing to let a
|
||||
service account through `AdminOnly`. It doesn't: the fix lives entirely
|
||||
in `terdut-operator`, because a team-scoped credential was *already*
|
||||
owner-equivalent for `POST /api/teams/{teamID}/invites`, and invite
|
||||
redemption (`POST /api/signup` with an `invite` token) bypasses
|
||||
`signup_mode` entirely — `terdut-operator` just never grew a feature to
|
||||
use either fact. Its `TerdutTeam` controller now mints and surfaces one
|
||||
via its own existing team-scoped credential (`spec.invite`,
|
||||
`status.inviteSecretRef`, see that repo's own docs), so a human joins a
|
||||
CRD-managed team by a real invite link, the same way anyone else would.
|
||||
`terdut-server#23` is closed with this note once that feature ships — its
|
||||
named routes stay human-only, correctly, not a gap.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Team lookup for service accounts: closing terdut-operator's create-path crash window
|
||||
|
||||
This is a design note for a feature, not an implementation plan — same posture as
|
||||
`SERVICE-ACCOUNTS.md`, and raised for the same reason: `terdut-operator`'s `TerdutTeam`
|
||||
controller (ROADMAP.md Stage 2, a separate repo, no shared code) hit a gap this server has
|
||||
no answer for yet.
|
||||
|
||||
## The problem
|
||||
|
||||
`POST /api/teams` (`handleCreateTeam`, confirmed against `internal/api/teams.go`) lets an
|
||||
instance-scoped service account create a team — it has its own explicit
|
||||
`isInstanceServiceAccount(...)` branch alongside the human-user path, not gated by
|
||||
`AdminOnly`. If that call succeeds server-side but the caller (`TerdutTeam`'s controller)
|
||||
crashes before persisting the resulting team ID locally, a retry's `POST` 409s on the name's
|
||||
unique constraint (confirmed: the `isUniqueViolation` branch in the same handler).
|
||||
|
||||
Recovering from that 409 means looking the team up by name, and nothing today permits that
|
||||
for a service account:
|
||||
|
||||
- `GET /api/teams` (`handleListTeams`) answers "what teams does the *caller* belong to", via
|
||||
a `team_members` join keyed on `userFromContext`'s `caller.ID` — confirmed against source.
|
||||
A service account is never a member of anything, so this always returns empty for one,
|
||||
regardless of what exists.
|
||||
- `GET /api/admin/teams` (`handleAdminListTeams`) is gated by `AdminOnly`, and `AdminOnly`'s
|
||||
actual code (`internal/api/middleware.go`) checks only `userFromContext(...).IsAdmin` — no
|
||||
branch for a service account at all, confirmed against source. This contradicts
|
||||
`SERVICE-ACCOUNTS.md`'s own text, which claims "an instance-scoped [service account
|
||||
satisfies] `AdminOnly` for team-creation/listing purposes" — that claim doesn't match this
|
||||
endpoint's actual, shipped code. (Team *creation* is fine: `handleCreateTeam` isn't behind
|
||||
`AdminOnly` at all, it has its own check. Only the listing half of that sentence is wrong.)
|
||||
|
||||
This is exactly the shape of gap `SERVICE-ACCOUNTS.md`'s own `GET /api/service-accounts?name=`
|
||||
closed for service accounts themselves (confirmed: that endpoint's own comment —
|
||||
"the name lookup is open to any authenticated caller... what lets a service account find its
|
||||
own account on the 403 that follows a second POST"). Teams never got the equivalent, because
|
||||
nothing needed it until an operator started creating them unattended.
|
||||
|
||||
## Goals
|
||||
|
||||
- A service-account-accessible way to look up one team by exact name, mirroring
|
||||
`GET /api/service-accounts?name=` as closely as possible — same shape, same reasoning,
|
||||
same low sensitivity of what it discloses.
|
||||
- No change to today's behavior for an empty/no-name request.
|
||||
|
||||
## Proposed shape
|
||||
|
||||
Extend `GET /api/teams` itself, the same way `handleListServiceAccounts` already branches on
|
||||
a `?name=` query param, rather than adding a new route:
|
||||
|
||||
- `name` unset (today's behavior, unchanged): the caller's own teams, via `team_members`.
|
||||
- `name=<value>` set: look up that one team by exact name — a one-or-zero-length array, not
|
||||
an error on no match, mirroring `GET /api/service-accounts?name=`'s own response shape and
|
||||
status codes exactly. Deliberately **not** gated by `isInstanceServiceAccount` or
|
||||
`AdminOnly`: a human caller who's already a member sees this same information in their own
|
||||
team list regardless, and a non-member learning only that a name is taken — not who's in
|
||||
the team, not any of its data — is the same low-sensitivity disclosure
|
||||
`GET /api/service-accounts?name=` already accepts for service-account names.
|
||||
|
||||
## What this unblocks
|
||||
|
||||
Directly resolves the crash-window gap in `terdut-operator`'s `TerdutTeam` controller: on a
|
||||
409 from `POST /api/teams`, `GET /api/teams?name=<the same name>` — authenticated with the
|
||||
same instance-scoped credential that just got the 409 — finds the id, and the controller
|
||||
proceeds as if its own create had returned it directly. The same adopt-on-409 pattern already
|
||||
proven for service accounts (that repo's `DESIGN.md` §6 point 1, §5's general rule), not a
|
||||
new one.
|
||||
|
||||
## Suggested sequencing
|
||||
|
||||
Land this before `TerdutTeam`'s create path is implemented — the same reasoning
|
||||
`SERVICE-ACCOUNTS.md` gave for its own sequencing: writing that code against today's gap as a
|
||||
"known-temporary workaround" is wasted effort when the fix is this small and this
|
||||
well-precedented.
|
||||
@@ -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.14.0
|
||||
appVersion: "v0.14.0"
|
||||
version: 0.41.1
|
||||
appVersion: "v0.41.1"
|
||||
|
||||
@@ -6,25 +6,72 @@ metadata:
|
||||
labels:
|
||||
{{- include "terdut-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "terdut-server.selectorLabels" . | nindent 6 }}
|
||||
# Recreate, not RollingUpdate, even though the PVC that forced it is gone: the
|
||||
# sweeper and the notifier are unsynchronised singletons, and two replicas
|
||||
# overlapping during a rollout would both page for the same incident.
|
||||
# RollingUpdate, not Recreate: the sweeper, notifier and migration runner
|
||||
# each take a Postgres advisory lock around their own pass, and new-incident
|
||||
# creation on the first webhook for a brand-new groupKey resolves its own
|
||||
# insert conflict -- so two replicas overlapping during a rollout no longer
|
||||
# double-page, race a migration, or drop a webhook payload (v0.36.0). No
|
||||
# explicit maxUnavailable/maxSurge: the 25%/25% default rounds to 0/1 at
|
||||
# replicaCount: 2, which is zero-downtime already.
|
||||
strategy:
|
||||
type: Recreate
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "terdut-server.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
# Pod-wide default; both containers below run as this UID regardless of
|
||||
# what their own image would otherwise pick (postgres:17-alpine's
|
||||
# pg_isready needs no particular user, and 65532 is what the app image
|
||||
# itself runs as now — see the Dockerfile's USER). seccompProfile here
|
||||
# rather than per-container: there is no reason it would ever differ
|
||||
# between them.
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
{{- if .Values.database.waitForPostgres.enabled }}
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.database.waitForPostgres.image.repository }}:{{ .Values.database.waitForPostgres.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.database.waitForPostgres.image.pullPolicy }}
|
||||
# No capability this loop needs, and nothing in it writes to disk:
|
||||
# sh, pg_isready, echo and sleep all run read-only.
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
env:
|
||||
- name: TERDUT_DB_DSN
|
||||
value: {{ required "database.dsn is required" .Values.database.dsn | quote }}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until pg_isready -d "$TERDUT_DB_DSN"; do
|
||||
echo "wait-for-postgres: not ready yet, retrying in 2s"
|
||||
sleep 2
|
||||
done
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: terdut-server
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
# scratch, nothing to write: the binary keeps no local state and
|
||||
# writes nothing to disk, so the root filesystem can stay read-only.
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
@@ -61,8 +108,6 @@ spec:
|
||||
value: "{{ .Values.notify.fallbackTopic }}"
|
||||
- name: TERDUT_NOTIFY_REPEAT
|
||||
value: "{{ .Values.notify.repeatEvery }}"
|
||||
- name: TERDUT_PUBLIC_URL
|
||||
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
|
||||
{{- if .Values.notify.tokenSecret.name }}
|
||||
- name: TERDUT_NTFY_TOKEN
|
||||
valueFrom:
|
||||
@@ -71,6 +116,47 @@ spec:
|
||||
key: {{ .Values.notify.tokenSecret.key }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
# Set whether or not ntfy is: single sign-on builds its redirect URI
|
||||
# from it, and sessions use it to decide the cookie's Secure flag.
|
||||
- name: TERDUT_PUBLIC_URL
|
||||
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 }}
|
||||
- name: TERDUT_OIDC_CLIENT_ID
|
||||
value: {{ required "oidc.clientId is required when oidc.enabled" .Values.oidc.clientId | quote }}
|
||||
- name: TERDUT_OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ required "oidc.clientSecret.name is required when oidc.enabled" .Values.oidc.clientSecret.name }}
|
||||
key: {{ .Values.oidc.clientSecret.key }}
|
||||
- name: TERDUT_OIDC_NAME
|
||||
value: {{ .Values.oidc.name | quote }}
|
||||
- name: TERDUT_OIDC_SCOPES
|
||||
value: {{ .Values.oidc.scopes | quote }}
|
||||
- name: TERDUT_OIDC_USERNAME_CLAIM
|
||||
value: {{ .Values.oidc.usernameClaim | quote }}
|
||||
- name: TERDUT_OIDC_EMAIL_CLAIM
|
||||
value: {{ .Values.oidc.emailClaim | quote }}
|
||||
- name: TERDUT_OIDC_GROUPS_CLAIM
|
||||
value: {{ .Values.oidc.groupsClaim | quote }}
|
||||
- name: TERDUT_OIDC_TRUST_EMAIL
|
||||
value: {{ .Values.oidc.trustEmail | quote }}
|
||||
- name: TERDUT_OIDC_SESSION_MAX_AGE
|
||||
value: {{ .Values.oidc.sessionMaxAge | quote }}
|
||||
{{- if .Values.oidc.allowedGroups }}
|
||||
- name: TERDUT_OIDC_ALLOWED_GROUPS
|
||||
value: {{ join "," .Values.oidc.allowedGroups | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.oidc.adminGroup }}
|
||||
- name: TERDUT_OIDC_ADMIN_GROUP
|
||||
value: {{ .Values.oidc.adminGroup | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
# Safe above 1 since v0.36.0: the sweeper, notifier and migration runner each
|
||||
# take a Postgres advisory lock around their own pass, and a webhook that
|
||||
# loses the race to open a brand-new incident attaches to the winner's row
|
||||
# instead of dropping its payload. An image older than v0.36.0 does not have
|
||||
# these guards -- do not raise this against one.
|
||||
replicaCount: 2
|
||||
|
||||
networking:
|
||||
hostname: "terdut.example.com"
|
||||
servicePort: 8080
|
||||
@@ -33,6 +40,26 @@ database:
|
||||
passwordSecret:
|
||||
name: ""
|
||||
key: password
|
||||
# Blocks the main container from starting until Postgres accepts
|
||||
# connections. Without this, a Deployment created before Postgres has
|
||||
# finished its very first boot -- initdb plus Patroni leader election, on a
|
||||
# from-scratch postgres-operator cluster -- crash-loops a few times: the
|
||||
# app's own ping-retry budget on startup (pingAttempts/pingRetryDelay in
|
||||
# internal/db/db.go) is sized for a much shorter, different race --
|
||||
# NetworkPolicy propagation, a few seconds -- not for genuine first-time
|
||||
# cluster creation, which routinely takes longer, so it exhausts and the
|
||||
# process exits before ever binding its HTTP port. A startupProbe cannot
|
||||
# help here: the crash happens before there is anything to probe.
|
||||
#
|
||||
# pg_isready needs no credentials -- it reports PQPING_OK on anything that
|
||||
# amounts to "a Postgres backend answered", including an auth challenge --
|
||||
# so no PGPASSWORD is wired into this container.
|
||||
waitForPostgres:
|
||||
enabled: true
|
||||
image:
|
||||
repository: postgres
|
||||
tag: "17-alpine"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
@@ -107,6 +134,62 @@ notify:
|
||||
name: ""
|
||||
key: token
|
||||
|
||||
# Whether a user may sign in, or sign up, with a password. Turn it off once
|
||||
# single sign-on works, to make it the only way in; turn it back on (and
|
||||
# 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
|
||||
# <notify.publicUrl>/api/oidc/callback
|
||||
# (publicUrl defaults to https://<networking.hostname>), a confidential client, and
|
||||
# put the client secret in an existing Secret named by clientSecret below.
|
||||
#
|
||||
# Groups from the provider decide what a person can do. Access it grants is
|
||||
# marked as managed by single sign-on and is re-read at every sign-in; anything
|
||||
# added by hand in terdut is left alone. Changes in the provider take effect at
|
||||
# the person's next sign-in, at most sessionMaxAge later. API keys are NOT
|
||||
# revoked when somebody is removed at the provider: disable the user in terdut too.
|
||||
oidc:
|
||||
enabled: false
|
||||
# Issuer URL. For Authentik: https://<authentik>/application/o/<app-slug>/
|
||||
issuer: ""
|
||||
clientId: ""
|
||||
clientSecret:
|
||||
name: ""
|
||||
key: client-secret
|
||||
# What the sign-in button calls the provider.
|
||||
name: SSO
|
||||
# Authentik puts the groups claim behind the profile scope.
|
||||
scopes: "openid profile email"
|
||||
usernameClaim: preferred_username
|
||||
emailClaim: email
|
||||
groupsClaim: groups
|
||||
# Link a first sign-in to an existing local user with the same email even when
|
||||
# the provider does not mark the address verified. Authentik reports
|
||||
# email_verified as false unless configured otherwise.
|
||||
trustEmail: false
|
||||
# Only people in one of these groups may sign in. Empty admits everybody the
|
||||
# provider authenticates, and access control is left to the provider.
|
||||
allowedGroups: []
|
||||
# Members of this group are system administrators.
|
||||
adminGroup: ""
|
||||
# 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
|
||||
|
||||
# Backups are no longer this chart's business. The SQLite database lived on a PVC
|
||||
# beside the app, so it needed a sidecar with a sqlite3 module for k8up to exec a
|
||||
# dump in; Postgres is backed up where it runs, through a k8up.io/backupcommand
|
||||
|
||||
+4
-1
@@ -17,6 +17,9 @@ var version = "dev"
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
database, err := db.Open(cfg.DSN)
|
||||
if err != nil {
|
||||
@@ -51,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,
|
||||
|
||||
@@ -3,13 +3,16 @@ module git.ryuvia.com/niklas/terdut-server
|
||||
go 1.25.9
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -22,6 +26,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
|
||||
@@ -237,6 +237,246 @@ func TestAdmin_GrantAndRevokeChangeWhatIsAllowed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An administrator passes every team-owner check without being in the team,
|
||||
// which is what lets them repair a team whose owner has left. It has been true
|
||||
// since teams landed and nothing pinned it, so a later reading of the epic's
|
||||
// "an admin is not implicitly in every team" could quietly take it away.
|
||||
//
|
||||
// The line it draws: configuring a team, yes; reading what the team owns, no.
|
||||
// The queue below is the half that stays shut.
|
||||
func TestAdmin_ConfiguresATeamTheyAreNotIn(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
// A team the admin is deliberately not a member of. It is created by
|
||||
// somebody else, so the admin's only claim on it is the flag.
|
||||
_, call := member(t, s, "founder")
|
||||
var team struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team)
|
||||
if team.ID == 0 {
|
||||
t.Fatal("no team was created")
|
||||
}
|
||||
|
||||
var mine []struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams", nil), &mine)
|
||||
for _, m := range mine {
|
||||
if m.ID == team.ID {
|
||||
t.Fatalf("the admin should not be a member of team %d", team.ID)
|
||||
}
|
||||
}
|
||||
|
||||
path := "/api/teams/" + id64(team.ID)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
want int
|
||||
}{
|
||||
{"rename it", http.MethodPut, path,
|
||||
map[string]string{"name": "theirs, renamed"}, http.StatusNoContent},
|
||||
{"mint an invite", http.MethodPost, path + "/invites",
|
||||
map[string]any{"role": "member", "max_uses": 1}, http.StatusCreated},
|
||||
{"add a member", http.MethodPost, path + "/members",
|
||||
map[string]any{"user_id": 1, "role": "member"}, http.StatusNoContent},
|
||||
{"remove a member", http.MethodDelete, path + "/members/1", nil, http.StatusNoContent},
|
||||
} {
|
||||
resp := s.req(t, c.method, c.path, c.body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != c.want {
|
||||
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the rule. An incident in that team is not the admin's
|
||||
// to read, because administration is about accounts — and the last case
|
||||
// above has just taken the admin back out of the membership.
|
||||
var integration struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, call(http.MethodPost, path+"/integrations",
|
||||
map[string]string{"name": "theirs alertmanager"}), &integration)
|
||||
postToIntegration(t, s, integration.Key, "fp-theirs", "TheirDiskFull")
|
||||
|
||||
var incidents []struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/incidents", nil), &incidents)
|
||||
if len(incidents) != 0 {
|
||||
t.Errorf("the admin should see none of that team's incidents, got %d", len(incidents))
|
||||
}
|
||||
}
|
||||
|
||||
// The team page at /admin/teams/{id} needs the one question the test above
|
||||
// leaves shut: who is in a team the administrator is not in.
|
||||
//
|
||||
// It is answered by a separate endpoint under AdminOnly rather than by letting
|
||||
// the admin flag through requireTeamMember, and the second half of this test is
|
||||
// the reason — /api/teams/{id}/members must keep answering 404, so that "member
|
||||
// means membership and nothing else" stays true of the endpoint it was said
|
||||
// about. Reading a team's shape and reading a team's work are different things.
|
||||
func TestAdminGetTeam_ReadsAnyTeamWithoutJoiningIt(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
founderID, call := member(t, s, "founder")
|
||||
var team struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, call(http.MethodPost, "/api/teams", map[string]string{"name": "theirs"}), &team)
|
||||
if team.ID == 0 {
|
||||
t.Fatal("no team was created")
|
||||
}
|
||||
|
||||
// The admin reads it whole, without being in it.
|
||||
var got struct {
|
||||
Team struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Members int64 `json:"members"`
|
||||
OpenIncidents int64 `json:"open_incidents"`
|
||||
} `json:"team"`
|
||||
Members []struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
} `json:"members"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got)
|
||||
|
||||
if got.Team.ID != team.ID || got.Team.Name != "theirs" {
|
||||
t.Errorf("expected team %d named theirs, got %d named %q", team.ID, got.Team.ID, got.Team.Name)
|
||||
}
|
||||
if got.Team.Members != 1 {
|
||||
t.Errorf("expected a member count of 1, got %d", got.Team.Members)
|
||||
}
|
||||
if len(got.Members) != 1 {
|
||||
t.Fatalf("expected one member, got %d", len(got.Members))
|
||||
}
|
||||
if got.Members[0].UserID != founderID || got.Members[0].Username != "founder" {
|
||||
t.Errorf("expected founder (%d), got %q (%d)",
|
||||
founderID, got.Members[0].Username, got.Members[0].UserID)
|
||||
}
|
||||
// Whoever creates a team owns it, and the page's role toggle depends on
|
||||
// that being reported rather than assumed.
|
||||
if got.Members[0].Role != "owner" {
|
||||
t.Errorf("expected the creator to be owner, got %q", got.Members[0].Role)
|
||||
}
|
||||
|
||||
// The rule this endpoint exists in order not to break. Same admin, same
|
||||
// team, the member-only endpoint: still not found.
|
||||
resp := s.req(t, http.MethodGet, "/api/teams/"+id64(team.ID)+"/members", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an admin outside the team must still get 404 from the member-only list, got %d",
|
||||
resp.StatusCode)
|
||||
}
|
||||
|
||||
// And the new one is administration, not membership: being in the team is
|
||||
// not enough.
|
||||
resp = call(http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("a non-admin member must get 403, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"a team that does not exist", "/api/admin/teams/999999", http.StatusNotFound},
|
||||
{"a team id that is not a number", "/api/admin/teams/nonsense", http.StatusBadRequest},
|
||||
} {
|
||||
resp := s.req(t, http.MethodGet, c.path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != c.want {
|
||||
t.Errorf("%s: expected %d, got %d", c.name, c.want, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A team name is trimmed when it is created, and renaming had not been, so " "
|
||||
// was a legal name to rename to and an illegal one to start with.
|
||||
func TestRenameTeam_TrimsTheName(t *testing.T) {
|
||||
s := newTS(t)
|
||||
var team struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "trimmed"}), &team)
|
||||
|
||||
path := "/api/teams/" + id64(team.ID)
|
||||
resp := s.req(t, http.MethodPut, path, map[string]string{"name": " "})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a blank name must be refused, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp = s.req(t, http.MethodPut, path, map[string]string{"name": " padded "})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Team struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"team"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/admin/teams/"+id64(team.ID), nil), &got)
|
||||
if got.Team.Name != "padded" {
|
||||
t.Errorf("expected the name to be trimmed to %q, got %q", "padded", got.Team.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// The admin page's per-user view asks what somebody is in. Self or admin, like
|
||||
// the rest of the per-user endpoints.
|
||||
func TestUserTeams_SelfOrAdmin(t *testing.T) {
|
||||
s := newTS(t)
|
||||
memberID, call := member(t, s, "joiner")
|
||||
path := "/api/users/" + id64(memberID) + "/teams"
|
||||
|
||||
// member() puts them in the default team, so both readings agree on one.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
do func() *http.Response
|
||||
}{
|
||||
{"the admin reading somebody else's", func() *http.Response { return s.req(t, http.MethodGet, path, nil) }},
|
||||
{"the user reading their own", func() *http.Response { return call(http.MethodGet, path, nil) }},
|
||||
} {
|
||||
var teams []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
decode(t, c.do(), &teams)
|
||||
if len(teams) != 1 {
|
||||
t.Fatalf("%s: expected 1 team, got %d", c.name, len(teams))
|
||||
}
|
||||
if teams[0].Role != "member" {
|
||||
t.Errorf("%s: expected role member, got %q", c.name, teams[0].Role)
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's is not theirs to read.
|
||||
otherID, _ := member(t, s, "nosy")
|
||||
resp := call(http.MethodGet, "/api/users/"+id64(otherID)+"/teams", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("reading another user's teams: expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// A user who does not exist is a 404 rather than an empty list, which is
|
||||
// how the page tells "no teams" from "no such person".
|
||||
resp = s.req(t, http.MethodGet, "/api/users/9999/teams", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("a missing user: expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The flag has to reach the client, or the web UI cannot decide what to show.
|
||||
func TestAdmin_MeReportsTheFlag(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package api
|
||||
|
||||
// This file is internal (package api, not api_test) because withAdvisoryLock is
|
||||
// unexported and these tests exercise its locking semantics directly rather than
|
||||
// through the full StartArchiver/StartNotifier loop, which would make the "does
|
||||
// not run while held" case timing-dependent instead of deterministic. It opens a
|
||||
// plain connection to TERDUT_TEST_DSN rather than reusing testdb_test.go's
|
||||
// newTestDB, since that helper lives in the separate, already-compiled
|
||||
// api_test package and a Postgres advisory lock needs no schema or migration
|
||||
// to exercise.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// advisoryTestDB opens a plain, unmigrated connection to the test database. An
|
||||
// unset DSN fails rather than skips, matching testdb_test.go's rationale: a
|
||||
// suite that quietly tests nothing is worse than one that does not run.
|
||||
func advisoryTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv("TERDUT_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Fatalf("TERDUT_TEST_DSN is not set: these tests need Postgres.\n" +
|
||||
"Run `make test-db` for a local one, then\n" +
|
||||
" export TERDUT_TEST_DSN=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable")
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect to TERDUT_TEST_DSN: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestWithAdvisoryLock_RunsWhenFree(t *testing.T) {
|
||||
db := advisoryTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
ran := false
|
||||
withAdvisoryLock(ctx, db, archiverLockKey, "test", func() { ran = true })
|
||||
|
||||
if !ran {
|
||||
t.Fatal("fn did not run although the lock was free")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAdvisoryLock_SkipsWhileHeldElsewhere(t *testing.T) {
|
||||
db := advisoryTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Hold the lock on a connection of our own, standing in for another
|
||||
// replica mid-pass.
|
||||
holder, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire holder connection: %v", err)
|
||||
}
|
||||
defer holder.Close()
|
||||
if _, err := holder.ExecContext(ctx, "SELECT pg_advisory_lock($1)", archiverLockKey); err != nil {
|
||||
t.Fatalf("pre-acquire lock: %v", err)
|
||||
}
|
||||
|
||||
ran := false
|
||||
withAdvisoryLock(ctx, db, archiverLockKey, "test", func() { ran = true })
|
||||
if ran {
|
||||
t.Fatal("fn ran although another connection already held the lock")
|
||||
}
|
||||
|
||||
if _, err := holder.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", archiverLockKey); err != nil {
|
||||
t.Fatalf("release held lock: %v", err)
|
||||
}
|
||||
|
||||
// Now that the holder released it, the next caller should get it.
|
||||
ran = false
|
||||
withAdvisoryLock(ctx, db, archiverLockKey, "test", func() { ran = true })
|
||||
if !ran {
|
||||
t.Fatal("fn did not run after the other connection released the lock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAdvisoryLock_ReleasesAfterFnReturns(t *testing.T) {
|
||||
db := advisoryTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
withAdvisoryLock(ctx, db, notifierLockKey, "test", func() {})
|
||||
|
||||
// If the first call had leaked the lock, this one would see it held and
|
||||
// skip, leaving ran false.
|
||||
ran := false
|
||||
withAdvisoryLock(ctx, db, notifierLockKey, "test", func() { ran = true })
|
||||
if !ran {
|
||||
t.Fatal("fn did not run on a later call: the earlier call leaked its lock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAdvisoryLock_KeysAreIndependent(t *testing.T) {
|
||||
db := advisoryTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
holder, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire holder connection: %v", err)
|
||||
}
|
||||
defer holder.Close()
|
||||
if _, err := holder.ExecContext(ctx, "SELECT pg_advisory_lock($1)", archiverLockKey); err != nil {
|
||||
t.Fatalf("pre-acquire archiver lock: %v", err)
|
||||
}
|
||||
defer holder.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", archiverLockKey)
|
||||
|
||||
// Holding archiverLockKey must not block notifierLockKey.
|
||||
ran := false
|
||||
withAdvisoryLock(ctx, db, notifierLockKey, "test", func() { ran = true })
|
||||
if !ran {
|
||||
t.Fatal("fn did not run under a different key although only archiverLockKey was held")
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ type ingested struct {
|
||||
// post, and which team the alerts belong to.
|
||||
func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, err := teamIDForKey(r.Context(), db, chi.URLParam(r, "key"))
|
||||
src, err := sourceForKey(r.Context(), db, chi.URLParam(r, "key"))
|
||||
if err != nil {
|
||||
if errors.Is(err, errUnknownIntegration) {
|
||||
// 401 and not 404: the path is real, the key is not, and a
|
||||
@@ -90,13 +90,20 @@ func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
receiveWebhook(w, r, db, notify, teamID)
|
||||
receiveWebhook(w, r, db, notify, src)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, teamID int64) {
|
||||
// maxWebhookBodyBytes is larger than maxBodyBytes: a real Alertmanager batch
|
||||
// can carry many alerts, each with several labels and annotations, and the
|
||||
// sender is a trusted piece of infrastructure rather than an arbitrary
|
||||
// caller.
|
||||
const maxWebhookBodyBytes = 8 << 20
|
||||
|
||||
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, src alertSource) {
|
||||
teamID := src.teamID
|
||||
var payload amPayload
|
||||
if err := decodeJSON(r, &payload); err != nil {
|
||||
if err := decodeJSONLimit(r, &payload, maxWebhookBodyBytes); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid payload"))
|
||||
return
|
||||
}
|
||||
@@ -104,7 +111,7 @@ func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify N
|
||||
// Alertmanager retries anything that is not 2xx, and a retry of a payload
|
||||
// we failed to store is more useful than an error it cannot act on — so
|
||||
// failures are logged, not surfaced.
|
||||
if err := ingest(r.Context(), db, notify, teamID, payload); err != nil {
|
||||
if err := ingest(r.Context(), db, notify, src, payload); err != nil {
|
||||
log.Printf("webhook ingest (team %d, group %q): %v", teamID, payload.GroupKey, err)
|
||||
}
|
||||
|
||||
@@ -114,7 +121,8 @@ func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify N
|
||||
// ingest stores a payload's alerts and reconciles the incident for its group.
|
||||
// The whole payload is one transaction: an incident that opened but whose alerts
|
||||
// failed to link would be a work item nobody could act on.
|
||||
func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64, payload amPayload) error {
|
||||
func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, src alertSource, payload amPayload) error {
|
||||
teamID := src.teamID
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -124,12 +132,12 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64,
|
||||
// Which arriving alerts are heartbeats is the team's own answer, read
|
||||
// inside the transaction so an owner editing it mid-payload cannot split
|
||||
// one webhook across two interpretations.
|
||||
deadman, err := deadmanConfigForTeam(ctx, tx, teamID)
|
||||
deadman, err := deadmanSetForTeam(ctx, tx, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accepted, err := upsertAlerts(ctx, tx, deadman, teamID, payload.Alerts)
|
||||
accepted, err := upsertAlerts(ctx, tx, deadman, src, payload.Alerts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -167,7 +175,7 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64,
|
||||
}
|
||||
touched[id] = true
|
||||
alertID := a.id
|
||||
if err := logEvent(ctx, tx, id, evAlertResolved, nil, &alertID, nil); err != nil {
|
||||
if err := logEvent(ctx, tx, id, evAlertResolved, nil, nil, &alertID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -186,7 +194,8 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64,
|
||||
|
||||
// upsertAlerts stores each alert of a payload and reports what changed. Payloads
|
||||
// the ordering guard rejected are left out entirely.
|
||||
func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID int64, alerts []amAlert) ([]ingested, error) {
|
||||
func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman deadmanSet, src alertSource, alerts []amAlert) ([]ingested, error) {
|
||||
teamID := src.teamID
|
||||
now := time.Now().Unix()
|
||||
accepted := make([]ingested, 0, len(alerts))
|
||||
|
||||
@@ -242,8 +251,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO alerts
|
||||
(team_id, fingerprint, name, status, labels, annotations, starts_at, ends_at,
|
||||
generator_url, received_at, resolution_source)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11)
|
||||
generator_url, received_at, resolution_source, integration_id)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (team_id, fingerprint) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
labels = excluded.labels,
|
||||
@@ -257,6 +266,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID
|
||||
-- is a breaking API change — see models.Alert.ReceivedAt.
|
||||
received_at = excluded.received_at,
|
||||
resolution_source = excluded.resolution_source,
|
||||
-- Last sender wins; see migration 010.
|
||||
integration_id = excluded.integration_id,
|
||||
-- A re-fire makes the alert current again, so it leaves the archive.
|
||||
archived_at = CASE WHEN excluded.status = 'firing'
|
||||
THEN NULL ELSE alerts.archived_at END
|
||||
@@ -267,7 +278,7 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID
|
||||
teamID, a.Fingerprint, name, a.Status,
|
||||
string(labelsJSON), string(annotationsJSON),
|
||||
a.StartsAt.Unix(), endsAtUnix,
|
||||
a.GeneratorURL, now, resolutionSource,
|
||||
a.GeneratorURL, now, resolutionSource, src.integrationID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -371,6 +382,17 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, team
|
||||
// its own. Hence the querier rather than a *sql.Tx. A nil severity leaves the
|
||||
// column for refreshSeverity to fill from the member alerts; the sweeper passes
|
||||
// one because its incidents have no members to derive it from.
|
||||
//
|
||||
// Both callers get here only after their own SELECT found no open incident for
|
||||
// this group_key — but on more than one replica, two webhook deliveries for the
|
||||
// very first occurrence of a brand-new group_key can both pass that SELECT
|
||||
// before either INSERTs. ON CONFLICT DO NOTHING against
|
||||
// incidents_open_group_key_idx is what makes the loser's INSERT a no-op instead
|
||||
// of a unique-violation error that would otherwise roll back its entire
|
||||
// payload; existingOpenIncident then hands it the winner's row. Postgres
|
||||
// resolves that conflict only once the winner's transaction has committed (or
|
||||
// rolled back), so by the time this RETURNING comes back empty, the winner's
|
||||
// row is guaranteed visible to that follow-up SELECT.
|
||||
func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID int64, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) {
|
||||
onCall, err := currentOnCall(ctx, q, teamID)
|
||||
if err != nil {
|
||||
@@ -384,21 +406,29 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID in
|
||||
|
||||
var id int64
|
||||
err = q.QueryRowContext(ctx, `
|
||||
INSERT INTO incidents (team_id, group_key, title, group_labels, status, severity, triggered_at, assigned_to)
|
||||
VALUES ($1, $2, $3, $4::jsonb, 'triggered', $5, $6, $7)
|
||||
INSERT INTO incidents (team_id, group_key, title, group_labels, signature, status, severity, triggered_at, assigned_to)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, 'triggered', $6, $7, $8)
|
||||
ON CONFLICT (team_id, group_key) WHERE resolved_at IS NULL DO NOTHING
|
||||
RETURNING id`,
|
||||
teamID, groupKey, title, string(labelsJSON), severity,
|
||||
teamID, groupKey, title, string(labelsJSON), incidentSignature(groupLabels, title), severity,
|
||||
time.Now().Unix(), onCall).Scan(&id)
|
||||
if err != nil {
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
// Lost the race: someone else's incident for this group_key exists now.
|
||||
// Everything below — the trigger event, assignment, page, escalation
|
||||
// clock — already happened for that row when it was created; attach to
|
||||
// it rather than fail this call (and the whole payload) outright.
|
||||
return existingOpenIncident(ctx, q, teamID, groupKey)
|
||||
case err != nil:
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if err := logEvent(ctx, q, id, evTriggered, nil, nil, nil); err != nil {
|
||||
if err := logEvent(ctx, q, id, evTriggered, nil, nil, nil, nil); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if onCall != nil {
|
||||
// On an "assigned" event user_id is the assignee, not the actor.
|
||||
if err := logEvent(ctx, q, id, evAssigned, onCall, nil, nil); err != nil {
|
||||
if err := logEvent(ctx, q, id, evAssigned, onCall, nil, nil, nil); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
@@ -418,6 +448,21 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID in
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// existingOpenIncident looks up the open incident openIncident's own INSERT just
|
||||
// lost a conflict against — the same lookup incidentForGroup does before ever
|
||||
// calling openIncident, repeated here for the caller that arrived second.
|
||||
func existingOpenIncident(ctx context.Context, q querier, teamID int64, groupKey string) (int64, error) {
|
||||
var id int64
|
||||
err := q.QueryRowContext(ctx,
|
||||
"SELECT id FROM incidents WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL",
|
||||
teamID, groupKey,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// linkAlert adds an alert to an incident, emitting a timeline entry only the
|
||||
// first time. Re-sends of an already-linked alert are silent.
|
||||
func linkAlert(ctx context.Context, tx *sql.Tx, incidentID, alertID int64) error {
|
||||
@@ -431,5 +476,5 @@ func linkAlert(ctx context.Context, tx *sql.Tx, incidentID, alertID int64) error
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil
|
||||
}
|
||||
return logEvent(ctx, tx, incidentID, evAlertAdded, nil, &alertID, nil)
|
||||
return logEvent(ctx, tx, incidentID, evAlertAdded, nil, nil, &alertID, nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWebhook_ConcurrentFirstOccurrenceOpensOneIncident reproduces two
|
||||
// replicas racing the very first webhook delivery for a brand-new group_key:
|
||||
// both see no open incident yet (incidentForGroup's own SELECT finds
|
||||
// nothing) and race openIncident's INSERT.
|
||||
//
|
||||
// The DB's own unique index already guarantees at most one incident either
|
||||
// way, with or without this fix — so "exactly one incident" alone cannot
|
||||
// tell a fixed run from a broken one. What ON CONFLICT handling actually
|
||||
// changes is what happens to the *loser*: before it, the loser's INSERT hit
|
||||
// incidents_open_group_key_idx's unique violation, which — since
|
||||
// upsertAlerts ran earlier in that same transaction — rolled back its whole
|
||||
// payload, alert insert included. ingest's error is only logged and
|
||||
// receiveWebhook answers 200 regardless, so nothing ever retried it: the
|
||||
// loser's alert silently never existed. That is the regression signal this
|
||||
// test checks — every caller's fingerprint must show up in /api/alerts, not
|
||||
// just the winner's.
|
||||
func TestWebhook_ConcurrentFirstOccurrenceOpensOneIncident(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
const callers = 8
|
||||
const groupKey = "race-group"
|
||||
|
||||
// Every caller needs its own fingerprint. A shared one would serialize all
|
||||
// of them at upsertAlerts' own ON CONFLICT (team_id, fingerprint) row lock,
|
||||
// long before any of them reached incidentForGroup — which would hide the
|
||||
// very race this test exists to force.
|
||||
bodies := make([][]byte, callers)
|
||||
for i := range callers {
|
||||
payload := map[string]any{
|
||||
"version": "4",
|
||||
"status": "firing",
|
||||
"groupKey": groupKey,
|
||||
"groupLabels": map[string]string{"alertname": "RaceAlert"},
|
||||
"alerts": []map[string]any{amAlert(fmt.Sprintf("fp-race-%d", i), "RaceAlert", "firing",
|
||||
"2026-05-20T10:00:00Z", "0001-01-01T00:00:00Z", nil)},
|
||||
}
|
||||
bodies[i], _ = json.Marshal(payload)
|
||||
}
|
||||
|
||||
// A start line, so every request is fired as close to simultaneously as
|
||||
// goroutine scheduling allows, rather than trickling out one dial at a
|
||||
// time — the race window is the gap between incidentForGroup's SELECT and
|
||||
// openIncident's INSERT, which a staggered start could easily miss.
|
||||
var ready sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
statuses := make([]int, callers)
|
||||
var wg sync.WaitGroup
|
||||
for i := range callers {
|
||||
ready.Add(1)
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
ready.Done()
|
||||
<-start
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", bytes.NewReader(bodies[i]))
|
||||
if err != nil {
|
||||
t.Errorf("post webhook #%d: %v", i, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
statuses[i] = resp.StatusCode
|
||||
}(i)
|
||||
}
|
||||
ready.Wait()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i, code := range statuses {
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("webhook #%d returned %d, want 200", i, code)
|
||||
}
|
||||
}
|
||||
|
||||
var matched []any
|
||||
for _, inc := range listIncidents(t, s, "") {
|
||||
if inc["group_key"] == groupKey {
|
||||
matched = append(matched, inc["id"])
|
||||
}
|
||||
}
|
||||
if len(matched) != 1 {
|
||||
t.Fatalf("expected exactly 1 incident for group_key %q after %d concurrent deliveries, got %d: %v",
|
||||
groupKey, callers, len(matched), matched)
|
||||
}
|
||||
|
||||
var alerts []map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/alerts", nil), &alerts)
|
||||
seen := map[string]bool{}
|
||||
for _, a := range alerts {
|
||||
if fp, ok := a["fingerprint"].(string); ok {
|
||||
seen[fp] = true
|
||||
}
|
||||
}
|
||||
for i := range callers {
|
||||
fp := fmt.Sprintf("fp-race-%d", i)
|
||||
if !seen[fp] {
|
||||
t.Errorf("alert %q is missing: its delivery's whole payload was silently rolled back "+
|
||||
"when it lost the race for the incident", fp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAPIKey_DefaultsToNeverExpiring(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]string{"name": "no-expiry"}), &key)
|
||||
|
||||
if key.ExpiresAt != nil {
|
||||
t.Errorf("expires_at = %v, want nil (unset expires_in_days means never expires)", *key.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ExpiresInDaysSetsExpiresAt(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var key struct {
|
||||
ID int64 `json:"id"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]any{"name": "rotates", "expires_in_days": 30}), &key)
|
||||
|
||||
if key.ExpiresAt == nil {
|
||||
t.Fatal("expires_at = nil, want a timestamp roughly 30 days out")
|
||||
}
|
||||
got, err := time.Parse(time.RFC3339, *key.ExpiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("parse expires_at: %v", err)
|
||||
}
|
||||
want := time.Now().AddDate(0, 0, 30)
|
||||
if diff := want.Sub(got).Abs(); diff > time.Hour {
|
||||
t.Errorf("expires_at = %v, want close to %v (30 days out)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ExpiresInDaysRejectsOutOfRange(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
for _, days := range []int{-1, 3651} {
|
||||
resp := s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]any{"name": "bad", "expires_in_days": days})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expires_in_days=%d: status = %d, want %d", days, resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_AnExpiredKeyCannotAuthenticate(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var key struct {
|
||||
ID int64 `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]any{"name": "soon-expired", "expires_in_days": 1}), &key)
|
||||
|
||||
// A fresh key works...
|
||||
req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/me: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("fresh key: status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
// ...and stops working once its expiry has passed.
|
||||
s.exec(t, "UPDATE api_keys SET expires_at = $1 WHERE id = $2", time.Now().Add(-time.Hour).Unix(), key.ID)
|
||||
|
||||
req2, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/me: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expired key: status = %d, want %d", resp2.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ListNeverReturnsTheRawKey(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
|
||||
map[string]string{"name": "listed"}), new(struct {
|
||||
Key string `json:"key"`
|
||||
}))
|
||||
|
||||
var keys []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/users/1/api-keys", nil), &keys)
|
||||
|
||||
found := false
|
||||
for _, k := range keys {
|
||||
if k.Name == "listed" {
|
||||
found = true
|
||||
}
|
||||
if k.Key != "" {
|
||||
t.Errorf("key %d (%s): raw key present in listing", k.ID, k.Name)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the key just created does not appear in the listing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_ListIsSelfOrAdmin(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "apikeys-a")
|
||||
|
||||
resp := a.call(http.MethodGet, "/api/users/1/api-keys", nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d (not self, not an admin)", resp.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
+20
-14
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
)
|
||||
|
||||
// ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so
|
||||
@@ -50,9 +51,16 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
|
||||
if len(notify) > 0 {
|
||||
cfg = notify[0]
|
||||
}
|
||||
return newTSWith(t, deadman, cfg, testConfig())
|
||||
}
|
||||
|
||||
// newTSWith is newDeadmanTS with the server's own configuration supplied, for
|
||||
// tests of behaviour that config switches on, such as single sign-on.
|
||||
func newTSWith(t *testing.T, deadman api.DeadmanConfig, cfg api.NotifyConfig, conf config.Config) *ts {
|
||||
t.Helper()
|
||||
|
||||
database := newTestDB(t)
|
||||
srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig()))
|
||||
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"})
|
||||
@@ -88,27 +96,25 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
|
||||
return s
|
||||
}
|
||||
|
||||
// setTeamDeadman configures the default team's switches over the API, rendering
|
||||
// the matchers back into the string form the endpoint takes.
|
||||
// setTeamDeadman gives the default team one switch per configured matcher, over
|
||||
// the API, the way an owner would add them.
|
||||
func setTeamDeadman(t *testing.T, s *ts, cfg api.DeadmanConfig) {
|
||||
t.Helper()
|
||||
matchers := make([]string, 0, len(cfg.Matchers))
|
||||
for _, m := range cfg.Matchers {
|
||||
parts := []string{"alertname=" + m.Name}
|
||||
for k, v := range m.Labels {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
sort.Strings(parts[1:])
|
||||
matchers = append(matchers, strings.Join(parts, ","))
|
||||
}
|
||||
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman", map[string]any{
|
||||
"matchers": strings.Join(matchers, "; "),
|
||||
"timeout_seconds": int64(cfg.Timeout.Seconds()),
|
||||
"severity": cfg.Severity,
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("configure the team's dead man's switches: %d", resp.StatusCode)
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", map[string]any{
|
||||
"matcher": strings.Join(parts, ","),
|
||||
"timeout_seconds": int64(cfg.Timeout.Seconds()),
|
||||
"severity": cfg.Severity,
|
||||
})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("add a dead man's switch: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ const (
|
||||
// expiryGrace absorbs clock skew and notification latency before an alert
|
||||
// whose ends_at watermark has passed is treated as stale.
|
||||
expiryGrace = 5 * time.Minute
|
||||
|
||||
// archiverLockKey is the Postgres advisory lock the sweeper takes for the
|
||||
// duration of each pass, so that running more than one replica does not run
|
||||
// the sweep concurrently on all of them. Its value has no meaning beyond
|
||||
// being distinct from notifierLockKey.
|
||||
archiverLockKey int64 = 7265_0001
|
||||
)
|
||||
|
||||
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
|
||||
@@ -23,15 +29,25 @@ const (
|
||||
// the fallback, not the setting: each pass reads the current value from the
|
||||
// settings table, so an administrator's change takes effect on the next tick
|
||||
// instead of at the next restart.
|
||||
//
|
||||
// Each pass runs under archiverLockKey (see withAdvisoryLock), so that on more
|
||||
// than one replica only whichever instance's tick takes the lock first actually
|
||||
// sweeps; the rest skip that tick rather than racing the same pass.
|
||||
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
||||
ticker := time.NewTicker(sweepInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
||||
sweep := func() {
|
||||
withAdvisoryLock(ctx, db, archiverLockKey, "sweeper", func() {
|
||||
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
||||
})
|
||||
}
|
||||
|
||||
sweep()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
||||
sweep()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -60,6 +76,7 @@ func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Durati
|
||||
archiveResolvedIncidents(ctx, db, archiveAfter)
|
||||
purgeAckTokens(ctx, db)
|
||||
purgeSessions(ctx, db)
|
||||
purgeRateLimits(ctx, db)
|
||||
}
|
||||
|
||||
// expireStale resolves firing alerts that Alertmanager has stopped refreshing.
|
||||
@@ -105,6 +122,10 @@ func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip
|
||||
for i, id := range ids {
|
||||
idList[i] = id
|
||||
}
|
||||
// #nosec G202 -- sqlArgs.add/addList only ever splice in the "$N"
|
||||
// placeholder they hand back, never a value; every value travels through
|
||||
// args.all() as a bound parameter. See the sqlArgs doc comment in
|
||||
// helpers.go.
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
UPDATE alerts
|
||||
SET status = 'resolved',
|
||||
@@ -126,7 +147,7 @@ func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip
|
||||
continue
|
||||
}
|
||||
alertID := id
|
||||
if err := logEvent(ctx, db, incidentID, evAlertResolved, nil, &alertID, nil); err != nil {
|
||||
if err := logEvent(ctx, db, incidentID, evAlertResolved, nil, nil, &alertID, nil); err != nil {
|
||||
log.Printf("sweeper: log expiry event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+145
-64
@@ -45,57 +45,85 @@ var dummyHash = sync.OnceValue(func() []byte {
|
||||
return h
|
||||
})
|
||||
|
||||
// loginLimiter counts failed logins in a fixed window, per username and per
|
||||
// client address. The username limit is what stops guessing one account; the
|
||||
// address limit is looser because every user behind the same gateway or NAT
|
||||
// shares it.
|
||||
// loginLimiter counts failed logins (and other unauthenticated attempts:
|
||||
// sign-up, OIDC/device start) in a fixed window, per key — a username, a
|
||||
// client address, or both, depending on the caller.
|
||||
//
|
||||
// Backed by Postgres rather than an in-memory map: this server runs more
|
||||
// than one replica in production (v0.37.0), and a counter that only ever
|
||||
// sees its own pod's traffic would quietly let every limit through
|
||||
// multiplied by the replica count — two loginLimiter values pointed at the
|
||||
// same db, standing in for two replicas, now share exactly one count per
|
||||
// key instead of each keeping their own.
|
||||
//
|
||||
// The window resets rather than slides, the same behavior the in-memory
|
||||
// version it replaces had: once a key's window is older than loginWindow,
|
||||
// the next fail() starts a fresh one instead of extending the stale one.
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
failures map[string]*loginWindowCount
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type loginWindowCount struct {
|
||||
start time.Time
|
||||
n int
|
||||
func newLoginLimiter(db *sql.DB) *loginLimiter {
|
||||
return &loginLimiter{db: db}
|
||||
}
|
||||
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
return &loginLimiter{failures: map[string]*loginWindowCount{}}
|
||||
}
|
||||
|
||||
func (l *loginLimiter) blocked(key string, max int) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
c, ok := l.failures[key]
|
||||
if !ok || time.Since(c.start) > loginWindow {
|
||||
func (l *loginLimiter) blocked(ctx context.Context, key string, max int) bool {
|
||||
cutoff := time.Now().Unix() - int64(loginWindow.Seconds())
|
||||
var count int
|
||||
err := l.db.QueryRowContext(ctx, `
|
||||
SELECT count FROM rate_limit_counters
|
||||
WHERE key = $1 AND window_start > $2`,
|
||||
key, cutoff,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
// No row (never failed, or its window already expired): not blocked.
|
||||
// A real query error fails the same way — a rate limiter that locks
|
||||
// everyone out during a brief database hiccup is worse than one that
|
||||
// is briefly too generous.
|
||||
return false
|
||||
}
|
||||
return c.n >= max
|
||||
return count >= max
|
||||
}
|
||||
|
||||
func (l *loginLimiter) fail(keys ...string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
for k, c := range l.failures {
|
||||
if now.Sub(c.start) > loginWindow {
|
||||
delete(l.failures, k)
|
||||
}
|
||||
}
|
||||
func (l *loginLimiter) fail(ctx context.Context, keys ...string) {
|
||||
now := time.Now().Unix()
|
||||
windowSecs := int64(loginWindow.Seconds())
|
||||
for _, key := range keys {
|
||||
c, ok := l.failures[key]
|
||||
if !ok {
|
||||
c = &loginWindowCount{start: now}
|
||||
l.failures[key] = c
|
||||
if _, err := l.db.ExecContext(ctx, `
|
||||
INSERT INTO rate_limit_counters (key, window_start, count)
|
||||
VALUES ($1, $2, 1)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
window_start = CASE WHEN rate_limit_counters.window_start <= $2 - $3
|
||||
THEN $2 ELSE rate_limit_counters.window_start END,
|
||||
count = CASE WHEN rate_limit_counters.window_start <= $2 - $3
|
||||
THEN 1 ELSE rate_limit_counters.count + 1 END`,
|
||||
key, now, windowSecs,
|
||||
); err != nil {
|
||||
log.Printf("rate limiter: record failure for %q: %v", key, err) // #nosec G706 -- %q
|
||||
}
|
||||
c.n++
|
||||
}
|
||||
}
|
||||
|
||||
func (l *loginLimiter) clear(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.failures, key)
|
||||
func (l *loginLimiter) clear(ctx context.Context, key string) {
|
||||
if _, err := l.db.ExecContext(ctx, "DELETE FROM rate_limit_counters WHERE key = $1", key); err != nil {
|
||||
log.Printf("rate limiter: clear %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// purgeRateLimits deletes rate-limit windows that have expired, from the
|
||||
// sweeper — otherwise every distinct username and address this server has
|
||||
// ever seen a failed attempt from would stay a row forever.
|
||||
func purgeRateLimits(ctx context.Context, db *sql.DB) {
|
||||
cutoff := time.Now().Unix() - int64(loginWindow.Seconds())
|
||||
res, err := db.ExecContext(ctx,
|
||||
"DELETE FROM rate_limit_counters WHERE window_start <= $1", cutoff)
|
||||
if err != nil {
|
||||
log.Printf("sweeper: purge rate limit counters: %v", err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
log.Printf("sweeper: purged %d expired rate limit counter(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// clientAddr is the address a login is counted against. Behind the gateway
|
||||
@@ -139,6 +167,53 @@ func hashPassword(pw string) (string, error) {
|
||||
return string(h), err
|
||||
}
|
||||
|
||||
// startSession mints a session and sets the cookie. Shared by login and
|
||||
// sign-up: somebody who has just chosen a password is signed in, rather than
|
||||
// being sent to a form to type the same credential again.
|
||||
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
|
||||
return startSessionCapped(w, r, db, userID, publicURL, 0)
|
||||
}
|
||||
|
||||
// startSessionCapped is startSession with a hard ceiling on the session's life,
|
||||
// which sliding never extends. maxAge zero means no ceiling. A single sign-on
|
||||
// login uses it: the login is the only moment the provider's groups are read, so
|
||||
// a session that could outlive it indefinitely would keep access the provider
|
||||
// has since taken away.
|
||||
func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string, maxAge time.Duration) error {
|
||||
raw, tokenHash, err := randomToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
life := sessionTTL
|
||||
var ceiling *int64
|
||||
if maxAge > 0 {
|
||||
c := now.Add(maxAge).Unix()
|
||||
ceiling = &c
|
||||
life = min(life, maxAge)
|
||||
}
|
||||
if _, err := db.ExecContext(r.Context(), `
|
||||
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, max_expires_at, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
tokenHash, userID, now.Unix(), now.Unix(), now.Add(life).Unix(), ceiling, r.UserAgent()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what trips
|
||||
// this rule. See cookieSecure's own doc comment above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: raw,
|
||||
Path: "/",
|
||||
MaxAge: int(life.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: cookieSecure(publicURL, r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleLogin exchanges a username and password for a session cookie.
|
||||
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -154,7 +229,7 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
|
||||
userKey := "user:" + strings.ToLower(username)
|
||||
addrKey := "addr:" + clientAddr(r)
|
||||
|
||||
if limiter.blocked(userKey, loginMaxPerUser) || limiter.blocked(addrKey, loginMaxPerAddr) {
|
||||
if limiter.blocked(r.Context(), userKey, loginMaxPerUser) || limiter.blocked(r.Context(), addrKey, loginMaxPerAddr) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many failed attempts, try again later"))
|
||||
return
|
||||
@@ -176,35 +251,16 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
|
||||
}
|
||||
match := bcrypt.CompareHashAndPassword(stored, []byte(req.Password)) == nil
|
||||
if !match || !hash.Valid {
|
||||
limiter.fail(userKey, addrKey)
|
||||
limiter.fail(r.Context(), userKey, addrKey)
|
||||
respond(w, http.StatusUnauthorized, errResp("invalid username or password"))
|
||||
return
|
||||
}
|
||||
limiter.clear(userKey)
|
||||
limiter.clear(r.Context(), userKey)
|
||||
|
||||
raw, tokenHash, err := randomToken()
|
||||
if err != nil {
|
||||
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if _, err := db.ExecContext(r.Context(), `
|
||||
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: raw,
|
||||
Path: "/",
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: cookieSecure(publicURL, r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
user, err := fetchUser(r.Context(), db, userID)
|
||||
if err != nil {
|
||||
@@ -227,6 +283,9 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
||||
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value))
|
||||
}
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what
|
||||
// trips this rule. See cookieSecure's own doc comment above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
@@ -243,22 +302,44 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||
type meResponse struct {
|
||||
User any `json:"user"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
|
||||
// OnboardingDismissed is whether this person has put the first-run
|
||||
// checklist away. Per user rather than per browser: somebody who finishes
|
||||
// setting up on a laptop should not be nagged again on their phone.
|
||||
OnboardingDismissed bool `json:"onboarding_dismissed"`
|
||||
}
|
||||
|
||||
// handleMe says who the caller is. The web UI calls it on load to decide
|
||||
// between the login form and the app, since it cannot read its own cookie.
|
||||
func handleMe(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
caller, _ := userFromContext(r.Context())
|
||||
caller, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
respond(w, http.StatusForbidden, errResp("this endpoint is for human accounts only"))
|
||||
return
|
||||
}
|
||||
user, err := fetchUser(r.Context(), db, caller.ID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
var hash sql.NullString
|
||||
db.QueryRowContext(r.Context(),
|
||||
"SELECT password_hash FROM users WHERE id = $1", caller.ID).Scan(&hash)
|
||||
respond(w, http.StatusOK, meResponse{User: user, HasPassword: hash.Valid})
|
||||
var dismissed *int64
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
|
||||
caller.ID).Scan(&hash, &dismissed); err != nil {
|
||||
// fetchUser above already found this row, so an error here is a
|
||||
// transient database problem, not a missing user — worth a 500
|
||||
// rather than silently answering "no password, not dismissed",
|
||||
// which a client would otherwise take at face value.
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, meResponse{
|
||||
User: user,
|
||||
HasPassword: hash.Valid,
|
||||
OnboardingDismissed: dismissed != nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 + `"}`
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This file is the regression test for the pattern documented throughout
|
||||
// middleware.go: every team-scoped handler calls requireTeamMember or
|
||||
// requireTeamOwner before touching data, every self-or-admin handler calls
|
||||
// requireSelfOrAdmin, and every admin-only route sits behind AdminOnly. That
|
||||
// pattern is enforced by convention, not by the type system — a new handler
|
||||
// that forgets the call would compile and pass review on a quick read just
|
||||
// as easily as one that remembers it. These tests exercise every route that
|
||||
// carries one of those guards as a caller who should be refused, so a future
|
||||
// handler missing its guard fails CI instead of becoming a silent IDOR.
|
||||
|
||||
// TestAuthzScope_TeamScopedRoutesRefuseANonMember builds two teams and, for
|
||||
// every team-scoped route, calls it as team A's owner against team B's
|
||||
// resources. requireTeamMember and requireTeamOwner both answer a non-member
|
||||
// with 404 (team.go's own reasoning: whether a team exists is itself
|
||||
// something only its members should learn), so every one of these must come
|
||||
// back 404 regardless of which of the two guards its handler uses.
|
||||
func TestAuthzScope_TeamScopedRoutesRefuseANonMember(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "authz-a")
|
||||
b := newTeam(t, s, "authz-b")
|
||||
|
||||
// An incident in B, to cover the ID-based routes under /api/incidents —
|
||||
// scoped by the incident's own team_id rather than a {teamID} path
|
||||
// segment, but through the same single chokepoint (incidentIDParam).
|
||||
postToIntegration(t, s, b.key, "fp-authz-scope", "AuthzScopeAlert")
|
||||
var incidents []struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, b.call(http.MethodGet, "/api/incidents", nil), &incidents)
|
||||
if len(incidents) == 0 {
|
||||
t.Fatal("setup: no incident in team B to test against")
|
||||
}
|
||||
incidentPath := "/api/incidents/" + id64(incidents[0].ID)
|
||||
|
||||
bPath := "/api/teams/" + id64(b.id)
|
||||
tests := []struct {
|
||||
method, path string
|
||||
}{
|
||||
// Team membership/ownership itself.
|
||||
{http.MethodPut, bPath},
|
||||
{http.MethodDelete, bPath},
|
||||
{http.MethodGet, bPath + "/members"},
|
||||
{http.MethodPost, bPath + "/members"},
|
||||
{http.MethodDelete, bPath + "/members/1"},
|
||||
|
||||
// OIDC group binding.
|
||||
{http.MethodGet, bPath + "/oidc-groups"},
|
||||
{http.MethodPut, bPath + "/oidc-groups"},
|
||||
|
||||
// Invites.
|
||||
{http.MethodGet, bPath + "/invites"},
|
||||
{http.MethodPost, bPath + "/invites"},
|
||||
{http.MethodDelete, bPath + "/invites/1"},
|
||||
|
||||
// Escalation.
|
||||
{http.MethodGet, bPath + "/escalation"},
|
||||
{http.MethodPut, bPath + "/escalation"},
|
||||
|
||||
// Dead man's switches.
|
||||
{http.MethodGet, bPath + "/deadman/switches"},
|
||||
{http.MethodPost, bPath + "/deadman/switches"},
|
||||
{http.MethodPut, bPath + "/deadman/switches/1"},
|
||||
{http.MethodDelete, bPath + "/deadman/switches/1"},
|
||||
|
||||
// Integrations.
|
||||
{http.MethodGet, bPath + "/integrations"},
|
||||
{http.MethodPost, bPath + "/integrations"},
|
||||
{http.MethodPatch, bPath + "/integrations/1"},
|
||||
{http.MethodDelete, bPath + "/integrations/1"},
|
||||
|
||||
// Schedule.
|
||||
{http.MethodGet, bPath + "/schedule"},
|
||||
{http.MethodPost, bPath + "/schedule"},
|
||||
{http.MethodDelete, bPath + "/schedule/1"},
|
||||
|
||||
// Incidents, scoped by the incident's own team rather than a
|
||||
// {teamID} segment.
|
||||
{http.MethodGet, incidentPath},
|
||||
{http.MethodGet, incidentPath + "/alerts"},
|
||||
{http.MethodGet, incidentPath + "/timeline"},
|
||||
{http.MethodGet, incidentPath + "/similar"},
|
||||
{http.MethodPost, incidentPath + "/acknowledge"},
|
||||
{http.MethodDelete, incidentPath + "/acknowledge"},
|
||||
{http.MethodPost, incidentPath + "/resolve"},
|
||||
{http.MethodPost, incidentPath + "/assign"},
|
||||
{http.MethodPost, incidentPath + "/snooze"},
|
||||
{http.MethodDelete, incidentPath + "/snooze"},
|
||||
{http.MethodPost, incidentPath + "/archive"},
|
||||
{http.MethodDelete, incidentPath + "/archive"},
|
||||
{http.MethodPost, incidentPath + "/notes"},
|
||||
{http.MethodDelete, incidentPath + "/notes/1"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
resp := a.call(tc.method, tc.path, nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d (A is not a member of B)", resp.StatusCode, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin exercises AdminOnly's group
|
||||
// in router.go directly: a signed-in, non-admin caller gets 403 from every
|
||||
// route in it, before any handler body runs.
|
||||
func TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "authz-admin")
|
||||
|
||||
tests := []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodPost, "/api/users"},
|
||||
{http.MethodDelete, "/api/users/1"},
|
||||
{http.MethodPut, "/api/users/1/admin"},
|
||||
{http.MethodPut, "/api/users/1/disabled"},
|
||||
{http.MethodGet, "/api/admin/teams"},
|
||||
{http.MethodGet, "/api/admin/teams/" + id64(a.id)},
|
||||
{http.MethodGet, "/api/admin/settings"},
|
||||
{http.MethodPut, "/api/admin/settings"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
resp := a.call(tc.method, tc.path, nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d (not an admin)", resp.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser exercises
|
||||
// requireSelfOrAdmin's call sites: a non-admin caller acting on a *different*
|
||||
// user's account must be refused, the same as AdminOnly's routes, even
|
||||
// though these sit in the general authenticated group rather than behind
|
||||
// AdminOnly itself.
|
||||
func TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser(t *testing.T) {
|
||||
s := newTS(t)
|
||||
a := newTeam(t, s, "authz-self-a")
|
||||
b := newTeam(t, s, "authz-self-b")
|
||||
|
||||
var members []struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams/"+id64(b.id)+"/members", nil), &members)
|
||||
if len(members) == 0 {
|
||||
t.Fatal("setup: team B has no members")
|
||||
}
|
||||
bUserID := id64(members[0].UserID)
|
||||
|
||||
tests := []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "/api/users/" + bUserID + "/teams"},
|
||||
{http.MethodPut, "/api/users/" + bUserID + "/notify"},
|
||||
{http.MethodPut, "/api/users/" + bUserID + "/password"},
|
||||
{http.MethodGet, "/api/users/" + bUserID + "/api-keys"},
|
||||
{http.MethodPost, "/api/users/" + bUserID + "/api-keys"},
|
||||
{http.MethodDelete, "/api/users/" + bUserID + "/api-keys/1"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
resp := a.call(tc.method, tc.path, nil)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d (not self, not an admin)", resp.StatusCode, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
// Caller is the one principal type every authorization predicate in this
|
||||
// package reads from. Before this, a human (ctxUser + ctxTeams) and a
|
||||
// service account (ctxServiceAccount + a synthetic ctxTeams entry) were two
|
||||
// parallel, un-unified context representations — every predicate had to
|
||||
// remember which one(s) it needed to check, and the ones that forgot either
|
||||
// 403'd a service account that should have been let through (terdut-server#23,
|
||||
// terdut-operator#3), crashed on an unchecked zero-value user ID (handleMe,
|
||||
// handleTestNotification), or silently no-op'd (handleDismissOnboarding).
|
||||
// serveAs and serveAsServiceAccount now both build exactly one Caller and
|
||||
// store it under one context key; everything else in this file is a read
|
||||
// of one of its methods.
|
||||
type Caller struct {
|
||||
// user is set for a human caller (session cookie or a user's own API
|
||||
// key), nil for a service account of either scope.
|
||||
user *models.User
|
||||
|
||||
// sa is set for a service-account caller, nil for a human.
|
||||
sa *serviceAccountPrincipal
|
||||
|
||||
// memberships is the caller's real team_members rows for a human, or —
|
||||
// for a team-scoped service account — the single synthetic owner
|
||||
// membership serveAsServiceAccount injects (see its own comment for
|
||||
// why). Always nil for an instance-scoped service account: it acts on
|
||||
// teams by id, not by belonging to one.
|
||||
memberships []membership
|
||||
}
|
||||
|
||||
// AsHuman returns the real user behind this caller, or false for a service
|
||||
// account of either scope. Every handler that needs a real user_id to act
|
||||
// on behalf of — not just "is this caller sufficiently privileged" — calls
|
||||
// this and handles the false case explicitly, replacing the unchecked
|
||||
// userFromContext(ctx) zero-value reads that used to silently misbehave for
|
||||
// a service-account caller.
|
||||
func (c Caller) AsHuman() (models.User, bool) {
|
||||
if c.user == nil {
|
||||
return models.User{}, false
|
||||
}
|
||||
return *c.user, true
|
||||
}
|
||||
|
||||
// IsAdmin is true only for a human system administrator — never for a
|
||||
// service account, of either scope, under any circumstance. AdminOnly and
|
||||
// requireSelfOrAdmin key on this and nothing else: user management and
|
||||
// /api/admin/settings stay human-only forever, by design (SERVICE-ACCOUNTS.md).
|
||||
func (c Caller) IsAdmin() bool {
|
||||
return c.user != nil && c.user.IsAdmin
|
||||
}
|
||||
|
||||
// IsInstanceServiceAccount reports whether this caller is specifically an
|
||||
// instance-scoped service account — never true for a human, including a
|
||||
// human admin. handleCreateTeam needs exactly this: a human creates a team
|
||||
// by being a human (and becomes its owner as a side effect), an
|
||||
// instance-scoped service account creates one with no human owner at all;
|
||||
// the two paths are not interchangeable, so this predicate must not also
|
||||
// admit a human admin the way MayActAsInstanceAdmin deliberately does.
|
||||
func (c Caller) IsInstanceServiceAccount() bool {
|
||||
return c.sa != nil && c.sa.scope == models.ServiceAccountScopeInstance
|
||||
}
|
||||
|
||||
// Role reports the caller's role in teamID, and whether they belong to it
|
||||
// at all.
|
||||
func (c Caller) Role(teamID int64) (string, bool) {
|
||||
for _, m := range c.memberships {
|
||||
if m.teamID == teamID {
|
||||
return m.role, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// TeamIDs lists every team this caller belongs to: a human's real
|
||||
// memberships, or a team-scoped service account's own single team. Always
|
||||
// empty for an instance-scoped service account.
|
||||
func (c Caller) TeamIDs() []int64 {
|
||||
ids := make([]int64, 0, len(c.memberships))
|
||||
for _, m := range c.memberships {
|
||||
ids = append(ids, m.teamID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ServiceAccountID reports this caller's own service-account id, for the
|
||||
// "may manage/rotate its own credential" self-check in
|
||||
// callerMayManageServiceAccount, and for OperatorModeBlock's "any service
|
||||
// account passes" rule.
|
||||
func (c Caller) ServiceAccountID() (int64, bool) {
|
||||
if c.sa == nil {
|
||||
return 0, false
|
||||
}
|
||||
return c.sa.id, true
|
||||
}
|
||||
|
||||
// ServiceAccountName reports this caller's own service-account name, for a
|
||||
// handler's synchronous response — the same credential it authenticated
|
||||
// with, already resolved onto the Caller by serveAsServiceAccount, so no
|
||||
// extra query is needed.
|
||||
func (c Caller) ServiceAccountName() (string, bool) {
|
||||
if c.sa == nil {
|
||||
return "", false
|
||||
}
|
||||
return c.sa.name, true
|
||||
}
|
||||
|
||||
// Identity is a stable, log/audit-facing string distinguishing a human
|
||||
// caller from a service account — "user:42" or "service-account:7". Not
|
||||
// wired into any database column — incidents.go's acknowledged_by/
|
||||
// incident_events.user_id use AsHuman()/ServiceAccountID() directly against
|
||||
// the parallel *_service_account_id columns (migration 015) instead, since a
|
||||
// column needs the id, not this rendered string. assigned_to stays
|
||||
// human-only and out of scope (terdut-server#25's follow-up).
|
||||
func (c Caller) Identity() string {
|
||||
switch {
|
||||
case c.user != nil:
|
||||
return fmt.Sprintf("user:%d", c.user.ID)
|
||||
case c.sa != nil:
|
||||
return fmt.Sprintf("service-account:%d", c.sa.id)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func callerFromContext(ctx context.Context) (Caller, bool) {
|
||||
c, ok := ctx.Value(ctxCaller).(Caller)
|
||||
return c, ok
|
||||
}
|
||||
+360
-176
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -39,6 +41,17 @@ func (m DeadmanMatcher) String() string {
|
||||
return m.Name + " (" + strings.Join(parts, ", ") + ")"
|
||||
}
|
||||
|
||||
// config renders the matcher in the form parseDeadmanMatcher reads, which is
|
||||
// what a switch row stores: `alertname=Watchdog,cluster=prod`.
|
||||
func (m DeadmanMatcher) config() string {
|
||||
parts := make([]string, 0, len(m.Labels))
|
||||
for k, v := range m.Labels {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(append([]string{"alertname=" + m.Name}, parts...), ",")
|
||||
}
|
||||
|
||||
// matches reports whether an alert's labels satisfy every condition.
|
||||
func (m DeadmanMatcher) matches(labels map[string]string) bool {
|
||||
if labels["alertname"] != m.Name {
|
||||
@@ -52,12 +65,10 @@ func (m DeadmanMatcher) matches(labels map[string]string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// DeadmanConfig inverts the handling of the alerts it matches: receiving one
|
||||
// opens nothing, and the absence of one opens an incident.
|
||||
//
|
||||
// The unit of monitoring is the fingerprint, not the matcher — two clusters
|
||||
// sending the same heartbeat alertname are two independent switches, so one
|
||||
// healthy cluster cannot mask a dead one.
|
||||
// DeadmanConfig is the server-wide default a team's switches are seeded from:
|
||||
// the environment's matchers, timeout and severity. Switches themselves are rows
|
||||
// of a team's own — see DeadmanSwitch — and this is only how a fresh install
|
||||
// starts out.
|
||||
type DeadmanConfig struct {
|
||||
Matchers []DeadmanMatcher
|
||||
|
||||
@@ -76,41 +87,81 @@ type DeadmanConfig struct {
|
||||
// enabled reports whether there is anything to watch.
|
||||
func (c DeadmanConfig) enabled() bool { return c.Timeout > 0 && len(c.Matchers) > 0 }
|
||||
|
||||
// match returns the first matcher an alert satisfies.
|
||||
func (c DeadmanConfig) match(labels map[string]string) (DeadmanMatcher, bool) {
|
||||
if !c.enabled() {
|
||||
return DeadmanMatcher{}, false
|
||||
}
|
||||
for _, m := range c.Matchers {
|
||||
if m.matches(labels) {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return DeadmanMatcher{}, false
|
||||
// DeadmanSwitch inverts the handling of the alerts it matches: receiving one
|
||||
// opens nothing, and the absence of one opens an incident.
|
||||
//
|
||||
// The unit of monitoring is the fingerprint, not the switch — two clusters
|
||||
// sending the same heartbeat alertname are two independent heartbeats under one
|
||||
// switch, so one healthy cluster cannot mask a dead one.
|
||||
type DeadmanSwitch struct {
|
||||
ID int64
|
||||
Name string
|
||||
Matcher DeadmanMatcher
|
||||
|
||||
// Timeout is how long a heartbeat may go unheard before it is declared dead.
|
||||
Timeout time.Duration
|
||||
|
||||
// Severity is what the incident opens at.
|
||||
Severity string
|
||||
}
|
||||
|
||||
// isDeadman is match without the matcher, for the ingest path.
|
||||
func (c DeadmanConfig) isDeadman(labels map[string]string) bool {
|
||||
_, ok := c.match(labels)
|
||||
// deadmanSet is one team's switches.
|
||||
type deadmanSet []DeadmanSwitch
|
||||
|
||||
// match returns the first switch an alert satisfies.
|
||||
func (d deadmanSet) match(labels map[string]string) (DeadmanSwitch, bool) {
|
||||
for _, sw := range d {
|
||||
if sw.Matcher.matches(labels) {
|
||||
return sw, true
|
||||
}
|
||||
}
|
||||
return DeadmanSwitch{}, false
|
||||
}
|
||||
|
||||
// isDeadman is match without the switch, for the ingest path.
|
||||
func (d deadmanSet) isDeadman(labels map[string]string) bool {
|
||||
_, ok := d.match(labels)
|
||||
return ok
|
||||
}
|
||||
|
||||
// names lists the distinct alertnames worth loading from the database.
|
||||
func (c DeadmanConfig) names() []string {
|
||||
func (d deadmanSet) names() []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(c.Matchers))
|
||||
for _, m := range c.Matchers {
|
||||
if !seen[m.Name] {
|
||||
seen[m.Name] = true
|
||||
out = append(out, m.Name)
|
||||
out := make([]string, 0, len(d))
|
||||
for _, sw := range d {
|
||||
if !seen[sw.Matcher.Name] {
|
||||
seen[sw.Matcher.Name] = true
|
||||
out = append(out, sw.Matcher.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseDeadmanMatcher reads one matcher from its configured form: "," separates
|
||||
// the conditions and "=" is exact label equality — `alertname=Watchdog,cluster=prod`.
|
||||
// The error says what is wrong with it, in words a form can show.
|
||||
func parseDeadmanMatcher(entry string) (DeadmanMatcher, error) {
|
||||
m := DeadmanMatcher{Labels: map[string]string{}}
|
||||
for _, cond := range strings.Split(strings.TrimSpace(entry), ",") {
|
||||
k, v, ok := strings.Cut(cond, "=")
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
if !ok || k == "" || v == "" {
|
||||
return DeadmanMatcher{}, fmt.Errorf("%q is not label=value", strings.TrimSpace(cond))
|
||||
}
|
||||
if k == "alertname" {
|
||||
m.Name = v
|
||||
continue
|
||||
}
|
||||
m.Labels[k] = v
|
||||
}
|
||||
if m.Name == "" {
|
||||
return DeadmanMatcher{}, errors.New("no alertname condition")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ParseDeadmanConfig reads the matcher list from its configured form:
|
||||
// ";" separates matchers, "," separates the conditions within one, and "=" is
|
||||
// exact label equality — `alertname=Watchdog,cluster=prod; alertname=Heartbeat`.
|
||||
// ";" separates matchers, and each is parsed as parseDeadmanMatcher does.
|
||||
//
|
||||
// A malformed or alertname-less entry is dropped rather than fatal, following
|
||||
// config.duration's rule that one bad tuning knob should not take the server
|
||||
@@ -125,28 +176,9 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
m := DeadmanMatcher{Labels: map[string]string{}}
|
||||
malformed := false
|
||||
for _, cond := range strings.Split(entry, ",") {
|
||||
k, v, ok := strings.Cut(cond, "=")
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
if !ok || k == "" || v == "" {
|
||||
log.Printf("deadman: ignoring matcher %q: %q is not label=value", entry, strings.TrimSpace(cond))
|
||||
malformed = true
|
||||
break
|
||||
}
|
||||
if k == "alertname" {
|
||||
m.Name = v
|
||||
continue
|
||||
}
|
||||
m.Labels[k] = v
|
||||
}
|
||||
if malformed {
|
||||
continue
|
||||
}
|
||||
if m.Name == "" {
|
||||
log.Printf("deadman: ignoring matcher %q: no alertname condition", entry)
|
||||
m, err := parseDeadmanMatcher(entry)
|
||||
if err != nil {
|
||||
log.Printf("deadman: ignoring matcher %q: %v", entry, err)
|
||||
continue
|
||||
}
|
||||
cfg.Matchers = append(cfg.Matchers, m)
|
||||
@@ -162,23 +194,35 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
|
||||
for _, m := range cfg.Matchers {
|
||||
rendered = append(rendered, m.String())
|
||||
}
|
||||
log.Printf("deadman: watching %s, timeout %s, severity %s",
|
||||
log.Printf("deadman: default for new teams: %s, timeout %s, severity %s",
|
||||
strings.Join(rendered, "; "), timeout, severity)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// deadmanAlert is one switch: the alert row carrying its last heartbeat.
|
||||
// deadmanAlert is one heartbeat: the alert row carrying its last sighting, and
|
||||
// the switch that claimed it.
|
||||
type deadmanAlert struct {
|
||||
id int64
|
||||
teamID int64
|
||||
fingerprint string
|
||||
labels map[string]string
|
||||
matcher DeadmanMatcher
|
||||
sw DeadmanSwitch
|
||||
resolved bool
|
||||
receivedAt int64
|
||||
}
|
||||
|
||||
// dead is the one rule for a silent heartbeat, shared by the sweeper that pages
|
||||
// on it and the status the Switches page shows, so the page cannot disagree
|
||||
// with the pager.
|
||||
//
|
||||
// An explicit resolved from Alertmanager is a stronger death signal than mere
|
||||
// absence: the sender is telling us the heartbeat stopped, so there is nothing
|
||||
// left to wait out.
|
||||
func (a deadmanAlert) dead(now time.Time) bool {
|
||||
return a.resolved || a.receivedAt < now.Add(-a.sw.Timeout).Unix()
|
||||
}
|
||||
|
||||
// groupKey is the switch's identity as an incident. Per fingerprint, so each
|
||||
// source is tracked on its own.
|
||||
func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerprint }
|
||||
@@ -189,13 +233,13 @@ func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerpr
|
||||
// It returns the ids of the alerts it owns, because the generic staleness
|
||||
// expiry must leave them alone — staleAfter and ends_at would otherwise resolve
|
||||
// a heartbeat long before its own, much tighter, timeout ever fired.
|
||||
// Each team is swept against its own configuration: its own matchers, its own
|
||||
// timeout, its own severity. A team watching nothing is skipped entirely, which
|
||||
// is most of them.
|
||||
// Each team is swept against its own switches, each with its own matcher,
|
||||
// timeout and severity. A team watching nothing is skipped entirely, which is
|
||||
// most of them.
|
||||
func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int64]bool {
|
||||
owned := map[int64]bool{}
|
||||
|
||||
configs, err := deadmanConfigs(ctx, db)
|
||||
configs, err := deadmanSets(ctx, db)
|
||||
if err != nil {
|
||||
log.Printf("deadman: load configs: %v", err)
|
||||
return owned
|
||||
@@ -203,39 +247,35 @@ func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int6
|
||||
|
||||
now := time.Now()
|
||||
for teamID, cfg := range configs {
|
||||
switches, err := deadmanAlerts(ctx, db, teamID, cfg)
|
||||
heartbeats, err := deadmanAlerts(ctx, db, teamID, cfg)
|
||||
if err != nil {
|
||||
log.Printf("deadman: load switches for team %d: %v", teamID, err)
|
||||
log.Printf("deadman: load heartbeats for team %d: %v", teamID, err)
|
||||
continue
|
||||
}
|
||||
cutoff := now.Add(-cfg.Timeout).Unix()
|
||||
|
||||
for _, sw := range switches {
|
||||
owned[sw.id] = true
|
||||
for _, hb := range heartbeats {
|
||||
owned[hb.id] = true
|
||||
|
||||
// An explicit resolved from Alertmanager is a stronger death signal
|
||||
// than mere absence: the sender is telling us the heartbeat
|
||||
// stopped, so there is nothing left to wait out.
|
||||
if sw.resolved || sw.receivedAt < cutoff {
|
||||
if err := deadmanDied(ctx, db, cfg, notify, sw, now); err != nil {
|
||||
log.Printf("deadman: open incident for %s: %v", sw.matcher.Name, err)
|
||||
if hb.dead(now) {
|
||||
if err := deadmanDied(ctx, db, notify, hb, now); err != nil {
|
||||
log.Printf("deadman: open incident for %s: %v", hb.sw.Matcher.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := deadmanRecovered(ctx, db, sw); err != nil {
|
||||
log.Printf("deadman: resolve incident for %s: %v", sw.matcher.Name, err)
|
||||
if err := deadmanRecovered(ctx, db, hb); err != nil {
|
||||
log.Printf("deadman: resolve incident for %s: %v", hb.sw.Matcher.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return owned
|
||||
}
|
||||
|
||||
// deadmanAlerts loads every alert row that a matcher claims. The candidate query
|
||||
// deadmanAlerts loads every alert row that one of a team's switches claims. The candidate query
|
||||
// is narrowed by alertname so it rides alerts_name_idx; the rest of the matching
|
||||
// happens in Go, which keeps one implementation of the rules. The rows are read
|
||||
// in full before the caller writes, so the writes do not run against an open
|
||||
// cursor over the same table.
|
||||
func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanConfig) ([]deadmanAlert, error) {
|
||||
func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg deadmanSet) ([]deadmanAlert, error) {
|
||||
names := cfg.names()
|
||||
args := &sqlArgs{}
|
||||
nameList := make([]any, len(names))
|
||||
@@ -243,6 +283,10 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanCon
|
||||
nameList[i] = n
|
||||
}
|
||||
|
||||
// #nosec G202 -- sqlArgs.add/addList only ever splice in the "$N"
|
||||
// placeholder they hand back, never a value; every value travels through
|
||||
// args.all() as a bound parameter. See the sqlArgs doc comment in
|
||||
// helpers.go.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, team_id, fingerprint, labels, status, received_at
|
||||
FROM alerts
|
||||
@@ -263,11 +307,11 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanCon
|
||||
}
|
||||
json.Unmarshal([]byte(labelsJSON), &a.labels) //nolint:errcheck
|
||||
|
||||
m, ok := cfg.match(a.labels)
|
||||
sw, ok := cfg.match(a.labels)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
a.matcher = m
|
||||
a.sw = sw
|
||||
a.resolved = status == "resolved"
|
||||
out = append(out, a)
|
||||
}
|
||||
@@ -284,16 +328,16 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanCon
|
||||
// incidentForGroup), and a source that is gone for good is a one-time page
|
||||
// rather than a nag. Only a heartbeat that comes back and dies again earns a new
|
||||
// incident.
|
||||
func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify NotifyConfig, sw deadmanAlert, now time.Time) error {
|
||||
func deadmanDied(ctx context.Context, db *sql.DB, notify NotifyConfig, hb deadmanAlert, now time.Time) error {
|
||||
var lastTriggered, open int64
|
||||
if err := db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(MAX(triggered_at), 0),
|
||||
COUNT(*) FILTER (WHERE resolved_at IS NULL)
|
||||
FROM incidents WHERE team_id = $1 AND group_key = $2`,
|
||||
sw.teamID, sw.groupKey()).Scan(&lastTriggered, &open); err != nil {
|
||||
hb.teamID, hb.groupKey()).Scan(&lastTriggered, &open); err != nil {
|
||||
return err
|
||||
}
|
||||
if open > 0 || sw.receivedAt <= lastTriggered {
|
||||
if open > 0 || hb.receivedAt <= lastTriggered {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -306,18 +350,18 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
|
||||
// A heartbeat nobody has heard from is not firing, and saying otherwise in
|
||||
// the alert list would be a lie. An Alertmanager-sourced resolution keeps its
|
||||
// own source: it told us the truth first.
|
||||
if !sw.resolved {
|
||||
if !hb.resolved {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE alerts
|
||||
SET status = 'resolved',
|
||||
resolution_source = $1,
|
||||
ends_at = COALESCE(ends_at, `+nowEpoch+`)
|
||||
WHERE id = $2 AND status = 'firing'`, resolutionDeadman, sw.id); err != nil {
|
||||
WHERE id = $2 AND status = 'firing'`, resolutionDeadman, hb.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
severity := cfg.Severity
|
||||
severity := hb.sw.Severity
|
||||
var sev *string
|
||||
if severity != "" {
|
||||
sev = &severity
|
||||
@@ -325,22 +369,22 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
|
||||
|
||||
// The incident opens in the team whose integration received the heartbeat:
|
||||
// the switch belongs to whoever is watching that source, not to the install.
|
||||
incidentID, err := openIncident(ctx, tx, notify, sw.teamID, sw.groupKey(),
|
||||
"No heartbeat from "+sw.matcher.String(), sw.labels, sev)
|
||||
incidentID, err := openIncident(ctx, tx, notify, hb.teamID, hb.groupKey(),
|
||||
"No heartbeat from "+hb.sw.Matcher.String(), hb.labels, sev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alertID := sw.id
|
||||
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(sw.receivedAt, 0))) + " ago"
|
||||
if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, &alertID, &detail); err != nil {
|
||||
alertID := hb.id
|
||||
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(hb.receivedAt, 0))) + " ago"
|
||||
if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, nil, &alertID, &detail); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("deadman: %s went silent, opened incident %d", sw.matcher.String(), incidentID)
|
||||
log.Printf("deadman: %s went silent, opened incident %d", hb.sw.Matcher.String(), incidentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -350,12 +394,12 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
|
||||
// member alerts (linking the heartbeat would have the settled-incident cascade
|
||||
// close it on the very same sweep that opened it), so the alert-driven cascade
|
||||
// ignores it entirely and recovery is the only automatic way out.
|
||||
func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
|
||||
func deadmanRecovered(ctx context.Context, db *sql.DB, hb deadmanAlert) error {
|
||||
var incidentID int64
|
||||
switch err := db.QueryRowContext(ctx, `
|
||||
SELECT id FROM incidents
|
||||
WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL`,
|
||||
sw.teamID, sw.groupKey()).Scan(&incidentID); {
|
||||
hb.teamID, hb.groupKey()).Scan(&incidentID); {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil
|
||||
case err != nil:
|
||||
@@ -375,7 +419,7 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
|
||||
time.Now().Unix(), incidentResolutionRecovered, incidentID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := logEvent(ctx, tx, incidentID, evResolved, nil, nil, nil); err != nil {
|
||||
if err := logEvent(ctx, tx, incidentID, evResolved, nil, nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
// The all-clear goes to whoever was paged, which enqueueResolved works out
|
||||
@@ -387,116 +431,256 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("deadman: %s is back, resolved incident %d", sw.matcher.String(), incidentID)
|
||||
log.Printf("deadman: %s is back, resolved incident %d", hb.sw.Matcher.String(), incidentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-team configuration
|
||||
// A team's switches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// deadmanConfigForTeam reads one team's switches. A team with no row, or with
|
||||
// nothing configured, gets a disabled config — which is the right answer rather
|
||||
// than an error: most teams watch no heartbeat at all.
|
||||
func deadmanConfigForTeam(ctx context.Context, q querier, teamID int64) (DeadmanConfig, error) {
|
||||
var matchers, severity string
|
||||
var timeout int64
|
||||
err := q.QueryRowContext(ctx,
|
||||
"SELECT matchers, timeout_seconds, severity FROM deadman_configs WHERE team_id = $1",
|
||||
teamID).Scan(&matchers, &timeout, &severity)
|
||||
if err == sql.ErrNoRows {
|
||||
return DeadmanConfig{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return DeadmanConfig{}, err
|
||||
}
|
||||
return parseDeadmanQuietly(matchers, time.Duration(timeout)*time.Second, severity), nil
|
||||
}
|
||||
const deadmanSwitchColumns = "id, team_id, name, matcher, timeout_seconds, severity"
|
||||
|
||||
// deadmanConfigs reads every team's switches in one query, for the sweeper.
|
||||
func deadmanConfigs(ctx context.Context, db *sql.DB) (map[int64]DeadmanConfig, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT team_id, matchers, timeout_seconds, severity FROM deadman_configs")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// scanDeadmanSwitches reads switch rows into per-team sets. A row whose matcher
|
||||
// no longer parses is skipped rather than fatal: the API refuses to store one,
|
||||
// so it can only mean a hand edit, and one bad row must not stop the others
|
||||
// from being watched.
|
||||
func scanDeadmanSwitches(rows *sql.Rows) (map[int64]deadmanSet, error) {
|
||||
defer rows.Close()
|
||||
|
||||
out := map[int64]DeadmanConfig{}
|
||||
out := map[int64]deadmanSet{}
|
||||
for rows.Next() {
|
||||
var sw DeadmanSwitch
|
||||
var teamID, timeout int64
|
||||
var matchers, severity string
|
||||
if err := rows.Scan(&teamID, &matchers, &timeout, &severity); err != nil {
|
||||
var matcher string
|
||||
if err := rows.Scan(&sw.ID, &teamID, &sw.Name, &matcher, &timeout, &sw.Severity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := parseDeadmanQuietly(matchers, time.Duration(timeout)*time.Second, severity)
|
||||
if cfg.enabled() {
|
||||
out[teamID] = cfg
|
||||
m, err := parseDeadmanMatcher(matcher)
|
||||
if err != nil {
|
||||
log.Printf("deadman: switch %d has an unusable matcher %q: %v", sw.ID, matcher, err)
|
||||
continue
|
||||
}
|
||||
sw.Matcher = m
|
||||
sw.Timeout = time.Duration(timeout) * time.Second
|
||||
out[teamID] = append(out[teamID], sw)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SeedDeadmanConfigs gives every team without a row the server's environment
|
||||
// configuration, so the install that upgrades into per-team switches keeps
|
||||
// watching exactly what it was watching before.
|
||||
// deadmanSetForTeam reads one team's switches. A team with none gets an empty
|
||||
// set — which is the right answer rather than an error: most teams watch no
|
||||
// heartbeat at all.
|
||||
func deadmanSetForTeam(ctx context.Context, q querier, teamID int64) (deadmanSet, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches WHERE team_id = $1 ORDER BY id", teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sets, err := scanDeadmanSwitches(rows)
|
||||
return sets[teamID], err
|
||||
}
|
||||
|
||||
// deadmanSets reads every team's switches in one query, for the sweeper.
|
||||
func deadmanSets(ctx context.Context, db *sql.DB) (map[int64]deadmanSet, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanDeadmanSwitches(rows)
|
||||
}
|
||||
|
||||
// deadmanSeededKey is the settings row that records the environment defaults
|
||||
// were handed out. Without it, a team that deleted its last switch would get
|
||||
// the default back on the next restart.
|
||||
const deadmanSeededKey = "deadman_seeded"
|
||||
|
||||
// SeedDeadmanConfigs gives every team the server's environment defaults as
|
||||
// switches, exactly once per install, so a fresh install watches Watchdog
|
||||
// without anybody setting it up.
|
||||
//
|
||||
// Idempotent, and never overwrites: once a team has a row it owns its own
|
||||
// configuration, and a redeploy must not quietly put the environment's value
|
||||
// back over an owner's edit.
|
||||
// Once seeded it never runs again: a team's switches are its own, and a redeploy
|
||||
// must not quietly put the environment's value back over an owner's edit or
|
||||
// deletion. Installs that upgraded from per-team configuration were already
|
||||
// seeded, which migration 009 records.
|
||||
//
|
||||
// A team created after startup gets no row and therefore watches nothing until
|
||||
// its owner says otherwise. That is deliberate: inheriting an install-wide
|
||||
// heartbeat would page a new team about a source it has never heard of, and a
|
||||
// switch nobody chose is the kind that gets muted rather than fixed.
|
||||
// A team created after that gets none and watches nothing until its owner says
|
||||
// otherwise. That is deliberate: inheriting an install-wide heartbeat would page
|
||||
// a new team about a source it has never heard of, and a switch nobody chose is
|
||||
// the kind that gets muted rather than fixed.
|
||||
func SeedDeadmanConfigs(ctx context.Context, db *sql.DB, cfg DeadmanConfig) error {
|
||||
matchers := make([]string, 0, len(cfg.Matchers))
|
||||
if !cfg.enabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
"INSERT INTO settings (key, value) VALUES ($1, '1') ON CONFLICT (key) DO NOTHING",
|
||||
deadmanSeededKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, m := range cfg.Matchers {
|
||||
parts := []string{"alertname=" + m.Name}
|
||||
for k, v := range m.Labels {
|
||||
parts = append(parts, k+"="+v)
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
|
||||
SELECT id, $1, $1, $2, $3 FROM teams`,
|
||||
m.config(), int64(cfg.Timeout.Seconds()), cfg.Severity); err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(parts[1:])
|
||||
matchers = append(matchers, strings.Join(parts, ","))
|
||||
}
|
||||
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO deadman_configs (team_id, matchers, timeout_seconds, severity)
|
||||
SELECT id, $1, $2, $3 FROM teams
|
||||
ON CONFLICT (team_id) DO NOTHING`,
|
||||
strings.Join(matchers, "; "), int64(cfg.Timeout.Seconds()), cfg.Severity)
|
||||
return err
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// parseDeadmanQuietly is ParseDeadmanConfig without the startup logging: a
|
||||
// team's configuration is read on every sweep and every webhook, and logging it
|
||||
// each time would bury everything else.
|
||||
func parseDeadmanQuietly(matchers string, timeout time.Duration, severity string) DeadmanConfig {
|
||||
cfg := DeadmanConfig{Timeout: timeout, Severity: severity}
|
||||
for _, entry := range strings.Split(matchers, ";") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
m := DeadmanMatcher{Labels: map[string]string{}}
|
||||
malformed := false
|
||||
for _, cond := range strings.Split(entry, ",") {
|
||||
k, v, ok := strings.Cut(cond, "=")
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
if !ok || k == "" || v == "" {
|
||||
malformed = true
|
||||
break
|
||||
}
|
||||
if k == "alertname" {
|
||||
m.Name = v
|
||||
continue
|
||||
}
|
||||
m.Labels[k] = v
|
||||
}
|
||||
if malformed || m.Name == "" {
|
||||
continue
|
||||
}
|
||||
cfg.Matchers = append(cfg.Matchers, m)
|
||||
}
|
||||
return cfg
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
switchHealthy = "healthy"
|
||||
switchDead = "dead"
|
||||
switchDormant = "dormant"
|
||||
)
|
||||
|
||||
// deadmanSource is one heartbeat under a switch: a fingerprint that matched.
|
||||
type deadmanSource struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
Status string `json:"status"`
|
||||
LastHeartbeatAt time.Time `json:"last_heartbeat_at"`
|
||||
LastTriggeredAt *time.Time `json:"last_triggered_at"`
|
||||
IncidentID *int64 `json:"incident_id"`
|
||||
}
|
||||
|
||||
// deadmanSwitchStatus is a switch as the Switches page shows it.
|
||||
type deadmanSwitchStatus struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Matcher string `json:"matcher"`
|
||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||
Severity string `json:"severity"`
|
||||
|
||||
// Status is dead when any source is, dormant when none has ever been heard
|
||||
// from, healthy otherwise — a live cluster must not hide a dead one.
|
||||
Status string `json:"status"`
|
||||
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
|
||||
LastTriggeredAt *time.Time `json:"last_triggered_at"`
|
||||
OpenIncidentID *int64 `json:"open_incident_id"`
|
||||
Sources []deadmanSource `json:"sources"`
|
||||
}
|
||||
|
||||
// deadmanStatuses reports every switch of a team with what its heartbeats are
|
||||
// doing. The liveness verdict is deadmanAlert.dead, the sweeper's own.
|
||||
func deadmanStatuses(ctx context.Context, db *sql.DB, teamID int64, set deadmanSet, now time.Time) ([]deadmanSwitchStatus, error) {
|
||||
out := make([]deadmanSwitchStatus, 0, len(set))
|
||||
if len(set) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
heartbeats, err := deadmanAlerts(ctx, db, teamID, set)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// One query for every switch's incident history, keyed the way the sweeper
|
||||
// keys it.
|
||||
type history struct {
|
||||
triggeredAt int64
|
||||
openID int64
|
||||
}
|
||||
incidents := map[string]history{}
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT group_key, MAX(triggered_at), COALESCE(MAX(id) FILTER (WHERE resolved_at IS NULL), 0)
|
||||
FROM incidents
|
||||
WHERE team_id = $1 AND group_key LIKE $2
|
||||
GROUP BY group_key`, teamID, deadmanGroupPrefix+"%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var h history
|
||||
if err := rows.Scan(&key, &h.triggeredAt, &h.openID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
incidents[key] = h
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bySwitch := map[int64][]deadmanAlert{}
|
||||
for _, hb := range heartbeats {
|
||||
bySwitch[hb.sw.ID] = append(bySwitch[hb.sw.ID], hb)
|
||||
}
|
||||
|
||||
later := func(cur *time.Time, unix int64) *time.Time {
|
||||
t := time.Unix(unix, 0).UTC()
|
||||
if cur == nil || t.After(*cur) {
|
||||
return &t
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
for _, sw := range set {
|
||||
st := deadmanSwitchStatus{
|
||||
ID: sw.ID, Name: sw.Name, Matcher: sw.Matcher.config(),
|
||||
TimeoutSeconds: int64(sw.Timeout.Seconds()), Severity: sw.Severity,
|
||||
Status: switchDormant, Sources: []deadmanSource{},
|
||||
}
|
||||
|
||||
for _, hb := range bySwitch[sw.ID] {
|
||||
src := deadmanSource{
|
||||
Fingerprint: hb.fingerprint,
|
||||
Labels: hb.labels,
|
||||
Status: switchHealthy,
|
||||
LastHeartbeatAt: time.Unix(hb.receivedAt, 0).UTC(),
|
||||
}
|
||||
if hb.dead(now) {
|
||||
src.Status = switchDead
|
||||
}
|
||||
if h, ok := incidents[hb.groupKey()]; ok {
|
||||
t := time.Unix(h.triggeredAt, 0).UTC()
|
||||
src.LastTriggeredAt = &t
|
||||
st.LastTriggeredAt = later(st.LastTriggeredAt, h.triggeredAt)
|
||||
if h.openID != 0 {
|
||||
id := h.openID
|
||||
src.IncidentID = &id
|
||||
if st.OpenIncidentID == nil || id > *st.OpenIncidentID {
|
||||
st.OpenIncidentID = &id
|
||||
}
|
||||
}
|
||||
}
|
||||
st.LastHeartbeatAt = later(st.LastHeartbeatAt, hb.receivedAt)
|
||||
st.Sources = append(st.Sources, src)
|
||||
|
||||
switch {
|
||||
case src.Status == switchDead:
|
||||
st.Status = switchDead
|
||||
case st.Status == switchDormant:
|
||||
st.Status = switchHealthy
|
||||
}
|
||||
}
|
||||
|
||||
// Dead ones first, then by fingerprint: what needs attention leads, and
|
||||
// the order does not shuffle between refreshes.
|
||||
sort.Slice(st.Sources, func(i, j int) bool {
|
||||
a, b := st.Sources[i], st.Sources[j]
|
||||
if (a.Status == switchDead) != (b.Status == switchDead) {
|
||||
return a.Status == switchDead
|
||||
}
|
||||
return a.Fingerprint < b.Fingerprint
|
||||
})
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
+166
-16
@@ -1,6 +1,7 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -483,13 +484,13 @@ func TestDeadman_ConfigurationIsPerTeam(t *testing.T) {
|
||||
unwatched := newTeam(t, s, "unwatched")
|
||||
|
||||
// Only the first team calls Watchdog a heartbeat.
|
||||
resp := s.req(t, http.MethodPut, "/api/teams/"+id64(watched.id)+"/deadman", map[string]any{
|
||||
"matchers": "alertname=Watchdog",
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(watched.id)+"/deadman/switches", map[string]any{
|
||||
"matcher": "alertname=Watchdog",
|
||||
"timeout_seconds": 3600,
|
||||
"severity": "critical",
|
||||
})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("configure the watched team: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -550,9 +551,9 @@ func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||
map[string]string{"name": "test"}), &key)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPut,
|
||||
s.URL+"/api/teams/"+id64(team.id)+"/deadman",
|
||||
strings.NewReader(`{"matchers":"alertname=Watchdog","timeout_seconds":60}`))
|
||||
req, _ := http.NewRequest(http.MethodPost,
|
||||
s.URL+"/api/teams/"+id64(team.id)+"/deadman/switches",
|
||||
strings.NewReader(`{"matcher":"alertname=Watchdog","timeout_seconds":60}`))
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
@@ -564,7 +565,7 @@ func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
|
||||
t.Errorf("a member editing the switches: expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
read, _ := http.NewRequest(http.MethodGet, s.URL+"/api/teams/"+id64(team.id)+"/deadman", nil)
|
||||
read, _ := http.NewRequest(http.MethodGet, s.URL+"/api/teams/"+id64(team.id)+"/deadman/switches", nil)
|
||||
read.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
got, err := http.DefaultClient.Do(read)
|
||||
if err != nil {
|
||||
@@ -577,16 +578,165 @@ func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
// A matcher with no alertname watches nothing, silently, which is the failure
|
||||
// this feature exists to prevent — so it is refused at the door.
|
||||
func TestDeadman_UnusableMatchersAreRejected(t *testing.T) {
|
||||
// this feature exists to prevent — so it is refused at the door, along with the
|
||||
// other things that would make a switch unable to fire.
|
||||
func TestDeadman_UnusableSwitchesAreRejected(t *testing.T) {
|
||||
s, _ := deadmanTS(t, deadmanCfg())
|
||||
|
||||
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman", map[string]any{
|
||||
"matchers": "cluster=prod",
|
||||
"timeout_seconds": 900,
|
||||
})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for a matcher with no alertname, got %d", resp.StatusCode)
|
||||
for name, body := range map[string]map[string]any{
|
||||
"no alertname": {"matcher": "cluster=prod", "timeout_seconds": 900},
|
||||
"malformed": {"matcher": "alertname=Watchdog,garbage", "timeout_seconds": 900},
|
||||
"several": {"matcher": "alertname=A; alertname=B", "timeout_seconds": 900},
|
||||
"zero timeout": {"matcher": "alertname=Watchdog", "timeout_seconds": 0},
|
||||
"bad severity": {"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "loud"},
|
||||
"empty matcher": {"matcher": "", "timeout_seconds": 900},
|
||||
} {
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("%s: expected 400, got %d", name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The switch list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// listSwitches reads the default team's switches as the Switches page does.
|
||||
func listSwitches(t *testing.T, s *ts) []map[string]any {
|
||||
t.Helper()
|
||||
return list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/deadman/switches", nil))
|
||||
}
|
||||
|
||||
// A switch is healthy while its heartbeat is fresh, dead once it is silent, and
|
||||
// dormant until the first one arrives.
|
||||
func TestDeadman_ListReportsStatus(t *testing.T) {
|
||||
s, _ := deadmanTS(t, api.ParseDeadmanConfig("alertname=Watchdog; alertname=NeverSent", time.Hour, "critical"))
|
||||
|
||||
got := listSwitches(t, s)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 switches, got %d", len(got))
|
||||
}
|
||||
for _, sw := range got {
|
||||
if sw["status"] != "dormant" || sw["last_heartbeat_at"] != nil || sw["last_triggered_at"] != nil {
|
||||
t.Errorf("a switch nobody has heard from should be dormant and blank, got %v", sw)
|
||||
}
|
||||
}
|
||||
|
||||
heartbeat(t, s, "fp-watchdog", nil)
|
||||
got = listSwitches(t, s)
|
||||
if got[0]["status"] != "healthy" || got[0]["last_heartbeat_at"] == nil {
|
||||
t.Errorf("a fresh heartbeat should be healthy with a timestamp, got %v", got[0])
|
||||
}
|
||||
if got[1]["status"] != "dormant" {
|
||||
t.Errorf("the other switch is still dormant, got %v", got[1]["status"])
|
||||
}
|
||||
|
||||
silence(t, s, "fp-watchdog", 2*time.Hour)
|
||||
sweep(t, s, noArchive)
|
||||
got = listSwitches(t, s)
|
||||
if got[0]["status"] != "dead" {
|
||||
t.Fatalf("a silent heartbeat should be dead, got %v", got[0]["status"])
|
||||
}
|
||||
if got[0]["last_triggered_at"] == nil || got[0]["open_incident_id"] == nil {
|
||||
t.Errorf("a dead switch should show when it triggered and its open incident, got %v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// One matcher, several clusters: the switch is as bad as its worst heartbeat and
|
||||
// each heartbeat is listed on its own.
|
||||
func TestDeadman_ListBreaksDownByFingerprint(t *testing.T) {
|
||||
s, _ := deadmanTS(t, deadmanCfg())
|
||||
|
||||
heartbeat(t, s, "fp-a", map[string]string{"cluster": "a"})
|
||||
heartbeat(t, s, "fp-b", map[string]string{"cluster": "b"})
|
||||
silence(t, s, "fp-b", 2*time.Hour)
|
||||
|
||||
sw := listSwitches(t, s)[0]
|
||||
if sw["status"] != "dead" {
|
||||
t.Errorf("one dead cluster makes the switch dead, got %v", sw["status"])
|
||||
}
|
||||
sources := sw["sources"].([]any)
|
||||
if len(sources) != 2 {
|
||||
t.Fatalf("expected 2 sources, got %d", len(sources))
|
||||
}
|
||||
first, second := sources[0].(map[string]any), sources[1].(map[string]any)
|
||||
if first["fingerprint"] != "fp-b" || first["status"] != "dead" || second["status"] != "healthy" {
|
||||
t.Errorf("the dead source should lead, got %v then %v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// Every switch keeps its own deadline.
|
||||
func TestDeadman_TimeoutsArePerSwitch(t *testing.T) {
|
||||
s, _ := deadmanTS(t, deadmanCfg())
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", map[string]any{
|
||||
"matcher": "alertname=Edge", "timeout_seconds": 300,
|
||||
})
|
||||
resp.Body.Close()
|
||||
|
||||
heartbeat(t, s, "fp-watchdog", nil)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-edge", "Edge", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
}, `{}:{alertname="Edge"}`)
|
||||
|
||||
// Ten minutes of silence: past the Edge switch's five, inside Watchdog's hour.
|
||||
silence(t, s, "fp-watchdog", 10*time.Minute)
|
||||
silence(t, s, "fp-edge", 10*time.Minute)
|
||||
|
||||
got := listSwitches(t, s)
|
||||
if got[0]["status"] != "healthy" || got[1]["status"] != "dead" {
|
||||
t.Errorf("want Watchdog healthy and Edge dead, got %v and %v", got[0]["status"], got[1]["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting is an owner's, is scoped to the team, and leaves what the switch
|
||||
// already opened alone.
|
||||
func TestDeadman_DeleteIsScopedToTheTeam(t *testing.T) {
|
||||
s, _ := deadmanTS(t, deadmanCfg())
|
||||
other := newTeam(t, s, "other")
|
||||
|
||||
id := int64(listSwitches(t, s)[0]["id"].(float64))
|
||||
|
||||
// Another team's owner cannot reach it.
|
||||
resp := other.call(http.MethodDelete, "/api/teams/"+id64(other.id)+"/deadman/switches/"+id64(id), nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("deleting another team's switch: expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
if got := len(listSwitches(t, s)); got != 1 {
|
||||
t.Fatalf("the switch should have survived, %d left", got)
|
||||
}
|
||||
|
||||
resp = s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(id), nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("deleting: expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
if got := len(listSwitches(t, s)); got != 0 {
|
||||
t.Errorf("expected no switches, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The environment's defaults are handed out once and then belong to the teams.
|
||||
func TestDeadman_SeedRunsOnce(t *testing.T) {
|
||||
s := newTS(t)
|
||||
cfg := api.ParseDeadmanConfig("alertname=Watchdog", time.Hour, "critical")
|
||||
|
||||
if err := api.SeedDeadmanConfigs(context.Background(), s.db, cfg); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if got := len(listSwitches(t, s)); got != 1 {
|
||||
t.Fatalf("the first seed should add the default, got %d switches", got)
|
||||
}
|
||||
|
||||
// The owner deletes it; a restart must not put it back.
|
||||
id := int64(listSwitches(t, s)[0]["id"].(float64))
|
||||
s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(id), nil).Body.Close()
|
||||
if err := api.SeedDeadmanConfigs(context.Background(), s.db, cfg); err != nil {
|
||||
t.Fatalf("seed again: %v", err)
|
||||
}
|
||||
if got := len(listSwitches(t, s)); got != 0 {
|
||||
t.Errorf("a second seed resurrected %d switch(es)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The device login flow lets a client that cannot open a browser sign in: it
|
||||
// shows a code, the person approves it in a browser they are signed in to, and
|
||||
// the client is handed an ordinary session. See migration 012.
|
||||
|
||||
const (
|
||||
// deviceTTL is how long a person has to get from the terminal's prompt to an
|
||||
// approval.
|
||||
deviceTTL = 10 * time.Minute
|
||||
|
||||
// deviceInterval is how often the client is told to poll. The server holds it
|
||||
// to that, with a second of slack for clocks and scheduling.
|
||||
deviceInterval = 5 * time.Second
|
||||
|
||||
// deviceStartMaxPerAddr bounds unauthenticated device logins started per
|
||||
// address, since each writes a row.
|
||||
deviceStartMaxPerAddr = 30
|
||||
|
||||
// userCodeAlphabet has no vowels, so a code cannot spell a word, and none of
|
||||
// the characters that read alike (0/O, 1/I/L).
|
||||
userCodeAlphabet = "BCDFGHJKMNPQRSTVWXZ23456789"
|
||||
userCodeLen = 8
|
||||
)
|
||||
|
||||
// newUserCode returns a code for a person to read, as XXXX-XXXX.
|
||||
func newUserCode() (string, error) {
|
||||
max := big.NewInt(int64(len(userCodeAlphabet)))
|
||||
b := make([]byte, userCodeLen)
|
||||
for i := range b {
|
||||
n, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[i] = userCodeAlphabet[n.Int64()]
|
||||
}
|
||||
return string(b[:4]) + "-" + string(b[4:]), nil
|
||||
}
|
||||
|
||||
// normalizeUserCode reduces whatever a person typed or pasted to the stored
|
||||
// form, so "bcdf ghjk" and "BCDF-GHJK" name the same login. It returns "" for
|
||||
// anything that cannot be a code.
|
||||
func normalizeUserCode(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToUpper(s) {
|
||||
if strings.ContainsRune(userCodeAlphabet, r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
code := b.String()
|
||||
if len(code) != userCodeLen {
|
||||
return ""
|
||||
}
|
||||
return code[:4] + "-" + code[4:]
|
||||
}
|
||||
|
||||
// handleDeviceStart begins a device login: it returns the device code the
|
||||
// client polls with, and the user code and URL the person is shown.
|
||||
func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
addrKey := "device:" + clientAddr(r)
|
||||
if limiter.blocked(r.Context(), addrKey, deviceStartMaxPerAddr) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
|
||||
return
|
||||
}
|
||||
limiter.fail(r.Context(), addrKey)
|
||||
|
||||
deviceCode, deviceHash, err := randomToken()
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
db.ExecContext(r.Context(), "DELETE FROM device_logins WHERE expires_at < $1", now.Unix())
|
||||
|
||||
// A collision on the user code is one in 27^8; retrying a few times makes
|
||||
// it a non-event rather than a 500.
|
||||
var userCode string
|
||||
for range 5 {
|
||||
userCode, err = newUserCode()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
_, err = db.ExecContext(r.Context(), `
|
||||
INSERT INTO device_logins (device_hash, user_code, expires_at) VALUES ($1, $2, $3)`,
|
||||
deviceHash, userCode, now.Add(deviceTTL).Unix())
|
||||
if err == nil || !isUniqueViolation(err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("device login: start: %v", err)
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
respond(w, http.StatusOK, map[string]any{
|
||||
"device_code": deviceCode,
|
||||
"user_code": userCode,
|
||||
// The code is in the URL so nobody has to type it; it is shown anyway,
|
||||
// for the person to check against the terminal before approving.
|
||||
"verification_url": strings.TrimRight(publicURL, "/") + "/device?code=" + url.QueryEscape(userCode),
|
||||
"interval": int(deviceInterval.Seconds()),
|
||||
"expires_in": int(deviceTTL.Seconds()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeviceDecision approves or denies a pending device login on behalf of
|
||||
// the signed-in caller.
|
||||
//
|
||||
// It takes a session, not an API key. Approving hands a terminal the caller's
|
||||
// identity, and the approval must come from a browser the person is looking at:
|
||||
// the page shows the code and asks. A script with a key has no business
|
||||
// approving one, and the check keeps it from being a way to mint sessions out of
|
||||
// keys.
|
||||
func handleDeviceDecision(db *sql.DB, approve bool) http.HandlerFunc {
|
||||
status := "denied"
|
||||
if approve {
|
||||
status = "approved"
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, viaSession := sessionFromContext(r.Context()); !viaSession {
|
||||
respond(w, http.StatusForbidden, errResp("sign in with the web UI to approve a device"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserCode string `json:"user_code"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
code := normalizeUserCode(req.UserCode)
|
||||
if code == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("that is not a sign-in code"))
|
||||
return
|
||||
}
|
||||
|
||||
caller, _ := userFromContext(r.Context())
|
||||
// Only a pending login can be decided, and only once: an approval cannot
|
||||
// be overwritten, so a second browser cannot take a login over.
|
||||
res, err := db.ExecContext(r.Context(), `
|
||||
UPDATE device_logins SET status = $1, user_id = $2
|
||||
WHERE user_code = $3 AND status = 'pending' AND expires_at > $4`,
|
||||
status, caller.ID, code, time.Now().Unix())
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusNotFound, errResp("that sign-in code is unknown, expired or already used"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeviceToken is what the client polls. Pending answers 202; an approval
|
||||
// answers 200 with the session cookie, once; anything else is 410.
|
||||
func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) http.HandlerFunc {
|
||||
gone := func(w http.ResponseWriter, why string) {
|
||||
respond(w, http.StatusGone, map[string]string{"error": why})
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.DeviceCode == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("device_code is required"))
|
||||
return
|
||||
}
|
||||
hash := hashToken(req.DeviceCode)
|
||||
now := time.Now()
|
||||
|
||||
tx, err := db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
var status string
|
||||
var userID sql.NullInt64
|
||||
var expires, lastPolled int64
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
SELECT status, user_id, expires_at, last_polled_at FROM device_logins
|
||||
WHERE device_hash = $1 FOR UPDATE`, hash).Scan(&status, &userID, &expires, &lastPolled)
|
||||
if errors.Is(err, sql.ErrNoRows) || (err == nil && expires <= now.Unix()) {
|
||||
gone(w, "expired")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
switch status {
|
||||
case "denied":
|
||||
tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash)
|
||||
tx.Commit() //nolint:errcheck
|
||||
gone(w, "denied")
|
||||
return
|
||||
|
||||
case "pending":
|
||||
// Held to the interval it was given, less a second of slack.
|
||||
if now.Unix()-lastPolled < int64(deviceInterval.Seconds())-1 {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(deviceInterval.Seconds())))
|
||||
respond(w, http.StatusTooManyRequests, map[string]string{"error": "slow_down"})
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(),
|
||||
"UPDATE device_logins SET last_polled_at = $1 WHERE device_hash = $2", now.Unix(), hash); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusAccepted, map[string]string{"status": "pending"})
|
||||
return
|
||||
}
|
||||
|
||||
// Approved. Single use: the row goes before the session is made, so two
|
||||
// racing polls cannot both be given one.
|
||||
if _, err := tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
var disabled, sso bool
|
||||
if err := tx.QueryRowContext(r.Context(), `
|
||||
SELECT disabled_at IS NOT NULL,
|
||||
EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1)
|
||||
FROM users WHERE id = $1`, userID.Int64).Scan(&disabled, &sso); err != nil {
|
||||
gone(w, "denied")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if disabled {
|
||||
gone(w, "denied")
|
||||
return
|
||||
}
|
||||
|
||||
// A session for somebody who signs in through the provider carries the
|
||||
// same ceiling as their browser's would, so the terminal is not a way
|
||||
// round it. Password users have none.
|
||||
var maxAge time.Duration
|
||||
if sso {
|
||||
maxAge = ssoMaxAge
|
||||
}
|
||||
if err := startSessionCapped(w, r, db, userID.Int64, publicURL, maxAge); err != nil {
|
||||
log.Printf("device login: start session: %v", err)
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
user, err := fetchUser(r.Context(), db, userID.Int64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, meResponse{User: user, HasPassword: false})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type deviceStart struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURL string `json:"verification_url"`
|
||||
Interval int `json:"interval"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// startDevice is the terminal asking for a login.
|
||||
func startDevice(t *testing.T, s *ts) deviceStart {
|
||||
t.Helper()
|
||||
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil)
|
||||
var d deviceStart
|
||||
decode(t, resp, &d)
|
||||
if d.DeviceCode == "" || d.UserCode == "" {
|
||||
t.Fatalf("device start returned %+v", d)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// pollDevice is the terminal polling. It returns the status, and the session
|
||||
// cookie the response set, if any.
|
||||
func pollDevice(t *testing.T, s *ts, code string) (int, *http.Cookie, string) {
|
||||
t.Helper()
|
||||
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device/token", map[string]string{"device_code": code})
|
||||
defer resp.Body.Close()
|
||||
var body map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
var cookie *http.Cookie
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == "terdut_session" {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
msg, _ := body["error"].(string)
|
||||
if msg == "" {
|
||||
msg, _ = body["status"].(string)
|
||||
}
|
||||
return resp.StatusCode, cookie, msg
|
||||
}
|
||||
|
||||
// readyToPoll lets the next poll through: the server holds a client to the
|
||||
// interval it was given, which a test has no wish to wait out.
|
||||
func (s *ts) readyToPoll(t *testing.T) {
|
||||
t.Helper()
|
||||
s.exec(t, "UPDATE device_logins SET last_polled_at = 0")
|
||||
}
|
||||
|
||||
func decide(t *testing.T, b *browser, what, code string) int {
|
||||
t.Helper()
|
||||
resp := b.do(t, http.MethodPost, "/api/oidc/device/"+what, map[string]string{"user_code": code})
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func TestDevice_FullFlow(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
|
||||
d := startDevice(t, s)
|
||||
if !strings.HasPrefix(d.VerificationURL, "http://terdut.test/device?code=") ||
|
||||
!strings.Contains(d.VerificationURL, url.QueryEscape(d.UserCode)) {
|
||||
t.Errorf("verification url %q", d.VerificationURL)
|
||||
}
|
||||
if len(d.UserCode) != 9 || d.UserCode[4] != '-' || d.Interval != 5 || d.ExpiresIn != 600 {
|
||||
t.Errorf("start: %+v", d)
|
||||
}
|
||||
|
||||
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || cookie != nil || msg != "pending" {
|
||||
t.Fatalf("first poll: %d %v %q, want 202 pending and no cookie", status, cookie, msg)
|
||||
}
|
||||
|
||||
// The person signs in through the provider in some browser and approves.
|
||||
person := ssoBrowser(t, s)
|
||||
signInSSO(t, idp, person, alice)
|
||||
if got := decide(t, person, "approve", d.UserCode); got != http.StatusNoContent {
|
||||
t.Fatalf("approve: %d", got)
|
||||
}
|
||||
|
||||
s.readyToPoll(t)
|
||||
status, cookie, _ := pollDevice(t, s, d.DeviceCode)
|
||||
if status != http.StatusOK || cookie == nil {
|
||||
t.Fatalf("poll after approval: %d, cookie %v", status, cookie)
|
||||
}
|
||||
// The cookie is a working session for the person who approved.
|
||||
term := newBrowser(t, s.URL)
|
||||
req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
|
||||
req.AddCookie(cookie)
|
||||
resp, err := term.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var me struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
}
|
||||
decode(t, resp, &me)
|
||||
if me.User.Username != "alice" {
|
||||
t.Errorf("session belongs to %q, want alice", me.User.Username)
|
||||
}
|
||||
|
||||
// Single use.
|
||||
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "expired" {
|
||||
t.Errorf("second redemption: %d %v %q, want 410 expired", status, cookie, msg)
|
||||
}
|
||||
// The session was made for an SSO user, so it carries the ceiling.
|
||||
var ceiling *int64
|
||||
s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling)
|
||||
if ceiling == nil {
|
||||
t.Error("a device session for an SSO user must carry the SSO session ceiling")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_PasswordUserGetsNoCeiling(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
admin := signedIn(t, s) // password sign-in as the bootstrap admin
|
||||
|
||||
d := startDevice(t, s)
|
||||
if got := decide(t, admin, "approve", d.UserCode); got != http.StatusNoContent {
|
||||
t.Fatalf("approve: %d", got)
|
||||
}
|
||||
s.readyToPoll(t)
|
||||
if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusOK || cookie == nil {
|
||||
t.Fatalf("poll: %d %v", status, cookie)
|
||||
}
|
||||
var ceiling *int64
|
||||
s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling)
|
||||
if ceiling != nil {
|
||||
t.Errorf("a password user's device session has a ceiling %d, want none", *ceiling)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_Denied(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
person := ssoBrowser(t, s)
|
||||
signInSSO(t, idp, person, alice)
|
||||
|
||||
d := startDevice(t, s)
|
||||
if got := decide(t, person, "deny", d.UserCode); got != http.StatusNoContent {
|
||||
t.Fatalf("deny: %d", got)
|
||||
}
|
||||
s.readyToPoll(t)
|
||||
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "denied" {
|
||||
t.Errorf("poll: %d %v %q, want 410 denied", status, cookie, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_DecisionNeedsABrowserSession(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
d := startDevice(t, s)
|
||||
|
||||
// Nobody signed in.
|
||||
if got := decide(t, newBrowser(t, s.URL), "approve", d.UserCode); got != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous approve: %d, want 401", got)
|
||||
}
|
||||
// An API key is a credential for scripts, not for approving a terminal.
|
||||
resp := s.req(t, http.MethodPost, "/api/oidc/device/approve", map[string]string{"user_code": d.UserCode})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("approve with an API key: %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || msg != "pending" {
|
||||
t.Errorf("the login must still be pending: %d %q", status, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_ApprovalIsFinal(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
first, second := ssoBrowser(t, s), ssoBrowser(t, s)
|
||||
signInSSO(t, idp, first, alice)
|
||||
signInSSO(t, idp, second, idpUser{sub: "sub-mallory", username: "mallory", email: "mallory@example.com", groups: []string{"terdut-users"}})
|
||||
|
||||
d := startDevice(t, s)
|
||||
if got := decide(t, first, "approve", d.UserCode); got != http.StatusNoContent {
|
||||
t.Fatalf("approve: %d", got)
|
||||
}
|
||||
// A second browser cannot take the login over, nor refuse it.
|
||||
for _, what := range []string{"approve", "deny"} {
|
||||
if got := decide(t, second, what, d.UserCode); got != http.StatusNotFound {
|
||||
t.Errorf("%s after approval: %d, want 404", what, got)
|
||||
}
|
||||
}
|
||||
s.readyToPoll(t)
|
||||
_, cookie, _ := pollDevice(t, s, d.DeviceCode)
|
||||
if cookie == nil {
|
||||
t.Fatal("no session")
|
||||
}
|
||||
var name string
|
||||
s.db.QueryRow("SELECT u.username FROM sessions ss JOIN users u ON u.id = ss.user_id ORDER BY ss.id DESC LIMIT 1").Scan(&name)
|
||||
if name != "alice" {
|
||||
t.Errorf("session for %q, want alice", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_CodeIsForgivingAboutHowItWasTyped(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
person := ssoBrowser(t, s)
|
||||
signInSSO(t, idp, person, alice)
|
||||
|
||||
d := startDevice(t, s)
|
||||
typed := strings.ToLower(strings.ReplaceAll(d.UserCode, "-", " "))
|
||||
if got := decide(t, person, "approve", typed); got != http.StatusNoContent {
|
||||
t.Errorf("approve %q: %d, want 204", typed, got)
|
||||
}
|
||||
if got := decide(t, person, "approve", "nonsense"); got != http.StatusBadRequest {
|
||||
t.Errorf("approve nonsense: %d, want 400", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_ExpiredAndUnknown(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
person := ssoBrowser(t, s)
|
||||
signInSSO(t, idp, person, alice)
|
||||
|
||||
d := startDevice(t, s)
|
||||
s.exec(t, "UPDATE device_logins SET expires_at = 1")
|
||||
if got := decide(t, person, "approve", d.UserCode); got != http.StatusNotFound {
|
||||
t.Errorf("approve expired: %d, want 404", got)
|
||||
}
|
||||
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || msg != "expired" {
|
||||
t.Errorf("poll expired: %d %q, want 410 expired", status, msg)
|
||||
}
|
||||
if status, _, msg := pollDevice(t, s, "not-a-device-code"); status != http.StatusGone || msg != "expired" {
|
||||
t.Errorf("poll unknown: %d %q, want 410 expired", status, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_PollingTooFastIsRefused(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
d := startDevice(t, s)
|
||||
if status, _, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted {
|
||||
t.Fatalf("first poll: %d", status)
|
||||
}
|
||||
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusTooManyRequests || msg != "slow_down" {
|
||||
t.Errorf("immediate second poll: %d %q, want 429 slow_down", status, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_DisabledUserGetsNoSession(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
person := ssoBrowser(t, s)
|
||||
signInSSO(t, idp, person, alice)
|
||||
|
||||
d := startDevice(t, s)
|
||||
decide(t, person, "approve", d.UserCode)
|
||||
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'")
|
||||
s.readyToPoll(t)
|
||||
var before int
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&before)
|
||||
if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil {
|
||||
t.Errorf("poll: %d %v, want 410 and no cookie", status, cookie)
|
||||
}
|
||||
var after int
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&after)
|
||||
if after != before {
|
||||
t.Error("a session was created for a disabled user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_OnlyExistsWithSSOConfigured(t *testing.T) {
|
||||
s := newTS(t) // no SSO
|
||||
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("device start with SSO off: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
idp := newFakeIdP(t)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
s *ts
|
||||
want bool
|
||||
}{{"off", s, false}, {"on", newSSOTS(t, idp), true}} {
|
||||
var cfg struct {
|
||||
DeviceLogin bool `json:"device_login"`
|
||||
}
|
||||
decode(t, newBrowser(t, c.s.URL).do(t, http.MethodGet, "/api/auth/config", nil), &cfg)
|
||||
if cfg.DeviceLogin != c.want {
|
||||
t.Errorf("auth config device_login with SSO %s: %v, want %v", c.name, cfg.DeviceLogin, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevice_StartIsRateLimited(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
b := newBrowser(t, s.URL)
|
||||
var last int
|
||||
for range 32 {
|
||||
resp := b.do(t, http.MethodPost, "/api/oidc/device", nil)
|
||||
resp.Body.Close()
|
||||
last = resp.StatusCode
|
||||
}
|
||||
if last != http.StatusTooManyRequests {
|
||||
t.Errorf("32nd start: %d, want 429", last)
|
||||
}
|
||||
}
|
||||
|
||||
// After signing in the browser is sent on to where the person was going, which
|
||||
// is how somebody without a session gets from /device?code=... through the
|
||||
// provider and back to it. Only paths on this server are honoured.
|
||||
func TestSSO_NextIsHonouredOnlyForPathsOnThisServer(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
|
||||
for _, c := range []struct{ next, want string }{
|
||||
{"/device?code=BCDF-GHJK", "/device?code=BCDF-GHJK"},
|
||||
{"/team/members", "/team/members"},
|
||||
{"", "/"},
|
||||
{"//evil.example/x", "/"},
|
||||
{"/\\evil.example", "/"},
|
||||
{"https://evil.example/", "/"},
|
||||
{"evil.example", "/"},
|
||||
{"/api/users", "/"},
|
||||
{"/ok\r\nSet-Cookie: x=y", "/"},
|
||||
{"/" + strings.Repeat("a", 600), "/"},
|
||||
} {
|
||||
b := ssoBrowser(t, s)
|
||||
resp := b.do(t, http.MethodGet, "/api/oidc/login?next="+url.QueryEscape(c.next), nil)
|
||||
resp.Body.Close()
|
||||
loc, _ := url.Parse(resp.Header.Get("Location"))
|
||||
q := loc.Query()
|
||||
got := callback(t, b, idp.issueCode(alice, q.Get("nonce"), q.Get("code_challenge")), q.Get("state"))
|
||||
if got != c.want {
|
||||
t.Errorf("next %q: redirected to %q, want %q", c.next, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+187
-3
@@ -229,7 +229,7 @@ func advanceEscalation(ctx context.Context, db *sql.DB, cfg NotifyConfig, policy
|
||||
// nobody. That is a policy that looks configured and is not.
|
||||
detail += ": nobody reachable"
|
||||
}
|
||||
if err := logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail); err != nil {
|
||||
if err := logEvent(ctx, tx, incidentID, evEscalated, nil, nil, nil, &detail); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
@@ -256,7 +256,7 @@ func escalationExhausted(ctx context.Context, tx *sql.Tx, policy *escalationPoli
|
||||
incidentID); err != nil {
|
||||
return err
|
||||
}
|
||||
return logEvent(ctx, tx, incidentID, evEscalated, nil, nil, &detail)
|
||||
return logEvent(ctx, tx, incidentID, evEscalated, nil, nil, nil, &detail)
|
||||
}
|
||||
|
||||
// pageLevel notifies every target of one level and reports who was woken.
|
||||
@@ -346,10 +346,194 @@ func handleGetEscalation(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, escalationResponse(policy, teamID))
|
||||
view, err := escalationStatus(r.Context(), db, teamID, policy)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, view)
|
||||
}
|
||||
}
|
||||
|
||||
// Level statuses, as the Escalation page colours them.
|
||||
const (
|
||||
levelReady = "ready"
|
||||
levelEscalating = "escalating"
|
||||
levelUnreachable = "unreachable"
|
||||
)
|
||||
|
||||
// escalationTargetView is a target with who it means today and whether that
|
||||
// person can actually be woken. The extra fields are output only: the PUT body
|
||||
// is the plain escalationTargetJSON, and anything else in it is ignored.
|
||||
type escalationTargetView struct {
|
||||
escalationTargetJSON
|
||||
|
||||
// Username is who the target resolves to right now: the named person, or
|
||||
// whoever the rota says is on call today. Empty when nobody is.
|
||||
Username string `json:"username,omitempty"`
|
||||
|
||||
// Reachable is whether a page to this target would go anywhere, and Problem
|
||||
// says why not when it would not — the same conditions pageLevel skips on.
|
||||
Reachable bool `json:"reachable"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
}
|
||||
|
||||
type escalationLevelView struct {
|
||||
Position int64 `json:"position"`
|
||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||
Targets []escalationTargetView `json:"targets"`
|
||||
|
||||
// Status is unreachable when no target of the level could be woken — a rung
|
||||
// that looks configured and pages nobody, which is worth seeing before an
|
||||
// incident finds it — escalating when an unanswered incident has climbed to
|
||||
// it, and ready otherwise.
|
||||
Status string `json:"status"`
|
||||
|
||||
// Waiting lists the open, unacknowledged incidents currently on this level.
|
||||
Waiting []int64 `json:"waiting"`
|
||||
}
|
||||
|
||||
type escalationView struct {
|
||||
TeamID int64 `json:"team_id"`
|
||||
RepeatCount int64 `json:"repeat_count"`
|
||||
FallbackTopic string `json:"fallback_topic"`
|
||||
Levels []escalationLevelView `json:"levels"`
|
||||
|
||||
// LastEscalatedAt is when an incident of this team last moved up the ladder,
|
||||
// or ran off the end of it, and LastEscalatedIncidentID which one. Absent
|
||||
// when nothing ever has: a ladder nobody has needed yet.
|
||||
LastEscalatedAt *time.Time `json:"last_escalated_at,omitempty"`
|
||||
LastEscalatedIncidentID *int64 `json:"last_escalated_incident_id,omitempty"`
|
||||
}
|
||||
|
||||
// escalationStatus is a team's ladder together with what it would do right now
|
||||
// and what it has been doing. The resolution follows pageLevel's rules, so the
|
||||
// page cannot promise a page that the notifier would skip.
|
||||
func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *escalationPolicy) (escalationView, error) {
|
||||
base := escalationResponse(policy, teamID)
|
||||
out := escalationView{
|
||||
TeamID: teamID, RepeatCount: base.RepeatCount, FallbackTopic: base.FallbackTopic,
|
||||
Levels: []escalationLevelView{},
|
||||
}
|
||||
if !policy.configured() {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
onCall, err := currentOnCall(ctx, db, teamID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
type account struct {
|
||||
username string
|
||||
topic bool
|
||||
disabled bool
|
||||
}
|
||||
accounts := map[int64]account{}
|
||||
lookup := func(id int64) (account, error) {
|
||||
if a, ok := accounts[id]; ok {
|
||||
return a, nil
|
||||
}
|
||||
var a account
|
||||
var topic *string
|
||||
var disabledAt *int64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
"SELECT username, ntfy_topic, disabled_at FROM users WHERE id = $1", id).
|
||||
Scan(&a.username, &topic, &disabledAt); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.topic = topic != nil && *topic != ""
|
||||
a.disabled = disabledAt != nil
|
||||
accounts[id] = a
|
||||
return a, nil
|
||||
}
|
||||
|
||||
waiting := map[int64][]int64{}
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, escalation_level FROM incidents
|
||||
WHERE team_id = $1 AND resolved_at IS NULL AND archived_at IS NULL
|
||||
AND status = 'triggered' AND escalation_level > 0
|
||||
ORDER BY id`, teamID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, level int64
|
||||
if err := rows.Scan(&id, &level); err != nil {
|
||||
rows.Close()
|
||||
return out, err
|
||||
}
|
||||
waiting[level] = append(waiting[level], id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
for _, l := range base.Levels {
|
||||
level := escalationLevelView{
|
||||
Position: l.Position, TimeoutSeconds: l.TimeoutSeconds,
|
||||
Targets: []escalationTargetView{}, Waiting: []int64{},
|
||||
}
|
||||
if w := waiting[l.Position]; w != nil {
|
||||
level.Waiting = w
|
||||
}
|
||||
|
||||
anyReachable := false
|
||||
for _, t := range l.Targets {
|
||||
view := escalationTargetView{escalationTargetJSON: t}
|
||||
userID := t.UserID
|
||||
if t.Kind == "oncall" {
|
||||
userID = onCall
|
||||
}
|
||||
switch {
|
||||
case userID == nil:
|
||||
view.Problem = "nobody is on call today"
|
||||
default:
|
||||
a, err := lookup(*userID)
|
||||
switch {
|
||||
case err != nil:
|
||||
view.Problem = "account not found"
|
||||
case a.disabled:
|
||||
view.Username, view.Problem = a.username, "account is disabled"
|
||||
case !a.topic:
|
||||
view.Username, view.Problem = a.username, "has no ntfy topic"
|
||||
default:
|
||||
view.Username, view.Reachable = a.username, true
|
||||
}
|
||||
}
|
||||
anyReachable = anyReachable || view.Reachable
|
||||
level.Targets = append(level.Targets, view)
|
||||
}
|
||||
|
||||
switch {
|
||||
case !anyReachable:
|
||||
level.Status = levelUnreachable
|
||||
case l.Position >= 2 && len(level.Waiting) > 0:
|
||||
level.Status = levelEscalating
|
||||
default:
|
||||
level.Status = levelReady
|
||||
}
|
||||
out.Levels = append(out.Levels, level)
|
||||
}
|
||||
|
||||
var incidentID, at int64
|
||||
switch err := db.QueryRowContext(ctx, `
|
||||
SELECT e.incident_id, e.created_at
|
||||
FROM incident_events e JOIN incidents i ON i.id = e.incident_id
|
||||
WHERE i.team_id = $1 AND e.type = $2
|
||||
ORDER BY e.created_at DESC, e.id DESC LIMIT 1`, teamID, evEscalated).
|
||||
Scan(&incidentID, &at); {
|
||||
case err == sql.ErrNoRows:
|
||||
case err != nil:
|
||||
return out, err
|
||||
default:
|
||||
t := time.Unix(at, 0).UTC()
|
||||
out.LastEscalatedAt, out.LastEscalatedIncidentID = &t, &incidentID
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type escalationLevelJSON struct {
|
||||
Position int64 `json:"position"`
|
||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||
|
||||
@@ -383,3 +383,122 @@ func TestEscalation_SkipsUnreachableTargets(t *testing.T) {
|
||||
t.Errorf("a target with no topic should page nothing, paged %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The ladder as the Escalation page reads it
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ladderLevel struct {
|
||||
Status string `json:"status"`
|
||||
Waiting []int64 `json:"waiting"`
|
||||
Targets []struct {
|
||||
Kind string `json:"kind"`
|
||||
Username string `json:"username"`
|
||||
Reachable bool `json:"reachable"`
|
||||
Problem string `json:"problem"`
|
||||
} `json:"targets"`
|
||||
}
|
||||
|
||||
type ladderView struct {
|
||||
Levels []ladderLevel `json:"levels"`
|
||||
LastEscalatedAt *string `json:"last_escalated_at"`
|
||||
LastEscalatedIncidentID *int64 `json:"last_escalated_incident_id"`
|
||||
}
|
||||
|
||||
func readLadder(t *testing.T, s *ts) ladderView {
|
||||
t.Helper()
|
||||
var v ladderView
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/escalation", nil), &v)
|
||||
return v
|
||||
}
|
||||
|
||||
// Targets say who they mean today, so "whoever is on call" is a name and not a
|
||||
// promise.
|
||||
func TestEscalation_StatusResolvesTargets(t *testing.T) {
|
||||
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
second := teamUser(t, s, "second", "terdut-second")
|
||||
ladder(t, s, second, 0, "terdut-fallback")
|
||||
|
||||
v := readLadder(t, s)
|
||||
if len(v.Levels) != 2 {
|
||||
t.Fatalf("expected 2 levels, got %d", len(v.Levels))
|
||||
}
|
||||
if got := v.Levels[0].Targets[0]; got.Kind != "oncall" || got.Username != "admin" || !got.Reachable {
|
||||
t.Errorf("the rota target should resolve to the person on call, got %+v", got)
|
||||
}
|
||||
if got := v.Levels[1].Targets[0]; got.Username != "second" || !got.Reachable {
|
||||
t.Errorf("the named target should be reachable, got %+v", got)
|
||||
}
|
||||
if v.Levels[0].Status != "ready" || v.Levels[1].Status != "ready" || v.LastEscalatedAt != nil {
|
||||
t.Errorf("an idle, healthy ladder is ready and has never escalated, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// A rung that would page nobody is called out before an incident finds it.
|
||||
func TestEscalation_StatusFlagsUnreachableLevels(t *testing.T) {
|
||||
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
silent := teamUser(t, s, "silent", "terdut-silent")
|
||||
ladder(t, s, silent, 0, "terdut-fallback")
|
||||
|
||||
// Nobody on call today, and the named person loses their topic.
|
||||
s.exec(t, "DELETE FROM schedule_entries")
|
||||
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
|
||||
|
||||
v := readLadder(t, s)
|
||||
if v.Levels[0].Status != "unreachable" || v.Levels[0].Targets[0].Problem != "nobody is on call today" {
|
||||
t.Errorf("an empty rota should make level 1 unreachable, got %+v", v.Levels[0])
|
||||
}
|
||||
if v.Levels[1].Status != "unreachable" || v.Levels[1].Targets[0].Problem != "has no ntfy topic" {
|
||||
t.Errorf("a person with no topic should make level 2 unreachable, got %+v", v.Levels[1])
|
||||
}
|
||||
|
||||
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)
|
||||
if p := readLadder(t, s).Levels[1].Targets[0].Problem; p != "account is disabled" {
|
||||
t.Errorf("a disabled account should say so, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Where unanswered incidents are right now, and when the ladder last did its
|
||||
// job.
|
||||
func TestEscalation_StatusShowsWhoIsWaitingAndLastEscalation(t *testing.T) {
|
||||
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
second := teamUser(t, s, "second", "terdut-second")
|
||||
ladder(t, s, second, 0, "terdut-fallback")
|
||||
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-wait", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.sweepNotify(t)
|
||||
|
||||
// On level 1 it is waiting, which is normal and not yet an escalation.
|
||||
v := readLadder(t, s)
|
||||
if len(v.Levels[0].Waiting) != 1 || v.Levels[0].Status != "ready" || v.LastEscalatedAt != nil {
|
||||
t.Fatalf("a fresh incident waits on level 1 quietly, got %+v", v)
|
||||
}
|
||||
|
||||
overdue(t, s, 1)
|
||||
s.sweepNotify(t)
|
||||
v = readLadder(t, s)
|
||||
if v.Levels[1].Status != "escalating" || len(v.Levels[1].Waiting) != 1 || v.Levels[1].Waiting[0] != 1 {
|
||||
t.Errorf("level 2 should be escalating with the incident on it, got %+v", v.Levels[1])
|
||||
}
|
||||
if v.LastEscalatedAt == nil || v.LastEscalatedIncidentID == nil || *v.LastEscalatedIncidentID != 1 {
|
||||
t.Errorf("the escalation should be recorded, got %+v", v)
|
||||
}
|
||||
|
||||
// Somebody answers: nothing is waiting, but the history stays.
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil).Body.Close()
|
||||
v = readLadder(t, s)
|
||||
if v.Levels[1].Status != "ready" || len(v.Levels[1].Waiting) != 0 || v.LastEscalatedAt == nil {
|
||||
t.Errorf("an acknowledged incident stops waiting but stays in the history, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// No ladder is a real answer, not an error.
|
||||
func TestEscalation_StatusWithoutALadder(t *testing.T) {
|
||||
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||
v := readLadder(t, s)
|
||||
if len(v.Levels) != 0 || v.LastEscalatedAt != nil {
|
||||
t.Errorf("a team with no ladder should read as empty, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -69,11 +72,64 @@ func respond(w http.ResponseWriter, status int, v any) {
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// maxBodyBytes caps an ordinary JSON request body. 1 MiB is far more than any
|
||||
// endpoint below needs — it exists so an unauthenticated caller (signup,
|
||||
// login, bootstrap) can't make the server buffer an arbitrarily large body
|
||||
// before the request is even validated.
|
||||
const maxBodyBytes = 1 << 20
|
||||
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
return decodeJSONLimit(r, v, maxBodyBytes)
|
||||
}
|
||||
|
||||
// decodeJSONLimit is decodeJSON with an explicit cap, for the one endpoint
|
||||
// (the Alertmanager webhook, see maxWebhookBodyBytes) whose real payloads can
|
||||
// legitimately be larger than maxBodyBytes.
|
||||
func decodeJSONLimit(r *http.Request, v any, limit int64) error {
|
||||
defer r.Body.Close()
|
||||
// w is nil: there is no ResponseWriter here to disable keep-alive with,
|
||||
// which net/http documents as fine — the limit is still enforced, the
|
||||
// connection just isn't closed early on a request that blows past it.
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, limit)
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
func errResp(msg string) map[string]string {
|
||||
return map[string]string{"error": msg}
|
||||
}
|
||||
|
||||
// withAdvisoryLock runs fn only if it can take the named Postgres advisory lock on a
|
||||
// dedicated connection, and skips fn otherwise. This is what keeps the archiver and
|
||||
// notifier safe to run on more than one replica: whichever instance's tick gets there
|
||||
// first does the work; the rest see the lock held and simply wait for their next tick
|
||||
// instead of running the same pass concurrently.
|
||||
//
|
||||
// pg_try_advisory_lock is session-scoped, so taking and releasing it must happen on the
|
||||
// same connection, reserved via db.Conn rather than borrowed from the pool's shared
|
||||
// connections fn itself may use — and released (unlocked, then closed) before returning,
|
||||
// since a session lock otherwise outlives this call and leaks onto whatever reuses the
|
||||
// pooled connection next.
|
||||
func withAdvisoryLock(ctx context.Context, db *sql.DB, key int64, name string, fn func()) {
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
log.Printf("%s: advisory lock: acquire connection: %v", name, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var locked bool
|
||||
if err := conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&locked); err != nil {
|
||||
log.Printf("%s: advisory lock: %v", name, err)
|
||||
return
|
||||
}
|
||||
if !locked {
|
||||
return // another replica is already running this pass
|
||||
}
|
||||
defer func() {
|
||||
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", key); err != nil {
|
||||
log.Printf("%s: advisory unlock: %v", name, err)
|
||||
}
|
||||
}()
|
||||
|
||||
fn()
|
||||
}
|
||||
|
||||
@@ -32,10 +32,16 @@ const (
|
||||
evAcknowledged = "acknowledged"
|
||||
evUnacknowledged = "unacknowledged"
|
||||
evAssigned = "assigned"
|
||||
evArchived = "archived"
|
||||
evUnarchived = "unarchived"
|
||||
evSnoozed = "snoozed"
|
||||
evUnsnoozed = "unsnoozed"
|
||||
evResolved = "resolved"
|
||||
evNote = "note"
|
||||
// evResolutionNote is the note worth finding again: what fixed it. The
|
||||
// similar-incidents lookup and the page lead with these; plain notes are
|
||||
// the working chatter and stay one click away.
|
||||
evResolutionNote = "resolution_note"
|
||||
evDeadmanSilent = "deadman_silent"
|
||||
)
|
||||
|
||||
@@ -61,11 +67,13 @@ const incidentSelectFrom = `
|
||||
WHERE el.team_id = i.team_id AND el.position = i.escalation_level),
|
||||
i.triggered_at,
|
||||
i.acknowledged_by, i.acknowledged_at, ack.username,
|
||||
i.acknowledged_by_service_account_id, acksa.name,
|
||||
i.assigned_to, asg.username, i.snoozed_until,
|
||||
i.resolved_at, i.resolution_source, i.archived_at
|
||||
FROM incidents i
|
||||
JOIN teams t ON t.id = i.team_id
|
||||
LEFT JOIN users ack ON ack.id = i.acknowledged_by
|
||||
LEFT JOIN service_accounts acksa ON acksa.id = i.acknowledged_by_service_account_id
|
||||
LEFT JOIN users asg ON asg.id = i.assigned_to`
|
||||
|
||||
func scanIncident(s scanner) (models.Incident, error) {
|
||||
@@ -79,6 +87,7 @@ func scanIncident(s scanner) (models.Incident, error) {
|
||||
&i.EscalationLevel, &escalationDue,
|
||||
&triggeredAt,
|
||||
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
|
||||
&i.AcknowledgedByServiceAccountID, &i.AcknowledgedByServiceAccountName,
|
||||
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
||||
&resolvedAt, &i.ResolutionSource, &archivedAt,
|
||||
); err != nil {
|
||||
@@ -108,13 +117,42 @@ func fetchIncident(ctx context.Context, q querier, id int64) (models.Incident, e
|
||||
return scanIncident(q.QueryRowContext(ctx, incidentSelectFrom+" WHERE i.id = $1", id))
|
||||
}
|
||||
|
||||
// logEvent appends one entry to an incident's timeline. A nil userID means the
|
||||
// server acted rather than a person.
|
||||
func logEvent(ctx context.Context, q querier, incidentID int64, evType string, userID, alertID *int64, detail *string) error {
|
||||
// callerActorIDs resolves the current request's caller into the pair of
|
||||
// nilable ids logEvent/acknowledgeIncidentAs expect: exactly one of userID/
|
||||
// serviceAccountID is set (never both), replacing the unchecked
|
||||
// userFromContext(ctx) zero-value reads that used to write a human-only id
|
||||
// of 0 for a service-account caller (terdut-server#25).
|
||||
func callerActorIDs(ctx context.Context) (userID, serviceAccountID *int64) {
|
||||
caller, _ := callerFromContext(ctx)
|
||||
if u, ok := caller.AsHuman(); ok {
|
||||
return &u.ID, nil
|
||||
}
|
||||
if id, ok := caller.ServiceAccountID(); ok {
|
||||
return nil, &id
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// logEvent appends one entry to an incident's timeline. userID and
|
||||
// serviceAccountID are mutually exclusive and both nilable; both nil means
|
||||
// the server acted rather than any caller (see incident_events_actor_xor_chk,
|
||||
// migration 015).
|
||||
func logEvent(ctx context.Context, q querier, incidentID int64, evType string, userID, serviceAccountID, alertID *int64, detail *string) error {
|
||||
_, err := q.ExecContext(ctx, `
|
||||
INSERT INTO incident_events (incident_id, type, user_id, alert_id, detail, created_at)
|
||||
INSERT INTO incident_events (incident_id, type, user_id, service_account_id, alert_id, detail, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
incidentID, evType, userID, serviceAccountID, alertID, detail, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// logAssignedEvent records an assignment: user_id is the assignee, and the
|
||||
// caller who performed it goes in the actor_* columns (migration 018), since
|
||||
// user_id cannot hold both.
|
||||
func logAssignedEvent(ctx context.Context, q querier, incidentID, assigneeID int64, actorUserID, actorServiceAccountID *int64) error {
|
||||
_, err := q.ExecContext(ctx, `
|
||||
INSERT INTO incident_events (incident_id, type, user_id, actor_user_id, actor_service_account_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
incidentID, evType, userID, alertID, detail, time.Now().Unix())
|
||||
incidentID, evAssigned, assigneeID, actorUserID, actorServiceAccountID, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -247,7 +285,7 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
|
||||
if err := stopEscalation(ctx, q, incidentID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := logEvent(ctx, q, incidentID, evResolved, nil, nil, nil); err != nil {
|
||||
if err := logEvent(ctx, q, incidentID, evResolved, nil, nil, nil, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// The all-clear goes only to whoever was paged in the first place, which
|
||||
@@ -256,16 +294,30 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
|
||||
return true, enqueueResolved(ctx, q, incidentID)
|
||||
}
|
||||
|
||||
// acknowledgeIncident records that userID has picked an incident up, and reports
|
||||
// whether it changed anything — an already-resolved incident is left alone.
|
||||
// Shared by the authenticated handler and the Acknowledge button in a push
|
||||
// notification, so both write the same state and the same timeline entry.
|
||||
// acknowledgeIncident records that userID — a human — has picked an incident
|
||||
// up, and reports whether it changed anything — an already-resolved or
|
||||
// already-acknowledged incident is left alone, so a second acknowledge (a
|
||||
// retried request, or a stale push notification tapped after the web UI
|
||||
// already acked it) is a no-op rather than a second "acknowledged" timeline
|
||||
// entry. Used only by the Acknowledge button in a push notification
|
||||
// (notify_ack.go), which always resolves a human from
|
||||
// incident_ack_tokens.user_id — there is no service-account equivalent of
|
||||
// that flow, so this keeps its human-only signature; the authenticated
|
||||
// handler goes through acknowledgeIncidentAs below instead.
|
||||
func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int64) (bool, error) {
|
||||
return acknowledgeIncidentAs(ctx, q, incidentID, &userID, nil)
|
||||
}
|
||||
|
||||
// acknowledgeIncidentAs is acknowledgeIncident generalized to either actor
|
||||
// kind. userID and serviceAccountID are mutually exclusive and nilable the
|
||||
// same way logEvent's are (see incidents_ack_actor_xor_chk, migration 015).
|
||||
func acknowledgeIncidentAs(ctx context.Context, q querier, incidentID int64, userID, serviceAccountID *int64) (bool, error) {
|
||||
res, err := q.ExecContext(ctx, `
|
||||
UPDATE incidents
|
||||
SET status = 'acknowledged', acknowledged_by = $1, acknowledged_at = $2
|
||||
WHERE id = $3 AND resolved_at IS NULL`,
|
||||
userID, time.Now().Unix(), incidentID)
|
||||
SET status = 'acknowledged', acknowledged_by = $1, acknowledged_by_service_account_id = $2,
|
||||
acknowledged_at = $3
|
||||
WHERE id = $4 AND status = 'triggered'`,
|
||||
userID, serviceAccountID, time.Now().Unix(), incidentID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -276,7 +328,7 @@ func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int6
|
||||
if err := stopEscalation(ctx, q, incidentID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, logEvent(ctx, q, incidentID, evAcknowledged, &userID, nil, nil)
|
||||
return true, logEvent(ctx, q, incidentID, evAcknowledged, userID, serviceAccountID, nil, nil)
|
||||
}
|
||||
|
||||
// openIncidentForAlert returns the open incident an alert currently belongs to,
|
||||
@@ -295,6 +347,34 @@ func openIncidentForAlert(ctx context.Context, q querier, alertID int64) (int64,
|
||||
return id, err
|
||||
}
|
||||
|
||||
// volatileLabels say where a problem ran this time, not what the problem is, so
|
||||
// they stay out of the signature. Migration 008's backfill lists the same set.
|
||||
var volatileLabels = map[string]bool{
|
||||
"instance": true, "pod": true, "pod_name": true, "pod_ip": true,
|
||||
"container": true, "container_name": true, "endpoint": true,
|
||||
}
|
||||
|
||||
// incidentSignature identifies "the same problem" across incidents: the alert
|
||||
// name plus the stable group labels, sorted. Incidents in one team with equal
|
||||
// signatures are what the similar-incidents lookup returns. title stands in for
|
||||
// the name when the payload carried no alertname (groupless and dead man's
|
||||
// switch incidents).
|
||||
func incidentSignature(groupLabels map[string]string, title string) string {
|
||||
name := groupLabels["alertname"]
|
||||
if name == "" {
|
||||
name = title
|
||||
}
|
||||
rest := make([]string, 0, len(groupLabels))
|
||||
for k, v := range groupLabels {
|
||||
if k == "alertname" || volatileLabels[k] {
|
||||
continue
|
||||
}
|
||||
rest = append(rest, k+"="+v)
|
||||
}
|
||||
sort.Strings(rest)
|
||||
return name + "|" + strings.Join(rest, ",")
|
||||
}
|
||||
|
||||
// incidentTitle renders a human-readable title from Alertmanager's groupLabels,
|
||||
// leading with the alert name and appending whatever else the operator grouped
|
||||
// by. Falls back to the alert's own name when the payload carried no groupLabels.
|
||||
|
||||
+95
-27
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -99,6 +100,10 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
incidents = append(incidents, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, incidents)
|
||||
}
|
||||
}
|
||||
@@ -156,9 +161,15 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT e.id, e.incident_id, e.type, e.user_id, u.username,
|
||||
e.service_account_id, sa.name,
|
||||
e.actor_user_id, au.username,
|
||||
e.actor_service_account_id, asa.name,
|
||||
e.alert_id, e.detail, e.created_at
|
||||
FROM incident_events e
|
||||
LEFT JOIN users u ON u.id = e.user_id
|
||||
LEFT JOIN service_accounts sa ON sa.id = e.service_account_id
|
||||
LEFT JOIN users au ON au.id = e.actor_user_id
|
||||
LEFT JOIN service_accounts asa ON asa.id = e.actor_service_account_id
|
||||
WHERE e.incident_id = $1
|
||||
ORDER BY e.created_at ASC, e.id ASC`, id)
|
||||
if err != nil {
|
||||
@@ -172,6 +183,9 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
var e models.IncidentEvent
|
||||
var ts int64
|
||||
if err := rows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username,
|
||||
&e.ServiceAccountID, &e.ServiceAccountName,
|
||||
&e.ActorUserID, &e.ActorUsername,
|
||||
&e.ActorServiceAccountID, &e.ActorServiceAccountName,
|
||||
&e.AlertID, &e.Detail, &ts); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -179,6 +193,10 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
||||
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||
events = append(events, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, events)
|
||||
}
|
||||
}
|
||||
@@ -189,17 +207,27 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
acked, err := acknowledgeIncident(r.Context(), db, id, user.ID)
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
acked, err := acknowledgeIncidentAs(r.Context(), db, id, userID, saID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if !acked {
|
||||
if !incidentExists(w, r, db, id) {
|
||||
// incidentIDParam above already confirmed the incident exists, so this
|
||||
// is either resolved, or already acknowledged — the latter is now a
|
||||
// no-op rather than an error, since the caller's desired state
|
||||
// (acknowledged) already holds.
|
||||
inc, err := fetchIncident(r.Context(), db, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusConflict, errResp("incident is resolved"))
|
||||
if inc.Status == "resolved" {
|
||||
respond(w, http.StatusConflict, errResp("incident is resolved"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, inc)
|
||||
return
|
||||
}
|
||||
respondIncident(w, r, db, id)
|
||||
@@ -212,13 +240,14 @@ func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
`UPDATE incidents SET status = 'triggered', acknowledged_by = NULL, acknowledged_at = NULL
|
||||
`UPDATE incidents SET status = 'triggered', acknowledged_by = NULL,
|
||||
acknowledged_by_service_account_id = NULL, acknowledged_at = NULL
|
||||
WHERE id = $1 AND resolved_at IS NULL`, id) {
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evUnacknowledged, &user.ID, nil, nil); err != nil {
|
||||
if err := logEvent(r.Context(), db, id, evUnacknowledged, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -236,7 +265,16 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
// The body is optional: clients that predate resolution notes send none.
|
||||
var req struct {
|
||||
Resolution string `json:"resolution"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil && err != io.EOF {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
req.Resolution = strings.TrimSpace(req.Resolution)
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
`UPDATE incidents SET status = 'resolved', resolved_at = $1, resolution_source = $2
|
||||
WHERE id = $3 AND resolved_at IS NULL`,
|
||||
@@ -248,10 +286,16 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evResolved, &user.ID, nil, nil); err != nil {
|
||||
if err := logEvent(r.Context(), db, id, evResolved, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if req.Resolution != "" {
|
||||
if err := logEvent(r.Context(), db, id, evResolutionNote, userID, saID, nil, &req.Resolution); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
}
|
||||
respondIncident(w, r, db, id)
|
||||
}
|
||||
}
|
||||
@@ -285,8 +329,10 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
|
||||
req.UserID, id) {
|
||||
return
|
||||
}
|
||||
// On an "assigned" event user_id is the assignee, not the actor.
|
||||
if err := logEvent(r.Context(), db, id, evAssigned, &req.UserID, nil, nil); err != nil {
|
||||
// On an "assigned" event user_id is the assignee; the actor goes in
|
||||
// the actor_* columns.
|
||||
actorUserID, actorSAID := callerActorIDs(r.Context())
|
||||
if err := logAssignedEvent(r.Context(), db, id, req.UserID, actorUserID, actorSAID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -337,14 +383,14 @@ func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := userFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
"UPDATE incidents SET snoozed_until = $1 WHERE id = $2 AND resolved_at IS NULL",
|
||||
until.Unix(), id) {
|
||||
return
|
||||
}
|
||||
detail := until.UTC().Format(time.RFC3339)
|
||||
if err := logEvent(r.Context(), db, id, evSnoozed, &user.ID, nil, &detail); err != nil {
|
||||
if err := logEvent(r.Context(), db, id, evSnoozed, userID, saID, nil, &detail); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -358,12 +404,12 @@ func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, _ := userFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if !updateOpenIncident(w, r, db, id,
|
||||
"UPDATE incidents SET snoozed_until = NULL WHERE id = $1 AND resolved_at IS NULL", id) {
|
||||
return
|
||||
}
|
||||
if err := logEvent(r.Context(), db, id, evUnsnoozed, &user.ID, nil, nil); err != nil {
|
||||
if err := logEvent(r.Context(), db, id, evUnsnoozed, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -387,6 +433,11 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||
return
|
||||
}
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if err := logEvent(r.Context(), db, id, evArchived, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respondIncident(w, r, db, id)
|
||||
}
|
||||
}
|
||||
@@ -407,6 +458,11 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||
return
|
||||
}
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
if err := logEvent(r.Context(), db, id, evUnarchived, userID, saID, nil, nil); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -421,11 +477,17 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
// Pinned files the note as the resolution note: what fixed it.
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
noteType := evNote
|
||||
if req.Pinned {
|
||||
noteType = evResolutionNote
|
||||
}
|
||||
if req.Content == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("content is required"))
|
||||
return
|
||||
@@ -434,27 +496,32 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := userFromContext(r.Context())
|
||||
caller, _ := callerFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
now := time.Now()
|
||||
var eventID int64
|
||||
err := db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO incident_events (incident_id, type, user_id, detail, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id`, id, evNote, user.ID, req.Content, now.Unix()).Scan(&eventID)
|
||||
INSERT INTO incident_events (incident_id, type, user_id, service_account_id, detail, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`, id, noteType, userID, saID, req.Content, now.Unix()).Scan(&eventID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
respond(w, http.StatusCreated, models.IncidentEvent{
|
||||
resp := models.IncidentEvent{
|
||||
ID: eventID,
|
||||
IncidentID: id,
|
||||
Type: evNote,
|
||||
UserID: &user.ID,
|
||||
Username: &user.Username,
|
||||
Type: noteType,
|
||||
Detail: &req.Content,
|
||||
CreatedAt: now.UTC().Truncate(time.Second),
|
||||
})
|
||||
}
|
||||
if u, ok := caller.AsHuman(); ok {
|
||||
resp.UserID, resp.Username = &u.ID, &u.Username
|
||||
} else if saName, ok := caller.ServiceAccountName(); ok {
|
||||
resp.ServiceAccountID, resp.ServiceAccountName = saID, &saName
|
||||
}
|
||||
respond(w, http.StatusCreated, resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,11 +539,12 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := userFromContext(r.Context())
|
||||
userID, saID := callerActorIDs(r.Context())
|
||||
res, err := db.ExecContext(r.Context(), `
|
||||
DELETE FROM incident_events
|
||||
WHERE id = $1 AND incident_id = $2 AND type = $3 AND user_id = $4`,
|
||||
eventID, id, evNote, user.ID)
|
||||
WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4)
|
||||
AND (user_id = $5 OR service_account_id = $6)`,
|
||||
eventID, id, evNote, evResolutionNote, userID, saID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
// amAlert builds one alert of a webhook payload.
|
||||
@@ -338,6 +339,48 @@ func TestIncident_Acknowledge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A second acknowledge — a retried request, or a stale push notification
|
||||
// tapped after the web UI already acked it — must be a no-op: same state,
|
||||
// no second "acknowledged" timeline entry. Regression test for the bug
|
||||
// described in issue #26 ("two acknowledged entries look like a bug").
|
||||
func TestIncident_AcknowledgeTwiceIsIdempotent(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-ack2", "Y", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
|
||||
resp := s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first acknowledge returned %d", resp.StatusCode)
|
||||
}
|
||||
var first map[string]any
|
||||
decode(t, resp, &first)
|
||||
|
||||
resp = s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second acknowledge returned %d, want 200 (idempotent)", resp.StatusCode)
|
||||
}
|
||||
var second map[string]any
|
||||
decode(t, resp, &second)
|
||||
if second["status"] != "acknowledged" {
|
||||
t.Errorf("expected status still acknowledged, got %v", second["status"])
|
||||
}
|
||||
if second["acknowledged_by"] != first["acknowledged_by"] {
|
||||
t.Errorf("expected the same acknowledged_by, got %v then %v", first["acknowledged_by"], second["acknowledged_by"])
|
||||
}
|
||||
|
||||
types := eventTypes(timeline(t, s, 1))
|
||||
n := 0
|
||||
for _, ty := range types {
|
||||
if ty == "acknowledged" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected exactly one acknowledged event, got %d in %v", n, types)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncident_ManualResolveIsTerminal(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
@@ -600,6 +643,106 @@ func TestIncident_ArchiveRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service accounts (terdut-server#25)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestServiceAccount_CanActOnItsTeamsIncidents is #25's regression test.
|
||||
// Before the fix: acknowledge/resolve/snooze/create-note each 500'd (writing
|
||||
// acknowledged_by/user_id = 0, violating the users(id) FK for a service
|
||||
// account), and delete-note silently matched zero rows (WHERE user_id = 0)
|
||||
// instead of deleting.
|
||||
func TestServiceAccount_CanActOnItsTeamsIncidents(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
|
||||
var integration struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
|
||||
map[string]string{"name": "test"}), &integration)
|
||||
postToIntegration(t, s, integration.Key, "fp-sa", "SAIncident") // incident 1
|
||||
|
||||
// Acknowledge.
|
||||
resp := s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("service account acknowledge: %d", resp.StatusCode)
|
||||
}
|
||||
var inc map[string]any
|
||||
decode(t, resp, &inc)
|
||||
if inc["acknowledged_by_service_account_id"] == nil {
|
||||
t.Error("expected acknowledged_by_service_account_id to be set")
|
||||
}
|
||||
if inc["acknowledged_by_id"] != nil {
|
||||
t.Errorf("expected acknowledged_by_id to stay nil for a service-account actor, got %v", inc["acknowledged_by_id"])
|
||||
}
|
||||
|
||||
// Unacknowledge.
|
||||
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/acknowledge", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("service account unacknowledge: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Snooze, then unsnooze.
|
||||
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/snooze",
|
||||
map[string]string{"duration": "1h"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("service account snooze: %d", resp.StatusCode)
|
||||
}
|
||||
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/snooze", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("service account unsnooze: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create, then delete, a note.
|
||||
var note map[string]any
|
||||
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/notes",
|
||||
map[string]string{"content": "looking into it"}), ¬e)
|
||||
if note["service_account_id"] == nil {
|
||||
t.Error("expected service_account_id on the note event")
|
||||
}
|
||||
if note["user_id"] != nil {
|
||||
t.Errorf("expected no user_id on a service-account note, got %v", note["user_id"])
|
||||
}
|
||||
noteID := int(note["id"].(float64))
|
||||
delResp := s.reqAs(t, keyA, http.MethodDelete, fmt.Sprintf("/api/incidents/1/notes/%d", noteID), nil)
|
||||
delResp.Body.Close()
|
||||
if delResp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("service account deleting its own note: %d", delResp.StatusCode)
|
||||
}
|
||||
|
||||
// Resolve.
|
||||
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/resolve", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("service account resolve: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression guard: a human actor must still write only the human columns,
|
||||
// unaffected by the service-account branch added above.
|
||||
func TestIncident_AcknowledgeStillWritesOnlyHumanColumn(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-human-ack", "Z", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
|
||||
var inc map[string]any
|
||||
decode(t, s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil), &inc)
|
||||
if inc["acknowledged_by_id"] == nil {
|
||||
t.Error("expected acknowledged_by_id to be set for a human actor")
|
||||
}
|
||||
if inc["acknowledged_by_service_account_id"] != nil {
|
||||
t.Errorf("expected acknowledged_by_service_account_id to stay nil for a human actor, got %v",
|
||||
inc["acknowledged_by_service_account_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweeper_ArchivesResolvedIncidents(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
@@ -727,3 +870,105 @@ func contains(haystack []string, needle string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actor on assign / archive / unarchive (terdut-server#35)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// lastEvent returns the newest timeline event of the given type.
|
||||
func lastEvent(t *testing.T, events []map[string]any, typ string) map[string]any {
|
||||
t.Helper()
|
||||
for i := len(events) - 1; i >= 0; i-- {
|
||||
if events[i]["type"] == typ {
|
||||
return events[i]
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %q event in %v", typ, eventTypes(events))
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestIncident_AssignRecordsHumanActor(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-asg-actor", "Assignable", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": "alice", "email": "alice@test.com"}).Body.Close()
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/assign", map[string]any{"user_id": 2}).Body.Close()
|
||||
|
||||
ev := lastEvent(t, timeline(t, s, 1), "assigned")
|
||||
if ev["username"] != "alice" {
|
||||
t.Errorf("expected the assignee alice in username, got %v", ev["username"])
|
||||
}
|
||||
if ev["actor_user_id"] == nil || ev["actor_username"] == nil {
|
||||
t.Errorf("expected the assigning human in actor_*, got %v", ev)
|
||||
}
|
||||
if ev["actor_service_account_id"] != nil {
|
||||
t.Errorf("expected no service-account actor, got %v", ev["actor_service_account_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncident_ArchiveUnarchiveRecordHumanActor(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
amAlert("fp-arc-actor", "Archivable", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||
})
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/archive", nil).Body.Close()
|
||||
s.req(t, http.MethodDelete, "/api/incidents/1/archive", nil).Body.Close()
|
||||
|
||||
events := timeline(t, s, 1)
|
||||
for _, typ := range []string{"archived", "unarchived"} {
|
||||
ev := lastEvent(t, events, typ)
|
||||
if ev["user_id"] == nil || ev["service_account_id"] != nil {
|
||||
t.Errorf("%s: expected only the human actor, got %v", typ, ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAccount_AssignArchiveUnarchiveRecordActor(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
var integration struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
|
||||
map[string]string{"name": "test"}), &integration)
|
||||
postToIntegration(t, s, integration.Key, "fp-sa-35", "SA35") // incident 1
|
||||
|
||||
resp := s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/assign", map[string]any{"user_id": 1})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("service account assign: %d", resp.StatusCode)
|
||||
}
|
||||
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/resolve", nil)
|
||||
resp.Body.Close()
|
||||
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/archive", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("service account archive: %d", resp.StatusCode)
|
||||
}
|
||||
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/archive", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("service account unarchive: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var events []map[string]any
|
||||
decode(t, s.reqAs(t, keyA, http.MethodGet, "/api/incidents/1/timeline", nil), &events)
|
||||
asg := lastEvent(t, events, "assigned")
|
||||
if asg["actor_service_account_id"] == nil || asg["actor_user_id"] != nil {
|
||||
t.Errorf("assigned: expected only the service-account actor, got %v", asg)
|
||||
}
|
||||
if asg["user_id"] == nil {
|
||||
t.Errorf("assigned: user_id must stay the assignee, got %v", asg)
|
||||
}
|
||||
for _, typ := range []string{"archived", "unarchived"} {
|
||||
ev := lastEvent(t, events, typ)
|
||||
if ev["service_account_id"] == nil || ev["user_id"] != nil {
|
||||
t.Errorf("%s: expected only the service-account actor, got %v", typ, ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
)
|
||||
|
||||
func testNotify() api.NotifyConfig {
|
||||
return api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute}
|
||||
}
|
||||
|
||||
type memberView struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
OnCall bool `json:"on_call"`
|
||||
NextShift *string `json:"next_shift"`
|
||||
Pageable bool `json:"pageable"`
|
||||
Problem string `json:"problem"`
|
||||
LastActiveAt *string `json:"last_active_at"`
|
||||
}
|
||||
|
||||
func readMembers(t *testing.T, s *ts) map[string]memberView {
|
||||
t.Helper()
|
||||
var list []memberView
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/members", nil), &list)
|
||||
out := map[string]memberView{}
|
||||
for _, m := range list {
|
||||
out[m.Username] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The list says who is on call, who could not be woken, and who is merely
|
||||
// there — and an on-call person who cannot be paged is the red one.
|
||||
func TestMembers_StatusReflectsRotaAndPageability(t *testing.T) {
|
||||
s, _ := notifyTS(t, testNotify()) // admin is on call today, with a topic
|
||||
teamUser(t, s, "reachable", "terdut-reachable")
|
||||
silent := teamUser(t, s, "silent", "terdut-silent")
|
||||
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
|
||||
|
||||
got := readMembers(t, s)
|
||||
if m := got["admin"]; m.Status != "oncall" || !m.OnCall || !m.Pageable {
|
||||
t.Errorf("the person on call should read on call, got %+v", m)
|
||||
}
|
||||
if m := got["reachable"]; m.Status != "reachable" || m.OnCall {
|
||||
t.Errorf("a member with a topic who is off the rota is reachable, got %+v", m)
|
||||
}
|
||||
if m := got["silent"]; m.Status != "unpageable" || m.Problem != "has no ntfy topic" {
|
||||
t.Errorf("no topic means they cannot be paged, got %+v", m)
|
||||
}
|
||||
|
||||
// Being on call does not rescue an account that cannot be woken.
|
||||
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE username = 'admin'")
|
||||
if m := readMembers(t, s)["admin"]; m.Status != "unpageable" || !m.OnCall {
|
||||
t.Errorf("an on-call person with no topic is the red case, got %+v", m)
|
||||
}
|
||||
|
||||
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)
|
||||
if m := readMembers(t, s)["silent"]; m.Problem != "account is disabled" {
|
||||
t.Errorf("a disabled account should say so, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// The next shift is the next day after today, not today itself.
|
||||
func TestMembers_NextShiftIsAfterToday(t *testing.T) {
|
||||
s, _ := notifyTS(t, testNotify())
|
||||
tomorrow := time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02")
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
|
||||
map[string]any{"user_id": 1, "dates": []string{tomorrow}})
|
||||
resp.Body.Close()
|
||||
|
||||
m := readMembers(t, s)["admin"]
|
||||
if !m.OnCall || m.NextShift == nil || *m.NextShift != tomorrow {
|
||||
t.Errorf("want on call today with the next shift on %s, got %+v", tomorrow, m)
|
||||
}
|
||||
teamUser(t, s, "idle", "terdut-idle")
|
||||
if m := readMembers(t, s)["idle"]; m.NextShift != nil {
|
||||
t.Errorf("somebody not on the rota has no next shift, got %v", *m.NextShift)
|
||||
}
|
||||
}
|
||||
|
||||
// Last active is the newer of a session and an API key, and absent when neither
|
||||
// has ever been used.
|
||||
func TestMembers_LastActive(t *testing.T) {
|
||||
s, _ := notifyTS(t, testNotify())
|
||||
idle := teamUser(t, s, "idle", "terdut-idle")
|
||||
|
||||
if m := readMembers(t, s)["idle"]; m.LastActiveAt != nil {
|
||||
t.Errorf("nobody has used idle's account, got %v", *m.LastActiveAt)
|
||||
}
|
||||
|
||||
old := time.Now().Add(-48 * time.Hour).Unix()
|
||||
s.exec(t, `INSERT INTO api_keys (user_id, key_hash, name, last_used_at) VALUES ($1, 'h1', 'k', $2)`, idle, old)
|
||||
s.exec(t, `INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at)
|
||||
VALUES ('h2', $1, $2, $3, $4)`, idle, old, old+3600, time.Now().Add(time.Hour).Unix())
|
||||
|
||||
m := readMembers(t, s)["idle"]
|
||||
if m.LastActiveAt == nil {
|
||||
t.Fatal("expected a last active time")
|
||||
}
|
||||
got, _ := time.Parse(time.RFC3339, *m.LastActiveAt)
|
||||
if got.Unix() != old+3600 {
|
||||
t.Errorf("last active should be the newer session (%d), got %d", old+3600, got.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
// The last owner can be neither removed nor demoted; with another owner in
|
||||
// place, both are fine.
|
||||
func TestMembers_LastOwnerIsProtected(t *testing.T) {
|
||||
s, _ := notifyTS(t, testNotify())
|
||||
tm := newTeam(t, s, "red")
|
||||
base := "/api/teams/" + id64(tm.id) + "/members"
|
||||
|
||||
// Creating a team makes the creator an owner too; step the admin out so
|
||||
// "red-user" is the only one left.
|
||||
resp := s.req(t, http.MethodDelete, base+"/1", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("removing the creator: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var members []map[string]any
|
||||
decode(t, tm.call(http.MethodGet, base, nil), &members)
|
||||
var owner int64
|
||||
for _, m := range members {
|
||||
if m["username"] == "red-user" {
|
||||
owner = int64(m["user_id"].(float64))
|
||||
}
|
||||
}
|
||||
|
||||
resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("demoting the last owner: expected 409, got %d", resp.StatusCode)
|
||||
}
|
||||
resp = tm.call(http.MethodDelete, base+"/"+id64(owner), nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("removing the last owner: expected 409, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// A second owner frees the first to step down.
|
||||
resp = s.req(t, http.MethodPost, base, map[string]any{"user_id": 1, "role": "owner"})
|
||||
resp.Body.Close()
|
||||
resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("demoting one of two owners: expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
+167
-26
@@ -9,15 +9,20 @@ 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"
|
||||
// ctxCaller holds the one Caller (see caller.go) every authorization
|
||||
// predicate in this package reads from — a human and a service account
|
||||
// used to be two parallel, un-unified context keys (ctxUser/ctxTeams vs.
|
||||
// ctxServiceAccount); this is why that was a mistake, not a smaller
|
||||
// version of the same idea.
|
||||
ctxCaller contextKey = "caller"
|
||||
ctxSession contextKey = "session"
|
||||
ctxTeams contextKey = "teams"
|
||||
)
|
||||
|
||||
// AuthMiddleware accepts either of the two credentials the server issues: an
|
||||
@@ -39,12 +44,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
|
||||
}
|
||||
|
||||
@@ -67,6 +79,28 @@ func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// securityHeaders sets headers that cost nothing to send on every response,
|
||||
// API or static site alike. nosniff is unconditional; HSTS only fires once
|
||||
// cookieSecure's signal says the browser is actually looking at this server
|
||||
// over HTTPS — TLS terminates at the gateway, which (as of this writing) sets
|
||||
// neither header itself.
|
||||
//
|
||||
// max-age is 180 days rather than the usual year-plus: short enough that if
|
||||
// HTTPS here ever broke for real, the header would age out of a browser's
|
||||
// cache well within a release cycle instead of locking anyone out of a
|
||||
// working server. Raise it once this has run clean for a while.
|
||||
func securityHeaders(publicURL string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if cookieSecure(publicURL, r) {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AdminOnly rejects a caller who is not a system administrator. It runs inside
|
||||
// AuthMiddleware's group, so by the time it sees a request the caller is known.
|
||||
//
|
||||
@@ -101,10 +135,16 @@ func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64)
|
||||
}
|
||||
|
||||
// apiKeyUser resolves an API key to its user and stamps its last use.
|
||||
// expires_at IS NULL OR > now is part of the lookup itself, the same way
|
||||
// serveAs's disabled_at check is: an expired key is one that cannot
|
||||
// authenticate, by construction, rather than one that happens to still
|
||||
// resolve and has to be caught afterwards.
|
||||
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
|
||||
var keyID, userID int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT id, user_id FROM api_keys WHERE key_hash = $1", hashToken(token),
|
||||
`SELECT id, user_id FROM api_keys
|
||||
WHERE key_hash = $1 AND (expires_at IS NULL OR expires_at > $2)`,
|
||||
hashToken(token), time.Now().Unix(),
|
||||
).Scan(&keyID, &userID)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
@@ -132,8 +172,13 @@ func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, user
|
||||
}
|
||||
|
||||
if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
|
||||
db.ExecContext(ctx,
|
||||
"UPDATE sessions SET last_seen_at = $1, expires_at = $2 WHERE id = $3",
|
||||
// LEAST keeps a capped session (a single sign-on login) from sliding
|
||||
// past its ceiling; with no ceiling COALESCE makes it the plain slide.
|
||||
db.ExecContext(ctx, `
|
||||
UPDATE sessions
|
||||
SET last_seen_at = $1,
|
||||
expires_at = LEAST($2::bigint, COALESCE(max_expires_at, $2::bigint))
|
||||
WHERE id = $3`,
|
||||
now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
|
||||
}
|
||||
return sessionID, userID, true
|
||||
@@ -165,8 +210,7 @@ func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxTeams, teams)
|
||||
ctx = context.WithValue(ctx, ctxUser, u)
|
||||
ctx := context.WithValue(r.Context(), ctxCaller, Caller{user: &u, memberships: teams})
|
||||
if sessionID != 0 {
|
||||
ctx = context.WithValue(ctx, ctxSession, sessionID)
|
||||
}
|
||||
@@ -178,9 +222,115 @@ func hashToken(token string) string {
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// userFromContext is a thin compatibility wrapper over Caller.AsHuman(), so
|
||||
// every call site written before the Caller abstraction (alerts.go,
|
||||
// incidents.go, schedule.go, stats.go, and more) needs no change and keeps
|
||||
// its exact existing behavior.
|
||||
func userFromContext(ctx context.Context) (models.User, bool) {
|
||||
u, ok := ctx.Value(ctxUser).(models.User)
|
||||
return u, ok
|
||||
c, _ := callerFromContext(ctx)
|
||||
return c.AsHuman()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var memberships []membership
|
||||
if sa.scope == models.ServiceAccountScopeTeam {
|
||||
memberships = []membership{{teamID: sa.teamID, role: models.RoleOwner}}
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxCaller, Caller{sa: &sa, memberships: memberships})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
// isInstanceServiceAccount is a thin compatibility wrapper over
|
||||
// Caller.IsInstanceServiceAccount(), for call sites outside this package's
|
||||
// core predicates (handleCreateTeam, handleCreateServiceAccount) that
|
||||
// needed this exact, narrow check before the Caller abstraction existed.
|
||||
func isInstanceServiceAccount(ctx context.Context) bool {
|
||||
c, _ := callerFromContext(ctx)
|
||||
return c.IsInstanceServiceAccount()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
caller, _ := callerFromContext(r.Context())
|
||||
if _, ok := caller.ServiceAccountID(); 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.
|
||||
@@ -213,24 +363,15 @@ func callerMemberships(ctx context.Context, db *sql.DB, userID int64) ([]members
|
||||
// administration is about accounts, not about reading other people's incidents,
|
||||
// and an admin who needs to see a team's queue can add themselves to it.
|
||||
func callerTeamIDs(ctx context.Context) []int64 {
|
||||
ms, _ := ctx.Value(ctxTeams).([]membership)
|
||||
ids := make([]int64, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
ids = append(ids, m.teamID)
|
||||
}
|
||||
return ids
|
||||
c, _ := callerFromContext(ctx)
|
||||
return c.TeamIDs()
|
||||
}
|
||||
|
||||
// callerRole reports the caller's role in one team, and whether they are in it
|
||||
// at all.
|
||||
func callerRole(ctx context.Context, teamID int64) (string, bool) {
|
||||
ms, _ := ctx.Value(ctxTeams).([]membership)
|
||||
for _, m := range ms {
|
||||
if m.teamID == teamID {
|
||||
return m.role, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
c, _ := callerFromContext(ctx)
|
||||
return c.Role(teamID)
|
||||
}
|
||||
|
||||
// requireTeamMember answers the request and reports false unless the caller
|
||||
|
||||
@@ -38,6 +38,13 @@ const (
|
||||
ackTokenTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// notifierLockKey is the Postgres advisory lock the notifier takes for the
|
||||
// duration of each pass, so that running more than one replica does not
|
||||
// deliver (or double-deliver) the same notification from more than one of
|
||||
// them at once. Its value has no meaning beyond being distinct from
|
||||
// archiverLockKey.
|
||||
const notifierLockKey int64 = 7265_0002
|
||||
|
||||
// Notification kinds, recording why a push was sent.
|
||||
const (
|
||||
notifyTriggered = "triggered"
|
||||
@@ -97,6 +104,10 @@ var notifyClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// StartNotifier delivers queued notifications until ctx is cancelled, starting
|
||||
// with an immediate pass so a restart flushes whatever the last one left behind.
|
||||
//
|
||||
// Each pass runs under notifierLockKey (see withAdvisoryLock), so that on more
|
||||
// than one replica only whichever instance's tick takes the lock first actually
|
||||
// delivers; the rest skip that tick rather than racing the same pass.
|
||||
func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
if !cfg.enabled() {
|
||||
log.Print("notifier: disabled (no ntfy URL configured)")
|
||||
@@ -107,11 +118,17 @@ func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
ticker := time.NewTicker(notifyInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
NotifySweep(ctx, db, cfg)
|
||||
sweep := func() {
|
||||
withAdvisoryLock(ctx, db, notifierLockKey, "notifier", func() {
|
||||
NotifySweep(ctx, db, cfg)
|
||||
})
|
||||
}
|
||||
|
||||
sweep()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
NotifySweep(ctx, db, cfg)
|
||||
sweep()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -240,7 +257,7 @@ func deliverPending(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||
}
|
||||
// Logged, not returned: the page has already gone out, and treating a
|
||||
// failed timeline write as a failed delivery would send it again.
|
||||
if err := logEvent(ctx, db, n.incidentID, eventNotified, n.userID, nil, &n.kind); err != nil {
|
||||
if err := logEvent(ctx, db, n.incidentID, eventNotified, n.userID, nil, nil, &n.kind); err != nil {
|
||||
log.Printf("notifier: log delivery of %d: %v", n.id, err)
|
||||
}
|
||||
sent++
|
||||
@@ -295,7 +312,7 @@ func markFailed(ctx context.Context, db *sql.DB, n outboxRow, cause error) {
|
||||
return
|
||||
}
|
||||
detail := fmt.Sprintf("%s: %s", n.kind, cause)
|
||||
if err := logEvent(ctx, db, n.incidentID, eventNotifyFailed, n.userID, nil, &detail); err != nil {
|
||||
if err := logEvent(ctx, db, n.incidentID, eventNotifyFailed, n.userID, nil, nil, &detail); err != nil {
|
||||
log.Printf("notifier: log failure of %d: %v", n.id, err)
|
||||
}
|
||||
}
|
||||
@@ -330,6 +347,16 @@ func deliver(ctx context.Context, db *sql.DB, cfg NotifyConfig, n outboxRow) err
|
||||
|
||||
msg := renderNotification(inc, n, firing, cfg)
|
||||
|
||||
// The page that opens an incident carries what fixed it last time, so the
|
||||
// person woken up starts from that. Best effort: a failed lookup must not
|
||||
// hold back the page itself.
|
||||
if n.kind == notifyTriggered {
|
||||
if sim, err := similarIncidents(ctx, db, n.incidentID, 1); err == nil && len(sim) > 0 && len(sim[0].ResolutionNotes) > 0 {
|
||||
notes := sim[0].ResolutionNotes
|
||||
msg.Message += "\nLast time: " + shorten(derefString(notes[len(notes)-1].Detail), 160)
|
||||
}
|
||||
}
|
||||
|
||||
// An Acknowledge button needs both a user to attribute the acknowledgement
|
||||
// to and a URL the phone can reach. Minted per delivery, so every push
|
||||
// carries its own short-lived token rather than reusing one.
|
||||
@@ -573,6 +600,17 @@ func plural(n int) string {
|
||||
return "s"
|
||||
}
|
||||
|
||||
// shorten cuts s to at most n runes, marking the cut, and flattens newlines so
|
||||
// a multi-line note stays one line in a push.
|
||||
func shorten(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n-1]) + "…"
|
||||
}
|
||||
|
||||
// derefString reads a nullable text column as a plain string.
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
|
||||
@@ -66,12 +66,19 @@ func handleNotifyAck(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if !acked {
|
||||
// The incident closed between the page and the tap. Nothing to do,
|
||||
// and nothing the responder did wrong — report the state, not an error,
|
||||
// so ntfy shows a success toast rather than a failure.
|
||||
// Either the incident closed between the page and the tap, or it was
|
||||
// already acknowledged (e.g. from the web UI, or an earlier tap of
|
||||
// the same button) — either way nothing the responder did wrong, so
|
||||
// report the actual state rather than assuming "resolved", and let
|
||||
// ntfy show a success toast rather than a failure.
|
||||
inc, err := fetchIncident(r.Context(), db, incidentID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, map[string]any{
|
||||
"incident_id": incidentID,
|
||||
"status": "resolved",
|
||||
"status": inc.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -408,6 +408,47 @@ func TestNotify_AckButtonAcknowledgesIncident(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The ack token isn't single-use (it stays valid for a day, in case the
|
||||
// first tap never reaches the server), so tapping the same notification's
|
||||
// Acknowledge button twice is a real scenario, not just a retried request.
|
||||
// It must report the incident's actual state, not assume "resolved" —
|
||||
// see handleNotifyAck's !acked branch — and must not log a second
|
||||
// "acknowledged" event.
|
||||
func TestNotify_AckButtonTwiceIsIdempotent(t *testing.T) {
|
||||
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||
|
||||
fireCritical(t, s)
|
||||
s.sweepNotify(t)
|
||||
|
||||
ackURL := f.messages()[0].Actions[0].URL
|
||||
path := ackURL[strings.Index(ackURL, "/api/notify/ack/"):]
|
||||
|
||||
for i := range 2 {
|
||||
resp, err := http.Post(s.URL+path, "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ack %d: %v", i+1, err)
|
||||
}
|
||||
var body map[string]any
|
||||
decode(t, resp, &body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ack %d returned %d", i+1, resp.StatusCode)
|
||||
}
|
||||
if body["status"] != "acknowledged" {
|
||||
t.Errorf("ack %d: expected status acknowledged, got %v", i+1, body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, ty := range eventTypes(timeline(t, s, 1)) {
|
||||
if ty == "acknowledged" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected exactly one acknowledged event after two taps, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotify_AckRejectsUnknownToken(t *testing.T) {
|
||||
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||
fireCritical(t, s)
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/oidc"
|
||||
)
|
||||
|
||||
const (
|
||||
// oidcStateCookie ties an in-flight login to the browser that started it.
|
||||
// Without it anybody could start a login, and send the callback URL that
|
||||
// results to somebody else, who would be signed in as the attacker.
|
||||
oidcStateCookie = "terdut_oidc_state"
|
||||
|
||||
// oidcLoginTTL is how long a login may take between the redirect to the
|
||||
// provider and the callback, which includes the person typing a password
|
||||
// and a second factor.
|
||||
oidcLoginTTL = 10 * time.Minute
|
||||
|
||||
// oidcStartMaxPerAddr bounds unauthenticated logins started per address.
|
||||
// Each writes a row, so an unbounded endpoint is a way to grow the table.
|
||||
oidcStartMaxPerAddr = 30
|
||||
)
|
||||
|
||||
// ssoError is a sign-in refusal the person can be told about. Its value is the
|
||||
// code the web UI is sent back with, as ?sso_error=<code>; the detail stays in
|
||||
// the server log, since it can name accounts.
|
||||
type ssoError string
|
||||
|
||||
func (e ssoError) Error() string { return "sso: " + string(e) }
|
||||
|
||||
const (
|
||||
ssoDenied ssoError = "denied" // the provider reported an error, or the person declined
|
||||
ssoExpired ssoError = "expired" // unknown, used or expired state; start again
|
||||
ssoFailed ssoError = "failed" // the token exchange or its verification failed
|
||||
ssoUnavailable ssoError = "unavailable" // the provider could not be reached
|
||||
ssoNotAllowed ssoError = "not_allowed" // authenticated, but in none of the allowed groups
|
||||
ssoNoEmail ssoError = "no_email" // the provider sent no email address
|
||||
ssoEmailConflict ssoError = "email_conflict" // a local account has this email and cannot be linked
|
||||
ssoDisabled ssoError = "disabled" // the linked account is disabled
|
||||
// ssoNotBootstrapped: this identity has no existing account, and no user
|
||||
// exists on this install yet either -- creating one here would race
|
||||
// POST /api/bootstrap for the one gitops-managed installs expect to win
|
||||
// it (terdut-operator's own DESIGN.md §1, §6), which has no way to
|
||||
// recover if it loses. The person sees this for at most as long as it
|
||||
// takes whatever is bootstrapping this install to finish; signing in
|
||||
// again afterward hits the ordinary first-sign-in path. Found by
|
||||
// terdut-operator#1: nothing stopped an otherwise-ordinary OIDC sign-in
|
||||
// from quietly winning this race against an operator that assumed it
|
||||
// was the only caller.
|
||||
ssoNotBootstrapped ssoError = "not_bootstrapped"
|
||||
)
|
||||
|
||||
// handleAuthConfig says how this server can be signed in to, so the login form
|
||||
// and the TUI can offer the right choices before anybody types anything. It is
|
||||
// unauthenticated by necessity, and reveals nothing beyond what the login page
|
||||
// shows anyway.
|
||||
func handleAuthConfig(cfg config.Config) http.HandlerFunc {
|
||||
type oidcInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
type response struct {
|
||||
PasswordLogin bool `json:"password_login"`
|
||||
OIDC oidcInfo `json:"oidc"`
|
||||
|
||||
// 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, OperatorMode: cfg.OperatorMode}
|
||||
if cfg.OIDC.Enabled() {
|
||||
resp.OIDC = oidcInfo{Enabled: true, Name: cfg.OIDC.Name}
|
||||
resp.DeviceLogin = true
|
||||
}
|
||||
respond(w, http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// passwordLoginOnly refuses a route when password login is switched off.
|
||||
func passwordLoginOnly(enabled bool) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
if enabled {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, http.StatusForbidden, errResp("password login is disabled on this server"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ssoRedirect sends the browser back to the web UI with the reason a sign-in
|
||||
// failed. It is a redirect and not a JSON error because the browser arrived
|
||||
// here by navigating from the provider: there is no page script to read one.
|
||||
func ssoRedirect(w http.ResponseWriter, r *http.Request, code ssoError) {
|
||||
http.Redirect(w, r, "/?sso_error="+url.QueryEscape(string(code)), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleOIDCLogin starts a sign-in: it records the state, nonce and PKCE
|
||||
// verifier the callback will need and sends the browser to the provider.
|
||||
func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
addrKey := "oidc:" + clientAddr(r)
|
||||
if limiter.blocked(r.Context(), addrKey, oidcStartMaxPerAddr) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
|
||||
return
|
||||
}
|
||||
limiter.fail(r.Context(), addrKey)
|
||||
|
||||
state, stateHash, err := randomToken()
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
nonce, _, err := randomToken()
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
verifier := oidc.NewVerifier()
|
||||
next := safeNext(r.URL.Query().Get("next"))
|
||||
|
||||
// Abandoned logins are swept here rather than by the sweeper: this is
|
||||
// the only place they are made, so the table cannot outgrow its writers.
|
||||
now := time.Now()
|
||||
db.ExecContext(r.Context(), "DELETE FROM oidc_logins WHERE expires_at < $1", now.Unix())
|
||||
if _, err := db.ExecContext(r.Context(), `
|
||||
INSERT INTO oidc_logins (state_hash, nonce, pkce_verifier, next, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
stateHash, nonce, verifier, next, now.Add(oidcLoginTTL).Unix()); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
authURL, err := prov.AuthURL(r.Context(), state, nonce, verifier)
|
||||
if err != nil {
|
||||
log.Printf("oidc: start login: %v", err)
|
||||
ssoRedirect(w, r, ssoUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what
|
||||
// trips this rule. See cookieSecure's own doc comment in auth.go.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: oidcStateCookie,
|
||||
Value: state,
|
||||
Path: "/api/oidc",
|
||||
MaxAge: int(oidcLoginTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: cookieSecure(publicURL, r),
|
||||
// Lax, not Strict: the callback is a top-level navigation from the
|
||||
// provider's site, which Strict would not send the cookie on.
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// handleOIDCCallback finishes a sign-in: it verifies the provider's answer,
|
||||
// finds or creates the user, applies their groups and starts a session.
|
||||
func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.HandlerFunc {
|
||||
cfg := prov.Config()
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// The state cookie has done its job once the callback arrives, whatever
|
||||
// the outcome.
|
||||
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
|
||||
// cookieSecure(publicURL, r), not a literal true, which is what
|
||||
// trips this rule. See cookieSecure's own doc comment in auth.go.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: oidcStateCookie, Value: "", Path: "/api/oidc", MaxAge: -1,
|
||||
HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
q := r.URL.Query()
|
||||
if e := q.Get("error"); e != "" {
|
||||
// %q on both: this runs before state is checked against the
|
||||
// cookie, so error and error_description are still whatever the
|
||||
// request's query string says, not yet known to be the real
|
||||
// provider's. %q keeps a crafted value (say, one holding a
|
||||
// newline) from forging a second log line rather than just
|
||||
// being a quoted string within this one.
|
||||
log.Printf("oidc: provider returned error %q: %q", e, q.Get("error_description")) // #nosec G706 -- both %q
|
||||
ssoRedirect(w, r, ssoDenied)
|
||||
return
|
||||
}
|
||||
state := q.Get("state")
|
||||
cookie, err := r.Cookie(oidcStateCookie)
|
||||
if state == "" || q.Get("code") == "" || err != nil || cookie.Value != state {
|
||||
ssoRedirect(w, r, ssoExpired)
|
||||
return
|
||||
}
|
||||
|
||||
// DELETE ... RETURNING makes the state single-use: a replayed callback
|
||||
// finds nothing.
|
||||
var nonce, verifier, next string
|
||||
err = db.QueryRowContext(r.Context(), `
|
||||
DELETE FROM oidc_logins WHERE state_hash = $1 AND expires_at > $2
|
||||
RETURNING nonce, pkce_verifier, next`,
|
||||
hashToken(state), time.Now().Unix()).Scan(&nonce, &verifier, &next)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
ssoRedirect(w, r, ssoExpired)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("oidc: load login state: %v", err)
|
||||
ssoRedirect(w, r, ssoFailed)
|
||||
return
|
||||
}
|
||||
|
||||
identity, err := prov.Exchange(r.Context(), q.Get("code"), verifier, nonce)
|
||||
if err != nil {
|
||||
log.Printf("oidc: %v", err)
|
||||
ssoRedirect(w, r, ssoFailed)
|
||||
return
|
||||
}
|
||||
|
||||
grants := oidc.ComputeGrants(cfg, identity.Groups)
|
||||
if !grants.Admitted {
|
||||
log.Printf("oidc: %q (%q) is in none of the allowed groups", identity.Username, identity.Subject) // #nosec G706 -- both %q
|
||||
ssoRedirect(w, r, ssoNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
log.Printf("oidc: refused %q (%q): %v", identity.Username, identity.Subject, se) // #nosec G706 -- both %q
|
||||
ssoRedirect(w, r, se)
|
||||
return
|
||||
}
|
||||
log.Printf("oidc: sign in %q: %v", identity.Username, err) // #nosec G706 -- %q
|
||||
ssoRedirect(w, r, ssoFailed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := startSessionCapped(w, r, db, userID, publicURL, cfg.SessionMaxAge); err != nil {
|
||||
log.Printf("oidc: start session: %v", err)
|
||||
ssoRedirect(w, r, ssoFailed)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, safeNext(next), http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// safeNext returns where to send the browser after a sign-in: the path asked
|
||||
// for, if it is one on this server, and the front page otherwise. It is the
|
||||
// only thing standing between a login link and an open redirect, so it accepts
|
||||
// a single leading slash and nothing that a browser could read as another host
|
||||
// ("//evil.example", "/\evil.example"), and never an API path, which would
|
||||
// land somebody on raw JSON.
|
||||
func safeNext(next string) string {
|
||||
switch {
|
||||
case next == "", len(next) > 512,
|
||||
!strings.HasPrefix(next, "/"),
|
||||
strings.HasPrefix(next, "//"),
|
||||
strings.HasPrefix(next, "/api/"),
|
||||
strings.ContainsAny(next, "\\\r\n"):
|
||||
return "/"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// 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, teamRoles map[int64]string) (int64, error) {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
userID, err := resolveSSOUser(ctx, tx, cfg, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var disabled bool
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
"SELECT disabled_at IS NOT NULL FROM users WHERE id = $1", userID).Scan(&disabled); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if disabled {
|
||||
return 0, ssoDisabled
|
||||
}
|
||||
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.
|
||||
//
|
||||
// The order matters. The (issuer, subject) pair is the identity; email is only
|
||||
// a way to recognise an existing local account the first time. Once linked, a
|
||||
// changed email at the provider must not move the account to somebody else.
|
||||
func resolveSSOUser(ctx context.Context, tx *sql.Tx, cfg config.OIDC, id *oidc.Identity) (int64, error) {
|
||||
now := time.Now().Unix()
|
||||
|
||||
var userID int64
|
||||
err := tx.QueryRowContext(ctx,
|
||||
"SELECT user_id FROM user_identities WHERE issuer = $1 AND subject = $2",
|
||||
id.Issuer, id.Subject).Scan(&userID)
|
||||
if err == nil {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"UPDATE user_identities SET last_login_at = $1 WHERE issuer = $2 AND subject = $3",
|
||||
now, id.Issuer, id.Subject); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return userID, refreshProfile(ctx, tx, userID, id)
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// First sign-in with this identity.
|
||||
if id.Email == "" {
|
||||
return 0, ssoNoEmail
|
||||
}
|
||||
err = tx.QueryRowContext(ctx,
|
||||
"SELECT id FROM users WHERE lower(email) = lower($1)", id.Email).Scan(&userID)
|
||||
switch {
|
||||
case err == nil:
|
||||
if !id.EmailVerified && !cfg.TrustEmail {
|
||||
return 0, ssoEmailConflict
|
||||
}
|
||||
// A local account that already has an identity from this issuer is a
|
||||
// different person at the provider using a recycled address. Linking
|
||||
// them would hand one person's account to another.
|
||||
var linked bool
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
"SELECT EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1 AND issuer = $2)",
|
||||
userID, id.Issuer).Scan(&linked); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if linked {
|
||||
return 0, ssoEmailConflict
|
||||
}
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// Creating the very first user is /api/bootstrap's own job (same
|
||||
// gate, same table: SELECT COUNT(*) FROM users in handleBootstrap).
|
||||
// An identity nobody has linked yet, on an install with no users at
|
||||
// all, is exactly the race terdut-operator#1 found: whoever gets
|
||||
// here first wins a slot the other side has no way to recover from
|
||||
// losing. Refusing it here costs an otherwise-ordinary sign-in
|
||||
// nothing but a retry once bootstrap has actually run.
|
||||
var userCount int
|
||||
if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if userCount == 0 {
|
||||
return 0, ssoNotBootstrapped
|
||||
}
|
||||
userID, err = createSSOUser(ctx, tx, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
default:
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"INSERT INTO user_identities (user_id, issuer, subject) VALUES ($1, $2, $3)",
|
||||
userID, id.Issuer, id.Subject); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// createSSOUser inserts a user with no password. The username is the provider's,
|
||||
// made unique with a numeric suffix when somebody local already has it.
|
||||
func createSSOUser(ctx context.Context, tx *sql.Tx, id *oidc.Identity) (int64, error) {
|
||||
base := strings.TrimSpace(id.Username)
|
||||
if base == "" {
|
||||
base, _, _ = strings.Cut(id.Email, "@")
|
||||
}
|
||||
if base == "" {
|
||||
base = "user"
|
||||
}
|
||||
for n := 1; n <= 100; n++ {
|
||||
name := base
|
||||
if n > 1 {
|
||||
name = base + "-" + strconv.Itoa(n)
|
||||
}
|
||||
var userID int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users (username, email) VALUES ($1, $2)
|
||||
ON CONFLICT (username) DO NOTHING RETURNING id`,
|
||||
name, id.Email).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue // taken; try the next suffix
|
||||
}
|
||||
return userID, err
|
||||
}
|
||||
return 0, errors.New("no free username for " + base)
|
||||
}
|
||||
|
||||
// refreshProfile brings a linked user's username and email in line with the
|
||||
// provider. Each update is skipped, not failed, when another user already holds
|
||||
// the value: both columns are unique, and a sign-in must not break over a name.
|
||||
func refreshProfile(ctx context.Context, tx *sql.Tx, userID int64, id *oidc.Identity) error {
|
||||
if id.Username != "" {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE users SET username = $1
|
||||
WHERE id = $2 AND username <> $1
|
||||
AND NOT EXISTS (SELECT 1 FROM users WHERE username = $1)`,
|
||||
id.Username, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if id.Email != "" {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE users SET email = $1
|
||||
WHERE id = $2 AND email <> $1
|
||||
AND NOT EXISTS (SELECT 1 FROM users WHERE lower(email) = lower($1))`,
|
||||
id.Email, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncGrants makes the user's OIDC-sourced access match what their groups grant
|
||||
// now, and touches nothing else.
|
||||
//
|
||||
// Rows the sync owns are marked source 'oidc'. It adds them, changes their role
|
||||
// and removes them. The last-owner and last-administrator guards do not apply:
|
||||
// they exist to stop a person's mistake, and the provider is the source of truth
|
||||
// for the access it grants, so a team or an install can be left without an
|
||||
// SSO-granted owner. Administrators can always repair a team, and the bootstrap
|
||||
// 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.
|
||||
//
|
||||
// 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,
|
||||
"UPDATE users SET is_admin = true, admin_source = 'oidc' WHERE id = $1 AND NOT is_admin",
|
||||
userID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := tx.ExecContext(ctx,
|
||||
"UPDATE users SET is_admin = false, admin_source = 'manual' WHERE id = $1 AND admin_source = 'oidc'",
|
||||
userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Teams. The result of the loop is the set of teams the groups grant.
|
||||
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 group says member.
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO team_members (team_id, user_id, role, source)
|
||||
VALUES ($1, $2, $3, 'oidc')
|
||||
ON CONFLICT (team_id, user_id) DO UPDATE
|
||||
SET role = excluded.role, source = 'oidc'
|
||||
WHERE team_members.source = 'oidc'
|
||||
OR (excluded.role = $4 AND team_members.role = $5)`,
|
||||
teamID, userID, role, models.RoleOwner, models.RoleMember); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Access the groups no longer grant. granted is never nil, or the ALL
|
||||
// comparison would be against NULL and delete nothing.
|
||||
_, err := tx.ExecContext(ctx,
|
||||
"DELETE FROM team_members WHERE user_id = $1 AND source = 'oidc' AND team_id <> ALL($2)",
|
||||
userID, granted)
|
||||
return err
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
)
|
||||
|
||||
// fakeIdP is just enough of an OpenID Connect provider for terdut to sign
|
||||
// somebody in against: discovery, a key set and a token endpoint that checks the
|
||||
// PKCE verifier. There is no authorize endpoint; the tests read the URL terdut
|
||||
// redirects to and play the part of the browser and the person themselves.
|
||||
type fakeIdP struct {
|
||||
*httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
|
||||
mu sync.Mutex
|
||||
codes map[string]pendingCode
|
||||
}
|
||||
|
||||
type pendingCode struct {
|
||||
claims map[string]any
|
||||
challenge string
|
||||
}
|
||||
|
||||
const (
|
||||
idpClientID = "terdut"
|
||||
idpClientSecret = "s3cret"
|
||||
)
|
||||
|
||||
func newFakeIdP(t *testing.T) *fakeIdP {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeIdP{key: key, codes: map[string]pendingCode{}}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": f.URL,
|
||||
"authorization_endpoint": f.URL + "/authorize",
|
||||
"token_endpoint": f.URL + "/token",
|
||||
"jwks_uri": f.URL + "/jwks",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||||
b64 := base64.RawURLEncoding.EncodeToString
|
||||
json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{
|
||||
"kty": "RSA", "kid": "k1", "use": "sig", "alg": "RS256",
|
||||
"n": b64(key.N.Bytes()),
|
||||
"e": b64(big.NewInt(int64(key.E)).Bytes()),
|
||||
}}})
|
||||
})
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
user, pass, basic := r.BasicAuth()
|
||||
if !basic {
|
||||
user, pass = r.PostForm.Get("client_id"), r.PostForm.Get("client_secret")
|
||||
}
|
||||
if user != idpClientID || pass != idpClientSecret {
|
||||
http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
p, ok := f.codes[r.PostForm.Get("code")]
|
||||
delete(f.codes, r.PostForm.Get("code")) // single use, like a real provider
|
||||
f.mu.Unlock()
|
||||
sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier")))
|
||||
if !ok || base64.RawURLEncoding.EncodeToString(sum[:]) != p.challenge {
|
||||
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// oauth2 picks the parser from the content type; without this it reads
|
||||
// the body as a form, finds no token and retries, spending the code.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "unused", "token_type": "Bearer", "expires_in": 300,
|
||||
"id_token": f.sign(t, p.claims),
|
||||
})
|
||||
})
|
||||
f.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(f.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
// sign returns claims as an RS256 JWT.
|
||||
func (f *fakeIdP) sign(t *testing.T, claims map[string]any) string {
|
||||
t.Helper()
|
||||
enc := func(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
signing := enc(map[string]string{"alg": "RS256", "kid": "k1", "typ": "JWT"}) + "." + enc(claims)
|
||||
sum := sha256.Sum256([]byte(signing))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// idpUser is who signs in, as the provider describes them.
|
||||
type idpUser struct {
|
||||
sub, username, email string
|
||||
unverified bool
|
||||
groups []string
|
||||
badNonce bool
|
||||
}
|
||||
|
||||
// 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{
|
||||
Issuer: idp.URL,
|
||||
ClientID: idpClientID,
|
||||
ClientSecret: idpClientSecret,
|
||||
Name: "Authentik",
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
UsernameClaim: "preferred_username",
|
||||
EmailClaim: "email",
|
||||
GroupsClaim: "groups",
|
||||
AllowedGroups: []string{"terdut-users"},
|
||||
AdminGroup: "terdut-admins",
|
||||
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)
|
||||
for _, f := range tweak {
|
||||
f(&c)
|
||||
}
|
||||
return newTSWith(t, api.DeadmanConfig{}, api.NotifyConfig{PublicURL: "http://terdut.test"}, c)
|
||||
}
|
||||
|
||||
// ssoBrowser is a browser that does not follow redirects, so a test can read
|
||||
// where each step sends it.
|
||||
func ssoBrowser(t *testing.T, s *ts) *browser {
|
||||
t.Helper()
|
||||
b := newBrowser(t, s.URL)
|
||||
b.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return b
|
||||
}
|
||||
|
||||
// startLogin visits /api/oidc/login and returns what terdut asked the provider
|
||||
// for: the state, nonce and PKCE challenge.
|
||||
func startLogin(t *testing.T, idp *fakeIdP, b *browser) (state, nonce, challenge string) {
|
||||
t.Helper()
|
||||
resp := b.do(t, http.MethodGet, "/api/oidc/login", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("login start: %d", resp.StatusCode)
|
||||
}
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil || !strings.HasPrefix(loc.String(), idp.URL+"/authorize") {
|
||||
t.Fatalf("login redirected to %q, want the provider", resp.Header.Get("Location"))
|
||||
}
|
||||
q := loc.Query()
|
||||
if q.Get("code_challenge_method") != "S256" || q.Get("client_id") != idpClientID ||
|
||||
q.Get("redirect_uri") != "http://terdut.test/api/oidc/callback" || q.Get("response_type") != "code" {
|
||||
t.Fatalf("unexpected authorization request: %v", q)
|
||||
}
|
||||
return q.Get("state"), q.Get("nonce"), q.Get("code_challenge")
|
||||
}
|
||||
|
||||
// issueCode has the provider authenticate u and hand back an authorization code.
|
||||
func (f *fakeIdP) issueCode(u idpUser, nonce, challenge string) string {
|
||||
if u.badNonce {
|
||||
nonce = "not-the-nonce"
|
||||
}
|
||||
claims := map[string]any{
|
||||
"iss": f.URL, "sub": u.sub, "aud": idpClientID,
|
||||
"iat": time.Now().Unix(), "exp": time.Now().Add(5 * time.Minute).Unix(),
|
||||
"nonce": nonce,
|
||||
"preferred_username": u.username,
|
||||
"email": u.email,
|
||||
"email_verified": !u.unverified,
|
||||
"groups": u.groups,
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
code := fmt.Sprintf("code-%d", len(f.codes)+int(time.Now().UnixNano()%1e6))
|
||||
f.codes[code] = pendingCode{claims: claims, challenge: challenge}
|
||||
return code
|
||||
}
|
||||
|
||||
// callback delivers the provider's answer to terdut and returns where terdut
|
||||
// sends the browser next.
|
||||
func callback(t *testing.T, b *browser, code, state string) string {
|
||||
t.Helper()
|
||||
resp := b.do(t, http.MethodGet, "/api/oidc/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("callback: %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Header.Get("Location")
|
||||
}
|
||||
|
||||
// signInSSO runs a whole sign-in and returns the Location the callback ended on.
|
||||
func signInSSO(t *testing.T, idp *fakeIdP, b *browser, u idpUser) string {
|
||||
t.Helper()
|
||||
state, nonce, challenge := startLogin(t, idp, b)
|
||||
return callback(t, b, idp.issueCode(u, nonce, challenge), state)
|
||||
}
|
||||
|
||||
var alice = idpUser{sub: "sub-alice", username: "alice", email: "alice@example.com", groups: []string{"terdut-users", "sre"}}
|
||||
|
||||
func withGroups(u idpUser, groups ...string) idpUser {
|
||||
u.groups = groups
|
||||
return u
|
||||
}
|
||||
|
||||
// meOf reads /api/me over the browser's session.
|
||||
func meOf(t *testing.T, b *browser) (status int, username string, isAdmin, hasPassword bool) {
|
||||
t.Helper()
|
||||
resp := b.do(t, http.MethodGet, "/api/me", nil)
|
||||
defer resp.Body.Close()
|
||||
var me struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
} `json:"user"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&me)
|
||||
return resp.StatusCode, me.User.Username, me.User.IsAdmin, me.HasPassword
|
||||
}
|
||||
|
||||
// memberships lists a user's teams as name -> "role/source".
|
||||
func (s *ts) memberships(t *testing.T, username string) map[string]string {
|
||||
t.Helper()
|
||||
rows, err := s.db.Query(`
|
||||
SELECT t.name, m.role, m.source FROM team_members m
|
||||
JOIN teams t ON t.id = m.team_id JOIN users u ON u.id = m.user_id
|
||||
WHERE u.username = $1`, username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var name, role, source string
|
||||
rows.Scan(&name, &role, &source)
|
||||
out[name] = role + "/" + source
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sameMap(a, b map[string]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k, v := range a {
|
||||
if b[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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 != "/" {
|
||||
t.Fatalf("signed in and was sent to %q, want /", loc)
|
||||
}
|
||||
status, name, isAdmin, hasPassword := meOf(t, b)
|
||||
if status != http.StatusOK || name != "alice" || isAdmin || hasPassword {
|
||||
t.Fatalf("me: status %d user %q admin %v has_password %v", status, name, isAdmin, hasPassword)
|
||||
}
|
||||
want := map[string]string{"SRE": "member/oidc", "Platform": "member/oidc"}
|
||||
if got := s.memberships(t, "alice"); !sameMap(got, want) {
|
||||
t.Errorf("memberships %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
b := ssoBrowser(t, s)
|
||||
|
||||
loc := signInSSO(t, idp, b, withGroups(alice, "sre", "terdut-admins"))
|
||||
if loc != "/?sso_error=not_allowed" {
|
||||
t.Fatalf("sent to %q, want the not_allowed error", loc)
|
||||
}
|
||||
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
|
||||
t.Errorf("a refused sign-in must not leave a session: /api/me %d", status)
|
||||
}
|
||||
var n int
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = 'alice'").Scan(&n)
|
||||
if n != 0 {
|
||||
t.Error("a refused sign-in must not create the user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_AdminFollowsTheAdminGroup(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
|
||||
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "terdut-admins"))
|
||||
var isAdmin bool
|
||||
var source string
|
||||
read := func() {
|
||||
s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'alice'").Scan(&isAdmin, &source)
|
||||
}
|
||||
if read(); !isAdmin || source != "oidc" {
|
||||
t.Fatalf("after admin sign-in: admin %v source %q", isAdmin, source)
|
||||
}
|
||||
|
||||
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users"))
|
||||
if read(); isAdmin || source != "manual" {
|
||||
t.Errorf("after losing the group: admin %v source %q, want revoked and manual", isAdmin, source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_ManualAdminIsNeverRevoked(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true })
|
||||
|
||||
// The bootstrap administrator is a manual one. Signing in through the
|
||||
// provider without the admin group must not take that away.
|
||||
signInSSO(t, idp, ssoBrowser(t, s), idpUser{sub: "sub-admin", username: "admin", email: "admin@test.com", groups: []string{"terdut-users"}})
|
||||
var isAdmin bool
|
||||
var source string
|
||||
s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'admin'").Scan(&isAdmin, &source)
|
||||
if !isAdmin || source != "manual" {
|
||||
t.Errorf("admin %v source %q, want still a manual admin", isAdmin, source)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
s.exec(t, "INSERT INTO teams (name) VALUES ('Hand')")
|
||||
s.exec(t, `INSERT INTO team_members (team_id, user_id, role)
|
||||
SELECT (SELECT id FROM teams WHERE name = 'Hand'), id, 'member' FROM users WHERE username = 'alice'`)
|
||||
|
||||
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users"))
|
||||
want := map[string]string{"Hand": "member/manual"}
|
||||
if got := s.memberships(t, "alice"); !sameMap(got, want) {
|
||||
t.Errorf("memberships %v, want %v: the SRE row is the sync's to remove, Hand is not", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
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"}) {
|
||||
t.Errorf("both groups: %v, want owner", got)
|
||||
}
|
||||
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre"))
|
||||
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) {
|
||||
t.Errorf("lead group dropped: %v, want member", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_ManualMemberIsRaisedNeverLowered(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
|
||||
// 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.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 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_LinksExistingUserByVerifiedEmail(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'Alice@Example.com')")
|
||||
|
||||
b := ssoBrowser(t, s)
|
||||
signInSSO(t, idp, b, alice)
|
||||
if _, name, _, _ := meOf(t, b); name != "alice-local" {
|
||||
t.Errorf("signed in as %q, want the existing local user", name)
|
||||
}
|
||||
var users, identities int
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&users)
|
||||
s.db.QueryRow("SELECT COUNT(*) FROM user_identities").Scan(&identities)
|
||||
if users != 2 || identities != 1 { // admin + alice-local
|
||||
t.Errorf("%d users, %d identities: linking must not create a second user", users, identities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_UnverifiedEmailIsNotLinkedUnlessTrusted(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
unverified := alice
|
||||
unverified.unverified = true
|
||||
|
||||
s := newSSOTS(t, idp)
|
||||
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')")
|
||||
if loc := signInSSO(t, idp, ssoBrowser(t, s), unverified); loc != "/?sso_error=email_conflict" {
|
||||
t.Errorf("unverified email: sent to %q, want email_conflict", loc)
|
||||
}
|
||||
|
||||
trusting := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true })
|
||||
trusting.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')")
|
||||
b := ssoBrowser(t, trusting)
|
||||
if loc := signInSSO(t, idp, b, unverified); loc != "/" {
|
||||
t.Fatalf("trusted email: sent to %q, want /", loc)
|
||||
}
|
||||
if _, name, _, _ := meOf(t, b); name != "alice-local" {
|
||||
t.Errorf("signed in as %q, want the existing local user", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_RecycledEmailDoesNotTakeOverALinkedAccount(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
signInSSO(t, idp, ssoBrowser(t, s), alice)
|
||||
|
||||
// A different person at the provider, same address.
|
||||
other := alice
|
||||
other.sub = "sub-someone-else"
|
||||
if loc := signInSSO(t, idp, ssoBrowser(t, s), other); loc != "/?sso_error=email_conflict" {
|
||||
t.Errorf("sent to %q, want email_conflict", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_UsernameCollisionGetsASuffix(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'someone-else@example.com')")
|
||||
|
||||
b := ssoBrowser(t, s)
|
||||
signInSSO(t, idp, b, alice)
|
||||
if _, name, _, _ := meOf(t, b); name != "alice-2" {
|
||||
t.Errorf("username %q, want alice-2", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_ProfileFollowsTheProvider(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
signInSSO(t, idp, ssoBrowser(t, s), alice)
|
||||
|
||||
renamed := alice
|
||||
renamed.username, renamed.email = "alice.smith", "alice.smith@example.com"
|
||||
b := ssoBrowser(t, s)
|
||||
signInSSO(t, idp, b, renamed)
|
||||
if _, name, _, _ := meOf(t, b); name != "alice.smith" {
|
||||
t.Errorf("username %q, want the provider's new one", name)
|
||||
}
|
||||
var email string
|
||||
s.db.QueryRow("SELECT email FROM users WHERE username = 'alice.smith'").Scan(&email)
|
||||
if email != "alice.smith@example.com" {
|
||||
t.Errorf("email %q", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_DisabledUserIsRefused(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
signInSSO(t, idp, ssoBrowser(t, s), alice)
|
||||
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'")
|
||||
|
||||
b := ssoBrowser(t, s)
|
||||
if loc := signInSSO(t, idp, b, alice); loc != "/?sso_error=disabled" {
|
||||
t.Errorf("sent to %q, want disabled", loc)
|
||||
}
|
||||
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
|
||||
t.Errorf("/api/me %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSO_FirstUserIsRefusedUntilBootstrap is terdut-operator#1: an
|
||||
// otherwise-ordinary OIDC sign-in against a brand-new, not-yet-bootstrapped
|
||||
// install must not be allowed to create the first user and win the race
|
||||
// POST /api/bootstrap expects to win uncontested. Built directly over
|
||||
// api.NewRouter rather than newSSOTS/newTS, both of which bootstrap before
|
||||
// a test body ever runs -- exactly the state this test needs to not have yet.
|
||||
func TestSSO_FirstUserIsRefusedUntilBootstrap(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
database := newTestDB(t)
|
||||
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{PublicURL: "http://terdut.test"}, ssoConfig(idp), "test"))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
first := newBrowser(t, srv.URL)
|
||||
first.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
if loc := signInSSO(t, idp, first, alice); loc != "/?sso_error=not_bootstrapped" {
|
||||
t.Fatalf("sent to %q, want not_bootstrapped", loc)
|
||||
}
|
||||
|
||||
// Bootstrap the install for real, the way terdut-operator's own
|
||||
// reconcileBootstrap does.
|
||||
resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json",
|
||||
strings.NewReader(`{"username":"admin","email":"admin@test.com"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("bootstrap: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The same identity, signing in again, is this install's ordinary first
|
||||
// SSO user now -- no longer refused.
|
||||
second := newBrowser(t, srv.URL)
|
||||
second.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
if loc := signInSSO(t, idp, second, alice); loc != "/" {
|
||||
t.Errorf("sent to %q after bootstrap, want success", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_NoEmailIsRefused(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
noEmail := alice
|
||||
noEmail.email = ""
|
||||
if loc := signInSSO(t, idp, ssoBrowser(t, s), noEmail); loc != "/?sso_error=no_email" {
|
||||
t.Errorf("sent to %q, want no_email", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_SessionIsCappedAndDoesNotSlidePastTheCap(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
b := ssoBrowser(t, s)
|
||||
signInSSO(t, idp, b, alice)
|
||||
|
||||
var expires, ceiling int64
|
||||
s.db.QueryRow(`SELECT expires_at, max_expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&expires, &ceiling)
|
||||
inTwelveHours := time.Now().Add(12 * time.Hour).Unix()
|
||||
if ceiling < inTwelveHours-60 || ceiling > inTwelveHours+60 || expires != ceiling {
|
||||
t.Fatalf("expires %d ceiling %d, want both about %d", expires, ceiling, inTwelveHours)
|
||||
}
|
||||
|
||||
// Age the session so the next request would slide it, with a ceiling well
|
||||
// inside the ordinary 30 days.
|
||||
s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200")
|
||||
if status, _, _, _ := meOf(t, b); status != http.StatusOK {
|
||||
t.Fatalf("/api/me %d", status)
|
||||
}
|
||||
var after int64
|
||||
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after)
|
||||
if after > ceiling {
|
||||
t.Errorf("expiry slid to %d, past the ceiling %d", after, ceiling)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_PasswordSessionsStillSlideWithoutACeiling(t *testing.T) {
|
||||
s := newTS(t)
|
||||
b := signedIn(t, s)
|
||||
var ceiling *int64
|
||||
s.db.QueryRow(`SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&ceiling)
|
||||
if ceiling != nil {
|
||||
t.Errorf("a password session has a ceiling %d, want none", *ceiling)
|
||||
}
|
||||
s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200, expires_at = expires_at - 7200")
|
||||
var before, after int64
|
||||
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&before)
|
||||
meOf(t, b)
|
||||
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after)
|
||||
if after <= before {
|
||||
t.Errorf("expiry %d -> %d, want it to slide forward", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_StateIsSingleUseAndBoundToTheBrowser(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
|
||||
// Replaying a callback finds no state.
|
||||
b := ssoBrowser(t, s)
|
||||
state, nonce, challenge := startLogin(t, idp, b)
|
||||
code := idp.issueCode(alice, nonce, challenge)
|
||||
if loc := callback(t, b, code, state); loc != "/" {
|
||||
t.Fatalf("first callback sent to %q", loc)
|
||||
}
|
||||
if loc := callback(t, b, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" {
|
||||
t.Errorf("replayed state: sent to %q, want expired", loc)
|
||||
}
|
||||
|
||||
// A callback from a browser that did not start the login is refused, which
|
||||
// is what stops a login being planted on somebody else.
|
||||
victim := ssoBrowser(t, s)
|
||||
state, nonce, challenge = startLogin(t, idp, ssoBrowser(t, s)) // the attacker's
|
||||
if loc := callback(t, victim, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" {
|
||||
t.Errorf("foreign browser: sent to %q, want expired", loc)
|
||||
}
|
||||
if status, _, _, _ := meOf(t, victim); status != http.StatusUnauthorized {
|
||||
t.Errorf("the victim has a session: /api/me %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_WrongNonceIsRefused(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
bad := alice
|
||||
bad.badNonce = true
|
||||
b := ssoBrowser(t, s)
|
||||
if loc := signInSSO(t, idp, b, bad); loc != "/?sso_error=failed" {
|
||||
t.Errorf("sent to %q, want failed", loc)
|
||||
}
|
||||
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
|
||||
t.Errorf("/api/me %d, want 401", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_ProviderErrorGoesBackToTheUI(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
b := ssoBrowser(t, s)
|
||||
resp := b.do(t, http.MethodGet, "/api/oidc/callback?error=access_denied", nil)
|
||||
resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=denied" {
|
||||
t.Errorf("%d to %q, want a redirect to denied", resp.StatusCode, loc)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID)
|
||||
s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID)
|
||||
teamPath := fmt.Sprintf("/api/teams/%d/members", sreID)
|
||||
|
||||
// The bootstrap admin is a system administrator, so may manage SRE.
|
||||
for _, c := range []struct {
|
||||
name, method, path string
|
||||
body any
|
||||
}{
|
||||
{"role change", http.MethodPost, teamPath, map[string]any{"user_id": aliceID, "role": "owner"}},
|
||||
{"removal", http.MethodDelete, fmt.Sprintf("%s/%d", teamPath, aliceID), nil},
|
||||
{"admin revoke", http.MethodPut, fmt.Sprintf("/api/users/%d/admin", aliceID), map[string]any{"is_admin": false}},
|
||||
} {
|
||||
resp := s.req(t, c.method, c.path, c.body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("%s: %d, want 409", c.name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) {
|
||||
t.Errorf("memberships changed by a refused edit: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_PasswordLoginCanBeSwitchedOff(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp, func(c *config.Config) { c.DisablePasswordLogin = true })
|
||||
b := newBrowser(t, s.URL)
|
||||
|
||||
resp := b.login(t, "admin", "whatever-password")
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("login: %d, want 403", resp.StatusCode)
|
||||
}
|
||||
resp = b.do(t, http.MethodPost, "/api/signup", map[string]string{"username": "x", "email": "x@example.com", "password": "correct horse battery"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("signup: %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
var cfg struct {
|
||||
PasswordLogin bool `json:"password_login"`
|
||||
OIDC struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
} `json:"oidc"`
|
||||
}
|
||||
resp = b.do(t, http.MethodGet, "/api/auth/config", nil)
|
||||
defer resp.Body.Close()
|
||||
json.NewDecoder(resp.Body).Decode(&cfg)
|
||||
if cfg.PasswordLogin || !cfg.OIDC.Enabled || cfg.OIDC.Name != "Authentik" {
|
||||
t.Errorf("auth config: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthConfig_DefaultsToPasswordOnly(t *testing.T) {
|
||||
s := newTS(t)
|
||||
var cfg struct {
|
||||
PasswordLogin bool `json:"password_login"`
|
||||
OIDC struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"oidc"`
|
||||
}
|
||||
resp := newBrowser(t, s.URL).do(t, http.MethodGet, "/api/auth/config", nil)
|
||||
defer resp.Body.Close()
|
||||
json.NewDecoder(resp.Body).Decode(&cfg)
|
||||
if !cfg.PasswordLogin || cfg.OIDC.Enabled {
|
||||
t.Errorf("auth config: %+v", cfg)
|
||||
}
|
||||
|
||||
// With SSO off the routes do not exist, rather than answering with an error
|
||||
// page a person could land on.
|
||||
resp = newBrowser(t, s.URL).do(t, http.MethodGet, "/api/oidc/login", nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("/api/oidc/login with SSO off: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSO_UnreachableProviderRedirectsWithAnError(t *testing.T) {
|
||||
idp := newFakeIdP(t)
|
||||
s := newSSOTS(t, idp)
|
||||
idp.Close() // the provider goes down after terdut has started
|
||||
|
||||
b := ssoBrowser(t, s)
|
||||
resp := b.do(t, http.MethodGet, "/api/oidc/login", nil)
|
||||
resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=unavailable" {
|
||||
t.Errorf("%d to %q, want a redirect to unavailable", resp.StatusCode, loc)
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
|
||||
var aliceID, sreID int64
|
||||
s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID)
|
||||
s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID)
|
||||
|
||||
// Users: alice's administrator flag is the groups', the bootstrap admin's is not.
|
||||
var users []struct {
|
||||
Username string `json:"username"`
|
||||
AdminSource string `json:"admin_source"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/users", nil), &users)
|
||||
got := map[string]string{}
|
||||
for _, u := range users {
|
||||
got[u.Username] = u.AdminSource
|
||||
}
|
||||
if got["alice"] != "oidc" || got["admin"] != "manual" {
|
||||
t.Errorf("admin_source by user: %v", got)
|
||||
}
|
||||
|
||||
// The team's own member list, as a member sees it.
|
||||
var members []struct {
|
||||
Username string `json:"username"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
resp := b.do(t, http.MethodGet, fmt.Sprintf("/api/teams/%d/members", sreID), nil)
|
||||
decode(t, resp, &members)
|
||||
if len(members) != 1 || members[0].Username != "alice" || members[0].Source != "oidc" {
|
||||
t.Errorf("team members: %+v", members)
|
||||
}
|
||||
|
||||
// The administrator's view of the same team, and of alice's teams.
|
||||
var adminTeam struct {
|
||||
Members []struct {
|
||||
Username string `json:"username"`
|
||||
Source string `json:"source"`
|
||||
} `json:"members"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/admin/teams/%d", sreID), nil), &adminTeam)
|
||||
if len(adminTeam.Members) != 1 || adminTeam.Members[0].Source != "oidc" {
|
||||
t.Errorf("admin team members: %+v", adminTeam.Members)
|
||||
}
|
||||
var teams []struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/users/%d/teams", aliceID), nil), &teams)
|
||||
if len(teams) != 1 || teams[0].Name != "SRE" || teams[0].Source != "oidc" {
|
||||
t.Errorf("user teams: %+v", teams)
|
||||
}
|
||||
|
||||
// The bootstrap admin's own membership is manual.
|
||||
var mine []struct {
|
||||
Source string `json:"source"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodGet, "/api/users/1/teams", nil), &mine)
|
||||
if len(mine) == 0 || mine[0].Source != "manual" {
|
||||
t.Errorf("bootstrap admin's teams: %+v", mine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package api
|
||||
|
||||
// This file is internal (package api, not api_test) because loginLimiter and
|
||||
// its blocked/fail/clear methods are unexported, and TestLoginLimiter_SharedAcrossReplicas
|
||||
// specifically needs to construct two separate loginLimiter values pointed at
|
||||
// one database — standing in for two replicas — which only this package can
|
||||
// do. It duplicates testdb_test.go's newTestDB/withSearchPath rather than
|
||||
// importing them: those live in the separate api_test package, compiled from
|
||||
// this directory's external test files, and are not visible here. Same
|
||||
// reasoning as advisory_lock_test.go, which makes the same trade for the
|
||||
// same reason.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
var rateLimiterSchemaSeq int
|
||||
|
||||
// rateLimiterTestDB returns a migrated database private to this test.
|
||||
func rateLimiterTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv("TERDUT_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Fatalf("TERDUT_TEST_DSN is not set: these tests need Postgres.\n" +
|
||||
"Run `make test-db` for a local one, then\n" +
|
||||
" export TERDUT_TEST_DSN=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable")
|
||||
}
|
||||
|
||||
rateLimiterSchemaSeq++
|
||||
schema := fmt.Sprintf("test_rl_%d_%d", os.Getpid(), rateLimiterSchemaSeq)
|
||||
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect to TERDUT_TEST_DSN: %v", err)
|
||||
}
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
t.Fatalf("create schema %s: %v", schema, err)
|
||||
}
|
||||
|
||||
database, err := db.Open(rateLimiterWithSearchPath(dsn, schema))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
database.Close()
|
||||
cleanup, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cleanup.Close()
|
||||
if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
|
||||
t.Logf("drop schema %s: %v", schema, err)
|
||||
}
|
||||
})
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
func rateLimiterWithSearchPath(dsn, schema string) string {
|
||||
opt := "-csearch_path=" + schema
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
u, err := url.Parse(dsn)
|
||||
if err == nil {
|
||||
q := u.Query()
|
||||
q.Set("options", opt)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
}
|
||||
return dsn + " options='" + opt + "'"
|
||||
}
|
||||
|
||||
func TestLoginLimiter_BlocksAtMax(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
l := newLoginLimiter(database)
|
||||
|
||||
for range 3 {
|
||||
if l.blocked(ctx, "k", 3) {
|
||||
t.Fatal("blocked before reaching max")
|
||||
}
|
||||
l.fail(ctx, "k")
|
||||
}
|
||||
if !l.blocked(ctx, "k", 3) {
|
||||
t.Fatal("not blocked after reaching max")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiter_ClearResetsTheCount(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
l := newLoginLimiter(database)
|
||||
|
||||
l.fail(ctx, "k")
|
||||
l.fail(ctx, "k")
|
||||
l.clear(ctx, "k")
|
||||
|
||||
if l.blocked(ctx, "k", 1) {
|
||||
t.Fatal("still blocked after clear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiter_KeysAreIndependent(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
l := newLoginLimiter(database)
|
||||
|
||||
l.fail(ctx, "a")
|
||||
if l.blocked(ctx, "b", 1) {
|
||||
t.Fatal("failing one key blocked an unrelated one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginLimiter_SharedAcrossReplicas is the regression test for the gap
|
||||
// this migration closes: an in-memory limiter would let each replica count
|
||||
// independently, so a caller hitting two different pods could rack up
|
||||
// max*replicaCount failures before either one blocked. Two loginLimiter
|
||||
// values sharing one database, standing in for two replicas behind the same
|
||||
// load balancer, must instead see one combined count.
|
||||
func TestLoginLimiter_SharedAcrossReplicas(t *testing.T) {
|
||||
database := rateLimiterTestDB(t)
|
||||
ctx := context.Background()
|
||||
replicaA := newLoginLimiter(database)
|
||||
replicaB := newLoginLimiter(database)
|
||||
|
||||
const max = 4
|
||||
// Alternate which "replica" records the failure, as a real deployment
|
||||
// would split requests across pods.
|
||||
for i := range max {
|
||||
replica := replicaA
|
||||
if i%2 == 1 {
|
||||
replica = replicaB
|
||||
}
|
||||
if replicaA.blocked(ctx, "k", max) || replicaB.blocked(ctx, "k", max) {
|
||||
t.Fatalf("blocked after only %d of %d failures", i, max)
|
||||
}
|
||||
replica.fail(ctx, "k")
|
||||
}
|
||||
|
||||
if !replicaA.blocked(ctx, "k", max) {
|
||||
t.Fatal("replica A does not see the combined count as blocked")
|
||||
}
|
||||
if !replicaB.blocked(ctx, "k", max) {
|
||||
t.Fatal("replica B does not see the combined count as blocked")
|
||||
}
|
||||
}
|
||||
+101
-11
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/oidc"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -13,15 +14,30 @@ 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.
|
||||
loginLimit := newLoginLimiter(db)
|
||||
signupLimiter := newLoginLimiter(db)
|
||||
oidcLimit := newLoginLimiter(db)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(securityHeaders(notify.PublicURL))
|
||||
|
||||
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
|
||||
@@ -39,17 +55,53 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
||||
// JSON 404 every unknown /api path gets.
|
||||
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
|
||||
|
||||
// Signing up. Both are unauthenticated by necessity: the caller has no
|
||||
// account yet. The info endpoint says whether the door is open and whether
|
||||
// an invite link is good, so the form can say so before somebody picks a
|
||||
// password.
|
||||
r.Get("/api/signup", handleSignupInfo(db))
|
||||
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
|
||||
Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
|
||||
|
||||
// How to sign in: what the login form and the TUI offer before anybody types.
|
||||
r.Get("/api/auth/config", handleAuthConfig(cfg))
|
||||
|
||||
// Signing in to the web UI. Login trades a password for a session cookie,
|
||||
// which AuthMiddleware accepts in place of an API key.
|
||||
r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL))
|
||||
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
|
||||
Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
|
||||
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
|
||||
|
||||
// Single sign-on. Both routes are navigations the browser makes, to and from
|
||||
// the provider, so they answer with redirects rather than JSON.
|
||||
if cfg.OIDC.Enabled() {
|
||||
prov := oidc.New(cfg.OIDC, notify.PublicURL)
|
||||
r.Get("/api/oidc/login", handleOIDCLogin(db, prov, oidcLimit, notify.PublicURL))
|
||||
r.Get("/api/oidc/callback", handleOIDCCallback(db, prov, notify.PublicURL))
|
||||
|
||||
// Device login, for a client with no browser of its own. Both are
|
||||
// unauthenticated: the device code in the body is the credential.
|
||||
r.Post("/api/oidc/device", handleDeviceStart(db, oidcLimit, notify.PublicURL))
|
||||
r.Post("/api/oidc/device/token", handleDeviceToken(db, cfg.OIDC.SessionMaxAge, notify.PublicURL))
|
||||
}
|
||||
|
||||
// All other /api routes require a valid API key.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(db))
|
||||
|
||||
r.Get("/api/me", handleMe(db))
|
||||
|
||||
// Approving or refusing a device login is done by somebody signed in
|
||||
// to a browser, and needs the same SSO configuration the flow does.
|
||||
if cfg.OIDC.Enabled() {
|
||||
r.Post("/api/oidc/device/approve", handleDeviceDecision(db, true))
|
||||
r.Post("/api/oidc/device/deny", handleDeviceDecision(db, false))
|
||||
}
|
||||
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
|
||||
// Proves the topic works, which is the only part of "notifications are
|
||||
// set up" that the person holding the phone can confirm.
|
||||
r.Post("/api/me/notify/test", handleTestNotification(notify, db))
|
||||
|
||||
// Readable by anyone signed in: the queue's assignment control and the
|
||||
// on-call schedule both need to name people.
|
||||
r.Get("/api/users", handleListUsers(db))
|
||||
@@ -57,8 +109,10 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
||||
// Your own account, or anybody's if you are an admin. The handlers call
|
||||
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
||||
// which rule applies depends on the {id} in the path.
|
||||
r.Get("/api/users/{id}/teams", handleUserTeams(db))
|
||||
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
|
||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
||||
r.Get("/api/users/{id}/api-keys", handleListAPIKeys(db))
|
||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
|
||||
|
||||
@@ -76,6 +130,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
||||
// What exists on this server, and how it behaves. /api/teams
|
||||
// answers "what am I in"; this one answers "what is there".
|
||||
r.Get("/api/admin/teams", handleAdminListTeams(db))
|
||||
// One team and who is in it. The member list under
|
||||
// /api/teams/{id}/members stays member-only and still 404s
|
||||
// an administrator from outside; this is a different
|
||||
// question, so it is a different endpoint.
|
||||
r.Get("/api/admin/teams/{teamID}", handleAdminGetTeam(db))
|
||||
r.Get("/api/admin/settings", handleGetSettings(db, cfg))
|
||||
r.Put("/api/admin/settings", handleSetSettings(db))
|
||||
})
|
||||
@@ -89,6 +148,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
||||
r.Get("/api/incidents/{id}", handleGetIncident(db))
|
||||
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
|
||||
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
|
||||
r.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db))
|
||||
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
|
||||
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
|
||||
r.Post("/api/incidents/{id}/resolve", handleIncidentResolve(db))
|
||||
@@ -100,28 +160,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))
|
||||
|
||||
// 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", handleGetTeamDeadman(db))
|
||||
r.Put("/api/teams/{teamID}/deadman", handleSetTeamDeadman(db))
|
||||
r.Get("/api/teams/{teamID}/deadman/switches", handleListTeamDeadman(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.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
|
||||
|
||||
@@ -235,6 +235,9 @@ func scheduleRange(ctx context.Context, db *sql.DB, teamID int64, from, to strin
|
||||
|
||||
clause := strings.Join(where, " AND ")
|
||||
|
||||
// #nosec G202 -- clause is built from sqlArgs.add's "$N" placeholders
|
||||
// only, never a value; every value travels through args.all() as a
|
||||
// bound parameter. See the sqlArgs doc comment in helpers.go.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
|
||||
FROM schedule_entries s
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security headers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSecurityHeaders_NosniffAlwaysSet(t *testing.T) {
|
||||
s := newTS(t) // no PublicURL: the HTTPS signal is off
|
||||
resp := s.req(t, http.MethodGet, "/api/me", nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
|
||||
}
|
||||
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
|
||||
t.Errorf("Strict-Transport-Security = %q, want unset without an https PublicURL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders_HSTSWhenPublicURLIsHTTPS(t *testing.T) {
|
||||
s := newTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||
resp := s.req(t, http.MethodGet, "/api/me", nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := resp.Header.Get("Strict-Transport-Security")
|
||||
if !strings.HasPrefix(got, "max-age=") || !strings.Contains(got, "includeSubDomains") {
|
||||
t.Errorf("Strict-Transport-Security = %q, want a max-age with includeSubDomains", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request body size limits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestBodySizeLimit_OrdinaryEndpointRejectsOversizedBody confirms an
|
||||
// unauthenticated endpoint can't be made to buffer an arbitrarily large body:
|
||||
// past maxBodyBytes, decodeJSON fails exactly as it would on any other
|
||||
// malformed body, rather than the server reading the whole thing first.
|
||||
func TestBodySizeLimit_OrdinaryEndpointRejectsOversizedBody(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB, past the 1 MiB default
|
||||
body := []byte(`{"username":"` + string(huge) + `","password":"x"}`)
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/login", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /api/login: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d (oversized body treated as invalid)", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodySizeLimit_WebhookAllowsLargerBodyThanDefault confirms the
|
||||
// Alertmanager webhook's separate, larger cap actually takes effect: a body
|
||||
// bigger than the ordinary default but within maxWebhookBodyBytes is still
|
||||
// accepted, not rejected by the smaller limit every other endpoint gets.
|
||||
func TestBodySizeLimit_WebhookAllowsLargerBodyThanDefault(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
// Padding kept inside one alert's annotation, comfortably past the 1 MiB
|
||||
// default and still well under the webhook's 8 MiB cap.
|
||||
padding := strings.Repeat("a", 3<<20) // 3 MiB
|
||||
payload := `{"version":"4","status":"firing","groupKey":"big-group",` +
|
||||
`"groupLabels":{"alertname":"BigAlert"},"alerts":[{"status":"firing",` +
|
||||
`"labels":{"alertname":"BigAlert"},"annotations":{"note":"` + padding + `"},` +
|
||||
`"startsAt":"2026-05-20T10:00:00Z","endsAt":"0001-01-01T00:00:00Z",` +
|
||||
`"fingerprint":"fp-big"}]}`
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("POST webhook: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d (body under the webhook's own cap)", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBodySizeLimit_WebhookRejectsPastItsOwnCap confirms the webhook's larger
|
||||
// cap is still a cap, not an exemption from one.
|
||||
func TestBodySizeLimit_WebhookRejectsPastItsOwnCap(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
huge := strings.Repeat("a", 9<<20) // 9 MiB, past the 8 MiB webhook cap
|
||||
payload := `{"version":"4","status":"firing","groupKey":"huge-group",` +
|
||||
`"groupLabels":{"alertname":"HugeAlert"},"alerts":[{"status":"firing",` +
|
||||
`"labels":{"alertname":"HugeAlert"},"annotations":{"note":"` + huge + `"},` +
|
||||
`"startsAt":"2026-05-20T10:00:00Z","endsAt":"0001-01-01T00:00:00Z",` +
|
||||
`"fingerprint":"fp-huge"}]}`
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("POST webhook: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d (body past the webhook's own cap)", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
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 owner-equivalent for teamID:
|
||||
// a human owner, or that team's own team-scoped service account (its single
|
||||
// synthetic membership, serveAsServiceAccount — ratified in
|
||||
// SERVICE-ACCOUNTS.md as intentional, not an accident: a team-scoped
|
||||
// credential is that team's owner's reach, full stop, membership and
|
||||
// invites included). Built on callerRole 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, the account rotating its own credential (not a privilege
|
||||
// escalation, the same reasoning requireSelfOrAdmin already rests on for a
|
||||
// user's own API keys) — or, new, an instance-scoped service account
|
||||
// managing any team-scoped account.
|
||||
//
|
||||
// That last branch closes terdut-operator#3: handleCreateServiceAccount
|
||||
// already lets an instance-scoped caller *create* a team-scoped account for
|
||||
// any team (the branch below it, isInstanceServiceAccount(ctx)) — this
|
||||
// account didn't have an equivalent reach to *adopt or rotate* one it
|
||||
// didn't just create in the same call, which is exactly the recovery path
|
||||
// terdut-operator's own documented crash-window handling depends on
|
||||
// (DESIGN.md §5's general adopt-on-conflict rule): a reconcile that creates
|
||||
// the account successfully but crashes before persisting its credential
|
||||
// locally retries into a 409, and without this branch the only available
|
||||
// recovery — minting a fresh key on the now-existing account — 403'd
|
||||
// forever, with no way out. Granting it here is not a new power: it
|
||||
// mirrors the create-time reach this scope already has, just extended to
|
||||
// the retry path DESIGN.md's own crash-window reasoning requires.
|
||||
func callerMayManageServiceAccount(ctx context.Context, sa models.ServiceAccount) bool {
|
||||
if callerIsAdmin(ctx) {
|
||||
return true
|
||||
}
|
||||
if sa.TeamID != nil && callerOwnsTeam(ctx, *sa.TeamID) {
|
||||
return true
|
||||
}
|
||||
caller, _ := callerFromContext(ctx)
|
||||
if id, ok := caller.ServiceAccountID(); ok && id == sa.ID {
|
||||
return true
|
||||
}
|
||||
if sa.TeamID != nil && caller.IsInstanceServiceAccount() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
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()
|
||||
}
|
||||
|
||||
// Documents the capability already granted at create time (handleCreateServiceAccount's
|
||||
// own callerOwnsTeam branch) also applies here: a team-scoped account is that
|
||||
// team's owner's reach, membership and further accounts included, not just
|
||||
// the handful of endpoints exercised above. Kept, not restricted, for
|
||||
// symmetry with the now-ratified membership/invite capability below.
|
||||
func TestServiceAccount_TeamScopeCanMintAnotherAccountForItsOwnTeam(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/service-accounts",
|
||||
map[string]any{"name": "team-a-sa-2", "scope": models.ServiceAccountScopeTeam, "team_id": teamA})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("team-scoped account minting another account for its own team: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// SERVICE-ACCOUNTS.md ratifies this explicitly: a team-scoped account is
|
||||
// owner-equivalent for every requireTeamOwner endpoint, membership and
|
||||
// invites included — terdut-operator's own invite-minting feature depends on
|
||||
// exactly this. No test exercised handleCreateInvite from a service account
|
||||
// before this change, and it would have 500'd (created_by written as a bare
|
||||
// zero value against a NOT-validated-but-FK'd column) rather than succeeded;
|
||||
// see the signup_test.go addition for that half.
|
||||
func TestServiceAccount_TeamScopeManagesItsOwnInvites(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)
|
||||
|
||||
var invite struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
resp := s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/invites", map[string]any{})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("team-scoped account creating an invite: %d", resp.StatusCode)
|
||||
}
|
||||
decode(t, resp, &invite)
|
||||
|
||||
var list []map[string]any
|
||||
decode(t, s.reqAs(t, keyA, http.MethodGet, "/api/teams/"+id64(teamA)+"/invites", nil), &list)
|
||||
if len(list) != 1 {
|
||||
t.Errorf("expected the invite to list back, got %d", len(list))
|
||||
}
|
||||
|
||||
if resp := s.reqAs(t, keyA, http.MethodDelete,
|
||||
"/api/teams/"+id64(teamA)+"/invites/"+id64(invite.ID), nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("team-scoped account revoking its own invite: %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key rotation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// terdut-operator#3: an instance-scoped account is already trusted to CREATE
|
||||
// a team-scoped account for any team (handleCreateServiceAccount's own
|
||||
// isInstanceServiceAccount branch) — this pins that it is equally trusted to
|
||||
// manage/rotate a key on one that already exists and that it did not just
|
||||
// create in this call, which is the exact shape of terdut-operator's own
|
||||
// crash-window recovery (mint succeeds, a later step is interrupted before
|
||||
// persisting the credential locally, and the next reconcile retries into a
|
||||
// 409 then needs to mint a fresh key on the now-existing account). Before
|
||||
// this fix, the second POST .../keys below 403'd forever.
|
||||
func TestServiceAccount_InstanceScopeAdoptsAnExistingTeamScopedAccountsKey(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
|
||||
resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/service-accounts",
|
||||
map[string]any{"name": "team-a-sa", "scope": models.ServiceAccountScopeTeam, "team_id": teamA})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create team-scoped account: %d", resp.StatusCode)
|
||||
}
|
||||
var created struct {
|
||||
ServiceAccount struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"service_account"`
|
||||
}
|
||||
decode(t, resp, &created)
|
||||
|
||||
// Simulates the adopt-on-409 recovery path: this instance-scoped caller
|
||||
// did not just create this account in this call (a fresh *tdclient.Client
|
||||
// request, same as a second, independent reconcile would issue), yet
|
||||
// still needs to mint it a fresh key.
|
||||
rotateResp := s.reqAs(t, instanceKey, http.MethodPost,
|
||||
"/api/service-accounts/"+id64(created.ServiceAccount.ID)+"/keys", map[string]string{"name": "adopted"})
|
||||
if rotateResp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("instance-scoped account adopting a team-scoped account's key: %d", rotateResp.StatusCode)
|
||||
}
|
||||
rotateResp.Body.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdminOnly / requireSelfOrAdmin — unchanged after the Caller refactor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The Caller abstraction must not have widened AdminOnly/requireSelfOrAdmin:
|
||||
// user management and /api/admin/settings stay human-only, for every scope
|
||||
// of service account, exactly as before.
|
||||
func TestAdminOnly_RefusesEveryServiceAccountScope(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
teamKey := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
|
||||
for _, key := range []string{instanceKey, teamKey} {
|
||||
if resp := s.reqAs(t, key, http.MethodGet, "/api/admin/settings", nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for a service account reading /api/admin/settings, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if resp := s.reqAs(t, key, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": "nope", "email": "nope@example.com"}); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for a service account creating a user, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if resp := s.reqAs(t, key, http.MethodGet, "/api/me", nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for a service account calling /api/me, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
+161
-24
@@ -6,9 +6,11 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -90,6 +92,16 @@ func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
|
||||
type settingsResponse struct {
|
||||
Editable map[string]settingValue `json:"editable"`
|
||||
FromEnv map[string]string `json:"from_env"`
|
||||
|
||||
// Choices are settings that are a word from a fixed list rather than a
|
||||
// duration. One so far: who may create an account.
|
||||
Choices map[string]choiceValue `json:"choices"`
|
||||
}
|
||||
|
||||
type choiceValue struct {
|
||||
Value string `json:"value"`
|
||||
Options []string `json:"options"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type settingValue struct {
|
||||
@@ -104,6 +116,14 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
out := settingsResponse{
|
||||
Editable: map[string]settingValue{},
|
||||
Choices: map[string]choiceValue{
|
||||
SettingSignupMode: {
|
||||
Value: signupMode(r.Context(), db),
|
||||
Options: []string{SignupInviteOnly, SignupOpen},
|
||||
Description: "who may create an account: invite_only means a link from a team owner, " +
|
||||
"open means anybody who can reach this server",
|
||||
},
|
||||
},
|
||||
FromEnv: map[string]string{
|
||||
// Never the ntfy token or the DSN: both are credentials, and an
|
||||
// admin page that renders them turns a browser tab into a place
|
||||
@@ -137,7 +157,7 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
|
||||
// sit in the table looking like configuration and doing nothing.
|
||||
func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]int64
|
||||
var req map[string]any
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
@@ -147,17 +167,37 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
for key, secs := range req {
|
||||
b, known := settingBounds[key]
|
||||
if !known {
|
||||
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
|
||||
return
|
||||
}
|
||||
d := time.Duration(secs) * time.Second
|
||||
if d < b.min || d > b.max {
|
||||
respond(w, http.StatusBadRequest, errResp(
|
||||
key+" must be between "+b.min.String()+" and "+b.max.String()))
|
||||
return
|
||||
// Validate everything before writing anything: a request that sets two
|
||||
// settings and gets one wrong should change neither.
|
||||
values := map[string]string{}
|
||||
for key, raw := range req {
|
||||
switch key {
|
||||
case SettingSignupMode:
|
||||
mode, _ := raw.(string)
|
||||
if mode != SignupOpen && mode != SignupInviteOnly {
|
||||
respond(w, http.StatusBadRequest,
|
||||
errResp("signup_mode must be "+SignupInviteOnly+" or "+SignupOpen))
|
||||
return
|
||||
}
|
||||
values[key] = mode
|
||||
default:
|
||||
b, known := settingBounds[key]
|
||||
if !known {
|
||||
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
|
||||
return
|
||||
}
|
||||
secs, ok := raw.(float64) // JSON numbers decode as float64
|
||||
if !ok {
|
||||
respond(w, http.StatusBadRequest, errResp(key+" must be a number of seconds"))
|
||||
return
|
||||
}
|
||||
d := time.Duration(int64(secs)) * time.Second
|
||||
if d < b.min || d > b.max {
|
||||
respond(w, http.StatusBadRequest, errResp(
|
||||
key+" must be between "+b.min.String()+" and "+b.max.String()))
|
||||
return
|
||||
}
|
||||
values[key] = strconv.FormatInt(int64(secs), 10)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,13 +208,13 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
for key, secs := range req {
|
||||
for key, value := range values {
|
||||
if _, err := tx.ExecContext(r.Context(), `
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES ($1, $2, `+nowEpoch+`)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
value = excluded.value, updated_at = excluded.updated_at`,
|
||||
key, strconv.FormatInt(secs, 10)); err != nil {
|
||||
key, value); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -188,6 +228,25 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// adminTeam is a team as an administrator sees it: what it is, plus how big it
|
||||
// is and how much is on fire in it. One definition, so a team in the list and a
|
||||
// team on its own page cannot describe themselves differently.
|
||||
type adminTeam struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
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
|
||||
// ordinary /api/teams answers "what am I in"; this one answers "what exists",
|
||||
// which only an administrator may ask.
|
||||
@@ -197,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 {
|
||||
@@ -206,18 +266,12 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type adminTeam struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Members int64 `json:"members"`
|
||||
OpenIncidents int64 `json:"open_incidents"`
|
||||
}
|
||||
teams := []adminTeam{}
|
||||
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
|
||||
}
|
||||
@@ -232,6 +286,81 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminGetTeam answers "what is this team, and who is in it" for any team
|
||||
// on the server, which is the one question an administrator could not ask.
|
||||
//
|
||||
// GET /api/teams/{id}/members is requireTeamMember and answers 404 to somebody
|
||||
// outside the team, administrator or not, and that stays exactly as it is:
|
||||
// member means membership and nothing else. Reading a team's shape is a
|
||||
// different thing from reading its work, so it gets an endpoint of its own
|
||||
// under AdminOnly rather than an exception carved into that rule. An
|
||||
// administrator still sees none of the team's incidents, alerts or rota.
|
||||
func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var t adminTeam
|
||||
var created int64
|
||||
err := db.QueryRowContext(r.Context(), `
|
||||
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),
|
||||
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,
|
||||
&t.OIDCMemberGroup, &t.OIDCOwnerGroup)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
|
||||
// Same query and same ordering as handleListTeamMembers, so the two
|
||||
// answers to "who is in this team" cannot disagree about the answer.
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source
|
||||
FROM team_members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.team_id = $1
|
||||
ORDER BY u.username`, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := []models.TeamMember{}
|
||||
for rows.Next() {
|
||||
var m models.TeamMember
|
||||
var joined int64
|
||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
m.JoinedAt = time.Unix(joined, 0).UTC()
|
||||
members = append(members, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
// A wrapper rather than a team with the members hung off it: "members"
|
||||
// already means a count on the list endpoint, and one name must not be
|
||||
// a number in one answer and an array in the next.
|
||||
respond(w, http.StatusOK, map[string]any{"team": t, "members": members})
|
||||
}
|
||||
}
|
||||
|
||||
// handleRenameTeam renames a team. An owner's job, and an administrator's when
|
||||
// a team has nobody left to do it.
|
||||
func handleRenameTeam(db *sql.DB) http.HandlerFunc {
|
||||
@@ -247,7 +376,15 @@ func handleRenameTeam(db *sql.DB) http.HandlerFunc {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Name == "" {
|
||||
// Trimmed, as handleCreateTeam trims: without it " " is a team name
|
||||
// here but not at creation, which is one rule stated twice and only
|
||||
// half applied.
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// SettingSignupMode says who may create an account. It lives in the settings
|
||||
// table with the other behaviour settings, so an administrator changes it in
|
||||
// the admin page rather than in a chart.
|
||||
//
|
||||
// Two modes, not three. A domain-restricted mode was considered and dropped:
|
||||
// with no email in this server there is nothing to verify an address against,
|
||||
// so it would check the domain of a string somebody typed — a speed bump
|
||||
// dressed as a control.
|
||||
const (
|
||||
SettingSignupMode = "signup_mode"
|
||||
|
||||
SignupInviteOnly = "invite_only"
|
||||
SignupOpen = "open"
|
||||
)
|
||||
|
||||
// defaultSignupMode is invite-only. An install that gets a public hostname
|
||||
// before anybody has thought about sign-up should not be collecting accounts
|
||||
// from the internet by default.
|
||||
const defaultSignupMode = SignupInviteOnly
|
||||
|
||||
// inviteTTL is how long a new invite link lives. Long enough to send it and be
|
||||
// read tomorrow, short enough that a link in an old chat log stops working.
|
||||
const inviteTTL = 7 * 24 * time.Hour
|
||||
|
||||
// signupMode reads the current mode, falling back to invite-only for a missing
|
||||
// or unrecognised value: the failure mode of a typo in this setting should be
|
||||
// the closed door, not the open one.
|
||||
func signupMode(ctx context.Context, db *sql.DB) string {
|
||||
var raw string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
"SELECT value FROM settings WHERE key = $1", SettingSignupMode).Scan(&raw); err != nil {
|
||||
return defaultSignupMode
|
||||
}
|
||||
if raw != SignupOpen && raw != SignupInviteOnly {
|
||||
return defaultSignupMode
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// handleSignupInfo tells the sign-up page what it may offer, without requiring
|
||||
// a session: whether open sign-up is on, and whether the invite in the URL is
|
||||
// any good. A bad invite is better reported before somebody picks a password.
|
||||
func handleSignupInfo(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
out := map[string]any{"mode": signupMode(r.Context(), db)}
|
||||
|
||||
if token := r.URL.Query().Get("invite"); token != "" {
|
||||
inv, err := loadInvite(r.Context(), db, token)
|
||||
switch {
|
||||
case err == nil:
|
||||
out["invite_valid"] = true
|
||||
out["invite_team"] = inv.teamName
|
||||
default:
|
||||
// Deliberately one answer for expired, revoked, used up and
|
||||
// never existed. Telling a stranger which it was tells them
|
||||
// something about links they do not hold.
|
||||
out["invite_valid"] = false
|
||||
}
|
||||
}
|
||||
respond(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
type invite struct {
|
||||
id int64
|
||||
teamID int64
|
||||
teamName string
|
||||
role string
|
||||
}
|
||||
|
||||
// loadInvite resolves a raw token to a usable invite, or an error. Usable means
|
||||
// it exists, has not been revoked, has not expired and has uses left.
|
||||
func loadInvite(ctx context.Context, q querier, token string) (invite, error) {
|
||||
var inv invite
|
||||
err := q.QueryRowContext(ctx, `
|
||||
SELECT i.id, i.team_id, t.name, i.role
|
||||
FROM invites i
|
||||
JOIN teams t ON t.id = i.team_id
|
||||
WHERE i.token_hash = $1
|
||||
AND i.revoked_at IS NULL
|
||||
AND i.expires_at > `+nowEpoch+`
|
||||
AND i.uses < i.max_uses`, hashToken(token)).
|
||||
Scan(&inv.id, &inv.teamID, &inv.teamName, &inv.role)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return invite{}, errInviteUnusable
|
||||
}
|
||||
return inv, err
|
||||
}
|
||||
|
||||
var errInviteUnusable = errors.New("invite is not usable")
|
||||
|
||||
// handleSignup creates an account, and puts it somewhere.
|
||||
//
|
||||
// Rate-limited on the same limiter as login, by address: sign-up is the other
|
||||
// unauthenticated endpoint that writes, and an open install without this is a
|
||||
// way to fill somebody's user table.
|
||||
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
addr := clientAddr(r)
|
||||
if limiter.blocked(r.Context(), "signup:"+addr, maxSignupsPerAddr) {
|
||||
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Invite string `json:"invite"`
|
||||
TeamName string `json:"team_name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
req.TeamName = strings.TrimSpace(req.TeamName)
|
||||
|
||||
if req.Username == "" || req.Email == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
||||
return
|
||||
}
|
||||
if msg := validatePassword(req.Password); msg != "" {
|
||||
respond(w, http.StatusBadRequest, errResp(msg))
|
||||
return
|
||||
}
|
||||
|
||||
mode := signupMode(r.Context(), db)
|
||||
var inv invite
|
||||
hasInvite := false
|
||||
if req.Invite != "" {
|
||||
var err error
|
||||
inv, err = loadInvite(r.Context(), db, req.Invite)
|
||||
if err != nil {
|
||||
limiter.fail(r.Context(), "signup:"+addr)
|
||||
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
||||
return
|
||||
}
|
||||
hasInvite = true
|
||||
}
|
||||
if !hasInvite && mode != SignupOpen {
|
||||
// No invite and the door is shut. Not 404: the endpoint exists and
|
||||
// saying so is how somebody knows to ask for a link.
|
||||
respond(w, http.StatusForbidden,
|
||||
errResp("sign-up is invite-only on this server"))
|
||||
return
|
||||
}
|
||||
if !hasInvite && req.TeamName == "" {
|
||||
// Open sign-up with no team would create an account that sees an
|
||||
// empty queue and can be paged by nobody.
|
||||
respond(w, http.StatusBadRequest, errResp("team_name is required"))
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := hashPassword(req.Password)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
var userID int64
|
||||
var invitedVia *int64
|
||||
if hasInvite {
|
||||
invitedVia = &inv.id
|
||||
}
|
||||
if err := tx.QueryRowContext(r.Context(), `
|
||||
INSERT INTO users (username, email, password_hash, invited_via)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
req.Username, req.Email, hash, invitedVia).Scan(&userID); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
teamID, role := inv.teamID, inv.role
|
||||
if !hasInvite {
|
||||
// Open sign-up makes a team, and its creator owns it.
|
||||
if err := tx.QueryRowContext(r.Context(),
|
||||
"INSERT INTO teams (name) VALUES ($1) RETURNING id", req.TeamName).Scan(&teamID); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
role = models.RoleOwner
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(r.Context(),
|
||||
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
|
||||
teamID, userID, role); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
if hasInvite {
|
||||
// Counted inside the transaction, so two people redeeming the last
|
||||
// use of a link at once cannot both get in.
|
||||
res, err := tx.ExecContext(r.Context(),
|
||||
"UPDATE invites SET uses = uses + 1 WHERE id = $1 AND uses < max_uses", inv.id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
// Signed in immediately: the alternative is a form that says "now go
|
||||
// and log in", which is the same credential typed twice.
|
||||
if err := startSession(w, r, db, userID, publicURL); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
user, _ := fetchUser(r.Context(), db, userID)
|
||||
respond(w, http.StatusCreated, meResponse{User: user, HasPassword: true})
|
||||
}
|
||||
}
|
||||
|
||||
// maxSignupsPerAddr is looser than the login limit: several people joining from
|
||||
// one office share an address, and the thing being limited is account creation
|
||||
// rather than password guessing.
|
||||
const maxSignupsPerAddr = 10
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type inviteJSON struct {
|
||||
ID int64 `json:"id"`
|
||||
TeamID int64 `json:"team_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
MaxUses int64 `json:"max_uses"`
|
||||
Uses int64 `json:"uses"`
|
||||
Revoked bool `json:"revoked"`
|
||||
|
||||
// URL is the whole link, returned once when the invite is created. Like an
|
||||
// integration key, only its hash is stored.
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func handleListInvites(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
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT id, team_id, role, created_at, expires_at, max_uses, uses, revoked_at
|
||||
FROM invites
|
||||
WHERE team_id = $1
|
||||
ORDER BY id DESC`, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []inviteJSON{}
|
||||
for rows.Next() {
|
||||
var i inviteJSON
|
||||
var created, expires int64
|
||||
var revoked *int64
|
||||
if err := rows.Scan(&i.ID, &i.TeamID, &i.Role, &created, &expires,
|
||||
&i.MaxUses, &i.Uses, &revoked); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||
i.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
i.Revoked = revoked != nil
|
||||
out = append(out, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCreateInvite mints a link into this team. Owner-only, like the rest of
|
||||
// a team's configuration: deciding who joins is configuring the team.
|
||||
func handleCreateInvite(db *sql.DB, publicURL string) 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 struct {
|
||||
Role string `json:"role"`
|
||||
MaxUses int64 `json:"max_uses"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
if req.Role == "" {
|
||||
req.Role = models.RoleMember
|
||||
}
|
||||
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
|
||||
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
|
||||
return
|
||||
}
|
||||
if req.MaxUses == 0 {
|
||||
req.MaxUses = 1
|
||||
}
|
||||
if req.MaxUses < 1 || req.MaxUses > 100 {
|
||||
respond(w, http.StatusBadRequest, errResp("max_uses must be between 1 and 100"))
|
||||
return
|
||||
}
|
||||
|
||||
raw, hash, err := randomToken()
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
// created_by is nullable (ON DELETE SET NULL) for exactly this
|
||||
// reason: the caller minting an invite is not always a human with a
|
||||
// real users row. A team-scoped service account is owner-equivalent
|
||||
// here (requireTeamOwner above already let it through), and this
|
||||
// must leave created_by NULL for one the same way
|
||||
// handleCreateServiceAccount already does for the analogous case —
|
||||
// an unchecked zero value would violate the users(id) foreign key
|
||||
// instead of recording "nobody" cleanly.
|
||||
var createdBy *int64
|
||||
if u, ok := userFromContext(r.Context()); ok {
|
||||
id := u.ID
|
||||
createdBy = &id
|
||||
}
|
||||
expires := time.Now().Add(inviteTTL)
|
||||
|
||||
var out inviteJSON
|
||||
var created, expiresAt int64
|
||||
if err := db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO invites (token_hash, team_id, role, created_by, expires_at, max_uses)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, team_id, role, created_at, expires_at, max_uses, uses`,
|
||||
hash, teamID, req.Role, createdBy, expires.Unix(), req.MaxUses).
|
||||
Scan(&out.ID, &out.TeamID, &out.Role, &created, &expiresAt, &out.MaxUses, &out.Uses); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
out.CreatedAt = time.Unix(created, 0).UTC()
|
||||
out.ExpiresAt = time.Unix(expiresAt, 0).UTC()
|
||||
out.URL = strings.TrimSuffix(publicURL, "/") + "/signup?invite=" + raw
|
||||
respond(w, http.StatusCreated, out)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRevokeInvite stops a link working without waiting for it to expire.
|
||||
func handleRevokeInvite(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
|
||||
}
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "inviteID"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid invite id"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"UPDATE invites SET revoked_at = "+nowEpoch+
|
||||
" WHERE id = $1 AND team_id = $2 AND revoked_at IS NULL", id, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Onboarding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleTestNotification publishes one push to the caller's own topic.
|
||||
//
|
||||
// The point of the first-run checklist's notification step is not that a topic
|
||||
// string has been typed but that a phone buzzes, and only the person holding it
|
||||
// can tell whether it did. Published directly rather than through the outbox:
|
||||
// the outbox row requires an incident, and this deliberately belongs to no
|
||||
// incident.
|
||||
func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if cfg.BaseURL == "" {
|
||||
respond(w, http.StatusServiceUnavailable,
|
||||
errResp("this server has no ntfy configured, so it can send nothing"))
|
||||
return
|
||||
}
|
||||
caller, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
respond(w, http.StatusForbidden, errResp("this endpoint is for human accounts only"))
|
||||
return
|
||||
}
|
||||
|
||||
var topic *string
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT ntfy_topic FROM users WHERE id = $1", caller.ID).Scan(&topic); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if topic == nil || *topic == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("set a notification topic first"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := publish(r.Context(), cfg, ntfyMessage{
|
||||
Topic: *topic,
|
||||
Title: "terdut test",
|
||||
Message: "If this arrived, your notifications work.",
|
||||
Tags: []string{"white_check_mark"},
|
||||
}); err != nil {
|
||||
// The failure is the useful part here: a wrong topic, a token the
|
||||
// ntfy server rejects, or an ntfy that is down all look the same
|
||||
// from the phone, which is silence.
|
||||
respond(w, http.StatusBadGateway, errResp("ntfy rejected the test: "+err.Error()))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDismissOnboarding hides the first-run checklist, or brings it back.
|
||||
// Stored per user rather than in the browser: somebody who finishes setting up
|
||||
// on a laptop should not be nagged again on their phone.
|
||||
func handleDismissOnboarding(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Dismissed *bool `json:"dismissed"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil || req.Dismissed == nil {
|
||||
respond(w, http.StatusBadRequest, errResp("dismissed is required"))
|
||||
return
|
||||
}
|
||||
caller, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
respond(w, http.StatusForbidden, errResp("this endpoint is for human accounts only"))
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if *req.Dismissed {
|
||||
_, err = db.ExecContext(r.Context(),
|
||||
"UPDATE users SET onboarding_dismissed_at = "+nowEpoch+" WHERE id = $1", caller.ID)
|
||||
} else {
|
||||
_, err = db.ExecContext(r.Context(),
|
||||
"UPDATE users SET onboarding_dismissed_at = NULL WHERE id = $1", caller.ID)
|
||||
}
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
// signup posts to the unauthenticated sign-up endpoint, the way the form does,
|
||||
// and returns the response and a client holding whatever cookie came back.
|
||||
func signup(t *testing.T, s *ts, body map[string]any) (*http.Response, *http.Client) {
|
||||
t.Helper()
|
||||
data, _ := json.Marshal(body)
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := &http.Client{Jar: jar}
|
||||
req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/signup", bytes.NewReader(data))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("signup: %v", err)
|
||||
}
|
||||
return resp, client
|
||||
}
|
||||
|
||||
// invite mints a link into the default team and returns its raw token.
|
||||
func invite(t *testing.T, s *ts, role string, maxUses int64) string {
|
||||
t.Helper()
|
||||
var out struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/invites",
|
||||
map[string]any{"role": role, "max_uses": maxUses}), &out)
|
||||
if out.URL == "" {
|
||||
t.Fatal("no invite URL returned")
|
||||
}
|
||||
// ...?invite=<token>
|
||||
i := len(out.URL) - 1
|
||||
for ; i >= 0 && out.URL[i] != '='; i-- {
|
||||
}
|
||||
return out.URL[i+1:]
|
||||
}
|
||||
|
||||
func setSignupMode(t *testing.T, s *ts, mode string) {
|
||||
t.Helper()
|
||||
resp := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": mode})
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("set signup mode: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// The default is the closed door. An install that gets a public hostname before
|
||||
// anybody has thought about sign-up should not be collecting accounts.
|
||||
func TestSignup_InviteOnlyByDefault(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var info map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
|
||||
if info["mode"] != "invite_only" {
|
||||
t.Errorf("default sign-up mode is %v, want invite_only", info["mode"])
|
||||
}
|
||||
|
||||
resp, _ := signup(t, s, map[string]any{
|
||||
"username": "stranger", "email": "s@test.com", "password": "correct-horse-battery",
|
||||
})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("sign-up without an invite: expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// An invite carries the team and the role, so redeeming one lands somewhere
|
||||
// usable rather than in an account that sees an empty queue.
|
||||
func TestSignup_InviteCreatesAMemberOfThatTeam(t *testing.T) {
|
||||
s := newTS(t)
|
||||
token := invite(t, s, "member", 1)
|
||||
|
||||
// The form checks the link before asking for a password.
|
||||
var info map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
|
||||
if info["invite_valid"] != true {
|
||||
t.Fatalf("a fresh invite should be valid: %v", info)
|
||||
}
|
||||
if info["invite_team"] != "Default" {
|
||||
t.Errorf("the form should name the team: %v", info["invite_team"])
|
||||
}
|
||||
|
||||
resp, client := signup(t, s, map[string]any{
|
||||
"username": "newcomer", "email": "n@test.com",
|
||||
"password": "correct-horse-battery", "invite": token,
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("redeeming an invite: %d", resp.StatusCode)
|
||||
}
|
||||
var me struct {
|
||||
User struct {
|
||||
ID int64 `json:"id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
} `json:"user"`
|
||||
}
|
||||
decode(t, resp, &me)
|
||||
if me.User.IsAdmin {
|
||||
t.Error("somebody who signs up must not be an administrator")
|
||||
}
|
||||
|
||||
// Signed in already: the cookie came back with the response.
|
||||
got, err := client.Get(s.URL + "/api/teams")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
teams := list(t, got)
|
||||
if len(teams) != 1 || teams[0]["name"] != "Default" || teams[0]["role"] != "member" {
|
||||
t.Errorf("expected membership of Default as member, got %v", teams)
|
||||
}
|
||||
}
|
||||
|
||||
// A single-use link is single-use, and the check is inside the transaction so
|
||||
// two people redeeming the last use at once cannot both get in.
|
||||
func TestSignup_InviteCannotBeUsedTwice(t *testing.T) {
|
||||
s := newTS(t)
|
||||
token := invite(t, s, "member", 1)
|
||||
|
||||
first, _ := signup(t, s, map[string]any{
|
||||
"username": "first", "email": "f@test.com",
|
||||
"password": "correct-horse-battery", "invite": token,
|
||||
})
|
||||
first.Body.Close()
|
||||
if first.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("first redemption: %d", first.StatusCode)
|
||||
}
|
||||
|
||||
second, _ := signup(t, s, map[string]any{
|
||||
"username": "second", "email": "s@test.com",
|
||||
"password": "correct-horse-battery", "invite": token,
|
||||
})
|
||||
second.Body.Close()
|
||||
if second.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("second redemption: expected 403, got %d", second.StatusCode)
|
||||
}
|
||||
|
||||
// And the link reports itself unusable before anybody types a password.
|
||||
var info map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
|
||||
if info["invite_valid"] != false {
|
||||
t.Error("a used-up invite should report itself invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// Revoking stops a link without waiting for it to expire.
|
||||
func TestSignup_RevokedInviteStopsWorking(t *testing.T) {
|
||||
s := newTS(t)
|
||||
token := invite(t, s, "member", 5)
|
||||
|
||||
invites := list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/invites", nil))
|
||||
if len(invites) != 1 {
|
||||
t.Fatalf("expected one invite, got %d", len(invites))
|
||||
}
|
||||
id := int64(invites[0]["id"].(float64))
|
||||
|
||||
resp := s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/invites/"+id64(id), nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("revoke: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
used, _ := signup(t, s, map[string]any{
|
||||
"username": "late", "email": "l@test.com",
|
||||
"password": "correct-horse-battery", "invite": token,
|
||||
})
|
||||
used.Body.Close()
|
||||
if used.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("a revoked invite: expected 403, got %d", used.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Open sign-up makes a team, because an account in no team sees an empty queue
|
||||
// and can be paged by nobody.
|
||||
func TestSignup_OpenModeMakesATeam(t *testing.T) {
|
||||
s := newTS(t)
|
||||
setSignupMode(t, s, "open")
|
||||
|
||||
missing, _ := signup(t, s, map[string]any{
|
||||
"username": "solo", "email": "s@test.com", "password": "correct-horse-battery",
|
||||
})
|
||||
missing.Body.Close()
|
||||
if missing.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("open sign-up with no team name: expected 400, got %d", missing.StatusCode)
|
||||
}
|
||||
|
||||
resp, client := signup(t, s, map[string]any{
|
||||
"username": "solo", "email": "s@test.com",
|
||||
"password": "correct-horse-battery", "team_name": "Solo",
|
||||
})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("open sign-up: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
got, err := client.Get(s.URL + "/api/teams")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
teams := list(t, got)
|
||||
if len(teams) != 1 || teams[0]["name"] != "Solo" || teams[0]["role"] != "owner" {
|
||||
t.Errorf("the creator should own their new team, got %v", teams)
|
||||
}
|
||||
}
|
||||
|
||||
// Switching the mode is an administrator's decision, and it takes effect at
|
||||
// once rather than at the next restart.
|
||||
func TestSignup_ModeIsAnAdminSetting(t *testing.T) {
|
||||
s := newTS(t)
|
||||
_, call := member(t, s, "plain")
|
||||
|
||||
resp := call(http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "open"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("a member changing the mode: expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bad := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "everybody"})
|
||||
bad.Body.Close()
|
||||
if bad.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("an unknown mode: expected 400, got %d", bad.StatusCode)
|
||||
}
|
||||
|
||||
setSignupMode(t, s, "open")
|
||||
var info map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
|
||||
if info["mode"] != "open" {
|
||||
t.Errorf("the change should be visible at once, got %v", info["mode"])
|
||||
}
|
||||
}
|
||||
|
||||
// Minting a link is configuring the team, so it is an owner's job.
|
||||
func TestSignup_InvitesAreOwnerOnly(t *testing.T) {
|
||||
s := newTS(t)
|
||||
_, call := member(t, s, "plain")
|
||||
|
||||
resp := call(http.MethodPost, "/api/teams/"+defaultTeam+"/invites", map[string]any{"role": "member"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("a member minting an invite: expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A password still has to be a password, and a taken username is still taken.
|
||||
func TestSignup_ValidatesLikeTheRestOfTheServer(t *testing.T) {
|
||||
s := newTS(t)
|
||||
token := invite(t, s, "member", 5)
|
||||
|
||||
short, _ := signup(t, s, map[string]any{
|
||||
"username": "shorty", "email": "sh@test.com", "password": "abc", "invite": token,
|
||||
})
|
||||
short.Body.Close()
|
||||
if short.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a short password: expected 400, got %d", short.StatusCode)
|
||||
}
|
||||
|
||||
taken, _ := signup(t, s, map[string]any{
|
||||
"username": "admin", "email": "other@test.com",
|
||||
"password": "correct-horse-battery", "invite": token,
|
||||
})
|
||||
taken.Body.Close()
|
||||
if taken.StatusCode != http.StatusConflict {
|
||||
t.Errorf("an existing username: expected 409, got %d", taken.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A team-scoped service account has no users row to attribute created_by to.
|
||||
// Before this fix, handleCreateInvite wrote its zero-value caller.ID straight
|
||||
// into that (nullable, ON DELETE SET NULL) foreign key instead of leaving it
|
||||
// NULL the way handleCreateServiceAccount already does for the same
|
||||
// situation — a 500, not the 201 TestServiceAccount_TeamScopeManagesItsOwnInvites
|
||||
// now confirms. This pins the column itself ends up NULL, not just "some
|
||||
// response came back".
|
||||
func TestSignup_InviteCreatedByAServiceAccountLeavesCreatedByNull(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)
|
||||
|
||||
var created struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/invites", map[string]any{}), &created)
|
||||
|
||||
var createdBy sql.NullInt64
|
||||
if err := s.db.QueryRow("SELECT created_by FROM invites WHERE id = $1", created.ID).Scan(&createdBy); err != nil {
|
||||
t.Fatalf("read back invites.created_by: %v", err)
|
||||
}
|
||||
if createdBy.Valid {
|
||||
t.Errorf("expected created_by to be NULL for a service-account-minted invite, got %d", createdBy.Int64)
|
||||
}
|
||||
}
|
||||
|
||||
// Neither of these has a real user_id to act on behalf of; both must 403 a
|
||||
// service account explicitly rather than 500 (handleTestNotification, which
|
||||
// used to query ntfy_topic for user id 0) or silently no-op (handleDismissOnboarding,
|
||||
// which used to UPDATE ... WHERE id = 0, affecting nothing and still
|
||||
// returning 204).
|
||||
func TestServiceAccount_HumanOnlyEndpointsRefuseExplicitly(t *testing.T) {
|
||||
// BaseURL set (even to a fake, unreachable address) so handleTestNotification
|
||||
// reaches its AsHuman() check instead of short-circuiting on "ntfy not
|
||||
// configured" first — this test is about the human-only check, not ntfy.
|
||||
s := newTS(t, api.NotifyConfig{BaseURL: "http://ntfy.invalid"})
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
|
||||
if resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/me/notify/test", nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for a service account testing notifications, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if resp := s.reqAs(t, instanceKey, http.MethodPut, "/api/me/onboarding",
|
||||
map[string]bool{"dismissed": true}); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for a service account dismissing onboarding, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
similarDefaultLimit = 5
|
||||
similarMaxLimit = 20
|
||||
)
|
||||
|
||||
// handleIncidentSimilar lists earlier, resolved incidents in the same team with
|
||||
// the same signature that someone left notes on, incidents with a resolution
|
||||
// note first. This is the "have we seen this before" answer for a responder
|
||||
// looking at a fresh incident; the plain notes are one timeline fetch away.
|
||||
func handleIncidentSimilar(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := incidentIDParam(w, r, db)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit := similarDefaultLimit
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid limit"))
|
||||
return
|
||||
}
|
||||
limit = min(n, similarMaxLimit)
|
||||
}
|
||||
|
||||
out, err := similarIncidents(r.Context(), db, id, limit)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func similarIncidents(ctx context.Context, q querier, id int64, limit int) ([]models.SimilarIncident, error) {
|
||||
rows, err := q.QueryContext(ctx, `
|
||||
SELECT o.id, o.title, o.triggered_at, o.resolved_at,
|
||||
(SELECT COUNT(*) FROM incident_events e
|
||||
WHERE e.incident_id = o.id AND e.type = $3)
|
||||
FROM incidents i
|
||||
JOIN incidents o ON o.team_id = i.team_id AND o.signature = i.signature
|
||||
WHERE i.id = $1 AND o.id <> i.id AND o.resolved_at IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM incident_events e
|
||||
WHERE e.incident_id = o.id AND e.type IN ($3, $4))
|
||||
ORDER BY EXISTS (SELECT 1 FROM incident_events e
|
||||
WHERE e.incident_id = o.id AND e.type = $4) DESC,
|
||||
o.triggered_at DESC
|
||||
LIMIT $2`, id, limit, evNote, evResolutionNote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []models.SimilarIncident{}
|
||||
ids := []int64{}
|
||||
for rows.Next() {
|
||||
var s models.SimilarIncident
|
||||
var triggered, resolved int64
|
||||
if err := rows.Scan(&s.ID, &s.Title, &triggered, &resolved, &s.NoteCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.TriggeredAt = time.Unix(triggered, 0).UTC()
|
||||
s.ResolvedAt = time.Unix(resolved, 0).UTC()
|
||||
s.ResolutionNotes = []models.IncidentEvent{}
|
||||
out = append(out, s)
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
nrows, err := q.QueryContext(ctx, `
|
||||
SELECT e.id, e.incident_id, e.type, e.user_id, u.username, e.detail, e.created_at
|
||||
FROM incident_events e
|
||||
LEFT JOIN users u ON u.id = e.user_id
|
||||
WHERE e.incident_id = ANY($1) AND e.type = $2
|
||||
ORDER BY e.created_at, e.id`, ids, evResolutionNote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer nrows.Close()
|
||||
|
||||
byID := make(map[int64]*models.SimilarIncident, len(out))
|
||||
for i := range out {
|
||||
byID[out[i].ID] = &out[i]
|
||||
}
|
||||
for nrows.Next() {
|
||||
var e models.IncidentEvent
|
||||
var ts int64
|
||||
if err := nrows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username, &e.Detail, &ts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||
s := byID[e.IncidentID]
|
||||
s.ResolutionNotes = append(s.ResolutionNotes, e)
|
||||
}
|
||||
return out, nrows.Err()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// postGrouped posts a firing webhook whose group labels are exactly the given
|
||||
// map, unlike postWebhook, which only ever groups by alertname.
|
||||
func postGrouped(t *testing.T, s *ts, fingerprint, startsAt string, groupLabels map[string]string) {
|
||||
t.Helper()
|
||||
labels := map[string]string{}
|
||||
for k, v := range groupLabels {
|
||||
labels[k] = v
|
||||
}
|
||||
payload := map[string]any{
|
||||
"version": "4", "status": "firing",
|
||||
"groupKey": fingerprint,
|
||||
"groupLabels": groupLabels,
|
||||
"alerts": []map[string]any{
|
||||
amAlert(fingerprint, groupLabels["alertname"], "firing", startsAt, zeroTime, labels),
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||
"application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("post webhook: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func similar(t *testing.T, s *ts, id int) []map[string]any {
|
||||
t.Helper()
|
||||
var out []map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/incidents/"+strconv.Itoa(id)+"/similar", nil), &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Same alert on another instance is the same problem; a resolution note left on
|
||||
// the first one is what the second one should be shown.
|
||||
func TestSimilar_IgnoresVolatileLabelsAndLeadsWithResolutionNote(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postGrouped(t, s, "fp-a", "2026-05-20T10:00:00Z",
|
||||
map[string]string{"alertname": "DiskFull", "instance": "web-1", "job": "node"})
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/resolve",
|
||||
map[string]string{"resolution": "rotated the logs"}).Body.Close()
|
||||
|
||||
postGrouped(t, s, "fp-b", "2026-05-21T10:00:00Z",
|
||||
map[string]string{"alertname": "DiskFull", "instance": "web-2", "job": "node"})
|
||||
|
||||
got := similar(t, s, 2)
|
||||
if len(got) != 1 || int(got[0]["id"].(float64)) != 1 {
|
||||
t.Fatalf("expected incident 1 as the only similar one, got %v", got)
|
||||
}
|
||||
notes := got[0]["resolution_notes"].([]any)
|
||||
if len(notes) != 1 || notes[0].(map[string]any)["detail"] != "rotated the logs" {
|
||||
t.Fatalf("expected the resolution note, got %v", notes)
|
||||
}
|
||||
}
|
||||
|
||||
// A different stable label (job) is a different problem, and an incident nobody
|
||||
// wrote a note on has nothing to show.
|
||||
func TestSimilar_DifferentSignatureOrNoNotesIsExcluded(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postGrouped(t, s, "fp-1", "2026-05-20T10:00:00Z",
|
||||
map[string]string{"alertname": "DiskFull", "job": "node"})
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/notes", map[string]string{"content": "checked"}).Body.Close()
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||
|
||||
postGrouped(t, s, "fp-2", "2026-05-20T11:00:00Z",
|
||||
map[string]string{"alertname": "DiskFull", "job": "db"})
|
||||
s.req(t, http.MethodPost, "/api/incidents/2/resolve", nil).Body.Close()
|
||||
|
||||
postGrouped(t, s, "fp-3", "2026-05-21T10:00:00Z",
|
||||
map[string]string{"alertname": "DiskFull", "job": "db"})
|
||||
|
||||
// Incident 3 matches 2 by signature, but 2 has no notes.
|
||||
if got := similar(t, s, 3); len(got) != 0 {
|
||||
t.Fatalf("expected nothing similar to incident 3, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An open incident is not "earlier experience" yet, and the incident itself is
|
||||
// never its own match.
|
||||
func TestSimilar_OpenIncidentsAreNotListed(t *testing.T) {
|
||||
s := newTS(t)
|
||||
postGrouped(t, s, "fp-o1", "2026-05-20T10:00:00Z", map[string]string{"alertname": "Flap"})
|
||||
s.req(t, http.MethodPost, "/api/incidents/1/notes",
|
||||
map[string]any{"content": "still open", "pinned": true}).Body.Close()
|
||||
postGrouped(t, s, "fp-o2", "2026-05-21T10:00:00Z", map[string]string{"alertname": "Flap"})
|
||||
|
||||
if got := similar(t, s, 2); len(got) != 0 {
|
||||
t.Fatalf("expected an open incident not to be listed, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// listSources reads a team's alert sources as the Sources page does.
|
||||
func listSources(t *testing.T, tm teamFixture) []map[string]any {
|
||||
t.Helper()
|
||||
return list(t, tm.call(http.MethodGet, "/api/teams/"+id64(tm.id)+"/integrations", nil))
|
||||
}
|
||||
|
||||
// addSource mints a second source in a team and returns its key.
|
||||
func addSource(t *testing.T, tm teamFixture, name string) string {
|
||||
t.Helper()
|
||||
var out struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, tm.call(http.MethodPost, "/api/teams/"+id64(tm.id)+"/integrations",
|
||||
map[string]string{"name": name}), &out)
|
||||
return out.Key
|
||||
}
|
||||
|
||||
// A source that has never posted is "never", with nothing to say about alerts.
|
||||
func TestSources_NeverUsedIsBlank(t *testing.T) {
|
||||
s := newTS(t)
|
||||
tm := newTeam(t, s, "red")
|
||||
|
||||
got := listSources(t, tm)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 source, got %d", len(got))
|
||||
}
|
||||
src := got[0]
|
||||
if src["status"] != "never" || src["last_used_at"] != nil || src["last_alert_at"] != nil {
|
||||
t.Errorf("a source nobody has posted on should be blank, got %v", src)
|
||||
}
|
||||
if src["alerts_24h"].(float64) != 0 {
|
||||
t.Errorf("alerts_24h = %v, want 0", src["alerts_24h"])
|
||||
}
|
||||
}
|
||||
|
||||
// Each source is credited with what arrived on its own key, and only that.
|
||||
func TestSources_AlertsAreAttributedToTheirSource(t *testing.T) {
|
||||
s := newTS(t)
|
||||
tm := newTeam(t, s, "red")
|
||||
second := addSource(t, tm, "staging")
|
||||
|
||||
postToIntegration(t, s, tm.key, "fp-1", "DiskFull")
|
||||
postToIntegration(t, s, tm.key, "fp-2", "CPUHot")
|
||||
|
||||
got := listSources(t, tm)
|
||||
first, other := got[0], got[1]
|
||||
if first["status"] != "active" || first["last_used_at"] == nil || first["last_alert_at"] == nil {
|
||||
t.Errorf("the source that posted should be active with timestamps, got %v", first)
|
||||
}
|
||||
if first["alerts_24h"].(float64) != 2 {
|
||||
t.Errorf("alerts_24h = %v, want 2", first["alerts_24h"])
|
||||
}
|
||||
if other["status"] != "never" || other["alerts_24h"].(float64) != 0 {
|
||||
t.Errorf("the other source should be untouched, got %v", other)
|
||||
}
|
||||
|
||||
// Re-sending the same alert on the other key moves it: last sender wins.
|
||||
postToIntegration(t, s, second, "fp-1", "DiskFull")
|
||||
got = listSources(t, tm)
|
||||
if got[0]["alerts_24h"].(float64) != 1 || got[1]["alerts_24h"].(float64) != 1 {
|
||||
t.Errorf("fp-1 should have moved to the second source, got %v and %v",
|
||||
got[0]["alerts_24h"], got[1]["alerts_24h"])
|
||||
}
|
||||
}
|
||||
|
||||
// A payload with no alerts in it is a webhook, not an alert: the source was
|
||||
// heard from, and nothing arrived.
|
||||
func TestSources_EmptyPayloadStampsUseButNotAlert(t *testing.T) {
|
||||
s := newTS(t)
|
||||
tm := newTeam(t, s, "red")
|
||||
|
||||
resp, err := http.Post(s.URL+"/api/integrations/"+tm.key+"/alertmanager",
|
||||
"application/json", bytes.NewReader([]byte(`{"version":"4","status":"firing","alerts":[]}`)))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
src := listSources(t, tm)[0]
|
||||
if src["status"] != "active" || src["last_alert_at"] != nil {
|
||||
t.Errorf("want active with no alert yet, got %v", src)
|
||||
}
|
||||
}
|
||||
|
||||
// Quiet is "has posted, not lately"; the alert counter forgets after a day but
|
||||
// the last alert's timestamp is kept.
|
||||
func TestSources_QuietAfterADay(t *testing.T) {
|
||||
s := newTS(t)
|
||||
tm := newTeam(t, s, "red")
|
||||
postToIntegration(t, s, tm.key, "fp-1", "DiskFull")
|
||||
|
||||
old := time.Now().Add(-48 * time.Hour).Unix()
|
||||
s.exec(t, "UPDATE integrations SET last_used_at = $1", old)
|
||||
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'fp-1'", old)
|
||||
|
||||
src := listSources(t, tm)[0]
|
||||
if src["status"] != "quiet" {
|
||||
t.Errorf("status = %v, want quiet", src["status"])
|
||||
}
|
||||
if src["alerts_24h"].(float64) != 0 {
|
||||
t.Errorf("alerts_24h = %v, want 0", src["alerts_24h"])
|
||||
}
|
||||
if src["last_alert_at"] == nil {
|
||||
t.Error("last_alert_at should survive the day")
|
||||
}
|
||||
}
|
||||
|
||||
// Revoking a source does not take its alerts with it.
|
||||
func TestSources_RevokeKeepsTheAlerts(t *testing.T) {
|
||||
s := newTS(t)
|
||||
tm := newTeam(t, s, "red")
|
||||
postToIntegration(t, s, tm.key, "fp-1", "DiskFull")
|
||||
|
||||
id := int64(listSources(t, tm)[0]["id"].(float64))
|
||||
resp := tm.call(http.MethodDelete, "/api/teams/"+id64(tm.id)+"/integrations/"+id64(id), nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("revoke: %d", resp.StatusCode)
|
||||
}
|
||||
if got := len(list(t, tm.call(http.MethodGet, "/api/alerts", nil))); got != 1 {
|
||||
t.Errorf("the alert should outlive its source, got %d alerts", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming is an owner's, scoped to the team, and does not touch the key.
|
||||
func TestSources_Rename(t *testing.T) {
|
||||
s := newTS(t)
|
||||
tm := newTeam(t, s, "red")
|
||||
other := newTeam(t, s, "blue")
|
||||
id := int64(listSources(t, tm)[0]["id"].(float64))
|
||||
path := "/api/teams/" + id64(tm.id) + "/integrations/" + id64(id)
|
||||
|
||||
resp := tm.call(http.MethodPatch, path, map[string]string{"name": " prod "})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("rename: %d", resp.StatusCode)
|
||||
}
|
||||
if name := listSources(t, tm)[0]["name"]; name != "prod" {
|
||||
t.Errorf("name = %q, want it trimmed to prod", name)
|
||||
}
|
||||
postToIntegration(t, s, tm.key, "fp-1", "DiskFull") // the old key still works
|
||||
|
||||
for name, body := range map[string]map[string]string{
|
||||
"empty": {"name": " "},
|
||||
"too long": {"name": strings.Repeat("x", 101)},
|
||||
} {
|
||||
resp := tm.call(http.MethodPatch, path, body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("%s name: expected 400, got %d", name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Another team's owner cannot reach it.
|
||||
resp = other.call(http.MethodPatch, "/api/teams/"+id64(other.id)+"/integrations/"+id64(id),
|
||||
map[string]string{"name": "mine now"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("renaming another team's source: expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,10 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
result = append(result, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
@@ -104,6 +108,10 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
counts[hr] = cnt
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
Hour int `json:"hour"`
|
||||
@@ -146,6 +154,10 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
counts[dow] = cnt
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
||||
type entry struct {
|
||||
|
||||
+554
-89
@@ -13,15 +13,22 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// handleListTeams lists the caller's own teams, each with their role in it. An
|
||||
// administrator listing every team goes through the admin endpoint instead:
|
||||
// this one answers "what am I part of", which is what the UI's team filter and
|
||||
// the combined queue are built from.
|
||||
// handleListTeams lists the caller's own teams, each with their role in it,
|
||||
// or — with ?name= — looks up one team by exact name regardless of caller
|
||||
// identity (TEAM-LOOKUP.md). An administrator listing every team goes
|
||||
// through the admin endpoint instead: the no-name case here answers "what am
|
||||
// I part of", which is what the UI's team filter and the combined queue are
|
||||
// built from.
|
||||
func handleListTeams(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" {
|
||||
handleListTeamsByName(db, w, r, name)
|
||||
return
|
||||
}
|
||||
|
||||
caller, _ := userFromContext(r.Context())
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT t.id, t.name, t.created_at, m.role
|
||||
SELECT t.id, t.name, t.created_at, m.role, m.source
|
||||
FROM teams t
|
||||
JOIN team_members m ON m.team_id = t.id
|
||||
WHERE m.user_id = $1
|
||||
@@ -36,7 +43,94 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var t models.Team
|
||||
var created int64
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
teams = append(teams, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, teams)
|
||||
}
|
||||
}
|
||||
|
||||
// handleListTeamsByName answers "is there a team named exactly this", open to
|
||||
// any authenticated caller including a service account (TEAM-LOOKUP.md) —
|
||||
// mirrors handleListServiceAccounts' own ?name= lookup: a one-or-zero-length
|
||||
// array, never an error on no match, and no caller-identity filtering at
|
||||
// all, since what it discloses (a name is taken, nothing about who's in it
|
||||
// or any of its data) is the same low sensitivity that lookup already
|
||||
// accepts for service-account names.
|
||||
func handleListTeamsByName(db *sql.DB, w http.ResponseWriter, r *http.Request, name string) {
|
||||
var t models.Team
|
||||
var created int64
|
||||
err := db.QueryRowContext(r.Context(),
|
||||
"SELECT id, name, created_at FROM teams WHERE name = $1", name,
|
||||
).Scan(&t.ID, &t.Name, &created)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respond(w, http.StatusOK, []models.Team{})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||
respond(w, http.StatusOK, []models.Team{t})
|
||||
}
|
||||
|
||||
// handleUserTeams lists one user's teams, for the admin page's per-user view:
|
||||
// "what is this person in", which /api/teams cannot answer because it is always
|
||||
// about the caller.
|
||||
//
|
||||
// Self or admin, matching the other per-user endpoints. It says which teams
|
||||
// somebody belongs to and in what role — not anything those teams own, so it
|
||||
// stays on the accounts side of the line the administrator flag draws.
|
||||
func handleUserTeams(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
if !requireSelfOrAdmin(w, r, id) {
|
||||
return
|
||||
}
|
||||
|
||||
// A user with no teams and a user who does not exist both list nothing,
|
||||
// so the existence check is what tells them apart.
|
||||
var exists bool
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1)", id).Scan(&exists); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT t.id, t.name, t.created_at, m.role, m.source
|
||||
FROM teams t
|
||||
JOIN team_members m ON m.team_id = t.id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY t.name`, id)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
teams := []models.Team{}
|
||||
for rows.Next() {
|
||||
var t models.Team
|
||||
var created int64
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -54,6 +148,15 @@ func handleListTeams(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 {
|
||||
@@ -69,7 +172,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 {
|
||||
@@ -90,11 +200,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"))
|
||||
@@ -102,7 +214,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)
|
||||
}
|
||||
}
|
||||
@@ -148,8 +262,40 @@ func handleDeleteTeam(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// handleListTeamMembers names everybody in a team. Visible to any member: you
|
||||
// can see who else is on the rota you are on.
|
||||
// Member statuses, as the Members page colours them.
|
||||
const (
|
||||
memberOnCall = "oncall"
|
||||
memberReachable = "reachable"
|
||||
memberUnpageable = "unpageable"
|
||||
)
|
||||
|
||||
// memberStatus is a team member with what matters about them at 03:00: whether
|
||||
// they are on call, whether a page to them would go anywhere, and whether they
|
||||
// have been around. The extra fields are output only.
|
||||
type memberStatus struct {
|
||||
models.TeamMember
|
||||
|
||||
// Status is unpageable when a page to them would go nowhere — even when
|
||||
// they are on call, since that is the case that matters most — on_call when
|
||||
// the rota has them today, reachable otherwise.
|
||||
Status string `json:"status"`
|
||||
|
||||
OnCall bool `json:"on_call"`
|
||||
|
||||
// NextShift is the first day after today the rota has them (YYYY-MM-DD).
|
||||
NextShift *string `json:"next_shift,omitempty"`
|
||||
|
||||
// Pageable is whether they have an ntfy topic and an enabled account — the
|
||||
// conditions pageLevel and the notifier skip on. Never the topic itself.
|
||||
Pageable bool `json:"pageable"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
|
||||
// LastActiveAt is the last time they used a session or an API key.
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
}
|
||||
|
||||
// handleListTeamMembers names everybody in a team, with their status. Visible to
|
||||
// any member: you can see who else is on the rota you are on.
|
||||
func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
@@ -161,26 +307,56 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at
|
||||
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source,
|
||||
u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '',
|
||||
u.disabled_at IS NOT NULL,
|
||||
GREATEST(
|
||||
COALESCE((SELECT MAX(last_seen_at) FROM sessions WHERE user_id = u.id), 0),
|
||||
COALESCE((SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id), 0)),
|
||||
EXISTS (SELECT 1 FROM schedule_entries s
|
||||
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date = $2),
|
||||
(SELECT MIN(date) FROM schedule_entries s
|
||||
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date > $2)
|
||||
FROM team_members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.team_id = $1
|
||||
ORDER BY u.username`, teamID)
|
||||
ORDER BY u.username`, teamID, todayUTC())
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := []models.TeamMember{}
|
||||
members := []memberStatus{}
|
||||
for rows.Next() {
|
||||
var m models.TeamMember
|
||||
var joined int64
|
||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil {
|
||||
var m memberStatus
|
||||
var joined, lastActive int64
|
||||
var hasTopic, disabled bool
|
||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source,
|
||||
&hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
m.JoinedAt = time.Unix(joined, 0).UTC()
|
||||
if lastActive > 0 {
|
||||
t := time.Unix(lastActive, 0).UTC()
|
||||
m.LastActiveAt = &t
|
||||
}
|
||||
switch {
|
||||
case disabled:
|
||||
m.Problem = "account is disabled"
|
||||
case !hasTopic:
|
||||
m.Problem = "has no ntfy topic"
|
||||
}
|
||||
m.Pageable = m.Problem == ""
|
||||
switch {
|
||||
case !m.Pageable:
|
||||
m.Status = memberUnpageable
|
||||
case m.OnCall:
|
||||
m.Status = memberOnCall
|
||||
default:
|
||||
m.Status = memberReachable
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -219,6 +395,28 @@ func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if managed, err := isSSOManagedMember(r.Context(), db, teamID, req.UserID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
} else if managed {
|
||||
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
|
||||
return
|
||||
}
|
||||
|
||||
// Demoting the last owner is removing them by another route: the team
|
||||
// would have nobody who can edit it.
|
||||
if req.Role == models.RoleMember {
|
||||
last, err := isLastTeamOwner(r.Context(), db, teamID, req.UserID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if last {
|
||||
respond(w, http.StatusConflict, errResp("cannot demote the last owner of a team"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.ExecContext(r.Context(), `
|
||||
INSERT INTO team_members (team_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -254,6 +452,14 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if managed, err := isSSOManagedMember(r.Context(), db, teamID, userID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
} else if managed {
|
||||
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
|
||||
return
|
||||
}
|
||||
|
||||
last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
@@ -278,6 +484,20 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ssoManagedMsg is the refusal for editing access that single sign-on owns.
|
||||
const ssoManagedMsg = "this membership is managed by single sign-on; change the user's groups in the identity provider"
|
||||
|
||||
// isSSOManagedMember reports whether the membership comes from the group sync.
|
||||
// Editing it here would be undone at the person's next sign-in, so it is refused
|
||||
// instead of appearing to work.
|
||||
func isSSOManagedMember(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
||||
var managed bool
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT EXISTS (SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND source = 'oidc')",
|
||||
teamID, userID).Scan(&managed)
|
||||
return managed, err
|
||||
}
|
||||
|
||||
func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
||||
var last bool
|
||||
err := db.QueryRowContext(ctx, `
|
||||
@@ -293,8 +513,42 @@ func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (boo
|
||||
// Integrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleListIntegrations lists a team's integrations. Never the keys: those
|
||||
// exist in plaintext only in the response that created them.
|
||||
// sourceQuietAfter is how long a source may go without posting before the
|
||||
// Sources page calls it quiet rather than active. A day is longer than any
|
||||
// repeat_interval worth having, so an Alertmanager that is up and has anything
|
||||
// firing never crosses it; a source with nothing firing may, and that is a
|
||||
// reason to look, not proof of a fault — which is why this is a colour and not
|
||||
// an alarm. Dead man's switches are where silence pages.
|
||||
const sourceQuietAfter = 24 * time.Hour
|
||||
|
||||
const (
|
||||
sourceActive = "active"
|
||||
sourceQuiet = "quiet"
|
||||
sourceNever = "never"
|
||||
)
|
||||
|
||||
// integrationStatus is an integration as the Sources page shows it.
|
||||
type integrationStatus struct {
|
||||
models.Integration
|
||||
|
||||
// Status is active when the key posted within sourceQuietAfter, quiet when
|
||||
// it has posted but not lately, never when it has not posted at all.
|
||||
Status string `json:"status"`
|
||||
|
||||
// LastAlertAt is when an alert last arrived on this source, which is not the
|
||||
// same as when it last posted: a payload with nothing usable in it stamps
|
||||
// last_used_at and not this. Absent until an alert has arrived since
|
||||
// migration 010 started recording it.
|
||||
LastAlertAt *time.Time `json:"last_alert_at,omitempty"`
|
||||
|
||||
// Alerts24h counts the distinct alerts this source refreshed in the last
|
||||
// day. An alert re-sent every few hours counts once, not once per re-send.
|
||||
Alerts24h int64 `json:"alerts_24h"`
|
||||
}
|
||||
|
||||
// handleListIntegrations lists a team's integrations with what each has been
|
||||
// delivering. Never the keys: those exist in plaintext only in the response that
|
||||
// created them.
|
||||
func handleListIntegrations(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
@@ -305,28 +559,45 @@ func handleListIntegrations(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT id, team_id, kind, name, created_at, last_used_at
|
||||
FROM integrations
|
||||
WHERE team_id = $1
|
||||
ORDER BY id`, teamID)
|
||||
SELECT i.id, i.team_id, i.kind, i.name, i.created_at, i.last_used_at,
|
||||
-- Scalar subqueries, not a join and GROUP BY: each is a
|
||||
-- single range over alerts_integration_idx, where the join
|
||||
-- would read every alert a source ever delivered.
|
||||
(SELECT MAX(received_at) FROM alerts WHERE integration_id = i.id),
|
||||
(SELECT COUNT(*) FROM alerts
|
||||
WHERE integration_id = i.id AND received_at >= $2)
|
||||
FROM integrations i
|
||||
WHERE i.team_id = $1
|
||||
ORDER BY i.id`, teamID, now.Add(-sourceQuietAfter).Unix())
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
integrations := []models.Integration{}
|
||||
integrations := []integrationStatus{}
|
||||
for rows.Next() {
|
||||
var i models.Integration
|
||||
var i integrationStatus
|
||||
var created int64
|
||||
var lastUsed *int64
|
||||
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed); err != nil {
|
||||
var lastUsed, lastAlert *int64
|
||||
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed,
|
||||
&lastAlert, &i.Alerts24h); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||
i.LastUsedAt = unixPtr(lastUsed)
|
||||
i.LastAlertAt = unixPtr(lastAlert)
|
||||
switch {
|
||||
case i.LastUsedAt == nil:
|
||||
i.Status = sourceNever
|
||||
case now.Sub(*i.LastUsedAt) > sourceQuietAfter:
|
||||
i.Status = sourceQuiet
|
||||
default:
|
||||
i.Status = sourceActive
|
||||
}
|
||||
integrations = append(integrations, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -395,6 +666,54 @@ func handleCreateIntegration(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// handleRenameIntegration renames a source. The key is untouched, so nothing
|
||||
// posting with it notices.
|
||||
func handleRenameIntegration(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
|
||||
}
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "integrationID"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid integration id"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
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 len(req.Name) > 100 {
|
||||
respond(w, http.StatusBadRequest, errResp("name is too long"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"UPDATE integrations SET name = $1 WHERE id = $2 AND team_id = $3", req.Name, id, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteIntegration(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
@@ -431,24 +750,32 @@ func integrationPath(key, kind string) string {
|
||||
return "/api/integrations/" + key + "/" + kind
|
||||
}
|
||||
|
||||
// teamIDForKey resolves an integration key to its team, and stamps the key's
|
||||
// alertSource is who an arriving webhook is from: the integration whose key it
|
||||
// used, and the team that integration puts its alerts in.
|
||||
type alertSource struct {
|
||||
integrationID int64
|
||||
teamID int64
|
||||
}
|
||||
|
||||
// sourceForKey resolves an integration key to its source, and stamps the key's
|
||||
// last use. An unknown key is not an error worth distinguishing: the caller is
|
||||
// told nothing beyond "no".
|
||||
func teamIDForKey(ctx context.Context, db *sql.DB, key string) (int64, error) {
|
||||
var teamID int64
|
||||
func sourceForKey(ctx context.Context, db *sql.DB, key string) (alertSource, error) {
|
||||
var src alertSource
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT team_id FROM integrations WHERE key_hash = $1", hashToken(key)).Scan(&teamID)
|
||||
"SELECT id, team_id FROM integrations WHERE key_hash = $1", hashToken(key)).
|
||||
Scan(&src.integrationID, &src.teamID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, errUnknownIntegration
|
||||
return alertSource{}, errUnknownIntegration
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return alertSource{}, err
|
||||
}
|
||||
// Best effort, like an API key's: a failed stamp must not reject an alert.
|
||||
db.ExecContext(ctx, //nolint:errcheck
|
||||
"UPDATE integrations SET last_used_at = $1 WHERE key_hash = $2",
|
||||
time.Now().Unix(), hashToken(key))
|
||||
return teamID, nil
|
||||
"UPDATE integrations SET last_used_at = $1 WHERE id = $2",
|
||||
time.Now().Unix(), src.integrationID)
|
||||
return src, nil
|
||||
}
|
||||
|
||||
var errUnknownIntegration = errors.New("unknown integration key")
|
||||
@@ -477,17 +804,24 @@ func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) {
|
||||
// A team's dead man's switches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// deadmanResponse is the wire shape of a team's switch configuration. The
|
||||
// timeout is seconds rather than a duration string, because that is what the
|
||||
// column holds and what arithmetic is done on; a client renders it.
|
||||
type deadmanResponse struct {
|
||||
TeamID int64 `json:"team_id"`
|
||||
Matchers string `json:"matchers"`
|
||||
// deadmanSwitchRequest is what creating a switch takes. The timeout is seconds,
|
||||
// because that is what the column holds and what arithmetic is done on; a client
|
||||
// renders it.
|
||||
type deadmanSwitchRequest struct {
|
||||
Name string `json:"name"`
|
||||
Matcher string `json:"matcher"`
|
||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
func handleGetTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||
// deadmanSeverities are the severities an incident can open at.
|
||||
var deadmanSeverities = map[string]bool{"critical": true, "error": true, "warning": true, "info": true}
|
||||
|
||||
// handleListTeamDeadman lists a team's switches with what each one's heartbeats
|
||||
// are doing. A team with none gets an empty list, which is a configuration and
|
||||
// not an absence: answering 404 would make "off" indistinguishable from "this
|
||||
// server does not do this".
|
||||
func handleListTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
@@ -497,27 +831,26 @@ func handleGetTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
out := deadmanResponse{TeamID: teamID, Severity: "critical"}
|
||||
err := db.QueryRowContext(r.Context(),
|
||||
"SELECT matchers, timeout_seconds, severity FROM deadman_configs WHERE team_id = $1",
|
||||
teamID).Scan(&out.Matchers, &out.TimeoutSeconds, &out.Severity)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
set, err := deadmanSetForTeam(r.Context(), db, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
out, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
// A team with no row watches nothing, which is a configuration and not
|
||||
// an absence: answering 404 would make "off" indistinguishable from
|
||||
// "this server does not do this".
|
||||
respond(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSetTeamDeadman replaces a team's switch configuration.
|
||||
// handleCreateTeamDeadman adds one switch.
|
||||
//
|
||||
// Validated by parsing: a matcher string that survives ParseDeadmanConfig with
|
||||
// nothing usable in it is rejected rather than stored, because a switch that
|
||||
// silently watches nothing is the failure this feature exists to prevent.
|
||||
func handleSetTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||
// Validated by parsing: a matcher with no alertname is rejected rather than
|
||||
// stored, because a switch that silently watches nothing is the failure this
|
||||
// feature exists to prevent.
|
||||
func handleCreateTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
@@ -527,50 +860,182 @@ func handleSetTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Matchers string `json:"matchers"`
|
||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
var req deadmanSwitchRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
req.Matchers = strings.TrimSpace(req.Matchers)
|
||||
req.Matcher = strings.TrimSpace(req.Matcher)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Severity == "" {
|
||||
req.Severity = "critical"
|
||||
}
|
||||
if req.TimeoutSeconds < 0 {
|
||||
respond(w, http.StatusBadRequest, errResp("timeout_seconds must not be negative"))
|
||||
if !deadmanSeverities[req.Severity] {
|
||||
respond(w, http.StatusBadRequest, errResp("severity must be critical, error, warning or info"))
|
||||
return
|
||||
}
|
||||
if req.Matchers != "" {
|
||||
parsed := parseDeadmanQuietly(req.Matchers, time.Duration(req.TimeoutSeconds)*time.Second, req.Severity)
|
||||
if len(parsed.Matchers) == 0 {
|
||||
respond(w, http.StatusBadRequest, errResp(
|
||||
"no usable matchers: each must name an alertname, as in alertname=Watchdog,cluster=prod"))
|
||||
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
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(r.Context(), `
|
||||
INSERT INTO deadman_configs (team_id, matchers, timeout_seconds, severity, updated_at)
|
||||
VALUES ($1, $2, $3, $4, `+nowEpoch+`)
|
||||
ON CONFLICT (team_id) DO UPDATE SET
|
||||
matchers = excluded.matchers,
|
||||
timeout_seconds = excluded.timeout_seconds,
|
||||
severity = excluded.severity,
|
||||
updated_at = excluded.updated_at`,
|
||||
teamID, req.Matchers, req.TimeoutSeconds, req.Severity); err != nil {
|
||||
var id int64
|
||||
if err := db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
teamID, req.Name, m.config(), req.TimeoutSeconds, req.Severity).Scan(&id); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
|
||||
respond(w, http.StatusOK, deadmanResponse{
|
||||
TeamID: teamID,
|
||||
Matchers: req.Matchers,
|
||||
TimeoutSeconds: req.TimeoutSeconds,
|
||||
Severity: req.Severity,
|
||||
respond(w, http.StatusCreated, deadmanSwitchStatus{
|
||||
ID: id, Name: req.Name, Matcher: m.config(),
|
||||
TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
|
||||
Status: switchDormant, Sources: []deadmanSource{},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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".
|
||||
func handleDeleteTeamDeadman(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
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"DELETE FROM deadman_switches WHERE id = $1 AND team_id = $2", 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
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
// The whole point of #4: two teams sharing one server must not see each other's
|
||||
@@ -327,6 +329,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)
|
||||
@@ -348,3 +456,75 @@ func TestTeams_OutsiderSeesNothing(t *testing.T) {
|
||||
t.Errorf("blue's team list: %v", teams)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/teams?name= (TEAM-LOOKUP.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestListTeamsByName_FindsExactMatch(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamID := createTeamAs(t, s, instanceKey, "platform")
|
||||
|
||||
teams := list(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=platform", nil))
|
||||
if len(teams) != 1 {
|
||||
t.Fatalf("expected exactly one match for ?name=platform, got %d: %v", len(teams), teams)
|
||||
}
|
||||
if int64(teams[0]["id"].(float64)) != teamID {
|
||||
t.Errorf("id = %v, want %d", teams[0]["id"], teamID)
|
||||
}
|
||||
// No membership, so no role to report (models.Team's own doc comment:
|
||||
// "empty when nobody in particular is asking").
|
||||
if _, has := teams[0]["role"]; has {
|
||||
t.Errorf("expected no role on a name-lookup match, got %v", teams[0]["role"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTeamsByName_NoMatchIsAnEmptyArrayNotAnError(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
|
||||
resp := s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=does-not-exist", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 on no match, got %d", resp.StatusCode)
|
||||
}
|
||||
teams := list(t, resp)
|
||||
if len(teams) != 0 {
|
||||
t.Errorf("expected an empty array, got %v", teams)
|
||||
}
|
||||
}
|
||||
|
||||
// The actual motivating scenario (TEAM-LOOKUP.md): a service account that
|
||||
// already created a team, interrupted before it could remember the id,
|
||||
// recovers it via ?name= on the same name its own POST 409s on.
|
||||
func TestListTeamsByName_RecoversAfterCreateConflict(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
original := createTeamAs(t, s, instanceKey, "recovered")
|
||||
|
||||
conflict := s.reqAs(t, instanceKey, http.MethodPost, "/api/teams", map[string]string{"name": "recovered"})
|
||||
if conflict.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("expected 409 recreating the same name, got %d", conflict.StatusCode)
|
||||
}
|
||||
conflict.Body.Close()
|
||||
|
||||
teams := list(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/teams?name=recovered", nil))
|
||||
if len(teams) != 1 || int64(teams[0]["id"].(float64)) != original {
|
||||
t.Fatalf("expected to recover the original team %d via ?name=, got %v", original, teams)
|
||||
}
|
||||
}
|
||||
|
||||
// Not gated by isInstanceServiceAccount or AdminOnly (TEAM-LOOKUP.md): any
|
||||
// authenticated caller may ask whether a name is taken, the same low
|
||||
// sensitivity GET /api/service-accounts?name= already accepts.
|
||||
func TestListTeamsByName_OpenToAnyAuthenticatedCaller(t *testing.T) {
|
||||
s := newTS(t)
|
||||
red := newTeam(t, s, "red")
|
||||
_ = createTeamAs(t, s, s.key, "blue-target")
|
||||
|
||||
// red's own member, not a member of "blue-target", still gets a match.
|
||||
teams := list(t, red.call(http.MethodGet, "/api/teams?name=blue-target", nil))
|
||||
if len(teams) != 1 || teams[0]["name"] != "blue-target" {
|
||||
t.Errorf("expected a non-member caller to still find the team by name, got %v", teams)
|
||||
}
|
||||
}
|
||||
|
||||
+96
-7
@@ -96,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
||||
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := db.QueryContext(r.Context(),
|
||||
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users ORDER BY id")
|
||||
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users ORDER BY id")
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -108,7 +108,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||
var u models.User
|
||||
var ts int64
|
||||
var disabled *int64
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled); err != nil {
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -116,6 +116,10 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||
u.DisabledAt = unixPtr(disabled)
|
||||
users = append(users, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, users)
|
||||
}
|
||||
}
|
||||
@@ -234,6 +238,11 @@ func handleDeleteUser(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// maxAPIKeyExpiryDays bounds expires_in_days: generous enough for any real
|
||||
// rotation policy, tight enough to reject a typo (a year in hours, say) that
|
||||
// would otherwise mint a key that outlives the server by decades.
|
||||
const maxAPIKeyExpiryDays = 3650 // ~10 years
|
||||
|
||||
func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
@@ -247,6 +256,11 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
// ExpiresInDays is optional and, left zero, means the key never
|
||||
// expires — the only behavior any key had before this field
|
||||
// existed, so an existing integration that does not send it is
|
||||
// unaffected.
|
||||
ExpiresInDays int64 `json:"expires_in_days,omitempty"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
@@ -256,6 +270,10 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
if req.ExpiresInDays < 0 || req.ExpiresInDays > maxAPIKeyExpiryDays {
|
||||
respond(w, http.StatusBadRequest, errResp("expires_in_days must be 0 (never expires) or up to "+strconv.Itoa(maxAPIKeyExpiryDays)))
|
||||
return
|
||||
}
|
||||
|
||||
var exists int
|
||||
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
|
||||
@@ -268,18 +286,77 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
var expiresAt *int64
|
||||
var expiresAtTime *time.Time
|
||||
if req.ExpiresInDays > 0 {
|
||||
t := time.Now().AddDate(0, 0, int(req.ExpiresInDays)).UTC()
|
||||
u := t.Unix()
|
||||
expiresAt = &u
|
||||
expiresAtTime = &t
|
||||
}
|
||||
var keyID int64
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id",
|
||||
userID, hash, req.Name).Scan(&keyID); err != nil {
|
||||
"INSERT INTO api_keys (user_id, key_hash, name, expires_at) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
userID, hash, req.Name, expiresAt).Scan(&keyID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()}
|
||||
key := models.APIKey{
|
||||
ID: keyID, UserID: userID, Name: req.Name, Key: raw,
|
||||
CreatedAt: time.Now().UTC(), ExpiresAt: expiresAtTime,
|
||||
}
|
||||
respond(w, http.StatusCreated, key)
|
||||
}
|
||||
}
|
||||
|
||||
// handleListAPIKeys lists a user's own API keys: never the raw key itself
|
||||
// (only ever returned once, at creation), just enough to tell them apart,
|
||||
// see which are stale (last_used_at) and which are about to stop working
|
||||
// (expires_at) — the data handleCreateAPIKey and apiKeyUser's last-use stamp
|
||||
// already produce, with no endpoint to read it back until now.
|
||||
func handleListAPIKeys(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||
return
|
||||
}
|
||||
if !requireSelfOrAdmin(w, r, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(r.Context(),
|
||||
`SELECT id, name, created_at, last_used_at, expires_at
|
||||
FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
keys := []models.APIKey{}
|
||||
for rows.Next() {
|
||||
var k models.APIKey
|
||||
var created int64
|
||||
var lastUsed, expires *int64
|
||||
if err := rows.Scan(&k.ID, &k.Name, &created, &lastUsed, &expires); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
k.UserID = userID
|
||||
k.CreatedAt = time.Unix(created, 0).UTC()
|
||||
k.LastUsedAt = unixPtr(lastUsed)
|
||||
k.ExpiresAt = unixPtr(expires)
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, keys)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
@@ -329,8 +406,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
||||
var ts int64
|
||||
var disabled *int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users WHERE id = $1", id).
|
||||
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled)
|
||||
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
|
||||
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled)
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
@@ -361,6 +438,18 @@ func handleSetAdmin(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
if !*req.IsAdmin {
|
||||
var managed bool
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin AND admin_source = 'oidc')",
|
||||
id).Scan(&managed); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if managed {
|
||||
respond(w, http.StatusConflict, errResp("administrator access is managed by single sign-on; change the user's groups in the identity provider"))
|
||||
return
|
||||
}
|
||||
|
||||
caller, _ := userFromContext(r.Context())
|
||||
if caller.ID == id {
|
||||
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -59,8 +63,71 @@ type Config struct {
|
||||
// NotifyRepeat is how long an incident may sit unacknowledged before it is
|
||||
// notified again. Zero disables reminders.
|
||||
NotifyRepeat time.Duration
|
||||
|
||||
// DisablePasswordLogin refuses signing in, or signing up, with a password.
|
||||
// It is how an install moves to SSO only, and turning it back off is the way
|
||||
// in when the identity provider is down. Stated negatively so that the zero
|
||||
// Config, which is what a test or a new caller builds, keeps passwords working.
|
||||
DisablePasswordLogin bool
|
||||
|
||||
// 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
|
||||
// who may sign in, which teams they belong to, and whether they administer the
|
||||
// install, in the manner of Grafana's org and role mapping.
|
||||
type OIDC struct {
|
||||
// Issuer is the provider's issuer URL. Discovery is fetched from
|
||||
// <Issuer>/.well-known/openid-configuration. For Authentik this is the
|
||||
// application's issuer, e.g. https://auth.example.com/application/o/terdut/.
|
||||
// Empty turns single sign-on off.
|
||||
Issuer string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
|
||||
// Name is what the sign-in button calls the provider.
|
||||
Name string
|
||||
|
||||
// Scopes to request. The groups claim normally needs "profile" on Authentik.
|
||||
Scopes []string
|
||||
|
||||
// UsernameClaim, EmailClaim and GroupsClaim name the ID token claims read.
|
||||
UsernameClaim string
|
||||
EmailClaim string
|
||||
GroupsClaim string
|
||||
|
||||
// TrustEmail links a sign-in to an existing local user by email even when the
|
||||
// provider does not vouch that the address is verified. Authentik reports
|
||||
// email_verified false unless told otherwise, and an install that runs its
|
||||
// own provider has already decided that its addresses can be trusted.
|
||||
TrustEmail bool
|
||||
|
||||
// AllowedGroups gates sign-in: somebody in none of them is refused, however
|
||||
// well the provider authenticated them. Empty admits everybody the provider
|
||||
// authenticates, and access control is left to the provider.
|
||||
AllowedGroups []string
|
||||
|
||||
// AdminGroup grants the system administrator flag while the user is in it.
|
||||
AdminGroup string
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Enabled reports whether single sign-on is configured.
|
||||
func (o OIDC) Enabled() bool { return o.Issuer != "" }
|
||||
|
||||
func Load() Config {
|
||||
addr := os.Getenv("TERDUT_ADDR")
|
||||
if addr == "" {
|
||||
@@ -89,9 +156,98 @@ func Load() Config {
|
||||
NtfyFallbackTopic: os.Getenv("TERDUT_NTFY_FALLBACK_TOPIC"),
|
||||
PublicURL: os.Getenv("TERDUT_PUBLIC_URL"),
|
||||
NotifyRepeat: duration("TERDUT_NOTIFY_REPEAT", 15*time.Minute),
|
||||
|
||||
DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true),
|
||||
OIDC: loadOIDC(),
|
||||
|
||||
OperatorMode: boolean("TERDUT_OPERATOR_MODE", false),
|
||||
}
|
||||
}
|
||||
|
||||
func loadOIDC() OIDC {
|
||||
o := OIDC{
|
||||
Issuer: strings.TrimSpace(os.Getenv("TERDUT_OIDC_ISSUER")),
|
||||
ClientID: os.Getenv("TERDUT_OIDC_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("TERDUT_OIDC_CLIENT_SECRET"),
|
||||
Name: str("TERDUT_OIDC_NAME", "SSO"),
|
||||
Scopes: list("TERDUT_OIDC_SCOPES", "openid profile email"),
|
||||
UsernameClaim: str("TERDUT_OIDC_USERNAME_CLAIM", "preferred_username"),
|
||||
EmailClaim: str("TERDUT_OIDC_EMAIL_CLAIM", "email"),
|
||||
GroupsClaim: str("TERDUT_OIDC_GROUPS_CLAIM", "groups"),
|
||||
TrustEmail: boolean("TERDUT_OIDC_TRUST_EMAIL", false),
|
||||
AllowedGroups: list("TERDUT_OIDC_ALLOWED_GROUPS", ""),
|
||||
AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"),
|
||||
SessionMaxAge: duration("TERDUT_OIDC_SESSION_MAX_AGE", 12*time.Hour),
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// Validate reports a configuration the server should refuse to start with.
|
||||
// Single sign-on is the only part that can be inconsistent: a half-configured
|
||||
// provider would come up and then fail every login, which is harder to notice
|
||||
// than not starting.
|
||||
func (c Config) Validate() error {
|
||||
o := c.OIDC
|
||||
if !o.Enabled() {
|
||||
if c.DisablePasswordLogin {
|
||||
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
|
||||
}
|
||||
if o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
|
||||
return errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if u, err := url.Parse(o.Issuer); err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("TERDUT_OIDC_ISSUER %q is not a URL", o.Issuer)
|
||||
}
|
||||
if o.ClientID == "" || o.ClientSecret == "" {
|
||||
return errors.New("TERDUT_OIDC_CLIENT_ID and TERDUT_OIDC_CLIENT_SECRET are required with TERDUT_OIDC_ISSUER")
|
||||
}
|
||||
if c.PublicURL == "" {
|
||||
return errors.New("TERDUT_PUBLIC_URL is required with TERDUT_OIDC_ISSUER: it is the base of the redirect URI")
|
||||
}
|
||||
if o.SessionMaxAge <= 0 {
|
||||
return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive")
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
func str(env, def string) string {
|
||||
if s := strings.TrimSpace(os.Getenv(env)); s != "" {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// list reads a comma- or space-separated env var.
|
||||
func list(env, def string) []string {
|
||||
s := os.Getenv(env)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
s = def
|
||||
}
|
||||
return strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' })
|
||||
}
|
||||
|
||||
// boolean reads a true/false env var. An unrecognised value takes the default,
|
||||
// so the two flags read this way (password login on, trusting email off) both
|
||||
// fail towards the cautious setting.
|
||||
func boolean(env string, def bool) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(env))) {
|
||||
case "true", "1", "yes":
|
||||
return true
|
||||
case "false", "0", "no":
|
||||
return false
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// duration reads a time.ParseDuration-formatted env var. An unset or
|
||||
// unparseable value falls back to def rather than failing startup: a typo in one
|
||||
// tuning knob should not take the server down.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
base := func() map[string]string {
|
||||
return map[string]string{
|
||||
"TERDUT_PUBLIC_URL": "https://terdut.example.com",
|
||||
"TERDUT_OIDC_ISSUER": "https://auth.example.com/application/o/terdut/",
|
||||
"TERDUT_OIDC_CLIENT_ID": "id",
|
||||
"TERDUT_OIDC_CLIENT_SECRET": "secret",
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
env func(map[string]string)
|
||||
wantErr string // substring; empty means valid
|
||||
}{
|
||||
{"off by default", func(m map[string]string) { clear(m) }, ""},
|
||||
{"minimal sso", func(m map[string]string) {}, ""},
|
||||
{"groups without issuer", func(m map[string]string) {
|
||||
clear(m)
|
||||
m["TERDUT_OIDC_ADMIN_GROUP"] = "admins"
|
||||
}, "ISSUER is not"},
|
||||
{"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"},
|
||||
{"password off without sso", func(m map[string]string) {
|
||||
clear(m)
|
||||
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
||||
}, "no way to sign in"},
|
||||
{"password off with sso but no grants", func(m map[string]string) {
|
||||
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
||||
}, "nobody able"},
|
||||
{"password off with admin group", func(m map[string]string) {
|
||||
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
||||
m["TERDUT_OIDC_ADMIN_GROUP"] = "admins"
|
||||
}, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := base()
|
||||
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",
|
||||
} {
|
||||
t.Setenv(k, env[k])
|
||||
}
|
||||
err := Load().Validate()
|
||||
switch {
|
||||
case tt.wantErr == "" && err != nil:
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
case tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)):
|
||||
t.Errorf("error %v, want one containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_OIDCDefaults(t *testing.T) {
|
||||
t.Setenv("TERDUT_OIDC_ISSUER", "https://auth.example.com/")
|
||||
o := Load().OIDC
|
||||
if o.UsernameClaim != "preferred_username" || o.EmailClaim != "email" || o.GroupsClaim != "groups" {
|
||||
t.Errorf("claim defaults: %+v", o)
|
||||
}
|
||||
if strings.Join(o.Scopes, " ") != "openid profile email" {
|
||||
t.Errorf("scopes: %v", o.Scopes)
|
||||
}
|
||||
if o.SessionMaxAge.Hours() != 12 {
|
||||
t.Errorf("max age: %v", o.SessionMaxAge)
|
||||
}
|
||||
if Load().DisablePasswordLogin {
|
||||
t.Error("password login should be on by default")
|
||||
}
|
||||
}
|
||||
+53
-4
@@ -1,10 +1,12 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,6 +17,19 @@ import (
|
||||
//go:embed migrations
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// pingAttempts and pingRetryDelay bound the retry on the first connection.
|
||||
// This pod's own IP can reach the Postgres pod's node before that node's
|
||||
// NetworkPolicy enforcement (kube-router, reacting to the pod's creation
|
||||
// event) has added it to the allowed-source set, which fails the ping with
|
||||
// "connection refused" rather than a timeout. That race resolves within
|
||||
// several seconds in practice; five attempts two seconds apart give it
|
||||
// comfortable room without turning a genuinely absent database into a long
|
||||
// hang.
|
||||
const (
|
||||
pingAttempts = 5
|
||||
pingRetryDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
// Open connects to Postgres. dsn is a libpq connection string or URL, e.g.
|
||||
// postgres://terdut:secret@localhost:5432/terdut?sslmode=disable.
|
||||
//
|
||||
@@ -33,13 +48,31 @@ func Open(dsn string) (*sql.DB, error) {
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
|
||||
for attempt := 1; ; attempt++ {
|
||||
err = db.Ping()
|
||||
if err == nil {
|
||||
return db, nil
|
||||
}
|
||||
if attempt == pingAttempts {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
log.Printf("open db: ping attempt %d/%d failed, retrying in %s: %v", attempt, pingAttempts, pingRetryDelay, err)
|
||||
time.Sleep(pingRetryDelay)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// migrationLockKey is the Postgres advisory lock Migrate holds for its whole
|
||||
// run. Two replicas starting at once would otherwise race the check-then-apply
|
||||
// loop below against schema_migrations: the loser could crash on a
|
||||
// duplicate-key insert, or contend with the winner's uncommitted DDL. Blocking
|
||||
// (pg_advisory_lock, not pg_try_advisory_lock as the archiver and notifier
|
||||
// use): on boot there is no later tick to defer to, so the right behaviour is
|
||||
// to wait for the other replica to finish migrating, not to skip ahead and
|
||||
// start serving against an unmigrated schema.
|
||||
const migrationLockKey int64 = 7265_0003
|
||||
|
||||
// Migrate applies every embedded migration that has not been applied yet, in
|
||||
// filename order, recording each in schema_migrations.
|
||||
//
|
||||
@@ -47,6 +80,22 @@ func Open(dsn string) (*sql.DB, error) {
|
||||
// migration that failed half way used to leave the schema in whatever state it
|
||||
// had reached. Postgres has transactional DDL, so the rollback is real.
|
||||
func Migrate(db *sql.DB) error {
|
||||
ctx := context.Background()
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: acquire connection: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
||||
return fmt.Errorf("migrate: acquire advisory lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil {
|
||||
log.Printf("migrate: release advisory lock: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// TERDUT_TEST_DSN must point at a database the test role may create schemas
|
||||
// in; see internal/api/testdb_test.go for the fuller rationale this mirrors.
|
||||
// An unset DSN fails rather than skips, deliberately.
|
||||
const testDSNEnv = "TERDUT_TEST_DSN"
|
||||
|
||||
// TestMigrate_ConcurrentCallersDoNotRace reproduces two replicas starting at
|
||||
// once against a brand-new, unmigrated schema: both call db.Migrate at the
|
||||
// same time. Before migrationLockKey, the loser could crash on a
|
||||
// duplicate-key insert into schema_migrations, or contend with the winner's
|
||||
// uncommitted DDL; with the advisory lock, one blocks until the other
|
||||
// finishes and both return cleanly.
|
||||
func TestMigrate_ConcurrentCallersDoNotRace(t *testing.T) {
|
||||
dsn := os.Getenv(testDSNEnv)
|
||||
if dsn == "" {
|
||||
t.Fatalf("%s is not set: these tests need Postgres.\n"+
|
||||
"Run `make test-db` for a local one, then\n"+
|
||||
" export %s=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable",
|
||||
testDSNEnv, testDSNEnv)
|
||||
}
|
||||
|
||||
schema := fmt.Sprintf("migrate_race_%d", os.Getpid())
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect to %s: %v", testDSNEnv, err)
|
||||
}
|
||||
defer admin.Close()
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
t.Fatalf("create schema %s: %v", schema, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanup, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cleanup.Close()
|
||||
if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
|
||||
t.Logf("drop schema %s: %v", schema, err)
|
||||
}
|
||||
})
|
||||
|
||||
scoped := withSearchPath(dsn, schema)
|
||||
|
||||
const callers = 2
|
||||
errs := make([]error, callers)
|
||||
var wg sync.WaitGroup
|
||||
for i := range callers {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
database, err := db.Open(scoped)
|
||||
if err != nil {
|
||||
errs[i] = fmt.Errorf("open: %w", err)
|
||||
return
|
||||
}
|
||||
defer database.Close()
|
||||
errs[i] = db.Migrate(database)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("Migrate #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir("migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("read migrations dir: %v", err)
|
||||
}
|
||||
var want int
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||
want++
|
||||
}
|
||||
}
|
||||
|
||||
check, err := sql.Open("pgx", scoped)
|
||||
if err != nil {
|
||||
t.Fatalf("connect for verification: %v", err)
|
||||
}
|
||||
defer check.Close()
|
||||
|
||||
var got int
|
||||
if err := check.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&got); err != nil {
|
||||
t.Fatalf("count schema_migrations: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("schema_migrations has %d row(s) after two concurrent Migrate calls, want %d (one per migration file, no duplicates)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// withSearchPath pins a DSN to one schema. Copied from
|
||||
// internal/api/testdb_test.go rather than shared: that helper lives in the
|
||||
// api_test package, a separate compiled package this one cannot import.
|
||||
func withSearchPath(dsn, schema string) string {
|
||||
opt := "-csearch_path=" + schema
|
||||
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
u, err := url.Parse(dsn)
|
||||
if err == nil {
|
||||
q := u.Query()
|
||||
q.Set("options", opt)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
}
|
||||
return dsn + " options='" + opt + "'"
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Self-service sign-up, and the invite links that make it useful.
|
||||
--
|
||||
-- Until now the only way to get an account was for somebody who already had one
|
||||
-- to create it, and the login page told people to "ask an admin". That is a
|
||||
-- workable arrangement for one operator and an impossible one for a team.
|
||||
--
|
||||
-- An invite is a link, not an email: this server has no SMTP and adding it to
|
||||
-- send one message would be a new subsystem to run, secure and monitor. The
|
||||
-- person inviting sends the link however they already talk to the person they
|
||||
-- are inviting.
|
||||
CREATE TABLE invites (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
|
||||
-- SHA-256 of the raw token, like api_keys, the integration keys and the
|
||||
-- acknowledgement tokens. A leaked database hands nobody an account.
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
|
||||
-- Which team the invitee lands in, and as what. An invite always names a
|
||||
-- team: an account in no team sees an empty queue and can be paged by
|
||||
-- nobody, which is not a state to invite somebody into.
|
||||
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
|
||||
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||
|
||||
-- Invites expire. A link that works forever is a credential nobody
|
||||
-- remembers issuing, sitting in a chat log.
|
||||
expires_at BIGINT NOT NULL,
|
||||
|
||||
-- Single-use by default: max_uses 1. A team onboarding six people at once
|
||||
-- can raise it rather than minting six links.
|
||||
max_uses BIGINT NOT NULL DEFAULT 1 CHECK (max_uses > 0 AND max_uses <= 100),
|
||||
uses BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Revoked by hand, separately from expiry, so "this link is no longer
|
||||
-- wanted" and "this link timed out" stay distinguishable in the listing.
|
||||
revoked_at BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX invites_team_idx ON invites(team_id);
|
||||
|
||||
-- Who redeemed which invite. Kept after the invite is gone — the answer to "how
|
||||
-- did this account get here" should outlive the link that made it.
|
||||
ALTER TABLE users ADD COLUMN invited_via BIGINT REFERENCES invites(id) ON DELETE SET NULL;
|
||||
|
||||
-- Where a person is in the first-run checklist, so it can be resumed and
|
||||
-- dismissed rather than nagging forever. One row per user, created on demand.
|
||||
ALTER TABLE users ADD COLUMN onboarding_dismissed_at BIGINT;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Similar incidents: a signature per incident, so "has this happened before"
|
||||
-- is an indexed equality instead of a search.
|
||||
--
|
||||
-- The signature is the alert name plus the group labels that identify WHAT is
|
||||
-- broken, minus the ones that only say WHERE it happened to run this time
|
||||
-- (instance, pod, ...). Two incidents with the same signature in the same team
|
||||
-- are the same problem for a responder's purposes.
|
||||
--
|
||||
-- Computed in Go for new incidents (incidentSignature in incident_store.go).
|
||||
-- The backfill below MUST produce the same string; keep the volatile list in
|
||||
-- both places in step.
|
||||
ALTER TABLE incidents ADD COLUMN signature TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE incidents SET signature =
|
||||
COALESCE(NULLIF(group_labels->>'alertname', ''), title) || '|' ||
|
||||
COALESCE((
|
||||
SELECT string_agg(e.k || '=' || e.v, ',' ORDER BY e.k)
|
||||
FROM jsonb_each_text(incidents.group_labels) AS e(k, v)
|
||||
WHERE e.k <> 'alertname'
|
||||
AND e.k NOT IN ('instance', 'pod', 'pod_name', 'pod_ip', 'container', 'container_name', 'endpoint')
|
||||
), '');
|
||||
|
||||
CREATE INDEX incidents_signature_idx ON incidents(team_id, signature, triggered_at DESC);
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Dead man's switches become rows of their own.
|
||||
--
|
||||
-- 004 kept a team's switches in one string with one timeout and one severity,
|
||||
-- which was enough to configure them and not enough to show them: there was no
|
||||
-- thing to list, nothing to hang a status on, and every switch in a team had to
|
||||
-- share a deadline. A row per switch gives each its own name, matcher, timeout
|
||||
-- and severity, and gives the Team → Switches page something to be a list of.
|
||||
--
|
||||
-- The matcher keeps the syntax the string used, one matcher per row:
|
||||
-- `alertname=Watchdog,cluster=prod`. The unit of monitoring is still the
|
||||
-- fingerprint, so a matcher that many clusters satisfy is still one switch row
|
||||
-- watching several independent heartbeats.
|
||||
CREATE TABLE deadman_switches (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
|
||||
-- What the owner calls it. Defaults to the matcher when they do not say.
|
||||
name TEXT NOT NULL,
|
||||
|
||||
-- "," separates the label conditions, "=" is exact equality, and alertname is
|
||||
-- mandatory: it is what keeps the sweeper's candidate query on an index.
|
||||
matcher TEXT NOT NULL,
|
||||
|
||||
-- Seconds of silence before the switch is declared dead. Never zero: a switch
|
||||
-- that cannot fire is deleted, not disabled.
|
||||
timeout_seconds BIGINT NOT NULL CHECK (timeout_seconds > 0),
|
||||
|
||||
-- The severity its incidents open at. See 004 for why they carry their own.
|
||||
severity TEXT NOT NULL DEFAULT 'critical',
|
||||
|
||||
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||
);
|
||||
|
||||
CREATE INDEX deadman_switches_team_idx ON deadman_switches (team_id);
|
||||
|
||||
-- Carry every team's configuration over, one row per matcher. A team whose
|
||||
-- timeout was zero had switches turned off, which is now "no rows".
|
||||
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
|
||||
SELECT c.team_id, btrim(m), btrim(m), c.timeout_seconds, c.severity
|
||||
FROM deadman_configs c,
|
||||
LATERAL regexp_split_to_table(c.matchers, ';') AS m
|
||||
WHERE c.timeout_seconds > 0
|
||||
AND btrim(m) <> ''
|
||||
ORDER BY c.team_id;
|
||||
|
||||
-- The server seeds environment defaults into teams once, and remembers that it
|
||||
-- did. An install that had a row per team was already seeded; without this
|
||||
-- marker the first start after upgrading would seed teams that had switched
|
||||
-- theirs off.
|
||||
INSERT INTO settings (key, value)
|
||||
SELECT 'deadman_seeded', '1'
|
||||
WHERE EXISTS (SELECT 1 FROM deadman_configs);
|
||||
|
||||
DROP TABLE deadman_configs;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Which alert source an alert last arrived on.
|
||||
--
|
||||
-- Team -> Sources shows when each source last posted, which integrations
|
||||
-- already knew (last_used_at, stamped on every webhook). What it could not say
|
||||
-- was what a source delivered: an alert never recorded the key it came in on, so
|
||||
-- "prod alertmanager" and "staging alertmanager" were indistinguishable once
|
||||
-- inside. This column is that link, and lets the page show each source's last
|
||||
-- alert and how many alerts it has kept fresh over the past day.
|
||||
--
|
||||
-- Last sender wins: every accepted payload restamps it, the way it advances
|
||||
-- received_at. Two sources posting the same fingerprint into one team is
|
||||
-- already one alert, and it is attributed to whichever spoke last.
|
||||
--
|
||||
-- Nullable, and not backfilled. Alerts that arrived before this migration have
|
||||
-- no source, and NULL says so honestly rather than guessing. It heals by itself:
|
||||
-- Alertmanager re-sends every alert each repeat_interval, and each re-send is an
|
||||
-- accepted payload. Deleting a source keeps its alerts, unattributed.
|
||||
ALTER TABLE alerts ADD COLUMN integration_id BIGINT REFERENCES integrations(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX alerts_integration_idx ON alerts (integration_id, received_at)
|
||||
WHERE integration_id IS NOT NULL;
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Single sign-on through an OpenID Connect provider (Authentik, and anything
|
||||
-- else that speaks OIDC).
|
||||
--
|
||||
-- Four things change, and none of them touches a password user: every new column
|
||||
-- has a default that says "this is how it has always worked".
|
||||
--
|
||||
-- 1. user_identities says which provider account a user is. It is keyed on
|
||||
-- (issuer, subject), never on email or username: those are mutable at the
|
||||
-- provider, and a recycled address must not inherit somebody's account. A
|
||||
-- user can have several identities (a second provider later), and none at all
|
||||
-- (a local, password-only user), which is why this is a table and not two
|
||||
-- columns on users.
|
||||
--
|
||||
-- 2. team_members.source and users.admin_source record who granted a role. 'oidc'
|
||||
-- rows are owned by the group sync: it adds them when a group grants access
|
||||
-- and removes them when it stops, and nothing else may edit them. 'manual' rows
|
||||
-- are everything that existed before this migration, and are never touched by
|
||||
-- the sync. Without the marker the sync could not tell a membership it created
|
||||
-- from one an owner added by hand, and would have to either leave stale access
|
||||
-- behind or delete people it had no business deleting.
|
||||
--
|
||||
-- 3. sessions.max_expires_at is a hard ceiling on a session's life. Ordinary
|
||||
-- sessions slide for as long as they are used; a session made by an SSO login
|
||||
-- must not, because the login is the only moment the groups are re-read.
|
||||
-- Capping the session is what makes "removed from the group in the provider"
|
||||
-- take effect within a bounded time. NULL means no ceiling.
|
||||
--
|
||||
-- 4. oidc_logins holds a login that has been started and not yet finished: the
|
||||
-- state, nonce and PKCE verifier the callback must see again. A row rather
|
||||
-- than a signed cookie, so it survives a restart and needs no signing key.
|
||||
-- Only the hash of the state is stored, like every other token here; the
|
||||
-- nonce and verifier are useless without the state that names the row.
|
||||
CREATE TABLE user_identities (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
issuer TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||
last_login_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||
UNIQUE (issuer, subject)
|
||||
);
|
||||
|
||||
CREATE INDEX user_identities_user_idx ON user_identities (user_id);
|
||||
|
||||
ALTER TABLE team_members
|
||||
ADD COLUMN source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc'));
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN admin_source TEXT NOT NULL DEFAULT 'manual' CHECK (admin_source IN ('manual', 'oidc'));
|
||||
|
||||
ALTER TABLE sessions ADD COLUMN max_expires_at BIGINT;
|
||||
|
||||
CREATE TABLE oidc_logins (
|
||||
state_hash TEXT PRIMARY KEY,
|
||||
nonce TEXT NOT NULL,
|
||||
pkce_verifier TEXT NOT NULL,
|
||||
expires_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX oidc_logins_expires_idx ON oidc_logins (expires_at);
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Signing in from a terminal, for clients that cannot open a browser on the
|
||||
-- machine they run on (the TUI over SSH is the reason).
|
||||
--
|
||||
-- The flow is the OAuth device authorization grant, run by terdut itself rather
|
||||
-- than the identity provider, so the terminal never talks to the provider and
|
||||
-- the server issues its ordinary session at the end:
|
||||
--
|
||||
-- 1. The terminal asks for a login and gets two secrets: a device code it
|
||||
-- keeps and polls with, and a short user code it shows the person.
|
||||
-- 2. The person opens the verification URL on any device, signs in by whatever
|
||||
-- means the server offers, sees the user code, and approves it.
|
||||
-- 3. The terminal's next poll finds the row approved and is given a session.
|
||||
--
|
||||
-- Only the hash of the device code is stored, like every other token here: the
|
||||
-- device code is what earns a session, so a database read must not yield one.
|
||||
-- The user code is shown on screens and typed by people, so it is stored as is;
|
||||
-- on its own it can only be approved, never redeemed.
|
||||
--
|
||||
-- user_id is the person who approved. It is empty until then, and the session
|
||||
-- is minted at redemption, not at approval: an approval nobody collects must not
|
||||
-- leave a live session lying about.
|
||||
--
|
||||
-- last_polled_at lets the server refuse a client that polls faster than the
|
||||
-- interval it was told.
|
||||
CREATE TABLE device_logins (
|
||||
device_hash TEXT PRIMARY KEY,
|
||||
user_code TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')),
|
||||
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at BIGINT NOT NULL,
|
||||
last_polled_at BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX device_logins_expires_idx ON device_logins (expires_at);
|
||||
|
||||
-- Where to send the browser once a single sign-on login completes. A person who
|
||||
-- opens /device?code=... without a session has to sign in first and then come
|
||||
-- back to it, and the same is true of any other deep link. Validated when it is
|
||||
-- stored: only a path on this server is ever kept.
|
||||
ALTER TABLE oidc_logins ADD COLUMN next TEXT NOT NULL DEFAULT '/';
|
||||
@@ -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);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Service-account actors on incident mutations (terdut-server#25). A
|
||||
-- team-scoped service account acknowledging/resolving/snoozing/noting an
|
||||
-- incident is not a users row, so it cannot be written into
|
||||
-- acknowledged_by/incident_events.user_id — doing so either violates the
|
||||
-- users(id) FK (new rows) or, for incident_events.user_id, silently matches
|
||||
-- zero rows on delete. These columns are the service-account-shaped parallel
|
||||
-- to the existing human ones: nullable, mutually exclusive with their human
|
||||
-- counterpart, ON DELETE SET NULL so a deleted service account doesn't take
|
||||
-- the incident history with it.
|
||||
ALTER TABLE incidents
|
||||
ADD COLUMN acknowledged_by_service_account_id BIGINT
|
||||
REFERENCES service_accounts(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE incident_events
|
||||
ADD COLUMN service_account_id BIGINT
|
||||
REFERENCES service_accounts(id) ON DELETE SET NULL;
|
||||
|
||||
-- At most one actor kind per row: both NULL ("the server acted") is valid,
|
||||
-- exactly one set is valid, both set is a bug this constraint refuses to
|
||||
-- store rather than silently accepting.
|
||||
ALTER TABLE incidents
|
||||
ADD CONSTRAINT incidents_ack_actor_xor_chk CHECK (
|
||||
acknowledged_by IS NULL OR acknowledged_by_service_account_id IS NULL
|
||||
);
|
||||
|
||||
ALTER TABLE incident_events
|
||||
ADD CONSTRAINT incident_events_actor_xor_chk CHECK (
|
||||
user_id IS NULL OR service_account_id IS NULL
|
||||
);
|
||||
|
||||
CREATE INDEX incidents_acknowledged_by_service_account_id_idx
|
||||
ON incidents(acknowledged_by_service_account_id);
|
||||
CREATE INDEX incident_events_service_account_id_idx
|
||||
ON incident_events(service_account_id);
|
||||
|
||||
-- assigned_to_service_account_id is deliberately not added here: it would sit
|
||||
-- unpopulated until handleIncidentAssign itself tracks an actor, which is a
|
||||
-- separate, pre-existing gap (it records the assignee today, never the
|
||||
-- actor, for humans either) tracked in its own follow-up issue.
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Backs the rate limiters (failed logins, sign-ups, OIDC/device start) with
|
||||
-- Postgres instead of an in-memory map, now that the server runs more than
|
||||
-- one replica in production (v0.37.0): a counter that only ever sees its own
|
||||
-- pod's traffic quietly let every one of these limits through multiplied by
|
||||
-- the replica count.
|
||||
--
|
||||
-- window_start is the start of the current fixed window for key, in the same
|
||||
-- "unix seconds" shape every other timestamp in this schema uses. The window
|
||||
-- resets rather than slides, matching the in-memory limiter it replaces:
|
||||
-- once a key's window is older than the limiter's window length, the next
|
||||
-- failure starts a fresh one instead of extending the stale one.
|
||||
CREATE TABLE rate_limit_counters (
|
||||
key TEXT PRIMARY KEY,
|
||||
window_start BIGINT NOT NULL,
|
||||
count INT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Optional expiry on a user's own API keys. NULL (the existing default for
|
||||
-- every row already in this table) means "never expires" -- the same
|
||||
-- behavior these keys have always had, so no existing integration breaks.
|
||||
-- Service account keys are deliberately NOT touched: they are a different
|
||||
-- table, managed by automation, and already distinguished by their own
|
||||
-- "tdsa_" prefix.
|
||||
ALTER TABLE api_keys ADD COLUMN expires_at BIGINT;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Who performed an assignment (terdut-server#35). On an 'assigned' event
|
||||
-- incident_events.user_id is the assignee, so the actor needs columns of its
|
||||
-- own. Only populated for 'assigned' events; every other event type keeps
|
||||
-- using user_id/service_account_id for the actor. Older 'assigned' rows stay
|
||||
-- NULL (the actor was never recorded). Same shape as migration 015: nullable,
|
||||
-- mutually exclusive, ON DELETE SET NULL.
|
||||
--
|
||||
-- assigned_to_service_account_id is still deliberately not added: making
|
||||
-- service accounts assignable is a separate change (request body, assignee
|
||||
-- picker, notifier, filters).
|
||||
ALTER TABLE incident_events
|
||||
ADD COLUMN actor_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
ADD COLUMN actor_service_account_id BIGINT REFERENCES service_accounts(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE incident_events
|
||||
ADD CONSTRAINT incident_events_assign_actor_xor_chk CHECK (
|
||||
actor_user_id IS NULL OR actor_service_account_id IS NULL
|
||||
);
|
||||
|
||||
CREATE INDEX incident_events_actor_user_id_idx ON incident_events(actor_user_id);
|
||||
CREATE INDEX incident_events_actor_service_account_id_idx ON incident_events(actor_service_account_id);
|
||||
+48
-10
@@ -43,6 +43,13 @@ type Incident struct {
|
||||
AcknowledgedByUser *string `json:"acknowledged_by,omitempty"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||
|
||||
// AcknowledgedByServiceAccountID/Name are the service-account-shaped
|
||||
// parallel to AcknowledgedByID/User above: mutually exclusive with it,
|
||||
// populated when a service account (not a human) acknowledged this
|
||||
// incident. See migration 015 and terdut-server#25.
|
||||
AcknowledgedByServiceAccountID *int64 `json:"acknowledged_by_service_account_id,omitempty"`
|
||||
AcknowledgedByServiceAccountName *string `json:"acknowledged_by_service_account,omitempty"`
|
||||
|
||||
AssignedToID *int64 `json:"assigned_to_id,omitempty"`
|
||||
AssignedToUser *string `json:"assigned_to,omitempty"`
|
||||
|
||||
@@ -67,15 +74,46 @@ type Incident struct {
|
||||
// and is the only history this server keeps — alert rows are mutated in place.
|
||||
//
|
||||
// Type is one of: triggered, alert_added, alert_resolved, acknowledged,
|
||||
// unacknowledged, assigned, snoozed, unsnoozed, resolved, note. A nil UserID
|
||||
// means the server acted rather than a person.
|
||||
// unacknowledged, assigned, archived, unarchived, snoozed, unsnoozed,
|
||||
// resolved, note. UserID and
|
||||
// ServiceAccountID are mutually exclusive; both nil means the server acted
|
||||
// rather than any caller.
|
||||
type IncidentEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
IncidentID int64 `json:"incident_id"`
|
||||
Type string `json:"type"`
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
AlertID *int64 `json:"alert_id,omitempty"`
|
||||
Detail *string `json:"detail,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int64 `json:"id"`
|
||||
IncidentID int64 `json:"incident_id"`
|
||||
Type string `json:"type"`
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
|
||||
// ServiceAccountID/Name are the service-account-shaped parallel to
|
||||
// UserID/Username above: mutually exclusive with it, populated when a
|
||||
// service account (not a human, and not nil-meaning-the-server-acted)
|
||||
// performed this event. Named Name, not Username — a ServiceAccount has
|
||||
// a Name field, not a Username. See migration 015 and terdut-server#25.
|
||||
ServiceAccountID *int64 `json:"service_account_id,omitempty"`
|
||||
ServiceAccountName *string `json:"service_account_name,omitempty"`
|
||||
|
||||
// Actor* name who performed an 'assigned' event, whose UserID is the
|
||||
// assignee. Mutually exclusive; unset on every other event type and on
|
||||
// assignments made before migration 018. See terdut-server#35.
|
||||
ActorUserID *int64 `json:"actor_user_id,omitempty"`
|
||||
ActorUsername *string `json:"actor_username,omitempty"`
|
||||
ActorServiceAccountID *int64 `json:"actor_service_account_id,omitempty"`
|
||||
ActorServiceAccountName *string `json:"actor_service_account_name,omitempty"`
|
||||
|
||||
AlertID *int64 `json:"alert_id,omitempty"`
|
||||
Detail *string `json:"detail,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SimilarIncident is an earlier, resolved incident with the same signature as
|
||||
// the one being looked at. ResolutionNotes are the "what fixed it" notes;
|
||||
// NoteCount counts the plain working notes, which live on the timeline.
|
||||
type SimilarIncident struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
TriggeredAt time.Time `json:"triggered_at"`
|
||||
ResolvedAt time.Time `json:"resolved_at"`
|
||||
NoteCount int `json:"note_count"`
|
||||
ResolutionNotes []IncidentEvent `json:"resolution_notes"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,6 +13,10 @@ type Team struct {
|
||||
// listed for a particular person. Empty when nobody in particular is
|
||||
// asking, as in the admin listing.
|
||||
Role string `json:"role,omitempty"`
|
||||
|
||||
// Source says who granted Role, on the endpoint that lists one user's teams:
|
||||
// "manual", or "oidc" when the identity provider's groups did.
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// Team roles. An owner configures the team — its schedule, its integrations and
|
||||
@@ -29,6 +33,10 @@ type TeamMember struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
|
||||
// Source is who granted the membership: "manual", or "oidc" when the
|
||||
// identity provider's groups did and only they can change it.
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// Integration is how alerts get in, and the only thing that says which team an
|
||||
|
||||
+15
-1
@@ -23,6 +23,12 @@ type User struct {
|
||||
// is too old to have the field", and the web UI decides what to show from
|
||||
// it.
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
|
||||
// AdminSource is who granted the flag: "manual" or "oidc". An "oidc"
|
||||
// administrator follows the identity provider's groups, so the UI shows it as
|
||||
// managed there and the API refuses to revoke it by hand. Only set on the
|
||||
// user endpoints that show it.
|
||||
AdminSource string `json:"admin_source,omitempty"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
@@ -31,5 +37,13 @@ type APIKey struct {
|
||||
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
|
||||
|
||||
// ExpiresAt is nil for a key that never expires, which is every key
|
||||
// created before this field existed and still the default for a new one
|
||||
// unless its creator asks otherwise (see handleCreateAPIKey's
|
||||
// expires_in_days). AuthMiddleware stops accepting a key once this
|
||||
// passes; nothing deletes the row for it.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
|
||||
Key string `json:"key,omitempty"` // populated only on creation, never stored
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package oidc signs users in through an OpenID Connect provider and turns the
|
||||
// groups it reports into the access terdut grants.
|
||||
//
|
||||
// The package knows nothing about the database or HTTP handlers: Grants is a
|
||||
// pure function of configuration and groups, and Provider is the protocol. The
|
||||
// api package joins them to users, teams and sessions.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
)
|
||||
|
||||
// Role names match models.RoleOwner and RoleMember. They are restated here so
|
||||
// the package stays free of the models import; config.Validate has already
|
||||
// refused anything else.
|
||||
const (
|
||||
roleOwner = "owner"
|
||||
roleMember = "member"
|
||||
)
|
||||
|
||||
// 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.
|
||||
Admitted bool
|
||||
|
||||
// Admin is whether the user is in the admin group.
|
||||
Admin bool
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
var g Grants
|
||||
|
||||
g.Admitted = len(cfg.AllowedGroups) == 0
|
||||
for _, allowed := range cfg.AllowedGroups {
|
||||
if in[allowed] {
|
||||
g.Admitted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !g.Admitted {
|
||||
return g
|
||||
}
|
||||
|
||||
g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup]
|
||||
return g
|
||||
}
|
||||
|
||||
// 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 tg.OwnerGroup != "" && in[tg.OwnerGroup] && rank(roleOwner) > rank(role) {
|
||||
role = roleOwner
|
||||
}
|
||||
if role != "" {
|
||||
out[tg.TeamID] = role
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rank orders roles; an unknown or absent role ranks lowest.
|
||||
func rank(role string) int {
|
||||
switch role {
|
||||
case roleOwner:
|
||||
return 2
|
||||
case roleMember:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// HigherRole reports whether role a outranks role b.
|
||||
func HigherRole(a, b string) bool { return rank(a) > rank(b) }
|
||||
@@ -0,0 +1,140 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
)
|
||||
|
||||
func testCfg() config.OIDC {
|
||||
return config.OIDC{
|
||||
AllowedGroups: []string{"terdut-users"},
|
||||
AdminGroup: "terdut-admins",
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeGrants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
groups []string
|
||||
want Grants
|
||||
}{
|
||||
{
|
||||
name: "not in an allowed group is refused",
|
||||
groups: []string{"sre", "terdut-admins"},
|
||||
want: Grants{Admitted: false},
|
||||
},
|
||||
{
|
||||
name: "allowed but no grants",
|
||||
groups: []string{"terdut-users"},
|
||||
want: Grants{Admitted: true},
|
||||
},
|
||||
{
|
||||
name: "admin group grants admin",
|
||||
groups: []string{"terdut-users", "terdut-admins"},
|
||||
want: Grants{Admitted: true, Admin: true},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ComputeGrants(testCfg(), tt.groups)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("got %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeGrants_NoAllowedGroupsAdmitsEveryone(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
cfg.AllowedGroups = nil
|
||||
if g := ComputeGrants(cfg, nil); !g.Admitted {
|
||||
t.Error("with no allowed groups configured, everybody the provider authenticates is admitted")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
if got := stringList("solo"); !reflect.DeepEqual(got, []string{"solo"}) {
|
||||
t.Errorf("single string: %v", got)
|
||||
}
|
||||
if got := stringList(nil); got != nil {
|
||||
t.Errorf("nil: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
)
|
||||
|
||||
// CallbackPath is where the provider sends the browser back to. Register
|
||||
// <TERDUT_PUBLIC_URL>/api/oidc/callback as the redirect URI at the provider.
|
||||
const CallbackPath = "/api/oidc/callback"
|
||||
|
||||
// Identity is what the provider says about somebody who has just signed in.
|
||||
type Identity struct {
|
||||
Issuer string
|
||||
Subject string
|
||||
Username string
|
||||
Email string
|
||||
|
||||
// EmailVerified is the provider's own claim. Whether to believe it is
|
||||
// config.OIDC.TrustEmail's business, not this package's.
|
||||
EmailVerified bool
|
||||
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// Provider runs the authorization-code flow with PKCE against one issuer.
|
||||
type Provider struct {
|
||||
cfg config.OIDC
|
||||
redirectURL string
|
||||
http *http.Client
|
||||
|
||||
// Discovery is fetched on first use, not at startup. A provider that is
|
||||
// down when terdut starts must not stop terdut starting: password login is
|
||||
// the way in while it is down, and it can only be that if the server is up.
|
||||
mu sync.Mutex
|
||||
provider *gooidc.Provider
|
||||
}
|
||||
|
||||
// New returns a Provider for cfg. publicURL is the base of the redirect URI.
|
||||
func New(cfg config.OIDC, publicURL string) *Provider {
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
redirectURL: trimSlash(publicURL) + CallbackPath,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func trimSlash(s string) string {
|
||||
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Name is what the sign-in button calls the provider.
|
||||
func (p *Provider) Name() string { return p.cfg.Name }
|
||||
|
||||
// Config is the configuration this provider was built from.
|
||||
func (p *Provider) Config() config.OIDC { return p.cfg }
|
||||
|
||||
// discover returns the provider's metadata, fetching it if need be. A failure is
|
||||
// not cached, so the next login tries again.
|
||||
func (p *Provider) discover(ctx context.Context) (*gooidc.Provider, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.provider != nil {
|
||||
return p.provider, nil
|
||||
}
|
||||
ctx = gooidc.ClientContext(ctx, p.http)
|
||||
prov, err := gooidc.NewProvider(ctx, p.cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc discovery: %w", err)
|
||||
}
|
||||
p.provider = prov
|
||||
return prov, nil
|
||||
}
|
||||
|
||||
func (p *Provider) oauth(prov *gooidc.Provider) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: p.cfg.ClientID,
|
||||
ClientSecret: p.cfg.ClientSecret,
|
||||
Endpoint: prov.Endpoint(),
|
||||
RedirectURL: p.redirectURL,
|
||||
Scopes: p.cfg.Scopes,
|
||||
}
|
||||
}
|
||||
|
||||
// NewVerifier returns a fresh PKCE code verifier.
|
||||
func NewVerifier() string { return oauth2.GenerateVerifier() }
|
||||
|
||||
// AuthURL is where to send the browser to sign in.
|
||||
func (p *Provider) AuthURL(ctx context.Context, state, nonce, verifier string) (string, error) {
|
||||
prov, err := p.discover(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p.oauth(prov).AuthCodeURL(state,
|
||||
oauth2.S256ChallengeOption(verifier),
|
||||
gooidc.Nonce(nonce),
|
||||
), nil
|
||||
}
|
||||
|
||||
// Exchange trades the authorization code for tokens, verifies the ID token
|
||||
// (signature, issuer, audience, expiry and nonce) and returns who it names.
|
||||
func (p *Provider) Exchange(ctx context.Context, code, verifier, nonce string) (*Identity, error) {
|
||||
prov, err := p.discover(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx = gooidc.ClientContext(ctx, p.http)
|
||||
|
||||
tok, err := p.oauth(prov).Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc token exchange: %w", err)
|
||||
}
|
||||
raw, _ := tok.Extra("id_token").(string)
|
||||
if raw == "" {
|
||||
return nil, errors.New("oidc: token response has no id_token")
|
||||
}
|
||||
idToken, err := prov.Verifier(&gooidc.Config{ClientID: p.cfg.ClientID}).Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: verify id_token: %w", err)
|
||||
}
|
||||
if idToken.Nonce != nonce {
|
||||
return nil, errors.New("oidc: id_token nonce mismatch")
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("oidc: read claims: %w", err)
|
||||
}
|
||||
return p.identity(idToken.Issuer, idToken.Subject, claims), nil
|
||||
}
|
||||
|
||||
// identity maps raw claims onto an Identity using the configured claim names.
|
||||
func (p *Provider) identity(issuer, subject string, claims map[string]any) *Identity {
|
||||
id := &Identity{Issuer: issuer, Subject: subject}
|
||||
id.Username, _ = claims[p.cfg.UsernameClaim].(string)
|
||||
id.Email, _ = claims[p.cfg.EmailClaim].(string)
|
||||
id.EmailVerified, _ = claims["email_verified"].(bool)
|
||||
id.Groups = stringList(claims[p.cfg.GroupsClaim])
|
||||
return id
|
||||
}
|
||||
|
||||
// stringList reads a claim that is a list of strings, or a single string, which
|
||||
// some providers send for a one-element list.
|
||||
func stringList(v any) []string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{t}
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, e := range t {
|
||||
if s, ok := e.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCopyIncidentIsEmbedded(t *testing.T) {
|
||||
sub, err := fs.Sub(files, "static")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for file, want := range map[string]string{
|
||||
"js/incident.js": "copyIncident",
|
||||
"js/ui.js": "copy:",
|
||||
} {
|
||||
b, err := fs.ReadFile(sub, file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), want) {
|
||||
t.Errorf("%s lacks %s", file, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
sub, err := fs.Sub(files, "static")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := fs.ReadFile(sub, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// The sign-in button has to be a link the browser navigates, not script: the
|
||||
// CSP's connect-src is 'self', so a fetch to the identity provider is blocked,
|
||||
// and it is a redirect to the provider that the server answers.
|
||||
func TestLoginPageOffersSSOAsAPlainLink(t *testing.T) {
|
||||
html := read(t, "index.html")
|
||||
if !regexp.MustCompile(`<a[^>]*id="sso-link"[^>]*href="/api/oidc/login"|<a[^>]*href="/api/oidc/login"[^>]*id="sso-link"`).MatchString(html) {
|
||||
t.Error("index.html has no <a id=sso-link href=/api/oidc/login>")
|
||||
}
|
||||
if !strings.Contains(html, `id="password-login"`) {
|
||||
t.Error("the password fields must sit in #password-login so a server can hide them")
|
||||
}
|
||||
}
|
||||
|
||||
// Every code the server can put in ?sso_error= must have a message, or a
|
||||
// refused person sees a generic failure and cannot tell what to ask for.
|
||||
func TestLoginExplainsEverySSOError(t *testing.T) {
|
||||
js := read(t, "js/app.js")
|
||||
for _, code := range []string{
|
||||
"denied", "expired", "failed", "unavailable",
|
||||
"not_allowed", "no_email", "email_conflict", "disabled",
|
||||
} {
|
||||
if !regexp.MustCompile(`\b` + code + `:`).MatchString(js) {
|
||||
t.Errorf("app.js has no message for sso_error=%s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The page a terminal's prompt links to has to be reachable as a route, or the
|
||||
// link 404s into the queue and the code is never seen.
|
||||
func TestDevicePageIsRoutedAndCallsTheApprovalAPI(t *testing.T) {
|
||||
if !strings.Contains(read(t, "index.html"), `id="view-device"`) {
|
||||
t.Error("index.html has no #view-device section")
|
||||
}
|
||||
app := read(t, "js/app.js")
|
||||
if !strings.Contains(app, "name === 'device'") || !strings.Contains(app, "device: {") {
|
||||
t.Error("app.js does not route /device")
|
||||
}
|
||||
// The SSO button must carry the page asked for through the provider.
|
||||
if !strings.Contains(app, "/api/oidc/login?next=") {
|
||||
t.Error("the SSO link does not carry next=")
|
||||
}
|
||||
dev := read(t, "js/device.js")
|
||||
for _, want := range []string{"approveDevice", "denyDevice"} {
|
||||
if !strings.Contains(dev, want) {
|
||||
t.Errorf("device.js never calls %s", want)
|
||||
}
|
||||
}
|
||||
api := read(t, "js/api.js")
|
||||
for _, want := range []string{"/oidc/device/approve", "/oidc/device/deny"} {
|
||||
if !strings.Contains(api, want) {
|
||||
t.Errorf("api.js has no call to %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SSO-managed access must be marked in every view that edits it.
|
||||
func TestManagedAccessIsMarkedWhereItIsEdited(t *testing.T) {
|
||||
for _, file := range []string{"js/team.js", "js/adminteam.js", "js/adminuser.js", "js/admin.js"} {
|
||||
js := read(t, file)
|
||||
if !strings.Contains(js, "ssoBadge") || !strings.Contains(js, "SSO_MANAGED") {
|
||||
t.Errorf("%s does not mark or explain SSO-managed access", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
+641
-58
@@ -10,27 +10,35 @@
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eff1f4;
|
||||
--surface-hover: #f7f8fa;
|
||||
--border: #e2e5ea;
|
||||
--border-strong: #cfd3da;
|
||||
--border: #d9dde4;
|
||||
--border-strong: #c5cad3;
|
||||
--text: #16181d;
|
||||
--muted: #5b626e;
|
||||
--faint: #8a909b;
|
||||
--faint: #676d79;
|
||||
|
||||
--accent: #2f5bd3;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #e8eefc;
|
||||
|
||||
--crit: #d0342c;
|
||||
--crit: #c22d26;
|
||||
--crit-soft: #fdecea;
|
||||
--warn: #b86e00;
|
||||
--warn: #8f5500;
|
||||
--warn-soft: #fdf3e1;
|
||||
--info: #2f6fdf;
|
||||
--info: #245fc7;
|
||||
--info-soft: #e9f0fd;
|
||||
--ok: #1d7f4c;
|
||||
--ok: #1a7445;
|
||||
--ok-soft: #e6f5ec;
|
||||
--snooze: #6b5bd2;
|
||||
--snooze: #6050c8;
|
||||
--snooze-soft: #efedfb;
|
||||
|
||||
/* Two hues that mean nothing on their own. The rota needs six colours to
|
||||
tell six people apart and the palette above only has four that are not
|
||||
already an alarm. */
|
||||
--teal: #0c7180;
|
||||
--teal-soft: #e3f4f6;
|
||||
--pink: #a63b73;
|
||||
--pink-soft: #fbe8f2;
|
||||
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%);
|
||||
@@ -39,23 +47,46 @@
|
||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
|
||||
/* A ~4-size type scale, per issue #26's "use one type scale" ask. This
|
||||
file still has a dozen one-off font-size values below; migrating the
|
||||
low-risk, purely cosmetic ones (standalone titles with no dimensional
|
||||
or functional constraint) onto these tokens is a start, not the whole
|
||||
job — the rest (sizes tied to a fixed shape like the avatar circle, to
|
||||
a deliberately prominent display like a stat tile or the on-call name,
|
||||
or to a non-negotiable constraint like the 16px that stops iOS zooming
|
||||
into an input) stay as either their own pixel value or a documented
|
||||
exception, since guessing at those without seeing them render risks
|
||||
trading one inconsistency for a worse one. */
|
||||
--fs-xs: 12px;
|
||||
--fs-sm: 13px;
|
||||
--fs-base: 14px;
|
||||
--fs-lg: 18px;
|
||||
--fs-xl: 21px;
|
||||
|
||||
--topbar-h: 52px;
|
||||
/* The phone-width bottom tab bar's height. Unused above 900px: the
|
||||
desktop block overrides .nav/.view/.toast directly rather than reading
|
||||
this back down to 0. */
|
||||
--tabbar-h: 58px;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Dark palette. Applied by the OS preference unless the user chose Light in
|
||||
Account (data-theme="light" on <html>, set by js/theme.js), and always when
|
||||
they chose Dark. The two blocks must stay identical: CSS has no way to share
|
||||
a declaration list between a media query and an attribute selector. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0f1115;
|
||||
--surface: #171a20;
|
||||
--surface-2: #1f232b;
|
||||
--surface-hover: #1c2027;
|
||||
--border: #2a2f38;
|
||||
--border-strong: #394050;
|
||||
--surface: #1a1e26;
|
||||
--surface-2: #232834;
|
||||
--surface-hover: #20252e;
|
||||
--border: #343b49;
|
||||
--border-strong: #444d5f;
|
||||
--text: #e7e9ed;
|
||||
--muted: #a0a7b3;
|
||||
--faint: #737a87;
|
||||
--faint: #8a92a0;
|
||||
|
||||
--accent: #6d8ff0;
|
||||
--accent-text: #0b0d12;
|
||||
@@ -72,10 +103,51 @@
|
||||
--snooze: #a89bff;
|
||||
--snooze-soft: #262245;
|
||||
|
||||
--teal: #4fc2d4;
|
||||
--teal-soft: #0f2e33;
|
||||
--pink: #f07fb8;
|
||||
--pink-soft: #3a1c2d;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0f1115;
|
||||
--surface: #1a1e26;
|
||||
--surface-2: #232834;
|
||||
--surface-hover: #20252e;
|
||||
--border: #343b49;
|
||||
--border-strong: #444d5f;
|
||||
--text: #e7e9ed;
|
||||
--muted: #a0a7b3;
|
||||
--faint: #8a92a0;
|
||||
|
||||
--accent: #6d8ff0;
|
||||
--accent-text: #0b0d12;
|
||||
--accent-soft: #1d2640;
|
||||
|
||||
--crit: #ff6b61;
|
||||
--crit-soft: #3a1c1b;
|
||||
--warn: #f0b140;
|
||||
--warn-soft: #362a14;
|
||||
--info: #74a3ff;
|
||||
--info-soft: #1a2640;
|
||||
--ok: #4cc488;
|
||||
--ok-soft: #15301f;
|
||||
--snooze: #a89bff;
|
||||
--snooze-soft: #262245;
|
||||
|
||||
--teal: #4fc2d4;
|
||||
--teal-soft: #0f2e33;
|
||||
--pink: #f07fb8;
|
||||
--pink-soft: #3a1c2d;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||
}
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
:root[data-theme="dark"] { color-scheme: dark; }
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
@@ -120,6 +192,13 @@ h1, h2, h3 { margin: 0; line-height: 1.25; }
|
||||
.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.login-brand h1 { font-size: 24px; letter-spacing: -0.01em; }
|
||||
.login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; }
|
||||
.login-password { display: grid; gap: 14px; }
|
||||
/* "or" between the single sign-on button and the password form. */
|
||||
.login-divider {
|
||||
display: flex; align-items: center; gap: 10px; margin: 0;
|
||||
color: var(--faint); font-size: 13px;
|
||||
}
|
||||
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
label { display: grid; gap: 6px; }
|
||||
label > span { font-size: 13px; font-weight: 600; color: var(--muted); }
|
||||
@@ -156,7 +235,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
|
||||
}
|
||||
.btn:hover { background: var(--surface-hover); }
|
||||
.btn:disabled { opacity: 0.55; cursor: default; }
|
||||
.btn:disabled, .btn-sm:disabled { opacity: 0.55; cursor: default; }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.btn-primary:hover { background: var(--accent); filter: brightness(1.06); }
|
||||
.btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; }
|
||||
@@ -183,8 +262,11 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
|
||||
.topbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.topbar-title {
|
||||
font-size: 18px; font-weight: 700; letter-spacing: -0.01em;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||
}
|
||||
.open-pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 3px 10px; border-radius: 999px;
|
||||
@@ -195,11 +277,17 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
|
||||
.open-pill.has-triggered::before { background: var(--crit); }
|
||||
.open-pill.all-acked::before { background: var(--warn); }
|
||||
/* "All clear" is a status, not an action: green with a check, no dot. */
|
||||
.open-pill.all-clear { background: var(--ok-soft); color: var(--ok); }
|
||||
.open-pill.all-clear::before { content: none; }
|
||||
.open-pill .icon { width: 14px; height: 14px; }
|
||||
|
||||
/* Bottom tab bar on phones. */
|
||||
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
|
||||
left sidebar from 900px, where the desktop block below redeclares display
|
||||
and shows every section flat, .nav-link-secondary included. */
|
||||
.nav {
|
||||
display: flex;
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||
padding-bottom: var(--safe-bottom);
|
||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||
@@ -208,12 +296,29 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.nav-brand { display: none; }
|
||||
/* Hidden here (shown from 900px below): on the phone bar the team switcher
|
||||
lives in the topbar instead, as #team-selector-mobile. */
|
||||
.nav-team-selector { display: none; }
|
||||
.nav-link {
|
||||
position: relative;
|
||||
/* flex: 1 spreads the tabs evenly across the bar's width; the desktop
|
||||
block below cancels it back to a natural-width row item. */
|
||||
flex: 1 1 0;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
||||
color: var(--faint); font-size: 11px; font-weight: 600;
|
||||
/* min-width lets a column shrink below its label's natural width, which is
|
||||
what stops six tabs widening the bar past the screen. */
|
||||
min-width: 0; padding: 0 2px;
|
||||
}
|
||||
.nav-link svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
/* Must come after .nav-link above: same specificity (one class each), so
|
||||
whichever is later in the file wins for an element wearing both classes,
|
||||
and this needs to beat .nav-link's display:flex here on the phone bar. */
|
||||
.nav-link-secondary { display: none; }
|
||||
.nav-label {
|
||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
.nav-link[aria-current="page"] { color: var(--accent); }
|
||||
.nav-badge {
|
||||
position: absolute; top: 6px; left: calc(50% + 6px);
|
||||
@@ -222,6 +327,34 @@ 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 {
|
||||
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 { display: inline-flex; padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
|
||||
|
||||
/* 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; }
|
||||
@@ -237,20 +370,36 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
/* ---------- chips ---------- */
|
||||
|
||||
.chips {
|
||||
position: relative;
|
||||
display: flex; gap: 6px;
|
||||
padding: 12px 16px 8px;
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.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;
|
||||
background: var(--surface); color: var(--muted);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
||||
.chip[aria-selected="true"], .chip[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
||||
.theme-picker { padding: 0 0 8px; }
|
||||
.chip .count { margin-left: 4px; opacity: 0.7; }
|
||||
/* Nothing in it: step back so the chips that have something stand out. */
|
||||
.chip .count.zero { opacity: 0.4; }
|
||||
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling)
|
||||
box stays flush with its real right edge regardless of scroll position,
|
||||
which turned out not to be true of position:sticky here — as a flex
|
||||
item, its sticky offset interacted with the row's gap and its own
|
||||
negative margin, landing short of the edge by about one gap's width. */
|
||||
.chips-fade {
|
||||
position: absolute; top: 0; right: 0; bottom: 0;
|
||||
width: 24px;
|
||||
background: linear-gradient(to right, transparent, var(--bg));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- lists ---------- */
|
||||
|
||||
@@ -275,7 +424,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
background: var(--sev, var(--border-strong));
|
||||
}
|
||||
.row-title {
|
||||
font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-weight: 650; overflow: hidden; overflow-wrap: anywhere;
|
||||
/* Two lines, not one: titles differ at the end ("PodRestarting
|
||||
(namespace=...") and an ellipsis cut exactly the part that tells rows apart. */
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
|
||||
}
|
||||
.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; }
|
||||
.row-meta {
|
||||
@@ -295,6 +447,16 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
color: var(--muted); font-size: 12px; white-space: nowrap;
|
||||
}
|
||||
/* The page a terminal's sign-in prompt links to. */
|
||||
.device-card { max-width: 420px; margin: 24px auto; padding: 20px; display: grid; gap: 14px; }
|
||||
.device-code {
|
||||
margin: 0; padding: 14px; text-align: center;
|
||||
font: 700 30px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.12em;
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
}
|
||||
/* Access the identity provider's groups grant. The tint says "not yours to edit here". */
|
||||
.row-team.sso { margin-left: 6px; background: var(--accent-soft); border-color: transparent; color: var(--accent); }
|
||||
.row.resolved .row-title { color: var(--muted); }
|
||||
|
||||
.sev-critical { --sev: var(--crit); }
|
||||
@@ -304,7 +466,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.empty {
|
||||
padding: 48px 16px; text-align: center; color: var(--muted);
|
||||
}
|
||||
.empty strong { display: block; color: var(--text); font-size: 16px; margin-bottom: 4px; }
|
||||
.empty strong { display: block; color: var(--text); font-size: var(--fs-lg); margin-bottom: 4px; }
|
||||
.empty .icon { width: 36px; height: 36px; color: var(--ok); margin-bottom: 8px; }
|
||||
|
||||
.load-error {
|
||||
@@ -324,10 +486,20 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
}
|
||||
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.badge.plain::before { display: none; }
|
||||
.badge .badge-icon { width: 12px; height: 12px; stroke-width: 2.4; }
|
||||
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
|
||||
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
|
||||
.badge.st-resolved { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge.st-resolved, .badge.st-healthy { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge.st-dead { background: var(--crit-soft); color: var(--crit); }
|
||||
/* Dormant is the plain badge on purpose: nothing has gone wrong and nothing has
|
||||
gone right, which is what the muted default already says. */
|
||||
.badge.st-dormant, .badge.st-never { background: var(--surface-2); color: var(--muted); }
|
||||
.badge.st-active { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge.st-quiet, .badge.st-escalating { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge.st-ready, .badge.st-oncall { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge.st-reachable { background: var(--surface-2); color: var(--muted); }
|
||||
.badge.st-unreachable, .badge.st-unpageable { background: var(--crit-soft); color: var(--crit); }
|
||||
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
|
||||
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge.sev-info { background: var(--info-soft); color: var(--info); }
|
||||
@@ -345,8 +517,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.detail-head .copy { margin-left: auto; }
|
||||
.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
||||
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
||||
.detail-title { font-size: var(--fs-xl); font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
||||
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
||||
|
||||
.card {
|
||||
@@ -362,6 +536,9 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.facts dt { color: var(--muted); }
|
||||
.facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.facts .sub { color: var(--faint); }
|
||||
.facts dd.fact-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.fact-chip { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.fact-icon { width: 15px; height: 15px; color: var(--faint); }
|
||||
|
||||
.section { margin-top: 22px; }
|
||||
.section-title {
|
||||
@@ -393,6 +570,17 @@ details > summary::before { content: "▸ "; }
|
||||
details[open] > summary::before { content: "▾ "; }
|
||||
details[open] > summary { margin-bottom: 8px; }
|
||||
|
||||
/* One .tl-phase per status the incident has been through (see
|
||||
timelinePhases() in incident.js) — each with its own .timeline <ol>, so
|
||||
the existing :first-child/:last-child rail-capping below gives each phase
|
||||
its own self-contained connecting line rather than one running through
|
||||
the headings. */
|
||||
.tl-phase + .tl-phase { border-top: 1px solid var(--border); }
|
||||
.tl-phase-title {
|
||||
padding: 10px 14px 0;
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--faint);
|
||||
}
|
||||
.timeline { list-style: none; margin: 0; padding: 4px 0; }
|
||||
.tl-item {
|
||||
position: relative;
|
||||
@@ -402,27 +590,40 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
.tl-item::before {
|
||||
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
|
||||
}
|
||||
.tl-item:first-child::before { top: 16px; }
|
||||
.tl-item:last-child::before { bottom: calc(100% - 16px); }
|
||||
.tl-item:first-child::before { top: 18px; }
|
||||
.tl-item:last-child::before { bottom: calc(100% - 18px); }
|
||||
.tl-dot {
|
||||
position: relative; z-index: 1;
|
||||
width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%;
|
||||
background: var(--surface); border: 2px solid var(--faint);
|
||||
display: grid; place-items: center;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: var(--surface); border: 2px solid var(--border-strong); color: var(--muted);
|
||||
}
|
||||
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); }
|
||||
.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); }
|
||||
.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); }
|
||||
.tl-snoozed .tl-dot { border-color: var(--snooze); }
|
||||
.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); }
|
||||
/* An event with no icon keeps the old plain dot. */
|
||||
.tl-dot:empty { width: 10px; height: 10px; margin: 5px 0 0 5px; }
|
||||
.tl-icon { width: 11px; height: 11px; stroke-width: 2.6; }
|
||||
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); color: var(--crit); }
|
||||
.tl-acknowledged .tl-dot { border-color: var(--warn); color: var(--warn); }
|
||||
.tl-resolved .tl-dot { border-color: var(--ok); color: var(--ok); }
|
||||
.tl-snoozed .tl-dot { border-color: var(--snooze); color: var(--snooze); }
|
||||
.tl-note .tl-dot, .tl-resolution_note .tl-dot { border-color: var(--accent); color: var(--accent); }
|
||||
.tl-problem .tl-dot { border-color: var(--warn); background: var(--warn-soft); color: var(--warn); }
|
||||
.tl-problem.tl-notify_failed .tl-dot, .tl-problem.tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit-soft); color: var(--crit); }
|
||||
.tl-problem .tl-text { color: var(--warn); font-weight: 600; }
|
||||
.tl-problem.tl-notify_failed .tl-text, .tl-problem.tl-deadman_silent .tl-text { color: var(--crit); }
|
||||
.tl-body { min-width: 0; font-size: 14px; }
|
||||
.tl-text { overflow-wrap: anywhere; }
|
||||
.tl-text .who { font-weight: 650; }
|
||||
.tl-time { color: var(--faint); font-size: 12px; }
|
||||
.tl-note .note {
|
||||
.note {
|
||||
margin-top: 6px; padding: 10px 12px;
|
||||
background: var(--surface-2); border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
}
|
||||
.note-fix { background: var(--ok-soft); border-left: 3px solid var(--ok); }
|
||||
.similar { list-style: none; margin: 0; padding: 0; }
|
||||
.similar-item { padding: 10px 14px; }
|
||||
.similar-item + .similar-item { border-top: 1px solid var(--border, var(--surface-2)); }
|
||||
.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); }
|
||||
.note-actions { display: flex; justify-content: flex-end; }
|
||||
.note-actions .btn { color: var(--muted); }
|
||||
|
||||
@@ -437,6 +638,10 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
}
|
||||
.actionbar .btn { min-height: 48px; }
|
||||
.actionbar .btn-primary { flex: 1; font-size: 16px; }
|
||||
/* The extra actions that only phones hide behind "More" — see actionBar() and
|
||||
extraActions() in incident.js. Hidden by default; the desktop block below
|
||||
shows them and hides the now-redundant More button instead. */
|
||||
.action-extra { display: none; }
|
||||
|
||||
.detail-placeholder {
|
||||
display: grid; place-items: center; height: 100%;
|
||||
@@ -459,7 +664,7 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
@keyframes sheet-up { from { transform: translateY(24px); opacity: 0.6; } }
|
||||
.sheet-inner { padding: 8px 16px calc(16px + var(--safe-bottom)); }
|
||||
.sheet-grab { width: 40px; height: 4px; margin: 0 auto 12px; border-radius: 2px; background: var(--border-strong); }
|
||||
.sheet-title { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
|
||||
.sheet-title { font-size: var(--fs-lg); font-weight: 700; margin: 0 0 4px; }
|
||||
.sheet-text { color: var(--muted); margin: 0 0 14px; font-size: 14px; }
|
||||
.sheet-form { display: grid; gap: 12px; }
|
||||
.sheet-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
@@ -500,7 +705,6 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
|
||||
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||
|
||||
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
|
||||
.avatar {
|
||||
flex: none; display: grid; place-items: center;
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
@@ -508,24 +712,62 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
||||
}
|
||||
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
||||
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||
.now-name { font-size: 20px; font-weight: 750; }
|
||||
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
||||
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
|
||||
is easier to spot there than this plain accent-coloured text. */
|
||||
.you-badge { margin-left: 6px; }
|
||||
|
||||
.week-nav { display: flex; align-items: center; gap: 4px; }
|
||||
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
|
||||
.days { list-style: none; margin: 0; padding: 0; }
|
||||
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
|
||||
.day + .day { border-top: 1px solid var(--border); }
|
||||
.day-name { font-weight: 650; }
|
||||
.day-date { color: var(--faint); font-size: 13px; }
|
||||
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.day-who.nobody { color: var(--faint); font-style: italic; }
|
||||
.day.today { background: var(--accent-soft); }
|
||||
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
|
||||
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
|
||||
.day.today .day-name { color: var(--accent); }
|
||||
.day.past { opacity: 0.6; }
|
||||
.week-nav .month-label { font-size: 15px; font-weight: 650; min-width: 8em; text-align: center; }
|
||||
/* The week-nav button that shows the date range, "28 Sep – 4 Oct", with the
|
||||
ISO week number as secondary text inside it — a separate class from
|
||||
.label above (team.js's month-nav uses that one) so its <small> isn't
|
||||
caught by the unrelated .label > span styling meant for label chips. */
|
||||
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
|
||||
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
|
||||
.oncall-title h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; margin: 8px 0 0; }
|
||||
.oncall-title p { margin: 2px 0 0; font-size: 14px; }
|
||||
.oncall-grid { display: grid; gap: 0; }
|
||||
.oncall-main, .oncall-side { min-width: 0; }
|
||||
.hero-list { display: grid; gap: 12px; }
|
||||
|
||||
.hero { display: grid; gap: 14px; padding: 18px; margin-top: 16px; }
|
||||
.hero-top { display: flex; align-items: center; gap: 16px; }
|
||||
.hero-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
|
||||
.hero-avatar { width: 64px; height: 64px; font-size: 24px; }
|
||||
.hero-name { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||
.hero-until { color: var(--muted); font-size: 15px; margin-top: 2px; }
|
||||
|
||||
.week-card { margin-top: 16px; padding: 16px 12px 12px; }
|
||||
.week-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; padding: 0 4px; }
|
||||
.week-head h2 { margin: 0; font-size: 15px; font-weight: 650; }
|
||||
/* Bordered, so the arrows read as buttons. 40px: a touch target without
|
||||
crowding the date between them. */
|
||||
.week-arrow { width: 40px; min-height: 40px; border: 1px solid var(--border-strong); }
|
||||
.strip { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
|
||||
.strip-day {
|
||||
position: relative; min-width: 0;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
padding: 10px 0 8px; border: 1px solid transparent; border-radius: var(--radius);
|
||||
}
|
||||
.strip-name { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
||||
.strip-date { font-size: 15px; font-weight: 650; }
|
||||
.strip-avatar { width: 32px; height: 32px; font-size: 13px; }
|
||||
.strip-who { max-width: 100%; padding: 0 2px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.strip-who.nobody { font-style: italic; color: var(--faint); }
|
||||
.strip-day.past { opacity: 0.6; }
|
||||
/* "Current", not "selected": a neutral tint, a bar and aria-current, so it
|
||||
reads as a marker and not as a pressed button. The accent stays for things
|
||||
you can act on. */
|
||||
.strip-day.today { background: var(--surface-2); border-color: var(--border-strong); }
|
||||
.strip-day.today::before {
|
||||
content: ""; position: absolute; top: -1px; left: 12px; right: 12px; height: 3px;
|
||||
border-radius: 0 0 3px 3px; background: var(--text);
|
||||
}
|
||||
.strip-day.today .strip-name { color: var(--text); }
|
||||
.strip-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px; padding: 10px 4px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--muted); }
|
||||
.strip-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.strip-legend .strip-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
|
||||
.shift-list { list-style: none; margin: 0; padding: 0; }
|
||||
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
||||
@@ -546,6 +788,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);
|
||||
@@ -566,8 +810,6 @@ kbd {
|
||||
/* ---------- desktop ---------- */
|
||||
|
||||
@media (min-width: 900px) {
|
||||
:root { --tabbar-h: 0px; }
|
||||
|
||||
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
|
||||
|
||||
.nav {
|
||||
@@ -582,8 +824,11 @@ 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 { display: inline-flex; margin: -8px 10px 14px; width: calc(100% - 20px); }
|
||||
.nav-link-secondary { display: flex; }
|
||||
.nav-more-btn { display: none; }
|
||||
.nav-link {
|
||||
flex-direction: row; justify-content: flex-start; gap: 12px;
|
||||
flex: none; flex-direction: row; justify-content: flex-start; gap: 12px;
|
||||
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
||||
color: var(--muted); font-size: 14px;
|
||||
}
|
||||
@@ -603,7 +848,20 @@ kbd {
|
||||
.view-queue .pane { overflow: auto; height: 100dvh; }
|
||||
.pane-list { border-right: 1px solid var(--border); }
|
||||
.pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; }
|
||||
.view-queue:not(.has-detail) .pane-detail { display: block; }
|
||||
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
|
||||
is hidden, so the chips wrap here instead: Archived stays reachable. */
|
||||
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
|
||||
.pane-list .chip-sep { display: none; }
|
||||
.chips-fade { display: none; }
|
||||
/* With nothing selected there is no detail to show next to, so the list
|
||||
takes the whole row instead of leaving the second column as dead space
|
||||
around the placeholder text. Selecting an incident (.has-detail) drops
|
||||
back to the base minmax(340,420) 1fr rule above. */
|
||||
.view-queue:not(.has-detail) { grid-template-columns: 1fr; }
|
||||
.view-queue:not(.has-detail) .pane-list { border-right: 0; }
|
||||
/* Full width reads better capped than edge-to-edge on a very wide monitor,
|
||||
matching .detail's own cap below. */
|
||||
.view-queue:not(.has-detail) .list { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
/* On desktop the list stays visible next to the detail. */
|
||||
.app.detail-open .nav { display: flex; }
|
||||
@@ -617,8 +875,12 @@ kbd {
|
||||
.actionbar {
|
||||
position: sticky; bottom: 0;
|
||||
padding: 12px 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actionbar .btn-primary { flex: 0 1 240px; }
|
||||
/* Room enough to show every action, so More is fully redundant here. */
|
||||
.action-extra { display: inline-flex; }
|
||||
.more-btn { display: none; }
|
||||
|
||||
.sheet {
|
||||
width: min(440px, calc(100% - 32px));
|
||||
@@ -650,6 +912,33 @@ kbd {
|
||||
.disabled-row td { opacity: 0.55; }
|
||||
.btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); }
|
||||
|
||||
/* --- status lists: alert sources and dead man's switches -----------------
|
||||
Six columns do not fit a phone, so the table scrolls inside its card rather
|
||||
than the page. A heartbeat under a switch with several is indented, the way
|
||||
the escalation ladder indents its levels. */
|
||||
/* A card with a header row (the Team and Admin settings cards) pads itself:
|
||||
plain .card has no padding, so these used to print their text flush against
|
||||
the border with the button jammed in the corner. The divider separates the
|
||||
title row from the content below it. */
|
||||
.card:has(> .card-head) { padding: 20px; }
|
||||
@media (min-width: 900px) { .card:has(> .card-head) { padding: 24px; } }
|
||||
.card-head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||
padding-bottom: 14px; margin-bottom: 14px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.card-head h2 { margin: 0; font-size: 17px; font-weight: 650; }
|
||||
.table-scroll { overflow-x: auto; margin-top: 12px; }
|
||||
.status-table th, .status-table td { white-space: nowrap; }
|
||||
.status-table td.wrap { white-space: normal; min-width: 12em; }
|
||||
.status-table .source-row td { border-bottom-style: dashed; }
|
||||
.status-table tr.clickable { cursor: pointer; }
|
||||
.status-table tr.clickable:hover td, .status-table tr.clickable:focus-visible td { background: rgba(127, 127, 127, .1); }
|
||||
.switch-facts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0; }
|
||||
.switch-facts dt { opacity: .7; }
|
||||
.switch-facts dd { margin: 0; }
|
||||
.status-table .source-row td:first-child { padding-left: 16px; }
|
||||
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||
|
||||
.inline-form { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.inline-form input { flex: 1; min-width: 0; }
|
||||
|
||||
@@ -658,6 +947,45 @@ kbd {
|
||||
.admin-settings button[type="submit"] { margin-top: 12px; }
|
||||
.small { font-size: 13px; }
|
||||
|
||||
/* A name in an admin table is the way to that row's own page -- a person's or
|
||||
a team's. */
|
||||
.row-link { color: var(--text); font-weight: 650; text-decoration: none; }
|
||||
.row-link:hover { color: var(--accent); text-decoration: underline; }
|
||||
|
||||
.invite-block { margin-top: 20px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.invite-block h3 { margin: 0 0 4px; font-size: 14px; }
|
||||
/* The link is shown once and never stored, so it has to be selectable and
|
||||
wrap rather than scroll off the side of a phone. */
|
||||
.invite-out { margin-top: 12px; font-size: 13px; }
|
||||
.invite-link {
|
||||
display: block; margin-top: 6px; padding: 8px; border-radius: var(--radius-sm);
|
||||
background: var(--surface-2); font-family: var(--mono); font-size: 12px;
|
||||
word-break: break-all; user-select: all;
|
||||
}
|
||||
|
||||
/* --- one user, one team --------------------------------------------------
|
||||
Both subject pages share this: .user-head and .user-facts are generic
|
||||
despite the names, and a team fills them with its own facts. */
|
||||
.back-link {
|
||||
display: inline-flex; align-items: center; gap: 2px; margin-bottom: 12px;
|
||||
color: var(--muted); font-size: 14px; text-decoration: none;
|
||||
}
|
||||
.back-link:hover { color: var(--text); }
|
||||
.back-link svg { width: 18px; height: 18px; }
|
||||
|
||||
.user-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.user-head h2 { margin: 0; }
|
||||
|
||||
.user-facts {
|
||||
display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px;
|
||||
margin: 12px 0 0; font-size: 14px;
|
||||
}
|
||||
.user-facts dt { color: var(--muted); }
|
||||
.user-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||
.admin-table .row-actions { margin-top: 0; gap: 6px; }
|
||||
|
||||
/* --- team settings -------------------------------------------------------
|
||||
Forms with a label above each control, rather than the queue's rows of
|
||||
links. The escalation ladder is the only nested structure in the app, so it
|
||||
@@ -666,7 +994,113 @@ 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
|
||||
person's colour and the legend underneath says whose. A shift is then a run
|
||||
of one colour, which is the shape the question actually has. */
|
||||
.rota-grid { padding: 10px 12px 4px; }
|
||||
.rota-heads, .rota-row { display: grid; grid-template-columns: 2.4em repeat(7, minmax(0, 1fr)); column-gap: 2px; }
|
||||
.rota-wd {
|
||||
padding-bottom: 6px; text-align: center;
|
||||
color: var(--muted); font-size: 11px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.rota-row { grid-template-rows: 26px 34px; padding: 4px 0 6px; }
|
||||
.rota-row + .rota-row { border-top: 1px solid var(--border); }
|
||||
/* A day is a cell that spans both of its row's lines: the number on the first,
|
||||
the shift bar drawn over the second. The bar ignores the pointer, so a tap
|
||||
anywhere in the cell reaches the button. */
|
||||
.rota-day {
|
||||
grid-row: 1 / span 2;
|
||||
display: flex; justify-content: center; align-items: flex-start;
|
||||
min-width: 0; padding-top: 2px;
|
||||
border: 0; border-radius: var(--radius-sm); background: none;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
button.rota-day { cursor: pointer; }
|
||||
button.rota-day:hover { background: var(--surface-2); }
|
||||
button.rota-day:focus-visible, button.rota-week:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
/* The week number starts each row. Quiet by default, because it is a label
|
||||
first; an owner's tap on it is the second thing it does. */
|
||||
.rota-week {
|
||||
grid-column: 1; grid-row: 1 / span 2;
|
||||
display: grid; place-items: center;
|
||||
border: 0; border-radius: var(--radius-sm); background: none;
|
||||
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
|
||||
color: var(--faint);
|
||||
}
|
||||
button.rota-week { cursor: pointer; }
|
||||
button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
.rota-week.current { color: var(--text); font-weight: 700; }
|
||||
/* The sheet's row of who holds each day of the week. */
|
||||
.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; }
|
||||
.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
|
||||
.week-holder.past { opacity: 0.55; }
|
||||
.rota-num {
|
||||
min-width: 22px; height: 22px; padding: 0 3px; border-radius: 11px;
|
||||
text-align: center; line-height: 22px;
|
||||
color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Today is a filled number, a marker and not the accent, which means "you can
|
||||
act on this". */
|
||||
.rota-day.today .rota-num { background: var(--text); color: var(--bg); font-weight: 700; }
|
||||
/* The days either side of the month are real days and are drawn, but they
|
||||
belong to the month you are not looking at. */
|
||||
.rota-day.outside .rota-num { opacity: 0.4; }
|
||||
|
||||
/* A shift: a run of consecutive days held by one person, in that person's
|
||||
colour (.rc1-.rc6 below). Rounded ends are where it really starts or stops;
|
||||
a flat end with a chevron carries on across the row break. */
|
||||
.rota-bar {
|
||||
grid-row: 2; align-self: center; z-index: 1; pointer-events: none;
|
||||
display: flex; align-items: center; min-width: 0; height: 28px; padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.rota-bar.past { opacity: 0.55; }
|
||||
.rota-bar.outside { opacity: 0.4; }
|
||||
.rota-bar.cont-l { margin-left: -2px; padding-left: 10px; border-top-left-radius: 0; border-bottom-left-radius: 0; }
|
||||
.rota-bar.cont-r { margin-right: -2px; border-top-right-radius: 0; border-bottom-right-radius: 0; }
|
||||
.rota-bar.cont-l::before { content: "‹"; margin-right: 4px; opacity: 0.7; }
|
||||
/* Nobody on call. Loud while it can still be fixed, quiet once it is history. */
|
||||
.rota-bar.gap {
|
||||
background: repeating-linear-gradient(135deg, transparent 0 5px, var(--warn-soft) 5px 10px);
|
||||
border: 1px dashed var(--warn); color: var(--warn);
|
||||
}
|
||||
.rota-bar.gap.past { background: none; border-color: var(--border-strong); color: var(--faint); }
|
||||
|
||||
.rota-chip {
|
||||
display: grid; place-items: center;
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
font-size: 12px; font-weight: 750; text-transform: uppercase;
|
||||
}
|
||||
/* An empty day is a dot rather than a hole, and keeps the chip's box so the
|
||||
rows stay on one baseline. */
|
||||
.rota-chip.none { width: 8px; height: 8px; margin: 9px; background: var(--border-strong); }
|
||||
|
||||
/* Six colours, then they repeat; the initial inside still tells two people
|
||||
apart. Deliberately not the severity palette — nothing here is critical. */
|
||||
.rc1 { background: var(--accent-soft); color: var(--accent); }
|
||||
.rc2 { background: var(--ok-soft); color: var(--ok); }
|
||||
.rc3 { background: var(--snooze-soft); color: var(--snooze); }
|
||||
.rc4 { background: var(--warn-soft); color: var(--warn); }
|
||||
.rc5 { background: var(--teal-soft); color: var(--teal); }
|
||||
.rc6 { background: var(--pink-soft); color: var(--pink); }
|
||||
|
||||
.rota-banner {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 14px; border-top: 1px solid var(--border); font-size: 14px;
|
||||
}
|
||||
.rota-banner .icon { width: 20px; height: 20px; }
|
||||
.rota-banner.ok { color: var(--ok); }
|
||||
.rota-banner.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
.rota-foot {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||
padding: 12px 14px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.rota-bulk-form { padding: 0 14px 14px; }
|
||||
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
|
||||
.ladder-level {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
@@ -675,6 +1109,48 @@ kbd {
|
||||
.ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
|
||||
.ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||
.target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.ladder-editor { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin-top: 12px; }
|
||||
/* A target that would not wake anybody says why, in place: it is the reason a
|
||||
level is red, and the thing to go and fix. */
|
||||
/* The escalation ladder: a numbered step per level, the wait between levels as
|
||||
its own line, and what happens after the last one at the bottom. */
|
||||
.ladder { list-style: none; margin: 16px 0 0; padding: 0; }
|
||||
.step { display: grid; grid-template-columns: 32px minmax(0, 1fr); column-gap: 14px; }
|
||||
.step-rail { display: flex; flex-direction: column; align-items: center; }
|
||||
.step-node {
|
||||
display: grid; place-items: center; flex: none;
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
border: 2px solid var(--border-strong); background: var(--surface);
|
||||
font-size: 14px; font-weight: 750;
|
||||
}
|
||||
.step-node.st-ready { border-color: var(--ok); color: var(--ok); }
|
||||
.step-node.st-escalating { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
|
||||
.step-node.st-unreachable { border-color: var(--crit); color: var(--crit); background: var(--crit-soft); }
|
||||
.step-node.end { border-style: dashed; color: var(--muted); }
|
||||
.step-node.end.warn { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
|
||||
.step-node .icon { width: 16px; height: 16px; }
|
||||
.step-line { flex: 1; width: 2px; min-height: 8px; background: var(--border-strong); }
|
||||
.step-body { min-width: 0; padding-bottom: 4px; }
|
||||
.step-top { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 32px; }
|
||||
.step-pages { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; font-size: 14px; }
|
||||
.step-waiting { margin-top: 6px; font-size: 13px; color: var(--muted); }
|
||||
.step-waiting a { font-weight: 650; }
|
||||
.step-gap { display: flex; align-items: center; gap: 8px; padding: 6px 0 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||
.step-gap .icon { width: 14px; height: 14px; }
|
||||
.callout {
|
||||
display: flex; align-items: flex-start; gap: 12px; margin-top: 4px;
|
||||
padding: 12px 14px; border: 1px solid var(--warn); border-radius: var(--radius);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
.callout > .icon { flex: none; width: 20px; height: 20px; margin-top: 1px; color: var(--warn); }
|
||||
.callout strong { color: var(--warn); }
|
||||
.callout p { margin: 2px 0 10px; font-size: 14px; }
|
||||
.ladder-facts { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
.ladder-facts p { margin: 0; }
|
||||
.ladder-facts p + p { margin-top: 4px; }
|
||||
|
||||
.target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
||||
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
|
||||
|
||||
/* An integration key is shown exactly once, so it should look like something
|
||||
to act on rather than another row of text. */
|
||||
@@ -687,3 +1163,110 @@ kbd {
|
||||
border-radius: 6px; padding: 8px; font-size: 12px;
|
||||
}
|
||||
.key-url code { word-break: break-all; }
|
||||
|
||||
/* --- onboarding checklist ------------------------------------------------
|
||||
Sits above the queue until it is finished or hidden. Deliberately plain:
|
||||
it is a list of things to do, not a celebration. */
|
||||
.onboarding { border-left: 3px solid var(--accent); }
|
||||
.onboarding-head { display: flex; align-items: center; gap: 10px; }
|
||||
.onboarding-head h2 { flex: 1; margin: 0; }
|
||||
.checklist { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.checklist .step { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.checklist .step p { margin: 2px 0 0; }
|
||||
.step-mark {
|
||||
flex: none; width: 20px; height: 20px; border-radius: 50%;
|
||||
border: 1px solid var(--border-strong); color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 13px;
|
||||
}
|
||||
.step.done .step-mark { border-color: var(--accent); }
|
||||
.step.done > div > strong { color: var(--muted); text-decoration: line-through; }
|
||||
.step-actions { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
||||
|
||||
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
|
||||
|
||||
/* --- sub-navigation -------------------------------------------------------
|
||||
A strip of links across the top of every Admin and every Team page, one per
|
||||
sub-section. Deliberately not .chip: chips filter what a page already shows,
|
||||
here and in the queue, and these go somewhere. Same aria-current convention
|
||||
as the tab bar, so the state lives on the attribute rather than in a class.
|
||||
|
||||
Six entries do not fit a phone's width, which is what the horizontal scroll
|
||||
below is for -- the Team tab's strip is the one that needs it. */
|
||||
.subnav {
|
||||
display: flex; gap: 2px;
|
||||
margin: 12px auto 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.subnav::-webkit-scrollbar { display: none; }
|
||||
/* A fade on the right edge says "there is more" where the strip overflows;
|
||||
the desktop width fits every entry, so it is phone-only. */
|
||||
@media (max-width: 899px) {
|
||||
.subnav { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
|
||||
}
|
||||
.subnav-link {
|
||||
flex: none;
|
||||
padding: 8px 12px; margin-bottom: -1px;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--muted); font-size: 14px; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.subnav-link:hover { color: var(--text); }
|
||||
.subnav-link[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* The overview a tab opens on, at /admin and at /team. The strip above already
|
||||
links to the sections, so these carry the counts, which is the part a menu
|
||||
cannot say. Not named for either tab: both use it, and the one that renamed
|
||||
.user-link to .row-link is the same rename for the same reason. */
|
||||
.overview-menu { display: grid; gap: 10px; margin-top: 16px; }
|
||||
/* The grid's gap is the spacing here, so .card + .card must not add its own. */
|
||||
.overview-menu .card + .card { margin-top: 0; }
|
||||
.overview-item { display: block; padding: 14px; }
|
||||
.overview-item:hover { background: var(--surface-hover); }
|
||||
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
||||
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||
.overview-item p { margin: 4px 0 0; }
|
||||
.overview-note-icon { width: 13px; height: 13px; vertical-align: -2px; color: var(--ok); }
|
||||
|
||||
/* ---------- stats page ---------- */
|
||||
|
||||
#view-stats .chips { padding-left: 0; padding-right: 0; }
|
||||
.stats { display: grid; gap: 14px; padding-bottom: 16px; }
|
||||
.stat-tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.stat-tile {
|
||||
--sev: var(--border-strong);
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--sev); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); padding: 12px;
|
||||
}
|
||||
.stat-tile.st-triggered { --sev: var(--crit); }
|
||||
.stat-tile.st-acknowledged { --sev: var(--warn); }
|
||||
.stat-tile.st-resolved { --sev: var(--ok); }
|
||||
.stat-value { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.stat-label { margin-top: 2px; font-size: 12px; color: var(--muted); }
|
||||
.chart-card + .chart-card { margin-top: 0; }
|
||||
.chart-title {
|
||||
margin-bottom: 10px; font-size: 13px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted);
|
||||
}
|
||||
.hbars { list-style: none; display: grid; gap: 6px; }
|
||||
.hbar { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.hbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hbar-track { height: 10px; border-radius: 5px; background: var(--surface-2); overflow: hidden; }
|
||||
.hbar-fill { display: block; height: 100%; border-radius: 5px; background: var(--ok); }
|
||||
.hbar-count { min-width: 2ch; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.columns { display: block; width: 100%; height: auto; }
|
||||
.columns .axis { stroke: var(--border-strong); stroke-width: 1; }
|
||||
.columns .col-hit { fill: transparent; }
|
||||
.columns .col-bar { fill: var(--accent); }
|
||||
.columns .col:hover .col-bar { opacity: 0.75; }
|
||||
.columns .col-label { fill: var(--faint); font-size: 10px; font-family: var(--font); }
|
||||
@media (min-width: 900px) {
|
||||
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
/* On-call, wide: the hero and the week on the left, your shifts on the right.
|
||||
.view-page > * caps its children at 760px, so the grid lifts that. */
|
||||
@media (min-width: 900px) {
|
||||
#view-oncall > .oncall-grid { max-width: 1080px; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 0 24px; align-items: start; }
|
||||
#view-oncall > .oncall-title { max-width: 1080px; }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
<script src="/js/theme.js"></script>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -25,18 +26,58 @@
|
||||
<img src="/icon.svg" alt="" width="40" height="40">
|
||||
<h1>terdut</h1>
|
||||
</div>
|
||||
<!-- Why a sign-in failed, when the identity provider sent the browser back
|
||||
here with ?sso_error=. Kept apart from the password form's own error. -->
|
||||
<p class="form-error" id="sso-error" role="alert" hidden></p>
|
||||
<!-- A plain link, not a fetch: the browser has to navigate to the provider,
|
||||
and the page's CSP allows no connection to anywhere else. -->
|
||||
<a class="btn btn-primary btn-block" id="sso-link" href="/api/oidc/login" hidden>Sign in with SSO</a>
|
||||
<p class="login-divider" id="login-or" hidden><span>or</span></p>
|
||||
<div class="login-password" id="password-login">
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<p class="form-error" role="alert" hidden></p>
|
||||
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
||||
<p class="login-hint">No password yet? Ask an admin to set one, or run
|
||||
<code>PUT /api/users/{id}/password</code> with your API key.</p>
|
||||
<p class="login-hint" id="signup-link" hidden>
|
||||
No account? <a href="/signup">Create one</a>.</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Sign-up. Shown instead of the login card at /signup, and only offers
|
||||
what the server allows: an invite link, or open sign-up. -->
|
||||
<form id="signup-form" class="login-card" autocomplete="on" hidden>
|
||||
<div class="login-brand">
|
||||
<img src="/icon.svg" alt="" width="40" height="40">
|
||||
<h1>terdut</h1>
|
||||
</div>
|
||||
<p class="signup-intro" id="signup-intro"></p>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input name="email" type="email" autocomplete="email" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
<input name="password" type="password" autocomplete="new-password" minlength="10" required>
|
||||
</label>
|
||||
<label id="signup-team-label" hidden>
|
||||
<span>Team name</span>
|
||||
<input name="team_name" autocomplete="off">
|
||||
</label>
|
||||
<p class="form-error" role="alert" hidden></p>
|
||||
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
||||
<p class="login-hint">No password yet? Ask an admin to set one, or run
|
||||
<code>PUT /api/users/{id}/password</code> with your API key.</p>
|
||||
<button class="btn btn-primary btn-block" type="submit">Create account</button>
|
||||
<p class="login-hint">Already have one? <a href="/">Sign in</a>.</p>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -46,38 +87,59 @@
|
||||
<img src="/icon.svg" alt="" width="28" height="28">
|
||||
<span>terdut</span>
|
||||
</a>
|
||||
<a class="nav-link" href="/" data-section="queue">
|
||||
<!-- 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>
|
||||
<span class="nav-badge" data-badge hidden></span>
|
||||
</a>
|
||||
<a class="nav-link" href="/oncall" data-section="oncall">
|
||||
<a class="nav-link" href="/oncall" data-section="oncall" aria-label="On-call">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>
|
||||
<span class="nav-label">On-call</span>
|
||||
</a>
|
||||
<a class="nav-link" href="/alerts" data-section="alerts">
|
||||
<a class="nav-link" href="/alerts" data-section="alerts" aria-label="Alerts">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
||||
<span class="nav-label">Alerts</span>
|
||||
</a>
|
||||
<a class="nav-link" href="/team" data-section="team">
|
||||
<!-- Secondary: full-width in the desktop sidebar, folded into the
|
||||
"More" tab's sheet on the phone-width bottom bar instead (see
|
||||
.nav-link-secondary in app.css and openNavMenu in app.js). -->
|
||||
<a class="nav-link nav-link-secondary" href="/stats" data-section="stats" aria-label="Stats">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
|
||||
<span class="nav-label">Stats</span>
|
||||
</a>
|
||||
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
|
||||
<span class="nav-label">Team</span>
|
||||
</a>
|
||||
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
||||
unhides it once /api/me says so. The server refuses every admin
|
||||
endpoint regardless, so this is a courtesy and not a gate. -->
|
||||
<a class="nav-link" href="/admin" data-section="admin" id="nav-admin" hidden>
|
||||
<a class="nav-link nav-link-secondary" href="/admin" data-section="admin" aria-label="Admin" id="nav-admin" hidden>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
|
||||
<span class="nav-label">Admin</span>
|
||||
</a>
|
||||
<a class="nav-link" href="/more" data-section="more">
|
||||
<a class="nav-link nav-link-secondary" href="/more" data-section="more" aria-label="Account">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
|
||||
<span class="nav-label">Account</span>
|
||||
</a>
|
||||
<!-- Phone-width only (see .nav-more-btn in app.css): opens the same
|
||||
sheet the old hamburger button did, for the sections the bottom
|
||||
bar has no room for. Not shown on the desktop sidebar, which lists
|
||||
every section already. -->
|
||||
<button class="nav-link nav-more-btn" id="nav-more-btn" type="button" aria-label="More sections" aria-haspopup="menu">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h.01M12 12h.01M19 12h.01"/></svg>
|
||||
<span class="nav-label">More</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<header class="topbar">
|
||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||
<div class="topbar-left">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@@ -91,9 +153,18 @@
|
||||
|
||||
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
|
||||
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
||||
<section id="view-stats" class="view view-page" data-view="stats" hidden></section>
|
||||
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
||||
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
||||
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
|
||||
list, and a section of its own so a deep link survives a reload. -->
|
||||
<section id="view-adminuser" class="view view-page" data-view="adminuser" hidden></section>
|
||||
<!-- One team, at /admin/teams/{id}: who is in it and the invites into it,
|
||||
which the Team tab cannot show for a team you are not a member of. -->
|
||||
<section id="view-adminteam" class="view view-page" data-view="adminteam" hidden></section>
|
||||
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
||||
<!-- Approve a terminal's sign-in, at /device?code=...: the page its prompt links to. -->
|
||||
<section id="view-device" class="view view-page" data-view="device" hidden></section>
|
||||
</div>
|
||||
|
||||
<dialog id="sheet" class="sheet"></dialog>
|
||||
|
||||
@@ -23,22 +23,149 @@ function render() {
|
||||
h('div', { class: 'account-name', text: user.username }),
|
||||
h('div', { class: 'account-email', text: user.email }))),
|
||||
|
||||
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
||||
passwordForm(user, hasPassword),
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
||||
notifyForm(user),
|
||||
|
||||
...passwordSection(user, hasPassword),
|
||||
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Appearance' })),
|
||||
themePicker(),
|
||||
|
||||
h('div', { class: 'only-desktop' },
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
||||
h('div', { class: 'card' }, shortcuts())),
|
||||
|
||||
h('div', { class: 'page-head' }),
|
||||
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
||||
h('p', { class: 'foot-note', text: 'Schedule editing, statistics and user management are in terdut-tui for now.' }),
|
||||
// Wrapped in a div: .btn is inline-flex, and only a block-level element
|
||||
// picks up .view-page > *'s margin:auto centering (see app.css:316).
|
||||
h('div', {}, h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out')),
|
||||
);
|
||||
}
|
||||
|
||||
// System / Light / Dark. Per browser, not per account: it lives in
|
||||
// localStorage (see js/theme.js), the same place the selected team does.
|
||||
const THEMES = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']];
|
||||
|
||||
function themePicker() {
|
||||
const theme = window.terdutTheme;
|
||||
const buttons = THEMES.map(([value, text]) => h('button', {
|
||||
class: 'chip', type: 'button', role: 'radio', text,
|
||||
onclick: () => { theme.set(value); sync(); },
|
||||
}));
|
||||
const sync = () => buttons.forEach((b, i) => b.setAttribute('aria-checked', String(THEMES[i][0] === theme.get())));
|
||||
sync();
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('div', { class: 'chips theme-picker', role: 'radiogroup', 'aria-label': 'Theme' }, ...buttons),
|
||||
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
|
||||
}
|
||||
|
||||
// Where this user's pages go. The onboarding checklist's first step sends
|
||||
// people here for it, and until now there was nothing here to send them to:
|
||||
// the topic could only be set with curl or by an administrator.
|
||||
//
|
||||
// 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 topic = h('input', {
|
||||
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
|
||||
autocapitalize: 'none', spellcheck: false,
|
||||
value: user.ntfy_topic || '',
|
||||
placeholder: 'terdut-a7f3c91e',
|
||||
});
|
||||
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
|
||||
|
||||
const form = h('form', { class: 'stacked-form' },
|
||||
h('label', {},
|
||||
h('span', { text: 'ntfy topic' }),
|
||||
topic),
|
||||
h('p', { class: 'muted small' },
|
||||
'Subscribe to this topic in the ntfy app and incidents assigned to you ',
|
||||
'reach your phone. Leave it empty and they page the team’s fallback ',
|
||||
'topic instead.'),
|
||||
// Worth saying plainly: people reach for their own name, and the topic is
|
||||
// the only thing standing between a stranger and their pages.
|
||||
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,
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
// has none is not asked to make one. Somebody who does keeps the form: it is
|
||||
// how they change or get rid of a credential the server still remembers.
|
||||
function passwordSection(user, hasPassword) {
|
||||
if (!hasPassword && state.auth.password_login === false) {
|
||||
const name = state.auth.oidc?.name || 'single sign-on';
|
||||
return [
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
|
||||
h('div', { class: 'card' },
|
||||
h('p', { class: 'muted', text: `You sign in with ${name}, and this server has turned password login off.` })),
|
||||
];
|
||||
}
|
||||
return [
|
||||
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;
|
||||
@@ -48,18 +175,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;
|
||||
@@ -71,13 +204,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;
|
||||
@@ -85,7 +219,7 @@ function passwordForm(user, hasPassword) {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
return form;
|
||||
return h('div', { class: 'card pw-form' }, status, details);
|
||||
}
|
||||
|
||||
function shortcuts() {
|
||||
|
||||
+136
-64
@@ -1,23 +1,51 @@
|
||||
// Administration: the teams on this server, the people who can sign in, and
|
||||
// the settings that change how the server behaves.
|
||||
//
|
||||
// Each of those three is a route of its own, reached from a strip across the
|
||||
// top, with /admin itself an overview. They used to be three cards stacked on
|
||||
// one page, which meant no way to link to the settings, no way back to the top
|
||||
// of the user list but scrolling, and a poll that refetched all three endpoints
|
||||
// however little of the page you were looking at.
|
||||
//
|
||||
// Only rendered for a system administrator. The server enforces that on every
|
||||
// endpoint regardless — hiding a section is a courtesy to the reader, not a
|
||||
// permission — so this view simply says so rather than pretending to be a
|
||||
// gate.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm } from './ui.js';
|
||||
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { state, myID } from './state.js';
|
||||
|
||||
const view = () => document.getElementById('view-admin');
|
||||
|
||||
let data = null; // { teams, users, settings }
|
||||
// The sub-sections, in the order the strip shows them. The overview is /admin
|
||||
// itself, so it has no tab of its own. This table is the only place the four
|
||||
// routes are written down: app.js parses against it and the strip is built
|
||||
// from it, so adding a fifth is one line here.
|
||||
export const TABS = [
|
||||
{ tab: null, path: '/admin', label: 'Overview' },
|
||||
{ tab: 'teams', path: '/admin/teams', label: 'Teams' },
|
||||
{ tab: 'users', path: '/admin/users', label: 'Users' },
|
||||
{ tab: 'settings', path: '/admin/settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
// Which sub-section is open. Remembered rather than passed, because the poll
|
||||
// loop calls refresh() with no route — the same reason adminuser.js keeps its
|
||||
// user ID in the module.
|
||||
let tab = null;
|
||||
let data = null; // whatever the current tab needs; the shape varies by tab
|
||||
let error = null;
|
||||
let busy = false;
|
||||
|
||||
export function show() {
|
||||
if (!data) clear(view(), spinner());
|
||||
export function show(route) {
|
||||
const next = route?.tab ?? null;
|
||||
// A different sub-section wants different data, so the old answer goes
|
||||
// rather than being shown under the new heading until the fetch lands.
|
||||
if (next !== tab) {
|
||||
tab = next;
|
||||
data = null;
|
||||
}
|
||||
if (!data) clear(view(), subnav(), spinner());
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -28,12 +56,7 @@ export async function refresh() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [teams, users, settings] = await Promise.all([
|
||||
api.adminTeams(),
|
||||
api.users(),
|
||||
api.adminSettings(),
|
||||
]);
|
||||
data = { teams, users, settings };
|
||||
data = await load();
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
@@ -41,6 +64,16 @@ export async function refresh() {
|
||||
render();
|
||||
}
|
||||
|
||||
// Only what the open sub-section shows. Users is the one that needs two: it
|
||||
// only points at Teams for an invite if there is a team to point at, and the
|
||||
// overview counts both.
|
||||
async function load() {
|
||||
if (tab === 'teams') return { teams: await api.adminTeams() };
|
||||
if (tab === 'settings') return { settings: await api.adminSettings() };
|
||||
const [teams, users] = await Promise.all([api.adminTeams(), api.users()]);
|
||||
return { teams, users };
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.me?.user?.is_admin) {
|
||||
clear(view(), h('div', { class: 'card' },
|
||||
@@ -48,14 +81,61 @@ function render() {
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
clear(view(), subnav(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
return;
|
||||
}
|
||||
clear(view(),
|
||||
subnav(),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
teamsCard(),
|
||||
usersCard(),
|
||||
settingsCard(),
|
||||
section(),
|
||||
);
|
||||
}
|
||||
|
||||
function section() {
|
||||
if (tab === 'teams') return teamsCard();
|
||||
if (tab === 'users') return usersCard();
|
||||
if (tab === 'settings') return settingsCard();
|
||||
return overview();
|
||||
}
|
||||
|
||||
// The strip across the top of every admin page. Ordinary links rather than
|
||||
// buttons, because these are four URLs: app.js intercepts the click, the
|
||||
// browser's Back walks them, and a reload lands where you were.
|
||||
function subnav() {
|
||||
const nav = h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
||||
TABS.map((t) => h('a', {
|
||||
class: 'subnav-link',
|
||||
href: t.path,
|
||||
text: t.label,
|
||||
'aria-current': t.tab === tab ? 'page' : null,
|
||||
})));
|
||||
// On a phone the strip overflows; bring the open section into view so a
|
||||
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||
return nav;
|
||||
}
|
||||
|
||||
// --- overview --------------------------------------------------------------
|
||||
|
||||
// /admin itself. The strip already links to the three, so this earns its place
|
||||
// by saying how much of each there is — the one thing a menu cannot.
|
||||
function overview() {
|
||||
const admins = data.users.filter((u) => u.is_admin).length;
|
||||
const disabled = data.users.filter((u) => u.disabled_at).length;
|
||||
const open = data.teams.reduce((n, t) => n + t.open_incidents, 0);
|
||||
|
||||
const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`];
|
||||
if (disabled > 0) people.push(`${disabled} disabled`);
|
||||
|
||||
return h('div', { class: 'overview-menu' },
|
||||
menuCard('/admin/teams', 'Teams', data.teams.length,
|
||||
open > 0
|
||||
? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.`
|
||||
: 'Nothing open anywhere.'),
|
||||
menuCard('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
|
||||
menuCard('/admin/settings', 'Settings', null,
|
||||
'How the server behaves, and where it is plugged in.'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,37 +144,24 @@ function render() {
|
||||
function teamsCard() {
|
||||
const rows = data.teams.map((t) =>
|
||||
h('tr', {},
|
||||
h('td', {}, h('strong', { text: t.name })),
|
||||
// The name is the way in: everything about one team lives on its own
|
||||
// page, and this table stays a list rather than becoming a form.
|
||||
h('td', {}, h('a', { class: 'row-link', href: `/admin/teams/${t.id}`, text: t.name })),
|
||||
h('td', { class: 'num', text: String(t.members) }),
|
||||
h('td', { class: 'num', text: String(t.open_incidents) }),
|
||||
h('td', {},
|
||||
h('button', {
|
||||
class: 'btn-sm',
|
||||
type: 'button',
|
||||
text: 'Rename',
|
||||
onclick: () => renameTeam(t),
|
||||
}),
|
||||
// A team with open incidents cannot be deleted, and saying so before
|
||||
// the click is kinder than a 409 afterwards.
|
||||
h('button', {
|
||||
class: 'btn-sm danger',
|
||||
type: 'button',
|
||||
text: 'Delete',
|
||||
disabled: t.open_incidents > 0,
|
||||
title: t.open_incidents > 0 ? 'Resolve its open incidents first' : '',
|
||||
onclick: () => deleteTeam(t),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Teams' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Open a team for who is in it, the invites into it, and renaming or ',
|
||||
'deleting it. Deleting takes its alerts, incidents, schedule and ',
|
||||
'integrations with it, and is refused while anything is still open.'),
|
||||
h('table', { class: 'admin-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'Name' }),
|
||||
h('th', { class: 'num', text: 'Members' }),
|
||||
h('th', { class: 'num', text: 'Open' }),
|
||||
h('th', { text: '' }))),
|
||||
h('th', { class: 'num', text: 'Open' }))),
|
||||
h('tbody', {}, rows)),
|
||||
newTeamForm(),
|
||||
);
|
||||
@@ -122,32 +189,6 @@ function newTeamForm() {
|
||||
return form;
|
||||
}
|
||||
|
||||
async function renameTeam(team) {
|
||||
const next = window.prompt(`Rename ${team.name} to:`, team.name);
|
||||
if (!next || next === team.name) return;
|
||||
try {
|
||||
await api.renameTeam(team.id, next);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function deleteTeam(team) {
|
||||
if (!(await confirm({
|
||||
title: `Delete ${team.name}?`,
|
||||
text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await api.deleteTeam(team.id);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
// --- users -----------------------------------------------------------------
|
||||
|
||||
function usersCard() {
|
||||
@@ -155,11 +196,15 @@ function usersCard() {
|
||||
const self = u.id === myID();
|
||||
return h('tr', { class: u.disabled_at ? 'disabled-row' : '' },
|
||||
h('td', {},
|
||||
h('strong', { text: u.username }),
|
||||
// The name is the way in: everything about one person lives on their
|
||||
// own page, and this table stays a list rather than becoming a form.
|
||||
h('a', { class: 'row-link', href: `/admin/users/${u.id}`, text: u.username }),
|
||||
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||
self && h('span', { class: 'you', text: 'you' })),
|
||||
h('td', { class: 'muted', text: u.email }),
|
||||
h('td', {}, u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null),
|
||||
h('td', {},
|
||||
u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null,
|
||||
u.is_admin && u.admin_source === 'oidc' ? ssoBadge() : null),
|
||||
h('td', {},
|
||||
// Neither action is offered for your own account: the server refuses
|
||||
// both, and an enabled-looking button that always fails is worse than
|
||||
@@ -168,6 +213,9 @@ function usersCard() {
|
||||
class: 'btn-sm',
|
||||
type: 'button',
|
||||
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||
// The server refuses to revoke what the groups grant.
|
||||
disabled: ssoAdmin(u),
|
||||
title: ssoAdmin(u) ? SSO_MANAGED : null,
|
||||
onclick: () => setAdmin(u, !u.is_admin),
|
||||
}),
|
||||
!self && h('button', {
|
||||
@@ -184,7 +232,8 @@ function usersCard() {
|
||||
h('h2', { text: 'Users' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Disabling an account stops it signing in and stops its API keys, and keeps ',
|
||||
'its acknowledgements and timeline entries. Deleting a user erases those.'),
|
||||
'its acknowledgements and timeline entries. Deleting a user erases those. ',
|
||||
'Open a name for their teams, their password and the rest.'),
|
||||
h('table', { class: 'admin-table' },
|
||||
h('thead', {}, h('tr', {},
|
||||
h('th', { text: 'User' }),
|
||||
@@ -192,9 +241,32 @@ function usersCard() {
|
||||
h('th', { text: '' }),
|
||||
h('th', { text: '' }))),
|
||||
h('tbody', {}, rows)),
|
||||
invitePointer(),
|
||||
);
|
||||
}
|
||||
|
||||
// Adding a person is minting them an invite into a team, not creating a row:
|
||||
// whoever accepts it picks their own password, so one never passes through an
|
||||
// administrator, and the link carries the team, so they do not land on an empty
|
||||
// queue.
|
||||
//
|
||||
// The form for it lives on the team's own page. It always needed a team beside
|
||||
// it, and a picker here was the admission that an invite is a fact about a team
|
||||
// rather than about the server.
|
||||
function invitePointer() {
|
||||
return h('div', { class: 'invite-block' },
|
||||
h('h3', { text: 'Add someone' }),
|
||||
data.teams.length > 0
|
||||
? h('p', { class: 'muted small' },
|
||||
'Open the team you want them in, under ',
|
||||
h('a', { class: 'row-link', href: '/admin/teams', text: 'Teams' }),
|
||||
', and mint an invite there.')
|
||||
: h('p', { class: 'muted small', text: 'Create a team first — an invite has to lead somewhere.' }),
|
||||
);
|
||||
}
|
||||
|
||||
const ssoAdmin = (u) => u.is_admin && u.admin_source === 'oidc';
|
||||
|
||||
async function setAdmin(user, next) {
|
||||
if (next && !(await confirm({
|
||||
title: `Make ${user.username} an administrator?`,
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// One team, at /admin/teams/{id}: what it is, who is in it, the invites into
|
||||
// it, and the two destructive things an administrator can do to it.
|
||||
//
|
||||
// The mirror of adminuser.js. That page answers "which teams is this person
|
||||
// in"; this one answers "who is in this team" for a team the administrator
|
||||
// need not be a member of — which the Team tab cannot do, because it only
|
||||
// offers teams the viewer is in.
|
||||
//
|
||||
// Only rendered for a system administrator. The server enforces that on every
|
||||
// endpoint regardless, so this view says so rather than pretending to be a
|
||||
// gate.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, toast, icon, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { state } from './state.js';
|
||||
import { navigate } from './app.js';
|
||||
import { when } from './format.js';
|
||||
|
||||
const view = () => document.getElementById('view-adminteam');
|
||||
|
||||
let teamID = null;
|
||||
let data = null; // { team, members, users, invites }
|
||||
let error = null;
|
||||
let busy = false;
|
||||
// An invite link is shown once and never stored, so it lives here until the
|
||||
// page is left rather than being toasted away after three seconds.
|
||||
let freshInvite = null;
|
||||
|
||||
export function show(route) {
|
||||
const next = route && route.team != null ? route.team : null;
|
||||
if (next !== teamID) {
|
||||
teamID = next;
|
||||
data = null;
|
||||
error = null;
|
||||
freshInvite = null;
|
||||
}
|
||||
if (!data) clear(view(), spinner());
|
||||
refresh();
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
if (teamID == null || !state.me?.user?.is_admin) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The team and its members come from the admin endpoint in one answer:
|
||||
// /teams/{id}/members is member-only and 404s an administrator from
|
||||
// outside the team, deliberately. users() is the add-a-member picker.
|
||||
const [team, users, invites] = await Promise.all([
|
||||
api.adminTeam(teamID),
|
||||
api.users(),
|
||||
api.invites(teamID),
|
||||
]);
|
||||
data = { team: team.team, members: team.members, users, invites };
|
||||
error = null;
|
||||
} catch (err) {
|
||||
// A team that is gone answers 404, where a missing user is simply absent
|
||||
// from a list adminuser.js already has. So the "no such team" state has to
|
||||
// be recognised here; left to the error banner it would read as a fetch
|
||||
// that failed, which is a different thing and invites a retry.
|
||||
if (err.status === 404) {
|
||||
data = { team: null, members: [], users: [], invites: [] };
|
||||
error = null;
|
||||
} else {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const el = view();
|
||||
if (!state.me?.user?.is_admin) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
clear(el, backLink(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
return;
|
||||
}
|
||||
if (!data.team) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
h('p', { class: 'muted', text: 'No such team. It may have just been deleted.' })));
|
||||
return;
|
||||
}
|
||||
clear(el,
|
||||
backLink(),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
identityCard(),
|
||||
membersCard(),
|
||||
invitesCard(),
|
||||
dangerCard(),
|
||||
);
|
||||
}
|
||||
|
||||
function backLink() {
|
||||
// Wrapped in a div: .back-link is inline-flex, and only a block-level
|
||||
// element picks up .view-page > *'s margin:auto centering (app.css:316).
|
||||
return h('div', {}, h('a', { class: 'back-link', href: '/admin/teams' }, icon('chevronLeft'), h('span', { text: 'Teams' })));
|
||||
}
|
||||
|
||||
// --- identity --------------------------------------------------------------
|
||||
|
||||
function identityCard() {
|
||||
const t = data.team;
|
||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||
const name = h('input', {
|
||||
name: 'name', type: 'text', value: t.name, required: true,
|
||||
autocomplete: 'off', spellcheck: false,
|
||||
});
|
||||
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save name' });
|
||||
|
||||
// A field rather than the window.prompt this used to be. The server answers
|
||||
// 409 for a name already taken, and a dialog is the wrong place to read that.
|
||||
const form = h('form', { class: 'inline-form' }, name, submit);
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
err.hidden = true;
|
||||
ok.hidden = true;
|
||||
const next = name.value.trim();
|
||||
if (!next || next === t.name) return;
|
||||
busy = true;
|
||||
submit.disabled = true;
|
||||
try {
|
||||
await api.renameTeam(teamID, next);
|
||||
ok.textContent = 'Name saved.';
|
||||
ok.hidden = false;
|
||||
error = null;
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
busy = false;
|
||||
submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
busy = false;
|
||||
submit.disabled = false;
|
||||
await refresh();
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'user-head' }, h('h2', { text: t.name })),
|
||||
h('dl', { class: 'user-facts' },
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
function fact(label, value) {
|
||||
return [h('dt', { text: label }), h('dd', { text: value })];
|
||||
}
|
||||
|
||||
// --- members ---------------------------------------------------------------
|
||||
|
||||
// An administrator passes every team-owner check without being in the team,
|
||||
// which is what lets them repair a team whose owner has left. So this card
|
||||
// edits rather than reporting what somebody else would have to do.
|
||||
function membersCard() {
|
||||
const rows = data.members.map((m) =>
|
||||
h('tr', {},
|
||||
// Unlike the Team tab's own member list, the name is a link: that
|
||||
// person's page is where the rest of them lives.
|
||||
h('td', {}, h('a', { class: 'row-link', href: `/admin/users/${m.user_id}`, text: m.username })),
|
||||
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
|
||||
h('td', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button',
|
||||
text: m.role === 'owner' ? 'Make member' : 'Make owner',
|
||||
// The server refuses to edit a membership the groups grant.
|
||||
disabled: m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||
// The same endpoint both ways: adding is an upsert on the role.
|
||||
onclick: () => act(() =>
|
||||
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
disabled: m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||
// The server refuses the last owner with a 409, which act() shows.
|
||||
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const inTeam = new Set(data.members.map((m) => m.user_id));
|
||||
// A disabled account cannot sign in, so putting one on a rota would be
|
||||
// staffing the team with somebody who cannot answer.
|
||||
const candidates = data.users.filter((u) => !inTeam.has(u.id) && !u.disabled_at);
|
||||
const pick = h('select', {},
|
||||
...candidates.map((u) => h('option', { value: String(u.id), text: u.username })));
|
||||
const role = h('select', {},
|
||||
h('option', { value: 'member', text: 'member' }),
|
||||
h('option', { value: 'owner', text: 'owner' }));
|
||||
const form = h('form', { class: 'inline-form' }, pick, role,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Members' }),
|
||||
data.members.length === 0 && h('p', { class: 'muted small' },
|
||||
'Nobody is in this team. Its queue has no one to work it and its ',
|
||||
'escalation has no one to reach — add somebody, or delete it.'),
|
||||
data.members.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||
candidates.length > 0 && form,
|
||||
);
|
||||
}
|
||||
|
||||
// --- invites ---------------------------------------------------------------
|
||||
|
||||
// Adding a person to the server is minting them an invite into a team, not
|
||||
// creating a row: whoever accepts it picks their own password, so one never
|
||||
// passes through an administrator, and the link carries the team, so they do
|
||||
// not land on an empty queue.
|
||||
//
|
||||
// This lives on the team rather than on the Users page, where it used to be
|
||||
// with a team picker beside it. The picker was the admission that an invite is
|
||||
// a fact about a team.
|
||||
function invitesCard() {
|
||||
const role = h('select', {},
|
||||
h('option', { value: 'member', text: 'member' }),
|
||||
h('option', { value: 'owner', text: 'owner' }));
|
||||
const form = h('form', { class: 'inline-form' }, role,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const inv = await api.createInvite(teamID, role.value, 1);
|
||||
freshInvite = inv.url;
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
|
||||
// The server lists spent and revoked invites too, and they are worth seeing:
|
||||
// "who was invited here" is part of the answer to "who is in this team".
|
||||
// Only a live one can be revoked, so only a live one offers the button.
|
||||
const rows = (data.invites || []).map((inv) => {
|
||||
const state = inviteState(inv);
|
||||
return h('tr', { class: state === 'live' ? '' : 'disabled-row' },
|
||||
h('td', {}, h('strong', { text: inv.role })),
|
||||
h('td', { class: 'muted small', text: `${inv.uses}/${inv.max_uses} used` }),
|
||||
h('td', { class: 'muted small', text: state === 'live' ? `expires ${when(inv.expires_at)}` : state }),
|
||||
h('td', { class: 'row-actions' },
|
||||
state === 'live' && h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Revoke',
|
||||
onclick: () => act(() => api.revokeInvite(teamID, inv.id)),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Invites' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'An invite link puts somebody in this team and lets them choose their ',
|
||||
'own password. It lasts a week and can be used once.'),
|
||||
rows.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||
form,
|
||||
// Shown once and never stored, so it goes on the page to be copied.
|
||||
freshInvite && h('p', { class: 'invite-out' },
|
||||
h('strong', { text: 'Send them this link. It is shown once.' }),
|
||||
h('code', { class: 'invite-link', text: freshInvite })),
|
||||
);
|
||||
}
|
||||
|
||||
// Why a link no longer works, in the server's own order of precedence: revoked
|
||||
// beats spent beats expired. Only 'live' is still usable.
|
||||
function inviteState(inv) {
|
||||
if (inv.revoked) return 'revoked';
|
||||
if (inv.uses >= inv.max_uses) return 'used up';
|
||||
if (new Date(inv.expires_at).getTime() <= Date.now()) return 'expired';
|
||||
return 'live';
|
||||
}
|
||||
|
||||
// --- delete ----------------------------------------------------------------
|
||||
|
||||
function dangerCard() {
|
||||
const t = data.team;
|
||||
const blocked = t.open_incidents > 0;
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Delete' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Its alerts, incidents, schedule and integrations go with it. This ',
|
||||
'cannot be undone. Everybody in it keeps their account and stays in ',
|
||||
'whatever other teams they are in.'),
|
||||
h('button', {
|
||||
class: 'btn btn-danger', type: 'button', text: `Delete ${t.name}`,
|
||||
// Saying so before the click is kinder than a 409 afterwards.
|
||||
disabled: blocked,
|
||||
title: blocked ? 'Resolve its open incidents first' : '',
|
||||
onclick: deleteTeam,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteTeam() {
|
||||
if (!(await confirm({
|
||||
title: `Delete ${data.team.name}?`,
|
||||
text: 'Its alerts, incidents, schedule and integrations go with it. This cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await api.deleteTeam(teamID);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
toast('Team deleted.');
|
||||
// Not act(): there is no longer a page here to refresh.
|
||||
navigate('/admin/teams');
|
||||
}
|
||||
|
||||
// --- plumbing --------------------------------------------------------------
|
||||
|
||||
// act runs a write and reloads. Errors are shown rather than thrown away: the
|
||||
// 409 from the last-owner guard, and the one for a duplicate name, are the
|
||||
// server explaining itself, and the reader needs to see it.
|
||||
async function act(fn) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await fn();
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// One person, at /admin/users/{id}: what they are, what they are in, and the
|
||||
// levers an administrator has over the account.
|
||||
//
|
||||
// A section of its own rather than an expanding row in the Admin tab's table,
|
||||
// because memberships and the account actions together are more than a row can
|
||||
// hold and still be read on a phone.
|
||||
//
|
||||
// Like the Admin tab, this hides nothing the server would allow and shows
|
||||
// nothing it would refuse: every write here is an endpoint that answers 403
|
||||
// without the flag, so the view is a description of the rules rather than an
|
||||
// enforcement of them.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, toast, icon, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { state, myID } from './state.js';
|
||||
import { navigate } from './app.js';
|
||||
import { when } from './format.js';
|
||||
|
||||
const view = () => document.getElementById('view-adminuser');
|
||||
|
||||
let userID = null;
|
||||
let data = null; // { user, teams, allTeams }
|
||||
let error = null;
|
||||
let busy = false;
|
||||
|
||||
export function show(route) {
|
||||
const next = route && route.user != null ? route.user : null;
|
||||
if (next !== userID) {
|
||||
userID = next;
|
||||
data = null;
|
||||
error = null;
|
||||
}
|
||||
if (!data) clear(view(), spinner());
|
||||
refresh();
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
if (userID == null || !state.me?.user?.is_admin) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The user comes from the list rather than a show endpoint: there is no
|
||||
// GET /api/users/{id}, and adding one for a row the list already carries
|
||||
// would be a second way to say the same thing.
|
||||
const [users, teams, allTeams] = await Promise.all([
|
||||
api.users(),
|
||||
api.userTeams(userID),
|
||||
api.adminTeams(),
|
||||
]);
|
||||
const user = users.find((u) => u.id === userID) || null;
|
||||
data = { user, teams, allTeams };
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const el = view();
|
||||
if (!state.me?.user?.is_admin) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
clear(el, backLink(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
return;
|
||||
}
|
||||
if (!data.user) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
h('p', { class: 'muted', text: 'No such user. They may have just been deleted.' })));
|
||||
return;
|
||||
}
|
||||
clear(el,
|
||||
backLink(),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
identityCard(),
|
||||
teamsCard(),
|
||||
accountCard(),
|
||||
);
|
||||
}
|
||||
|
||||
function backLink() {
|
||||
// Wrapped in a div: .back-link is inline-flex, and only a block-level
|
||||
// element picks up .view-page > *'s margin:auto centering (app.css:316).
|
||||
return h('div', {}, h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' })));
|
||||
}
|
||||
|
||||
// --- identity --------------------------------------------------------------
|
||||
|
||||
function identityCard() {
|
||||
const u = data.user;
|
||||
const self = u.id === myID();
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'user-head' },
|
||||
h('h2', { text: u.username }),
|
||||
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
|
||||
u.is_admin && u.admin_source === 'oidc' && ssoBadge(),
|
||||
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||
self && h('span', { class: 'you', text: 'you' })),
|
||||
h('dl', { class: 'user-facts' },
|
||||
fact('Email', u.email),
|
||||
fact('Joined', when(u.created_at)),
|
||||
fact('Notifications', u.ntfy_topic ? `ntfy: ${u.ntfy_topic}` : 'None of their own'),
|
||||
u.disabled_at && fact('Disabled', when(u.disabled_at)),
|
||||
),
|
||||
// Both of these refuse your own account, and the last administrator's. An
|
||||
// enabled button that always fails is worse than no button.
|
||||
h('div', { class: 'row-actions' },
|
||||
!self && h('button', {
|
||||
class: 'btn', type: 'button',
|
||||
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||
// The server refuses to revoke what the groups grant.
|
||||
disabled: u.is_admin && u.admin_source === 'oidc',
|
||||
title: u.is_admin && u.admin_source === 'oidc' ? SSO_MANAGED : null,
|
||||
onclick: () => setAdmin(!u.is_admin),
|
||||
}),
|
||||
!self && h('button', {
|
||||
class: 'btn', type: 'button',
|
||||
text: u.disabled_at ? 'Enable account' : 'Disable account',
|
||||
onclick: () => setDisabled(!u.disabled_at),
|
||||
}),
|
||||
),
|
||||
self && h('p', { class: 'muted small' },
|
||||
'You cannot change your own administrator flag or disable yourself — ',
|
||||
'that is how an install ends up with nobody who can administer it.'),
|
||||
);
|
||||
}
|
||||
|
||||
function fact(label, value) {
|
||||
return [h('dt', { text: label }), h('dd', { text: value })];
|
||||
}
|
||||
|
||||
async function setAdmin(next) {
|
||||
if (next && !(await confirm({
|
||||
title: `Make ${data.user.username} an administrator?`,
|
||||
text: 'They will be able to manage every account, configure any team, and grant this to others.',
|
||||
confirmLabel: 'Make admin',
|
||||
}))) return;
|
||||
await act(() => api.setUserAdmin(userID, next));
|
||||
}
|
||||
|
||||
async function setDisabled(next) {
|
||||
if (next && !(await confirm({
|
||||
title: `Disable ${data.user.username}?`,
|
||||
text: 'They cannot sign in and their API keys stop working. Their acknowledgements and timeline entries stay.',
|
||||
confirmLabel: 'Disable',
|
||||
danger: true,
|
||||
}))) return;
|
||||
await act(() => api.setUserDisabled(userID, next));
|
||||
}
|
||||
|
||||
// --- teams -----------------------------------------------------------------
|
||||
|
||||
// An administrator passes every team-owner check without being in the team,
|
||||
// which is what lets them repair a team whose owner has left. So this card
|
||||
// edits, rather than reporting what somebody else would have to do.
|
||||
//
|
||||
// It is the one place membership can be changed from the person's side: the
|
||||
// Team tab asks "who is in this team", and answering "which teams is this
|
||||
// person in" there means visiting each team in turn.
|
||||
function teamsCard() {
|
||||
const rows = data.teams.map((t) =>
|
||||
h('tr', {},
|
||||
// Not a link: the Team tab always shows the viewer's own team, so
|
||||
// sending them there from somebody else's membership would be a lie.
|
||||
h('td', {}, h('strong', { text: t.name })),
|
||||
h('td', { class: 'muted small' }, t.role, t.source === 'oidc' && ssoBadge()),
|
||||
h('td', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button',
|
||||
text: t.role === 'owner' ? 'Make member' : 'Make owner',
|
||||
// The server refuses to edit a membership the groups grant.
|
||||
disabled: t.source === 'oidc',
|
||||
title: t.source === 'oidc' ? SSO_MANAGED : null,
|
||||
onclick: () => act(() =>
|
||||
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
disabled: t.source === 'oidc',
|
||||
title: t.source === 'oidc' ? SSO_MANAGED : null,
|
||||
// The server refuses the last owner with a 409, which act() shows.
|
||||
onclick: () => act(() => api.removeTeamMember(t.id, userID)),
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
const inTeam = new Set(data.teams.map((t) => t.id));
|
||||
const candidates = (data.allTeams || []).filter((t) => !inTeam.has(t.id));
|
||||
const pick = h('select', {},
|
||||
...candidates.map((t) => h('option', { value: String(t.id), text: t.name })));
|
||||
const role = h('select', {},
|
||||
h('option', { value: 'member', text: 'member' }),
|
||||
h('option', { value: 'owner', text: 'owner' }));
|
||||
const form = h('form', { class: 'inline-form' }, pick, role,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Teams' }),
|
||||
data.teams.length === 0 && h('p', { class: 'muted small' },
|
||||
'In no team. They can sign in, but there is no queue for them to work ',
|
||||
'and nothing to page them about.'),
|
||||
data.teams.length > 0 && h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||
candidates.length > 0 && form,
|
||||
);
|
||||
}
|
||||
|
||||
// --- account ---------------------------------------------------------------
|
||||
|
||||
function accountCard() {
|
||||
const u = data.user;
|
||||
const self = u.id === myID();
|
||||
|
||||
const pw = h('input', {
|
||||
type: 'password', name: 'password', autocomplete: 'new-password',
|
||||
minlength: '10', required: true, placeholder: 'At least 10 characters',
|
||||
});
|
||||
const form = h('form', { class: 'inline-form' }, pw,
|
||||
h('button', { class: 'btn', type: 'submit', text: 'Set password' }));
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
// No current password: that check is for changing your own, and an
|
||||
// administrator setting somebody else's does not know it by design.
|
||||
await api.setPassword(userID, pw.value);
|
||||
pw.value = '';
|
||||
toast(`Password set for ${u.username}. Their other sessions are signed out.`);
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
h('h2', { text: 'Account' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Setting a password here is how somebody gets their first one, or a new ',
|
||||
'one after forgetting it. It signs them out everywhere else. They change ',
|
||||
'it themselves under Account afterwards.'),
|
||||
self ? h('p', { class: 'muted small' },
|
||||
'Change your own password under Account, where the current one is asked for.')
|
||||
: form,
|
||||
h('h3', { text: 'Delete' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Deleting erases their acknowledgements and timeline entries — incidents ',
|
||||
'they handled stop saying who did. Disabling keeps the history and is ',
|
||||
'almost always what is meant.'),
|
||||
h('button', {
|
||||
class: 'btn btn-danger', type: 'button', text: `Delete ${u.username}`,
|
||||
disabled: self,
|
||||
title: self ? 'You cannot delete your own account' : '',
|
||||
onclick: deleteUser,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
if (!(await confirm({
|
||||
title: `Delete ${data.user.username}?`,
|
||||
text: 'Their API keys go with them, and their name comes off every incident they acknowledged. This cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await api.deleteUser(userID);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
toast('User deleted.');
|
||||
navigate('/admin/users');
|
||||
}
|
||||
|
||||
// --- plumbing --------------------------------------------------------------
|
||||
|
||||
// act runs a write and reloads. Errors are shown rather than thrown away: the
|
||||
// 409 from the last-owner or last-administrator guard is the server explaining
|
||||
// itself, and the reader needs to see it.
|
||||
async function act(fn) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await fn();
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
// incident it belongs to, which is where anything can be done about it.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||
import { age, severityClass, labelSummary } from './format.js';
|
||||
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
|
||||
import { age, labelSummary } from './format.js';
|
||||
|
||||
const FILTERS = [
|
||||
{ id: 'firing', label: 'Firing', query: { status: 'firing' } },
|
||||
@@ -60,7 +60,11 @@ function render() {
|
||||
let body;
|
||||
if (error && !items) body = h('div', { class: 'load-error', text: error });
|
||||
else if (!items) body = spinner();
|
||||
else if (!items.length) body = emptyState(filter === 'firing' ? 'Nothing firing' : 'No alerts', '', filter === 'firing' ? 'checkCircle' : null);
|
||||
else if (!items.length) {
|
||||
body = filter === 'firing'
|
||||
? emptyState('Nothing firing', 'No alerts are currently firing.', 'checkCircle')
|
||||
: emptyState('No alerts', 'None match this filter.', 'bell');
|
||||
}
|
||||
else {
|
||||
body = h('div', { class: 'list' },
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
@@ -83,7 +87,7 @@ function row(a) {
|
||||
h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }),
|
||||
h('div', { class: 'row-meta' },
|
||||
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
|
||||
sev && badge(sev, `plain ${severityClass(sev)}`),
|
||||
sev && severityBadge(sev),
|
||||
summary && h('span', { text: summary }),
|
||||
labels && h('span', { class: 'labels', text: labels }),
|
||||
),
|
||||
|
||||
@@ -59,14 +59,28 @@ async function call(method, path, { query, body, signal } = {}) {
|
||||
|
||||
// session
|
||||
export const me = () => call('GET', '/me');
|
||||
// How this server can be signed in to: { password_login, oidc: { enabled, name } }.
|
||||
export const authConfig = () => call('GET', '/auth/config');
|
||||
export const login = (username, password) => call('POST', '/login', { body: { username, password } });
|
||||
export const logout = () => call('POST', '/logout');
|
||||
// Approve or refuse a sign-in a terminal started; code is what it is showing.
|
||||
export const approveDevice = (code) => call('POST', '/oidc/device/approve', { body: { user_code: code } });
|
||||
export const denyDevice = (code) => call('POST', '/oidc/device/deny', { body: { user_code: code } });
|
||||
export const setPassword = (userID, password, currentPassword) =>
|
||||
call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } });
|
||||
|
||||
// users
|
||||
export const users = () => call('GET', '/users');
|
||||
|
||||
// What one person is in. /teams answers "what am I in" and cannot be asked
|
||||
// about anybody else, which is what the admin page's per-user view needs.
|
||||
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
|
||||
|
||||
// Where this user's pages go. An empty topic clears it, which the server
|
||||
// treats as "no topic of their own" rather than an error.
|
||||
export const setNotifyTarget = (id, ntfyTopic) =>
|
||||
call('PUT', `/users/${id}/notify`, { body: { ntfy_topic: ntfyTopic } });
|
||||
|
||||
// incidents
|
||||
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
|
||||
export const incident = (id) => call('GET', `/incidents/${id}`);
|
||||
@@ -74,19 +88,40 @@ export const timeline = (id) => call('GET', `/incidents/${id}/timeline`);
|
||||
|
||||
export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`);
|
||||
export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`);
|
||||
export const resolve = (id) => call('POST', `/incidents/${id}/resolve`);
|
||||
export const resolve = (id, resolution) => call('POST', `/incidents/${id}/resolve`, resolution ? { body: { resolution } } : {});
|
||||
export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } });
|
||||
export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec });
|
||||
export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`);
|
||||
export const archive = (id) => call('POST', `/incidents/${id}/archive`);
|
||||
export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
|
||||
export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } });
|
||||
export const addNote = (id, content, pinned = false) => call('POST', `/incidents/${id}/notes`, { body: { content, pinned } });
|
||||
export const similar = (id) => call('GET', `/incidents/${id}/similar`);
|
||||
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
||||
|
||||
// stats
|
||||
export const statsIncidents = (query) => call('GET', '/stats/incidents', { query });
|
||||
export const statsTop = (query) => call('GET', '/stats/alerts/top', { query });
|
||||
export const statsByHour = (query) => call('GET', '/stats/alerts/by-hour', { query });
|
||||
export const statsByDay = (query) => call('GET', '/stats/alerts/by-day', { query });
|
||||
|
||||
// alerts
|
||||
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
||||
|
||||
// schedule
|
||||
// Sign-up, both halves unauthenticated: the caller has no account yet.
|
||||
export const signupInfo = (invite) =>
|
||||
call('GET', '/signup', { query: invite ? { invite } : {} });
|
||||
export const signup = (body) => call('POST', '/signup', { body });
|
||||
|
||||
export const invites = (id) => call('GET', `/teams/${id}/invites`);
|
||||
export const createInvite = (id, role, maxUses) =>
|
||||
call('POST', `/teams/${id}/invites`, { body: { role, max_uses: maxUses } });
|
||||
export const revokeInvite = (id, inviteID) => call('DELETE', `/teams/${id}/invites/${inviteID}`);
|
||||
|
||||
export const testNotification = () => call('POST', '/me/notify/test');
|
||||
export const dismissOnboarding = (dismissed) =>
|
||||
call('PUT', '/me/onboarding', { body: { dismissed } });
|
||||
|
||||
export const teams = () => call('GET', '/teams');
|
||||
export const createTeam = (name) => call('POST', '/teams', { body: { name } });
|
||||
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
|
||||
@@ -100,14 +135,25 @@ 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 } });
|
||||
export const renameIntegration = (id, integrationID, name) =>
|
||||
call('PATCH', `/teams/${id}/integrations/${integrationID}`, { body: { name } });
|
||||
export const deleteIntegration = (id, integrationID) =>
|
||||
call('DELETE', `/teams/${id}/integrations/${integrationID}`);
|
||||
|
||||
export const deadman = (id) => call('GET', `/teams/${id}/deadman`);
|
||||
export const setDeadman = (id, body) => call('PUT', `/teams/${id}/deadman`, { body });
|
||||
export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
|
||||
export const createDeadmanSwitch = (id, body) =>
|
||||
call('POST', `/teams/${id}/deadman/switches`, { body });
|
||||
export const updateDeadmanSwitch = (id, switchID, body) =>
|
||||
call('PUT', `/teams/${id}/deadman/switches/${switchID}`, { body });
|
||||
export const deleteDeadmanSwitch = (id, switchID) =>
|
||||
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
|
||||
|
||||
export const escalation = (id) => call('GET', `/teams/${id}/escalation`);
|
||||
export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body });
|
||||
@@ -119,12 +165,17 @@ export const unassignSchedule = (id, entryID) => call('DELETE', `/teams/${id}/sc
|
||||
// Administration. Every one of these is refused with 403 for anybody without
|
||||
// the flag, so the UI hides the section rather than guarding it.
|
||||
export const adminTeams = () => call('GET', '/admin/teams');
|
||||
// One team and who is in it: { team, members }. /teams/{id}/members is
|
||||
// member-only and answers 404 to an administrator from outside the team, which
|
||||
// is the rule rather than an oversight -- this asks the other question.
|
||||
export const adminTeam = (id) => call('GET', `/admin/teams/${id}`);
|
||||
export const adminSettings = () => call('GET', '/admin/settings');
|
||||
export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body });
|
||||
export const setUserAdmin = (id, isAdmin) =>
|
||||
call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
|
||||
export const setUserDisabled = (id, disabled) =>
|
||||
call('PUT', `/users/${id}/disabled`, { body: { disabled } });
|
||||
export const deleteUser = (id) => call('DELETE', `/users/${id}`);
|
||||
export const schedule = (teamID, from, to) =>
|
||||
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
|
||||
|
||||
|
||||
+281
-15
@@ -8,30 +8,79 @@ import * as queue from './queue.js';
|
||||
import * as incident from './incident.js';
|
||||
import * as oncall from './oncall.js';
|
||||
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';
|
||||
import * as device from './device.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// One route per section; /incidents/{id} is the queue with a detail open.
|
||||
// One route per section; /incidents/{id} is the queue with a detail open, and
|
||||
// /admin/users/{id} is a section of its own rather than a mode of the Admin
|
||||
// tab, because it replaces the page rather than opening beside it.
|
||||
const SECTIONS = {
|
||||
queue: { title: 'Queue', view: queue },
|
||||
oncall: { title: 'On-call', view: oncall },
|
||||
alerts: { title: 'Alerts', view: alerts },
|
||||
stats: { title: 'Stats', view: stats },
|
||||
team: { title: 'Team', view: team },
|
||||
admin: { title: 'Admin', view: admin },
|
||||
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
||||
adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
|
||||
more: { title: 'Account', view: account },
|
||||
// Reached by link from a terminal's sign-in prompt, not from the nav.
|
||||
device: { title: 'Sign in a terminal', view: device },
|
||||
};
|
||||
|
||||
// The desktop sidebar's .nav-link list in index.html, in the same order.
|
||||
// `secondary` marks the ones that fold into the phone bottom bar's "More"
|
||||
// sheet (openNavMenu below) instead of getting a tab of their own there.
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
|
||||
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
|
||||
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
|
||||
{ path: '/stats', section: 'stats', label: 'Stats', icon: 'chart', secondary: true },
|
||||
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
|
||||
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true, secondary: true },
|
||||
{ path: '/more', section: 'more', label: 'Account', icon: 'user', secondary: true },
|
||||
];
|
||||
|
||||
function parseRoute(pathname) {
|
||||
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
||||
if (m) return { section: 'queue', incident: Number(m[1]) };
|
||||
const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/);
|
||||
if (u) return { section: 'adminuser', user: Number(u[1]) };
|
||||
// Before the TABS lookup below, which matches a path exactly and would let
|
||||
// /admin/teams/7 fall through to the queue.
|
||||
const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/);
|
||||
if (g) return { section: 'adminteam', team: Number(g[1]) };
|
||||
const name = pathname.replace(/^\/|\/$/g, '');
|
||||
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name };
|
||||
// The Admin and Team tabs' sub-sections are routes of their own. Each view
|
||||
// owns the table of its own, since each also builds the strip that links to
|
||||
// them; /team is in team.TABS as the overview, so it is matched here too.
|
||||
const t = admin.TABS.find((x) => x.path === `/${name}`);
|
||||
if (t) return { section: 'admin', tab: t.tab };
|
||||
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
||||
if (tt) return { section: 'team', tab: tt.tab };
|
||||
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more' || name === 'device') return { section: name };
|
||||
return { section: 'queue', incident: null };
|
||||
}
|
||||
|
||||
// What the top bar and the document title call this route. The sub-sections of
|
||||
// Admin and Team are pages in their own right, so they say which one rather
|
||||
// than the tab's name four or six times; either overview keeps the tab's own
|
||||
// name. A tab may carry a `title` where its strip label is too short to name a
|
||||
// page on its own.
|
||||
function title(r) {
|
||||
const tabs = r.section === 'admin' ? admin.TABS : r.section === 'team' ? team.TABS : null;
|
||||
const t = tabs && r.tab ? tabs.find((x) => x.tab === r.tab) : null;
|
||||
return t ? (t.title || t.label) : SECTIONS[r.section].title;
|
||||
}
|
||||
|
||||
let route = parseRoute(location.pathname);
|
||||
// How many in-app navigations deep we are, so Back can use the browser's
|
||||
// history when there is somewhere to go back to, and the queue otherwise.
|
||||
@@ -64,13 +113,16 @@ function render() {
|
||||
route = parseRoute(location.pathname);
|
||||
const app = $('app');
|
||||
|
||||
for (const [name, s] of Object.entries(SECTIONS)) {
|
||||
for (const name of Object.keys(SECTIONS)) {
|
||||
const el = $(`view-${name}`);
|
||||
el.hidden = name !== route.section;
|
||||
if (name === route.section) $('topbar-title').textContent = s.title;
|
||||
if (name === route.section) $('topbar-title').textContent = title(route);
|
||||
}
|
||||
// A section may light up somebody else's tab: /admin/users/{id} is still the
|
||||
// Admin tab as far as the nav is concerned, since there is no tab of its own.
|
||||
const current = SECTIONS[route.section].nav || route.section;
|
||||
for (const link of document.querySelectorAll('.nav-link')) {
|
||||
if (link.dataset.section === route.section) link.setAttribute('aria-current', 'page');
|
||||
if (link.dataset.section === current) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
}
|
||||
|
||||
@@ -85,16 +137,49 @@ function render() {
|
||||
incident.show(route.incident);
|
||||
} else {
|
||||
incident.show(null);
|
||||
SECTIONS[route.section].view.show();
|
||||
SECTIONS[route.section].view.show(route);
|
||||
}
|
||||
|
||||
if (detailOpen && !wasOpen) window.scrollTo(0, 0);
|
||||
else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll));
|
||||
else if (prev.section !== route.section) window.scrollTo(0, 0);
|
||||
// A changed tab counts as a changed page: stepping from a long user list to
|
||||
// the settings should not land you halfway down them. So does a changed
|
||||
// subject — one team to the next is two pages, not one scrolled page.
|
||||
else if (prev.section !== route.section || prev.tab !== route.tab
|
||||
|| prev.user !== route.user || prev.team !== route.team) window.scrollTo(0, 0);
|
||||
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
// ---------- nav menu ----------
|
||||
|
||||
// The phone bottom bar's "More" sheet: same shape as the sheet-based action
|
||||
// menus in incident.js (openSheet + a <ul class="menu"> of menu-item
|
||||
// buttons), one item per secondary NAV_ITEMS entry — the ones the bar itself
|
||||
// has no room for, since Queue/On-call/Alerts/Team already have their own
|
||||
// tab and don't need to be reachable here too.
|
||||
function openNavMenu() {
|
||||
const current = SECTIONS[route.section].nav || route.section;
|
||||
const items = NAV_ITEMS.filter((n) => n.secondary && (!n.adminOnly || state.me?.user?.is_admin));
|
||||
ui.openSheet(() => [
|
||||
ui.h('h2', { class: 'sheet-title', text: 'More' }),
|
||||
ui.h('ul', { class: 'menu', role: 'menu' }, items.map((n) =>
|
||||
ui.h('li', {}, ui.h('button', {
|
||||
class: 'menu-item',
|
||||
type: 'button',
|
||||
role: 'menuitemradio',
|
||||
'aria-checked': String(n.section === current),
|
||||
onclick: () => ui.closeSheet(n.path),
|
||||
},
|
||||
ui.icon(n.icon),
|
||||
n.label,
|
||||
))),
|
||||
),
|
||||
]).then((path) => {
|
||||
if (path) navigate(path);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- refresh + badges ----------
|
||||
|
||||
async function refresh() {
|
||||
@@ -117,19 +202,23 @@ function updateBadges() {
|
||||
|
||||
const pill = $('open-pill');
|
||||
pill.hidden = false;
|
||||
pill.textContent = open ? `${open} open` : 'All clear';
|
||||
pill.replaceChildren(...(open ? [`${open} open`] : [ui.icon('checkCircle'), 'All clear']));
|
||||
pill.classList.toggle('all-clear', open === 0);
|
||||
pill.classList.toggle('has-triggered', triggered > 0);
|
||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||
|
||||
const badge = document.querySelector('[data-badge]');
|
||||
badge.hidden = triggered === 0;
|
||||
badge.textContent = String(triggered);
|
||||
// Just the Queue tab's own badge now — phone bottom bar and desktop
|
||||
// sidebar both read the same [data-badge] span on that one nav-link.
|
||||
for (const badge of document.querySelectorAll('[data-badge]')) {
|
||||
badge.hidden = triggered === 0;
|
||||
badge.textContent = String(triggered);
|
||||
}
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
function updateTitle() {
|
||||
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
||||
const section = SECTIONS[route.section].title;
|
||||
const section = title(route);
|
||||
const base = route.section === 'queue' && route.incident == null ? 'terdut' : `${section} · terdut`;
|
||||
document.title = triggered ? `(${triggered}) ${base}` : base;
|
||||
}
|
||||
@@ -142,10 +231,25 @@ async function boot() {
|
||||
document.addEventListener('click', interceptLinks);
|
||||
document.addEventListener('keydown', onKey);
|
||||
$('login-form').addEventListener('submit', onLogin);
|
||||
$('signup-form').addEventListener('submit', onSignup);
|
||||
$('nav-more-btn').addEventListener('click', openNavMenu);
|
||||
teamselector.init();
|
||||
ssoErrorCode = takeSSOError();
|
||||
|
||||
// /signup is the one route that works without a session.
|
||||
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
||||
$('boot').hidden = true;
|
||||
await showSignup();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Before anything renders: the account view decides from it whether a
|
||||
// password is worth offering to set.
|
||||
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;
|
||||
@@ -161,15 +265,172 @@ function showBootError(err) {
|
||||
$('boot').append(ui.h('button', { class: 'btn', onclick: () => location.reload(), text: 'Retry' }));
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
// The sign-up screen. Reached at /signup, with an optional ?invite= that the
|
||||
// server has already judged — the form says whether the link is good before
|
||||
// somebody picks a password, rather than after.
|
||||
async function showSignup() {
|
||||
poll.stop();
|
||||
ui.closeSheet(null);
|
||||
reset();
|
||||
$('boot').hidden = true;
|
||||
$('app').hidden = true;
|
||||
$('login').hidden = false;
|
||||
const form = $('login-form');
|
||||
$('login-form').hidden = true;
|
||||
$('signup-form').hidden = false;
|
||||
|
||||
const invite = new URLSearchParams(location.search).get('invite');
|
||||
const intro = $('signup-intro');
|
||||
const form = $('signup-form');
|
||||
const teamLabel = $('signup-team-label');
|
||||
form.querySelector('.form-error').hidden = true;
|
||||
|
||||
let info;
|
||||
try {
|
||||
info = await api.signupInfo(invite);
|
||||
} catch (err) {
|
||||
intro.textContent = err.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (invite && info.invite_valid) {
|
||||
intro.textContent = `You have been invited to ${info.invite_team}.`;
|
||||
teamLabel.hidden = true;
|
||||
form.team_name.required = false;
|
||||
} else if (invite) {
|
||||
// One answer for expired, revoked, used up and never existed, matching the
|
||||
// server: which it was is not a stranger's business.
|
||||
intro.textContent = 'That invite link is not usable. Ask whoever sent it for a new one.';
|
||||
form.querySelector('button[type=submit]').disabled = true;
|
||||
} else if (info.mode === 'open') {
|
||||
intro.textContent = 'Create an account and a team to put your alerts in.';
|
||||
teamLabel.hidden = false;
|
||||
form.team_name.required = true;
|
||||
} else {
|
||||
intro.textContent = 'Sign-up on this server is invite-only. Ask a team owner for a link.';
|
||||
form.querySelector('button[type=submit]').disabled = true;
|
||||
}
|
||||
form.username.focus();
|
||||
}
|
||||
|
||||
async function onSignup(e) {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const err = form.querySelector('.form-error');
|
||||
const btn = form.querySelector('button[type=submit]');
|
||||
err.hidden = true;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
state.me = await api.signup({
|
||||
username: form.username.value.trim(),
|
||||
email: form.email.value.trim(),
|
||||
password: form.password.value,
|
||||
invite: new URLSearchParams(location.search).get('invite') || undefined,
|
||||
team_name: form.team_name.value.trim() || undefined,
|
||||
});
|
||||
form.password.value = '';
|
||||
// Signing up signs you in, so go straight to the queue rather than to a
|
||||
// login form asking for the credential just chosen.
|
||||
history.replaceState({ depth: 0 }, '', '/');
|
||||
route = parseRoute('/');
|
||||
await loadTeams();
|
||||
teamselector.render();
|
||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||
showApp();
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Why single sign-on sent the browser back, by the code the server puts in
|
||||
// ?sso_error=. The provider's name is the one the administrator configured.
|
||||
function ssoErrorText(code, name) {
|
||||
const sso = name || 'single sign-on';
|
||||
return {
|
||||
denied: `Signing in with ${sso} was cancelled or refused.`,
|
||||
expired: 'That sign-in expired or was already used. Try again.',
|
||||
failed: `Signing in with ${sso} failed. Try again, and tell an administrator if it keeps happening.`,
|
||||
unavailable: `${sso} could not be reached. Try again in a moment.`,
|
||||
not_allowed: 'Your account is not allowed to use terdut. Ask an administrator to add you to the right group.',
|
||||
no_email: `${sso} did not send an email address for you, which terdut needs.`,
|
||||
email_conflict: 'An account with your email address already exists and could not be linked to this sign-in. Ask an administrator.',
|
||||
disabled: 'Your account is disabled. Ask an administrator.',
|
||||
not_bootstrapped: 'This install is still setting up. Try again in a moment.',
|
||||
}[code] || `Signing in with ${sso} failed.`;
|
||||
}
|
||||
|
||||
// Reads how the server can be signed in to. An older server has no such
|
||||
// endpoint, and one that cannot be asked is treated as offering passwords only:
|
||||
// the form that always existed is better than a blank page.
|
||||
async function loadAuthConfig() {
|
||||
try {
|
||||
state.auth = await api.authConfig();
|
||||
} catch {
|
||||
/* keep the last answer, or the defaults */
|
||||
}
|
||||
return state.auth;
|
||||
}
|
||||
|
||||
// The reason the last single sign-on attempt failed, read once at boot. It is
|
||||
// held here rather than re-read from the address because showLogin runs more
|
||||
// than once on the way to the form (the 401 from /api/me reaches it through the
|
||||
// API layer and again through boot's own catch), and only the first would see it.
|
||||
let ssoErrorCode = null;
|
||||
|
||||
// Takes ?sso_error= off the address, so a reload does not repeat the message.
|
||||
function takeSSOError() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const code = params.get('sso_error');
|
||||
if (code === null) return null;
|
||||
params.delete('sso_error');
|
||||
const query = params.toString();
|
||||
history.replaceState(null, '', location.pathname + (query ? `?${query}` : '') + location.hash);
|
||||
return code;
|
||||
}
|
||||
|
||||
async function showLogin() {
|
||||
poll.stop();
|
||||
ui.closeSheet(null);
|
||||
reset();
|
||||
$('app').hidden = true;
|
||||
$('signup-form').hidden = true;
|
||||
|
||||
// Ask before showing anything, so the form does not flash the password
|
||||
// fields at somebody whose server has turned them off.
|
||||
const auth = await loadAuthConfig();
|
||||
const sso = auth.oidc?.enabled ? auth.oidc : null;
|
||||
const passwords = auth.password_login !== false;
|
||||
|
||||
const link = $('sso-link');
|
||||
link.hidden = !sso;
|
||||
if (sso) {
|
||||
link.textContent = `Sign in with ${sso.name || 'SSO'}`;
|
||||
// Come back to the page that was asked for: a link to /device?code=... has
|
||||
// to survive the trip through the provider. The server only honours paths
|
||||
// on this server, and ignores the front page.
|
||||
const here = location.pathname + location.search;
|
||||
link.href = here === '/' ? '/api/oidc/login' : `/api/oidc/login?next=${encodeURIComponent(here)}`;
|
||||
}
|
||||
$('login-or').hidden = !(sso && passwords);
|
||||
$('password-login').hidden = !passwords;
|
||||
|
||||
const ssoErr = $('sso-error');
|
||||
ssoErr.hidden = ssoErrorCode === null;
|
||||
if (ssoErrorCode !== null) ssoErr.textContent = ssoErrorText(ssoErrorCode, sso?.name);
|
||||
|
||||
$('boot').hidden = true;
|
||||
$('login').hidden = false;
|
||||
$('login-form').hidden = false;
|
||||
const form = $('login-form');
|
||||
form.querySelector('#password-login .form-error').hidden = true;
|
||||
if (!passwords) return;
|
||||
// Only offer the door that is open. Somebody without an invite on an
|
||||
// invite-only server should be told, not sent to a form that refuses them.
|
||||
api.signupInfo().then((info) => {
|
||||
$('signup-link').hidden = info.mode !== 'open';
|
||||
}).catch(() => {});
|
||||
form.password.value = '';
|
||||
(form.username.value ? form.password : form.username).focus();
|
||||
}
|
||||
@@ -177,9 +438,13 @@ function showLogin() {
|
||||
async function onLogin(e) {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const err = form.querySelector('.form-error');
|
||||
// Not the first .form-error: that one is the single sign-on message above.
|
||||
const err = form.querySelector('#password-login .form-error');
|
||||
const btn = form.querySelector('button[type=submit]');
|
||||
err.hidden = true;
|
||||
// Whatever single sign-on said is about the last attempt, not this one.
|
||||
ssoErrorCode = null;
|
||||
$('sso-error').hidden = true;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
state.me = await api.login(form.username.value.trim(), form.password.value);
|
||||
@@ -203,6 +468,7 @@ export async function signOut() {
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
ssoErrorCode = null;
|
||||
$('boot').hidden = true;
|
||||
$('login').hidden = true;
|
||||
$('app').hidden = false;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Approving a sign-in that a terminal started, at /device?code=XXXX-XXXX.
|
||||
//
|
||||
// The terminal (the TUI) shows a code and a link to this page. Whoever opens it
|
||||
// is already signed in — by the provider or by password, whichever the login
|
||||
// page offered — and is asked to approve. Approving hands that terminal a
|
||||
// session for *this* account, so the page names the account and the code, and
|
||||
// tells anybody who did not start this to refuse.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear } from './ui.js';
|
||||
import { state } from './state.js';
|
||||
import { navigate } from './app.js';
|
||||
|
||||
const view = () => document.getElementById('view-device');
|
||||
|
||||
// What has been decided for the code on screen, so a re-render does not offer
|
||||
// to approve it a second time.
|
||||
let outcome = null; // { code, approved }
|
||||
|
||||
export function show() {
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const code = new URLSearchParams(location.search).get('code') || '';
|
||||
if (!code) return clear(view(), enterCode());
|
||||
if (outcome && outcome.code === code) return clear(view(), decided(outcome.approved));
|
||||
return clear(view(), confirmCard(code));
|
||||
}
|
||||
|
||||
// Reached without a code, for somebody who typed the address by hand.
|
||||
function enterCode() {
|
||||
const input = h('input', {
|
||||
name: 'code', autocomplete: 'off', autocapitalize: 'characters', spellcheck: 'false',
|
||||
placeholder: 'XXXX-XXXX', required: true,
|
||||
});
|
||||
const form = h('form', { class: 'card device-card' },
|
||||
h('h2', { text: 'Sign in a terminal' }),
|
||||
h('p', { class: 'muted', text: 'Enter the code the terminal is showing.' }),
|
||||
h('label', {}, h('span', { text: 'Code' }), input),
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Continue' }));
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/device?code=${encodeURIComponent(input.value.trim())}`);
|
||||
});
|
||||
return form;
|
||||
}
|
||||
|
||||
function confirmCard(code) {
|
||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||
const approve = h('button', { class: 'btn btn-primary', type: 'button', text: 'Approve' });
|
||||
const refuse = h('button', { class: 'btn', type: 'button', text: 'Refuse' });
|
||||
|
||||
const decide = (approved) => async () => {
|
||||
err.hidden = true;
|
||||
approve.disabled = refuse.disabled = true;
|
||||
try {
|
||||
await (approved ? api.approveDevice(code) : api.denyDevice(code));
|
||||
outcome = { code, approved };
|
||||
render();
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
approve.disabled = refuse.disabled = false;
|
||||
}
|
||||
};
|
||||
approve.addEventListener('click', decide(true));
|
||||
refuse.addEventListener('click', decide(false));
|
||||
|
||||
return h('div', { class: 'card device-card' },
|
||||
h('h2', { text: 'Sign in a terminal?' }),
|
||||
h('p', {}, 'A terminal is asking to sign in as ', h('strong', { text: state.me.user.username }),
|
||||
'. Check that this code matches the one it is showing:'),
|
||||
h('p', { class: 'device-code', text: code }),
|
||||
h('p', { class: 'muted small',
|
||||
text: 'Only approve a sign-in you started yourself. Whoever is approved here acts as you.' }),
|
||||
err,
|
||||
h('div', { class: 'row-actions' }, approve, refuse));
|
||||
}
|
||||
|
||||
function decided(approved) {
|
||||
return h('div', { class: 'card device-card' },
|
||||
h('h2', { text: approved ? 'Approved' : 'Refused' }),
|
||||
h('p', { class: 'muted', text: approved
|
||||
? 'You can go back to your terminal. It signs in within a few seconds.'
|
||||
: 'That terminal will not be signed in.' }),
|
||||
h('a', { class: 'btn', href: '/', text: 'Go to the queue' }));
|
||||
}
|
||||
@@ -66,21 +66,22 @@ export function mondayOf(d) {
|
||||
return r;
|
||||
}
|
||||
|
||||
// ISO 8601 week number: weeks start on Monday and week 1 is the one holding the
|
||||
// year's first Thursday, which is what a rota that runs Monday to Sunday means
|
||||
// by "week 40". Taken from the Thursday of d's week, whose year is the week's.
|
||||
export function isoWeek(d) {
|
||||
const thu = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
thu.setDate(thu.getDate() + 3 - ((thu.getDay() + 6) % 7));
|
||||
const jan4 = new Date(thu.getFullYear(), 0, 4);
|
||||
return 1 + Math.round(((thu - jan4) / 86400000 - 3 + ((jan4.getDay() + 6) % 7)) / 7);
|
||||
}
|
||||
|
||||
export function addDays(d, n) {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + n);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ISO 8601 week number.
|
||||
export function isoWeek(d) {
|
||||
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
const day = t.getUTCDay() || 7;
|
||||
t.setUTCDate(t.getUTCDate() + 4 - day);
|
||||
const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((t - yearStart) / DAY + 1) / 7);
|
||||
}
|
||||
|
||||
export const STATUS_LABEL = {
|
||||
triggered: 'Triggered',
|
||||
acknowledged: 'Acknowledged',
|
||||
@@ -97,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 || {})
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import * as api from './api.js';
|
||||
import * as poll from './poll.js';
|
||||
import {
|
||||
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
||||
h, clear, icon, badge, severityBadge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
||||
} from './ui.js';
|
||||
import {
|
||||
ago, when, until, isFuture, severityClass, STATUS_LABEL,
|
||||
ago, when, until, duration, isFuture, STATUS_LABEL,
|
||||
} from './format.js';
|
||||
import { myID, users } from './state.js';
|
||||
import { back } from './app.js';
|
||||
@@ -17,6 +17,7 @@ const pane = () => document.getElementById('detail');
|
||||
let currentID = null;
|
||||
let inc = null;
|
||||
let events = [];
|
||||
let similarList = [];
|
||||
let error = null;
|
||||
let busy = false;
|
||||
|
||||
@@ -38,10 +39,15 @@ export async function refresh() {
|
||||
const id = currentID;
|
||||
if (id == null) return;
|
||||
try {
|
||||
const [i, t] = await Promise.all([api.incident(id), api.timeline(id)]);
|
||||
// Similar incidents are a courtesy: an older server answers 404 and a
|
||||
// failure here must not hide the incident itself.
|
||||
const [i, t, sim] = await Promise.all([
|
||||
api.incident(id), api.timeline(id), api.similar(id).catch(() => []),
|
||||
]);
|
||||
if (id !== currentID) return;
|
||||
inc = i;
|
||||
events = t;
|
||||
similarList = sim;
|
||||
error = null;
|
||||
} catch (err) {
|
||||
if (id !== currentID) return;
|
||||
@@ -60,6 +66,8 @@ function render() {
|
||||
h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back },
|
||||
icon('back')),
|
||||
h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }),
|
||||
inc && h('button', { class: 'btn btn-ghost btn-icon copy', type: 'button', 'aria-label': 'Copy incident', title: 'Copy incident (y)', onclick: copyIncident },
|
||||
icon('copy')),
|
||||
);
|
||||
|
||||
if (!inc) {
|
||||
@@ -81,6 +89,7 @@ function render() {
|
||||
facts(),
|
||||
groupLabels(),
|
||||
alertsSection(),
|
||||
similarSection(),
|
||||
timelineSection(),
|
||||
),
|
||||
actionBar(),
|
||||
@@ -90,7 +99,7 @@ function render() {
|
||||
|
||||
function statusBadges() {
|
||||
const out = [];
|
||||
if (inc.severity) out.push(badge(inc.severity, `plain ${severityClass(inc.severity)}`));
|
||||
if (inc.severity) out.push(severityBadge(inc.severity));
|
||||
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
|
||||
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
||||
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
|
||||
@@ -113,12 +122,35 @@ function who(id, name) {
|
||||
return name || 'someone';
|
||||
}
|
||||
|
||||
// ackActorLabel renders whoever acknowledged inc, human or service account —
|
||||
// the two are mutually exclusive (migration 015), and a service account is a
|
||||
// credential, not "you" or "nobody", so it gets its own branch rather than
|
||||
// going through who()'s id-vs-myID() check.
|
||||
function ackActorLabel() {
|
||||
if (inc.acknowledged_by_id != null) return who(inc.acknowledged_by_id, inc.acknowledged_by);
|
||||
if (inc.acknowledged_by_service_account_id != null) return inc.acknowledged_by_service_account || 'a service account';
|
||||
return null;
|
||||
}
|
||||
|
||||
function facts() {
|
||||
const rows = [];
|
||||
const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v));
|
||||
|
||||
// Duration, severity and who's on it, in one scannable row up top — the
|
||||
// rest of this card has each of those too, but spread across rows that
|
||||
// take reading top to bottom to piece together.
|
||||
const elapsedTo = inc.resolved_at ? Date.parse(inc.resolved_at) : Date.now();
|
||||
const responsible = inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to)
|
||||
: ackActorLabel() || 'Unassigned';
|
||||
rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' },
|
||||
h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))),
|
||||
inc.severity && severityBadge(inc.severity),
|
||||
h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible),
|
||||
));
|
||||
|
||||
add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` }));
|
||||
if (inc.acknowledged_at) {
|
||||
add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`);
|
||||
add('Acknowledged', `${ackActorLabel()} · ${when(inc.acknowledged_at)}`);
|
||||
}
|
||||
add('Assigned', inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to) : 'Unassigned');
|
||||
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
||||
@@ -139,9 +171,12 @@ function facts() {
|
||||
function groupLabels() {
|
||||
const entries = Object.entries(inc.group_labels || {});
|
||||
if (!entries.length) return null;
|
||||
// Collapsed by default, the same disclosure alertItem() below uses for an
|
||||
// alert's own labels — this is background, not something to scan past.
|
||||
return h('section', { class: 'section' },
|
||||
h('h2', { class: 'section-title', text: 'Grouped by' }),
|
||||
h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v))),
|
||||
h('details', {},
|
||||
h('summary', { text: `Grouped by (${entries.length})` }),
|
||||
h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v)))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,8 +214,28 @@ function alertItem(a) {
|
||||
|
||||
// ---------- timeline ----------
|
||||
|
||||
function eventText(ev) {
|
||||
const person = ev.user_id != null ? who(ev.user_id, ev.username) : null;
|
||||
// actorLabel renders whoever performed ev, human or service account — the
|
||||
// two are mutually exclusive (migration 015). null means the server acted:
|
||||
// ev.user_id == null no longer means that by itself, now that a service
|
||||
// account's events also leave it null.
|
||||
function actorLabel(ev, named = false) {
|
||||
if (ev.user_id != null) return named ? (ev.username || 'someone') : who(ev.user_id, ev.username);
|
||||
if (ev.service_account_id != null) return ev.service_account_name || 'a service account';
|
||||
return null;
|
||||
}
|
||||
|
||||
// assignerLabel is actorLabel for an 'assigned' event, whose user_id is the
|
||||
// assignee: the person who made the assignment is in the actor_* fields
|
||||
// (null for assignments from before they were recorded).
|
||||
function assignerLabel(ev, named = false) {
|
||||
if (ev.actor_user_id != null) return named ? (ev.actor_username || 'someone') : who(ev.actor_user_id, ev.actor_username);
|
||||
if (ev.actor_service_account_id != null) return ev.actor_service_account_name || 'a service account';
|
||||
return null;
|
||||
}
|
||||
|
||||
// named spells users out instead of "you", for text that leaves this page.
|
||||
function eventText(ev, named = false) {
|
||||
const person = actorLabel(ev, named);
|
||||
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
|
||||
const alertName = () => {
|
||||
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
|
||||
@@ -192,15 +247,29 @@ function eventText(ev) {
|
||||
case 'alert_resolved': return [`Alert resolved: ${alertName()}`];
|
||||
case 'acknowledged': return [strong(person), ' acknowledged'];
|
||||
case 'unacknowledged': return [strong(person), ' cleared the acknowledgement'];
|
||||
case 'assigned': return ['Assigned to ', strong(person)];
|
||||
case 'assigned': {
|
||||
const by = assignerLabel(ev, named);
|
||||
return by ? ['Assigned to ', strong(person), ' by ', strong(by)] : ['Assigned to ', strong(person)];
|
||||
}
|
||||
case 'archived': return [strong(person), ' archived the incident'];
|
||||
case 'unarchived': return [strong(person), ' unarchived the incident'];
|
||||
case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`];
|
||||
case 'unsnoozed': return [strong(person), ' ended the snooze'];
|
||||
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
|
||||
// Falls through to the generic `${ev.type}: ${ev.detail}` below otherwise
|
||||
// — this just capitalises it and drops the redundant "escalated:" prefix
|
||||
// from detail (already "level 2: alice, bob" or "escalation exhausted: …").
|
||||
case 'escalated': return [`Escalated — ${ev.detail}`];
|
||||
case 'note': return [strong(person), ' added a note'];
|
||||
case 'resolution_note': return [strong(person), ' noted what fixed it'];
|
||||
case 'notified': {
|
||||
const to = person ? strong(person) : 'the fallback topic';
|
||||
if (ev.detail === 'reminder') return ['Reminder sent to ', to];
|
||||
if (ev.detail === 'resolved') return ['Resolution sent to ', to];
|
||||
// 'escalated' is a second, later page — the next level firing, not the
|
||||
// same page landing twice — so it reads as a bug unless told apart
|
||||
// from the initial 'triggered' page below.
|
||||
if (ev.detail === 'escalated') return ['Escalation paged ', to];
|
||||
return ['Paged ', to];
|
||||
}
|
||||
case 'notify_failed': return ['Notification failed', ev.detail ? `: ${ev.detail}` : ''];
|
||||
@@ -209,8 +278,50 @@ function eventText(ev) {
|
||||
}
|
||||
}
|
||||
|
||||
// Earlier incidents with the same signature that someone left notes on, the
|
||||
// ones that recorded what fixed it first. Plain notes are on that incident's
|
||||
// own page.
|
||||
function similarSection() {
|
||||
if (!similarList.length) return null;
|
||||
return h('section', { class: 'section' },
|
||||
h('h2', { class: 'section-title' }, h('span', { text: 'Seen before' })),
|
||||
h('div', { class: 'card' },
|
||||
h('ul', { class: 'similar' }, similarList.map((s) => h('li', { class: 'similar-item' },
|
||||
h('a', { href: `/incidents/${s.id}`, text: `#${s.id} ${s.title}` }),
|
||||
h('div', { class: 'sub', text: `${when(s.resolved_at)} · ${ago(s.resolved_at)}${s.note_count ? ` · ${s.note_count} note${s.note_count === 1 ? '' : 's'}` : ''}` }),
|
||||
...s.resolution_notes.map((n) => h('div', { class: 'note note-fix', text: n.detail || '' })),
|
||||
)))),
|
||||
);
|
||||
}
|
||||
|
||||
// Splits the already-sorted timeline on the status transitions that matter —
|
||||
// first acknowledged, then resolved — so a long incident reads as "before
|
||||
// anyone had it" / "while someone did" / "after it closed" instead of one
|
||||
// undifferentiated list. A later re-acknowledge (after an unacknowledge)
|
||||
// doesn't open a second "Acknowledged" phase; it's still the same spell of
|
||||
// somebody owning it.
|
||||
function timelinePhases(sorted) {
|
||||
const phases = [{ label: 'Triggered', events: [] }];
|
||||
let acked = false;
|
||||
for (const ev of sorted) {
|
||||
if (ev.type === 'acknowledged' && !acked) {
|
||||
phases.push({ label: 'Acknowledged', events: [] });
|
||||
acked = true;
|
||||
} else if (ev.type === 'resolved') {
|
||||
phases.push({ label: 'Resolved', events: [] });
|
||||
}
|
||||
phases[phases.length - 1].events.push(ev);
|
||||
}
|
||||
return phases.filter((p) => p.events.length);
|
||||
}
|
||||
|
||||
function timelineSection() {
|
||||
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
||||
const phases = timelinePhases(sorted);
|
||||
// A single phase (the common case: most incidents are acked once and
|
||||
// resolved) names nothing extra — only a split timeline needs the
|
||||
// headings to make sense of.
|
||||
const named = phases.length > 1;
|
||||
return h('section', { class: 'section' },
|
||||
h('h2', { class: 'section-title' },
|
||||
h('span', { text: 'Timeline' }),
|
||||
@@ -218,51 +329,189 @@ function timelineSection() {
|
||||
icon('note'), 'Add note')),
|
||||
h('div', { class: 'card' },
|
||||
sorted.length
|
||||
? h('ol', { class: 'timeline' }, sorted.map(timelineItem))
|
||||
: emptyState('No events yet', '')),
|
||||
? phases.map((p) => h('div', { class: 'tl-phase' },
|
||||
named && h('div', { class: 'tl-phase-title', text: p.label }),
|
||||
h('ol', { class: 'timeline' }, p.events.map(timelineItem))))
|
||||
: emptyState('No events yet', 'Nothing has happened on this incident yet.', 'clock')),
|
||||
);
|
||||
}
|
||||
|
||||
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
|
||||
|
||||
// One icon per kind of event, so the rail says what happened before the text
|
||||
// does. Anything not listed keeps a plain dot.
|
||||
const EVENT_ICON = {
|
||||
triggered: 'bell', notified: 'bell', escalated: 'arrowUp',
|
||||
acknowledged: 'check', unacknowledged: 'undo', assigned: 'user',
|
||||
snoozed: 'clock', unsnoozed: 'clock', resolved: 'checkCircle',
|
||||
alert_added: 'plus', alert_resolved: 'check',
|
||||
archived: 'archive', unarchived: 'undo',
|
||||
note: 'note', resolution_note: 'note',
|
||||
notify_failed: 'alertTriangle', deadman_silent: 'alertTriangle',
|
||||
};
|
||||
|
||||
// An escalation that ran out of levels, or of anywhere to send the page, is
|
||||
// the one event on the rail that means "nobody is being told": it gets the
|
||||
// warning treatment instead of reading like any other step.
|
||||
const isProblem = (ev) => ev.type === 'notify_failed' || ev.type === 'deadman_silent'
|
||||
|| (ev.type === 'escalated' && /exhausted/i.test(ev.detail || ''));
|
||||
|
||||
function timelineItem(ev) {
|
||||
const mine = ev.type === 'note' && ev.user_id === myID();
|
||||
return h('li', { class: `tl-item tl-${ev.type}` },
|
||||
h('span', { class: 'tl-dot' }),
|
||||
const mine = isNote(ev) && ev.user_id === myID();
|
||||
const problem = isProblem(ev);
|
||||
const iconName = problem ? 'alertTriangle' : EVENT_ICON[ev.type];
|
||||
return h('li', { class: `tl-item tl-${ev.type}${problem ? ' tl-problem' : ''}` },
|
||||
h('span', { class: 'tl-dot' }, iconName && icon(iconName, 'icon tl-icon')),
|
||||
h('div', { class: 'tl-body' },
|
||||
h('div', { class: 'tl-text' }, eventText(ev)),
|
||||
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
|
||||
ev.type === 'note' && h('div', { class: 'note', text: ev.detail || '' }),
|
||||
isNote(ev) && h('div', { class: ev.type === 'resolution_note' ? 'note note-fix' : 'note', text: ev.detail || '' }),
|
||||
mine && h('div', { class: 'note-actions' },
|
||||
h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => deleteNote(ev) }, icon('trash'), 'Delete')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- copy ----------
|
||||
|
||||
const fence = (rows) => ['```', ...rows, '```'];
|
||||
const pairs = (obj) => Object.entries(obj || {}).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`);
|
||||
|
||||
// incidentMarkdown is everything on this page as text that reads well in a chat
|
||||
// or an agent prompt. Times are ISO 8601, since "3 min ago" means nothing once
|
||||
// it has been pasted somewhere else.
|
||||
function incidentMarkdown() {
|
||||
const out = [`# Incident #${inc.id}: ${inc.title}`, ''];
|
||||
const add = (k, v) => { if (v != null && v !== '') out.push(`- ${k}: ${v}`); };
|
||||
add('Status', inc.status);
|
||||
add('Severity', inc.severity);
|
||||
add('Team', inc.team_name);
|
||||
add('Assigned to', inc.assigned_to_id != null ? inc.assigned_to || 'someone' : 'unassigned');
|
||||
add('Triggered', inc.triggered_at);
|
||||
if (inc.acknowledged_at) add('Acknowledged', `${inc.acknowledged_at} by ${inc.acknowledged_by || 'someone'}`);
|
||||
if (isOpen() && isFuture(inc.snoozed_until)) add('Snoozed until', inc.snoozed_until);
|
||||
if (inc.escalation_level > 0) add('Escalation level', inc.escalation_level);
|
||||
if (inc.resolved_at) add('Resolved', `${inc.resolved_at} (${inc.resolution_source === 'manual' ? 'manually' : 'all alerts stopped firing'})`);
|
||||
if (inc.archived_at) add('Archived', inc.archived_at);
|
||||
const group = pairs(inc.group_labels);
|
||||
if (group.length) out.push('- Grouped by:', ...group.map((g) => ` - ${g}`));
|
||||
|
||||
const alerts = inc.alerts || [];
|
||||
out.push('', `## Alerts (${alerts.length})`);
|
||||
for (const a of alerts) {
|
||||
out.push('', `### ${a.name} (${a.status})`);
|
||||
out.push(`- Started: ${a.starts_at}`);
|
||||
if (a.status === 'resolved' && a.ends_at) out.push(`- Ended: ${a.ends_at}`);
|
||||
if (a.generator_url) out.push(`- Source: ${a.generator_url}`);
|
||||
const labels = pairs(a.labels);
|
||||
if (labels.length) out.push('', 'Labels:', ...fence(labels));
|
||||
const annotations = Object.entries(a.annotations || {}).sort(([x], [y]) => x.localeCompare(y));
|
||||
if (annotations.length) out.push('', 'Annotations:', ...fence(annotations.map(([k, v]) => `${k}: ${v}`)));
|
||||
}
|
||||
|
||||
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
||||
if (sorted.length) {
|
||||
out.push('', '## Timeline', '');
|
||||
for (const ev of sorted) {
|
||||
const text = eventText(ev, true).map((f) => (f instanceof Node ? f.textContent : f)).join('');
|
||||
out.push(`- ${ev.created_at} ${text}`);
|
||||
if (isNote(ev) && ev.detail) {
|
||||
const label = ev.type === 'resolution_note' ? ' (what fixed it)' : '';
|
||||
out.push(...(label ? [label] : []), ...ev.detail.split('\n').map((l) => ` > ${l}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (similarList.length) {
|
||||
out.push('', '## Seen before', '', 'Earlier incidents with the same signature:');
|
||||
for (const s of similarList) {
|
||||
out.push(`- #${s.id} ${s.title} (resolved ${s.resolved_at})`);
|
||||
for (const n of s.resolution_notes || []) {
|
||||
out.push(' - What fixed it:', ...(n.detail || '').split('\n').map((l) => ` > ${l}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.push('', `_Copied from Terminal Duty at ${new Date().toISOString()}_`, '');
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// writeClipboard falls back to execCommand: the async API needs a secure
|
||||
// context, and this server is often reached over plain HTTP.
|
||||
async function writeClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
const ta = h('textarea', { readonly: true, 'aria-hidden': 'true', class: 'clip-buffer' });
|
||||
ta.value = text;
|
||||
document.body.append(ta);
|
||||
ta.select();
|
||||
try {
|
||||
if (!document.execCommand('copy')) throw new Error('copy refused');
|
||||
} finally {
|
||||
ta.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyIncident() {
|
||||
if (!inc) return;
|
||||
try {
|
||||
await writeClipboard(incidentMarkdown());
|
||||
toast('Copied incident');
|
||||
} catch {
|
||||
toast('Could not copy', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- actions ----------
|
||||
|
||||
const isOpen = () => inc.status !== 'resolved';
|
||||
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
|
||||
|
||||
function actionBar() {
|
||||
let primary;
|
||||
let secondary;
|
||||
// primaryAction and secondaryAction are factories, not shared nodes: a button
|
||||
// can only live in one place, and the actionbar is rebuilt on every render.
|
||||
function primaryAction() {
|
||||
if (inc.status === 'triggered') {
|
||||
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
|
||||
} else if (inc.status === 'acknowledged') {
|
||||
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve');
|
||||
} else {
|
||||
primary = inc.archived_at
|
||||
? h('button', { class: 'btn btn-primary', type: 'button', onclick: unarchive }, icon('undo'), 'Unarchive')
|
||||
: h('button', { class: 'btn btn-primary', type: 'button', onclick: archive }, icon('archive'), 'Archive');
|
||||
return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
|
||||
}
|
||||
if (inc.status === 'acknowledged') {
|
||||
return h('button', { class: 'btn btn-primary', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve');
|
||||
}
|
||||
return inc.archived_at
|
||||
? h('button', { class: 'btn btn-primary', type: 'button', onclick: unarchive }, icon('undo'), 'Unarchive')
|
||||
: h('button', { class: 'btn btn-primary', type: 'button', onclick: archive }, icon('archive'), 'Archive');
|
||||
}
|
||||
|
||||
function secondaryAction() {
|
||||
if (isOpen()) {
|
||||
secondary = isSnoozed()
|
||||
return isSnoozed()
|
||||
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
|
||||
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
|
||||
} else {
|
||||
secondary = h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
|
||||
}
|
||||
const more = h('button', { class: 'btn btn-icon', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'));
|
||||
const bar = h('div', { class: 'actionbar' }, primary, secondary, more);
|
||||
return h('button', { class: 'btn', type: 'button', onclick: copyIncident }, icon('copy'), 'Copy');
|
||||
}
|
||||
|
||||
// Desktop has room to show what a phone folds into the More sheet below — see
|
||||
// the .action-extra/.more-btn rules in app.css. Resolved/archived incidents
|
||||
// already say everything via primaryAction()/secondaryAction(), so there is
|
||||
// nothing extra to surface for them.
|
||||
function extraActions() {
|
||||
if (!isOpen()) return [];
|
||||
const out = [
|
||||
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: assign }, icon('user'), 'Assign…'),
|
||||
];
|
||||
out.push(inc.status === 'acknowledged'
|
||||
? h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: unacknowledge }, icon('undo'), 'Clear ack')
|
||||
: h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve…'));
|
||||
return out;
|
||||
}
|
||||
|
||||
function actionBar() {
|
||||
const more = h('button', { class: 'btn more-btn', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'), 'More');
|
||||
const bar = h('div', { class: 'actionbar' }, primaryAction(), secondaryAction(), ...extraActions(), more);
|
||||
if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true;
|
||||
return bar;
|
||||
}
|
||||
@@ -296,15 +545,35 @@ function unacknowledge() {
|
||||
|
||||
async function resolve() {
|
||||
const id = inc.id;
|
||||
const ok = await confirm({
|
||||
title: 'Resolve this incident?',
|
||||
text: 'Resolving is final. If these alerts fire again they open a new incident, '
|
||||
+ 'and if any are still firing this one stays closed regardless. '
|
||||
+ 'Use snooze if you only need it out of the way.',
|
||||
confirmLabel: 'Resolve',
|
||||
danger: true,
|
||||
const res = await openSheet(() => {
|
||||
const textarea = h('textarea', {
|
||||
name: 'resolution', autofocus: true, maxlength: '10000',
|
||||
placeholder: 'What fixed it? Optional, shown on the next similar incident.',
|
||||
});
|
||||
const form = h('form', {
|
||||
class: 'sheet-form',
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
closeSheet({ resolution: textarea.value.trim() });
|
||||
},
|
||||
},
|
||||
h('h2', { class: 'sheet-title', text: 'Resolve this incident?' }),
|
||||
h('p', {
|
||||
text: 'Resolving is final. If these alerts fire again they open a new incident, '
|
||||
+ 'and if any are still firing this one stays closed regardless. '
|
||||
+ 'Use snooze if you only need it out of the way.',
|
||||
}),
|
||||
textarea,
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
|
||||
h('button', { class: 'btn btn-danger', type: 'submit', text: 'Resolve' })),
|
||||
);
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) form.requestSubmit();
|
||||
});
|
||||
return form;
|
||||
});
|
||||
if (ok) await run(() => api.resolve(id), 'Resolved');
|
||||
if (res) await run(() => api.resolve(id, res.resolution), 'Resolved');
|
||||
}
|
||||
|
||||
function archive() {
|
||||
@@ -382,16 +651,18 @@ async function addNote() {
|
||||
const textarea = h('textarea', {
|
||||
name: 'content', required: true, autofocus: true, placeholder: 'What did you find? What did you do?', maxlength: '10000',
|
||||
});
|
||||
const fix = h('input', { type: 'checkbox', name: 'fix' });
|
||||
const form = h('form', {
|
||||
class: 'sheet-form',
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
const v = textarea.value.trim();
|
||||
if (v) closeSheet(v);
|
||||
if (v) closeSheet({ content: v, pinned: fix.checked });
|
||||
},
|
||||
},
|
||||
h('h2', { class: 'sheet-title', text: 'Add note' }),
|
||||
textarea,
|
||||
h('label', { class: 'check' }, fix, ' This is what fixed it (shown on similar incidents)'),
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save note' })),
|
||||
@@ -402,7 +673,7 @@ async function addNote() {
|
||||
});
|
||||
return form;
|
||||
});
|
||||
if (content) await run(() => api.addNote(id, content), 'Note added');
|
||||
if (content) await run(() => api.addNote(id, content.content, content.pinned), 'Note added');
|
||||
}
|
||||
|
||||
async function deleteNote(ev) {
|
||||
@@ -422,11 +693,13 @@ async function moreMenu() {
|
||||
items.push(item('user', 'Assign…', assign));
|
||||
items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze));
|
||||
items.push(item('note', 'Add note…', addNote));
|
||||
items.push(item('copy', 'Copy incident', copyIncident));
|
||||
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
|
||||
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
|
||||
} else {
|
||||
// The bar already carries the primary action (Archive/Unarchive) and
|
||||
// Copy, so the sheet adds only what it lacks.
|
||||
items.push(item('note', 'Add note…', addNote));
|
||||
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
|
||||
}
|
||||
|
||||
const fn = await openSheet(() => [
|
||||
@@ -454,6 +727,7 @@ export function key(e) {
|
||||
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
|
||||
case 'Z': if (isSnoozed()) unsnooze(); return true;
|
||||
case 'c': addNote(); return true;
|
||||
case 'y': copyIncident(); return true;
|
||||
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
|
||||
default: return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// The first-run checklist: the four things a new install or a new person has
|
||||
// to do before an alert reaches a phone.
|
||||
//
|
||||
// It is computed from what the server already knows rather than from stored
|
||||
// progress — a topic is set or it is not, an integration exists or it does not
|
||||
// — so it cannot claim a step is done when it is not, and it comes back by
|
||||
// itself if somebody deletes their integration a month later.
|
||||
//
|
||||
// Dismissal is the one piece of state, kept per user so finishing on a laptop
|
||||
// does not leave the phone nagging.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner } from './ui.js';
|
||||
import { state, currentTeam } from './state.js';
|
||||
import { navigate } from './app.js';
|
||||
import { isoDate } from './format.js';
|
||||
|
||||
let steps = null;
|
||||
let error = null;
|
||||
let busy = false;
|
||||
let testResult = null;
|
||||
|
||||
// done() is deliberately a question about the world, not a flag: each step asks
|
||||
// the data whether it happened.
|
||||
export async function load() {
|
||||
const team = currentTeam();
|
||||
if (!team) {
|
||||
steps = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [schedule, integrations, alerts] = await Promise.all([
|
||||
api.schedule(team.id, isoDate(new Date()), isoDate(new Date())),
|
||||
api.integrations(team.id),
|
||||
api.alerts({ limit: 1 }),
|
||||
]);
|
||||
steps = [
|
||||
{
|
||||
id: 'topic',
|
||||
title: 'Set where your pages go',
|
||||
text: 'An ntfy topic on your account. Without one, incidents assigned to you page the team’s fallback topic instead of your phone.',
|
||||
done: Boolean(state.me?.user?.ntfy_topic),
|
||||
action: { label: 'Account', go: '/more' },
|
||||
},
|
||||
{
|
||||
id: 'rota',
|
||||
title: 'Put somebody on call',
|
||||
text: 'An incident opens assigned to whoever the rota says is on call today. With an empty rota it opens unassigned.',
|
||||
done: (schedule || []).length > 0,
|
||||
action: { label: 'Team', go: '/team' },
|
||||
},
|
||||
{
|
||||
id: 'integration',
|
||||
title: 'Create an alert source',
|
||||
text: 'Alerts arrive on an integration key, which says which team they belong to. Nothing can reach this team without one.',
|
||||
done: (integrations || []).length > 0,
|
||||
action: { label: 'Team', go: '/team' },
|
||||
},
|
||||
{
|
||||
id: 'alert',
|
||||
title: 'Send a test alert',
|
||||
text: 'Post to the integration URL and watch it appear in the queue. Until one arrives, none of the above is proven.',
|
||||
done: (alerts || []).length > 0,
|
||||
action: { label: 'How', go: '/team' },
|
||||
},
|
||||
];
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
// visible reports whether there is anything worth showing: something undone,
|
||||
// and not dismissed.
|
||||
export function visible() {
|
||||
if (!steps || state.me?.onboarding_dismissed) return false;
|
||||
return steps.some((s) => !s.done);
|
||||
}
|
||||
|
||||
export function card() {
|
||||
if (!visible()) return null;
|
||||
const remaining = steps.filter((s) => !s.done).length;
|
||||
|
||||
return h('div', { class: 'card onboarding' },
|
||||
h('div', { class: 'onboarding-head' },
|
||||
h('h2', { text: 'Finish setting up' }),
|
||||
h('span', { class: 'muted small', text: `${remaining} left` }),
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button', text: 'Hide',
|
||||
title: 'Hide this checklist for good',
|
||||
onclick: async () => {
|
||||
try {
|
||||
await api.dismissOnboarding(true);
|
||||
if (state.me) state.me.onboarding_dismissed = true;
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
rerender();
|
||||
},
|
||||
})),
|
||||
error && h('p', { class: 'load-error', text: error }),
|
||||
h('ol', { class: 'checklist' }, ...steps.map(stepRow)),
|
||||
testResult && h('p', { class: testResult.ok ? 'muted small' : 'load-error', text: testResult.text }),
|
||||
);
|
||||
}
|
||||
|
||||
function stepRow(step) {
|
||||
return h('li', { class: step.done ? 'step done' : 'step' },
|
||||
h('span', { class: 'step-mark', text: step.done ? '✓' : '' }),
|
||||
h('div', {},
|
||||
h('strong', { text: step.title }),
|
||||
h('p', { class: 'muted small', text: step.text }),
|
||||
!step.done && h('div', { class: 'step-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button', text: step.action.label,
|
||||
onclick: () => navigate(step.action.go),
|
||||
}),
|
||||
// The topic step is the only one this page can finish by itself, and
|
||||
// the only proof that matters is a phone buzzing.
|
||||
step.id === 'topic' && state.me?.user?.ntfy_topic && h('button', {
|
||||
class: 'btn-sm', type: 'button', text: 'Send a test push',
|
||||
disabled: busy,
|
||||
onclick: sendTest,
|
||||
}),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
busy = true;
|
||||
try {
|
||||
await api.testNotification();
|
||||
testResult = { ok: true, text: 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.' };
|
||||
} catch (err) {
|
||||
testResult = { ok: false, text: err.message };
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
rerender();
|
||||
}
|
||||
|
||||
// The queue owns the card's place on the page, so ask it to redraw rather than
|
||||
// reaching into its list.
|
||||
let rerender = () => {};
|
||||
export function onRerender(fn) {
|
||||
rerender = fn;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
// On-call: who is on duty now, the week around it, and your own next shifts.
|
||||
// Read-only for now; the TUI edits the schedule.
|
||||
// Read-only for now; the TUI edits the schedule, so there is no add or swap
|
||||
// button here.
|
||||
//
|
||||
// One team's rota at a time — the viewer's first team, since a viewer in one
|
||||
// team has nothing to choose between. "On call now" is the exception and shows
|
||||
// every team the viewer is in, because somebody on two rotas wants both.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, icon, spinner } from './ui.js';
|
||||
import { isoDate, mondayOf, addDays, isoWeek, initial } from './format.js';
|
||||
import { h, clear, badge, icon, spinner } from './ui.js';
|
||||
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
|
||||
import { myID, currentTeam } from './state.js';
|
||||
|
||||
const view = () => document.getElementById('view-oncall');
|
||||
@@ -59,16 +60,22 @@ function render() {
|
||||
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||
return;
|
||||
}
|
||||
const team = currentTeam();
|
||||
clear(view(),
|
||||
// The top bar carries the title on a phone; the desktop has none.
|
||||
h('div', { class: 'only-desktop oncall-title' },
|
||||
h('h1', { text: 'On-call' }),
|
||||
team && h('p', { class: 'muted', text: `${team.name} · who is on call, the week ahead and your shifts` })),
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
nowCard(),
|
||||
weekCard(),
|
||||
myShifts(),
|
||||
h('div', { class: 'oncall-grid' },
|
||||
h('div', { class: 'oncall-main' }, nowCard(), weekCard()),
|
||||
h('div', { class: 'oncall-side' }, myShifts()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function you(userID) {
|
||||
return userID === myID() ? h('span', { class: 'you', text: 'you' }) : null;
|
||||
return userID === myID() ? badge('you', 'plain st-oncall you-badge') : null;
|
||||
}
|
||||
|
||||
// One card per team with somebody on call, and a single empty card when there
|
||||
@@ -78,64 +85,93 @@ function nowCard() {
|
||||
const entries = data.now || [];
|
||||
const showTeam = entries.length > 1;
|
||||
if (entries.length === 0) {
|
||||
return h('div', { class: 'card now-card' },
|
||||
h('div', { class: 'avatar none', text: '–' }),
|
||||
h('div', {},
|
||||
h('div', { class: 'now-label', text: 'On call now' }),
|
||||
h('div', { class: 'now-name', text: 'Nobody' }),
|
||||
),
|
||||
);
|
||||
return h('div', { class: 'card hero' },
|
||||
h('div', { class: 'hero-label', text: 'On call now' }),
|
||||
h('div', { class: 'hero-top' },
|
||||
h('div', { class: 'avatar hero-avatar none', text: '–' }),
|
||||
h('div', { class: 'hero-name', text: 'Nobody' })));
|
||||
}
|
||||
return h('div', {}, ...entries.map((n) =>
|
||||
h('div', { class: 'card now-card' },
|
||||
h('div', { class: 'avatar', text: initial(n.username) }),
|
||||
h('div', {},
|
||||
h('div', {
|
||||
class: 'now-label',
|
||||
text: showTeam ? `On call now · ${n.team_name}` : 'On call now',
|
||||
}),
|
||||
h('div', { class: 'now-name' }, n.username, you(n.user_id)),
|
||||
),
|
||||
)));
|
||||
return h('div', { class: 'hero-list' }, ...entries.map((n) => {
|
||||
const until = shiftEnd(n);
|
||||
return h('div', { class: 'card hero' },
|
||||
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
|
||||
h('div', { class: 'hero-top' },
|
||||
h('div', { class: 'avatar hero-avatar', text: initial(n.username) }),
|
||||
h('div', {},
|
||||
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
|
||||
until && h('div', { class: 'hero-until', text: until }))));
|
||||
}));
|
||||
}
|
||||
|
||||
// "until Mon 12 Oct · ends in 3d 20h", for the current team only: the shift's
|
||||
// end is read off the rota (the run of consecutive days from today held by the
|
||||
// same person), and that is only loaded for one team. The same midnight
|
||||
// boundary the "Current shift" row below uses.
|
||||
function shiftEnd(n) {
|
||||
const team = currentTeam();
|
||||
if (!team || n.team_id !== team.id) return null;
|
||||
const mine = new Map(data.upcoming.map((e) => [e.date, e.user_id]));
|
||||
let day = new Date();
|
||||
if (mine.get(isoDate(day)) !== n.user_id) return null;
|
||||
while (mine.get(isoDate(addDays(day, 1))) === n.user_id) day = addDays(day, 1);
|
||||
// Still holding the last day loaded: the shift may run on past it.
|
||||
if (isoDate(day) >= data.upcoming.reduce((m, e) => (e.date > m ? e.date : m), '')) return null;
|
||||
const end = addDays(parse(isoDate(day)), 1);
|
||||
return `until ${dayName.format(end)} ${dayDate.format(end)} · ends in ${duration(end - Date.now())}`;
|
||||
}
|
||||
|
||||
// A stable colour per person from the six-colour rcN palette. The week page
|
||||
// has no member list to take an index from (team.js does), so the id decides.
|
||||
const personClass = (userID) => `rc${(userID % 6) + 1}`;
|
||||
|
||||
// The week as seven cells, Monday to Sunday, each showing who holds that day.
|
||||
// A hand-over in the middle of the week is visible without reading anything.
|
||||
function weekCard() {
|
||||
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
||||
const today = isoDate(new Date());
|
||||
const days = [];
|
||||
const mine = myID();
|
||||
const seen = new Map();
|
||||
const cells = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = addDays(weekStart, i);
|
||||
const key = isoDate(d);
|
||||
const e = byDate.get(key);
|
||||
days.push(h('li', { class: `day ${key === today ? 'today' : ''} ${key < today ? 'past' : ''}` },
|
||||
h('span', { class: 'day-name', text: dayName.format(d) }),
|
||||
h('span', { class: 'day-date', text: dayDate.format(d) }),
|
||||
h('span', { class: `day-who ${e ? '' : 'nobody'}` }, e ? e.username : 'nobody', e && you(e.user_id)),
|
||||
));
|
||||
const date = isoDate(d);
|
||||
const e = byDate.get(date) || null;
|
||||
if (e) seen.set(e.user_id, e.username);
|
||||
cells.push(h('li', {
|
||||
class: ['strip-day', date === today && 'today', date < today && 'past'].filter(Boolean).join(' '),
|
||||
'aria-current': date === today ? 'date' : null,
|
||||
},
|
||||
h('span', { class: 'strip-name', text: dayName.format(d) }),
|
||||
h('span', { class: 'strip-date', text: String(d.getDate()) }),
|
||||
h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }),
|
||||
h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
|
||||
}
|
||||
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
||||
return [
|
||||
h('div', { class: 'page-head' },
|
||||
return h('div', { class: 'card week-card' },
|
||||
h('div', { class: 'week-head' },
|
||||
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
|
||||
h('div', { class: 'week-nav' },
|
||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
||||
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
||||
icon('chevronLeft')),
|
||||
h('button', {
|
||||
class: 'btn btn-ghost label',
|
||||
class: 'btn btn-ghost week-label',
|
||||
type: 'button',
|
||||
title: 'Back to this week',
|
||||
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
||||
text: `Week ${isoWeek(weekStart)}`,
|
||||
}),
|
||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
||||
icon('chevronRight')),
|
||||
),
|
||||
),
|
||||
h('ul', { class: 'card days' }, days),
|
||||
];
|
||||
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
|
||||
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
|
||||
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
||||
icon('chevronRight')))),
|
||||
h('ul', { class: 'strip' }, cells),
|
||||
seen.size > 0 && h('div', { class: 'strip-legend' },
|
||||
[...seen].map(([id, name]) => h('span', {},
|
||||
h('i', { class: `strip-dot ${personClass(id)}` }),
|
||||
id === mine ? `${name} (you)` : name))));
|
||||
}
|
||||
|
||||
// myShifts groups your upcoming dates into runs of consecutive days.
|
||||
// myShifts groups your upcoming dates into runs of consecutive days, then
|
||||
// splits off the one you're already in — listing it again under "Next
|
||||
// shifts" told people they hadn't started a shift they were already on.
|
||||
function myShifts() {
|
||||
const mine = data.upcoming.filter((e) => e.user_id === myID()).map((e) => e.date).sort();
|
||||
const runs = [];
|
||||
@@ -145,16 +181,34 @@ function myShifts() {
|
||||
else runs.push({ from: date, to: date });
|
||||
}
|
||||
const fmt = (s) => `${dayName.format(parse(s))} ${dayDate.format(parse(s))}`;
|
||||
return [
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Your next shifts' })),
|
||||
const label = (r) => (r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}`);
|
||||
|
||||
const today = isoDate(new Date());
|
||||
const current = runs[0] && runs[0].from <= today ? runs[0] : null;
|
||||
const next = current ? runs.slice(1) : runs;
|
||||
|
||||
const currentCard = current ? [
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Current shift' })),
|
||||
h('div', { class: 'card' },
|
||||
runs.length
|
||||
? h('ul', { class: 'shift-list' }, runs.slice(0, 8).map((r) =>
|
||||
h('ul', { class: 'shift-list' }, h('li', {},
|
||||
h('span', { text: label(current) }),
|
||||
h('span', { class: 'muted', text: `ends in ${duration(addDays(parse(current.to), 1) - Date.now())}` })))),
|
||||
] : [];
|
||||
|
||||
return [
|
||||
...currentCard,
|
||||
h('div', { class: 'page-head' }, h('h2', { text: current ? 'Next shifts' : 'Your next shifts' })),
|
||||
h('div', { class: 'card' },
|
||||
next.length
|
||||
? h('ul', { class: 'shift-list' }, next.slice(0, 8).map((r) =>
|
||||
h('li', {},
|
||||
h('span', { text: r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}` }),
|
||||
h('span', { text: label(r) }),
|
||||
h('span', { class: 'muted', text: days(r) })),
|
||||
))
|
||||
: h('div', { class: 'empty', text: 'Nothing scheduled in the next 60 days.' })),
|
||||
: h('div', {
|
||||
class: 'empty',
|
||||
text: current ? 'Nothing else scheduled in the next 60 days.' : 'Nothing scheduled in the next 60 days.',
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// The incident queue: filter chips and a list of incident rows.
|
||||
|
||||
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 { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
|
||||
import { ago, 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';
|
||||
|
||||
// The same filters as the TUI's `f` cycle, plus archived ones to get back to.
|
||||
@@ -17,41 +18,30 @@ const FILTERS = [
|
||||
];
|
||||
|
||||
const EMPTY = {
|
||||
open: ['All clear', 'Nothing open right now.'],
|
||||
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.'],
|
||||
acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.'],
|
||||
snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.'],
|
||||
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.'],
|
||||
archived: ['Nothing archived', ''],
|
||||
open: ['All clear', 'Nothing open right now.', 'checkCircle'],
|
||||
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.', 'checkCircle'],
|
||||
acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.', 'checkCircle'],
|
||||
snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.', 'clock'],
|
||||
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.', null],
|
||||
archived: ['Nothing archived', 'Resolved incidents land here once archived.', 'archive'],
|
||||
};
|
||||
|
||||
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');
|
||||
@@ -86,9 +76,10 @@ 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;
|
||||
items = result;
|
||||
error = null;
|
||||
@@ -96,6 +87,10 @@ export async function refresh({ fresh = false } = {}) {
|
||||
if (requested !== filter) return;
|
||||
error = err.message;
|
||||
}
|
||||
// state.open (what the chip counts read) has just been refreshed too, by
|
||||
// whichever caller updated it before calling here — app.js's poll, or the
|
||||
// `cached` branch above.
|
||||
renderChips();
|
||||
renderList();
|
||||
}
|
||||
|
||||
@@ -110,18 +105,32 @@ function setFilter(id) {
|
||||
refresh({ fresh: true });
|
||||
}
|
||||
|
||||
// Counts for the three chips derivable from the open list already fetched
|
||||
// for the badges — Snoozed/Resolved/Archived would need a request of their
|
||||
// own, so those chips stay count-less for now.
|
||||
function chipCount(id) {
|
||||
const open = state.selectedTeamID == null
|
||||
? state.open
|
||||
: state.open.filter((i) => i.team_id === state.selectedTeamID);
|
||||
if (id === 'open') return open.length;
|
||||
if (id === 'triggered') return open.filter((i) => i.status === 'triggered').length;
|
||||
if (id === 'acknowledged') return open.filter((i) => i.status === 'acknowledged').length;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
const el = document.getElementById('queue-filters');
|
||||
const chips = FILTERS.map((f) =>
|
||||
h('button', {
|
||||
const chips = FILTERS.map((f) => {
|
||||
const count = chipCount(f.id);
|
||||
return h('button', {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(f.id === filter),
|
||||
onclick: () => setFilter(f.id),
|
||||
text: f.label,
|
||||
}),
|
||||
);
|
||||
}, count != null && h('span', { class: count === 0 ? 'count zero' : 'count', text: String(count) }));
|
||||
});
|
||||
|
||||
// Somebody in one team has nothing to choose between, so the row of team
|
||||
// chips appears only when there is more than one. The default is all of
|
||||
@@ -132,41 +141,50 @@ 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// A scroll hint for the phone-width row, where the chips can run off the
|
||||
// right edge with nothing to suggest there's more; the desktop sidebar
|
||||
// wraps instead of scrolling (see .pane-list .chips), so this fades out
|
||||
// there via CSS rather than being left out here.
|
||||
chips.push(h('span', { class: 'chips-fade', 'aria-hidden': 'true' }));
|
||||
|
||||
clear(el, chips);
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById('queue-list');
|
||||
const checklist = onboarding.card();
|
||||
if (error && !items) {
|
||||
clear(el, h('div', { class: 'load-error', text: error }));
|
||||
clear(el, checklist, h('div', { class: 'load-error', text: error }));
|
||||
return;
|
||||
}
|
||||
if (!items) {
|
||||
clear(el, spinner());
|
||||
clear(el, checklist, spinner());
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
const [title, text] = EMPTY[filter];
|
||||
clear(el, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
|
||||
const [title, text, iconName] = EMPTY[filter];
|
||||
clear(el, checklist, emptyState(title, text, iconName));
|
||||
return;
|
||||
}
|
||||
clear(el,
|
||||
checklist,
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
items.map((inc, i) => row(inc, i)),
|
||||
);
|
||||
@@ -176,6 +194,10 @@ function row(inc, index) {
|
||||
const snoozed = isFuture(inc.snoozed_until);
|
||||
const resolved = inc.status === 'resolved';
|
||||
|
||||
// A filter that already says what the status is (Triggered, Resolved,
|
||||
// Archived) would only have every row repeat it.
|
||||
const impliedStatus = filter === 'triggered' || filter === 'resolved' || filter === 'archived';
|
||||
|
||||
let status;
|
||||
if (resolved) status = badge('Resolved', 'st-resolved');
|
||||
else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed');
|
||||
@@ -196,7 +218,9 @@ function row(inc, index) {
|
||||
// The team is shown only to somebody who is in more than one. For everybody
|
||||
// else it is the same word on every row, which is noise rather than
|
||||
// information.
|
||||
const team = state.teams.length > 1 && inc.team_name
|
||||
// ...and not when the queue is already narrowed to one team: that team is
|
||||
// the same word on every row there too.
|
||||
const team = state.teams.length > 1 && state.selectedTeamID == null && inc.team_name
|
||||
? h('span', { class: 'row-team', text: inc.team_name })
|
||||
: null;
|
||||
|
||||
@@ -207,9 +231,12 @@ function row(inc, index) {
|
||||
dataset: { index: String(index) },
|
||||
},
|
||||
h('div', { class: 'row-title', text: inc.title }),
|
||||
h('div', { class: 'row-age', title: inc.triggered_at, text: age(inc.triggered_at) }),
|
||||
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
|
||||
h('div', { class: 'row-meta' },
|
||||
status,
|
||||
!impliedStatus && status,
|
||||
// The left-border colour alone doesn't say what it means; spell it out
|
||||
// too, same badge the incident detail page uses for severity.
|
||||
inc.severity && severityBadge(inc.severity),
|
||||
assignee,
|
||||
team,
|
||||
labels && h('span', { class: 'labels', text: labels }),
|
||||
|
||||
@@ -5,14 +5,58 @@ import * as api from './api.js';
|
||||
|
||||
export const state = {
|
||||
me: null, // { user, has_password }
|
||||
// How the server can be signed in to. The defaults are an older server's
|
||||
// answer: passwords, no single sign-on.
|
||||
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() {
|
||||
@@ -33,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Statistics: how many incidents, how fast they are answered, and when and
|
||||
// what the alerts are. The same figures the TUI's Stats tab shows, over a
|
||||
// range picked with the chips. The server scopes them to the caller's teams.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, emptyState, spinner } from './ui.js';
|
||||
import { duration } from './format.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// `days` counts back from today, inclusive; the server reads from/to as UTC
|
||||
// dates, so these are too.
|
||||
const RANGES = [
|
||||
{ id: 'today', label: 'Today', days: 1 },
|
||||
{ id: '7d', label: '7d', days: 7 },
|
||||
{ id: '30d', label: '30d', days: 30 },
|
||||
{ id: '90d', label: '90d', days: 90 },
|
||||
{ id: 'all', label: 'All', days: null },
|
||||
];
|
||||
|
||||
const view = () => document.getElementById('view-stats');
|
||||
|
||||
let range = '30d';
|
||||
let data = null;
|
||||
let error = null;
|
||||
|
||||
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
||||
|
||||
function query(r) {
|
||||
if (!r.days) return {};
|
||||
const now = Date.now();
|
||||
return { from: utcDate(now - (r.days - 1) * DAY_MS), to: utcDate(now) };
|
||||
}
|
||||
|
||||
export function show() {
|
||||
render();
|
||||
refresh();
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
const requested = range;
|
||||
const q = query(RANGES.find((x) => x.id === range));
|
||||
try {
|
||||
const [incidents, top, byHour, byDay] = await Promise.all([
|
||||
api.statsIncidents(q),
|
||||
api.statsTop({ ...q, limit: 10 }),
|
||||
api.statsByHour(q),
|
||||
api.statsByDay(q),
|
||||
]);
|
||||
if (requested !== range) return;
|
||||
data = { incidents, top, byHour, byDay };
|
||||
error = null;
|
||||
} catch (err) {
|
||||
if (requested !== range) return;
|
||||
error = err.message;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function setRange(id) {
|
||||
if (id === range) return;
|
||||
range = id;
|
||||
data = null;
|
||||
render();
|
||||
refresh();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const chips = h('div', { class: 'chips', role: 'tablist', 'aria-label': 'Time range' },
|
||||
RANGES.map((r) => h('button', {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(r.id === range),
|
||||
onclick: () => setRange(r.id),
|
||||
text: r.label,
|
||||
})));
|
||||
|
||||
let body;
|
||||
if (error && !data) body = h('div', { class: 'load-error', text: error });
|
||||
else if (!data) body = spinner();
|
||||
else if (!data.incidents.total && !data.byHour.some((x) => x.count)) {
|
||||
body = emptyState('No data in this range', 'Nothing happened in this window.', 'chart');
|
||||
} else {
|
||||
body = h('div', { class: 'stats' },
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
tiles(data.incidents),
|
||||
data.top.length > 0 && card('Top alerts', topAlerts(data.top)),
|
||||
card('Alerts by hour (UTC)', columns(
|
||||
data.byHour.map((x) => ({ label: String(x.hour), value: x.count, tick: x.hour % 6 === 0 })),
|
||||
'Alerts by hour of day')),
|
||||
card('Alerts by day', columns(
|
||||
data.byDay.map((x) => ({ label: x.day_name.slice(0, 3), value: x.count, tick: true })),
|
||||
'Alerts by day of week')));
|
||||
}
|
||||
clear(view(), h('div', {}, chips, body));
|
||||
}
|
||||
|
||||
// A missing mean means nothing has been acknowledged or resolved yet.
|
||||
const mean = (s) => (s == null ? '—' : duration(s * 1000));
|
||||
|
||||
function tiles(s) {
|
||||
const tile = (label, value, cls = '') => h('div', { class: `stat-tile ${cls}` },
|
||||
h('div', { class: 'stat-value', text: String(value) }),
|
||||
h('div', { class: 'stat-label', text: label }));
|
||||
return h('div', { class: 'stat-tiles' },
|
||||
tile('Incidents', s.total),
|
||||
tile('Triggered', s.triggered, 'st-triggered'),
|
||||
tile('Acknowledged', s.acknowledged, 'st-acknowledged'),
|
||||
tile('Resolved', s.resolved, 'st-resolved'),
|
||||
tile('Mean time to acknowledge', mean(s.mtta_seconds)),
|
||||
tile('Mean time to resolve', mean(s.mttr_seconds)));
|
||||
}
|
||||
|
||||
function card(title, content) {
|
||||
return h('section', { class: 'chart-card card card-pad' },
|
||||
h('h3', { class: 'chart-title', text: title }), content);
|
||||
}
|
||||
|
||||
// Ranked names with a bar scaled to the busiest one.
|
||||
function topAlerts(items) {
|
||||
const max = Math.max(...items.map((x) => x.count), 1);
|
||||
return h('ol', { class: 'hbars' }, items.map((x) => {
|
||||
const fill = h('span', { class: 'hbar-fill' });
|
||||
fill.style.width = `${Math.max(2, (x.count / max) * 100)}%`;
|
||||
return h('li', { class: 'hbar' },
|
||||
h('span', { class: 'hbar-name', title: x.name, text: x.name }),
|
||||
h('span', { class: 'hbar-track' }, fill),
|
||||
h('span', { class: 'hbar-count', text: String(x.count) }));
|
||||
}));
|
||||
}
|
||||
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function svg(tag, attrs = {}, text) {
|
||||
const el = document.createElementNS(SVG_NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
||||
if (text != null) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
// A column chart: one bar per item, the value in a tooltip, and a label under
|
||||
// the items marked `tick`.
|
||||
function columns(items, label) {
|
||||
const W = 480;
|
||||
const H = 140;
|
||||
const base = H - 18;
|
||||
const step = W / items.length;
|
||||
const max = Math.max(...items.map((x) => x.value), 1);
|
||||
const root = svg('svg', {
|
||||
class: 'columns', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': label,
|
||||
});
|
||||
root.appendChild(svg('line', { class: 'axis', x1: 0, x2: W, y1: base, y2: base }));
|
||||
items.forEach((it, i) => {
|
||||
const bh = it.value ? Math.max(2, (it.value / max) * (base - 6)) : 0;
|
||||
const x = i * step + step * 0.15;
|
||||
const g = svg('g', { class: 'col' });
|
||||
g.appendChild(svg('title', {}, `${it.label}: ${it.value}`));
|
||||
// A full-height transparent hit area, so a tiny bar is still hoverable.
|
||||
g.appendChild(svg('rect', { class: 'col-hit', x: i * step, y: 0, width: step, height: base }));
|
||||
if (bh) g.appendChild(svg('rect', { class: 'col-bar', x, y: base - bh, width: step * 0.7, height: bh, rx: 2 }));
|
||||
root.appendChild(g);
|
||||
if (it.tick) {
|
||||
root.appendChild(svg('text', { class: 'col-label', x: i * step + step / 2, y: H - 4, 'text-anchor': 'middle' }, it.label));
|
||||
}
|
||||
});
|
||||
return root;
|
||||
}
|
||||
+1175
-225
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user