Compare commits

..

14 Commits

Author SHA1 Message Date
Niklas Ye 97a4814c04 Set the chart's placeholder version to 0.29.1
CI / chart (push) Successful in 2s
CI / test (push) Successful in 12s
CI / security (push) Successful in 15s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 22s
Release / image (push) Successful in 1m3s
Release / scan-image (push) Successful in 5s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 155f27c and c5be55d, so a
tree heading for v0.29.1 doesn't say 0.29.0.
2026-09-26 22:05:33 +02:00
Niklas Ye a2dc9e3b03 Ship a CA bundle in the image so single sign-on can reach the provider
The image is built FROM scratch and carried only the binary, so it had no
trust store, and every HTTPS call failed with "x509: certificate signed by
unknown authority". Nothing needed one until v0.29.0: OIDC discovery and the
token exchange are HTTPS calls to the identity provider, and the first
sign-in against Authentik died in discovery. The tests could not see it,
because they run on the host, whose trust store is fine.

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

Password login and everything that talks only to Postgres were unaffected.
2026-09-26 22:05:33 +02:00
Niklas Ye 155f27ca62 Set the chart's placeholder version to 0.29.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 19s
CI / test (push) Successful in 4m1s
Release / test (push) Successful in 5s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 46s
Release / image (push) Successful in 1m17s
Release / scan-image (push) Successful in 5s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as c5be55d and 36c00ac, so a
tree heading for v0.29.0 doesn't say 0.28.1.
2026-09-26 21:37:46 +02:00
Niklas Ye a27ff49171 Sign in through an OpenID Connect provider, and from a terminal
terdut can now sign people in through any OIDC provider (written against
Authentik), and let groups at the provider decide who may sign in, which
teams they belong to and whether they administer the install. Password
login keeps working alongside it; TERDUT_PASSWORD_LOGIN=false turns it off,
and is refused at startup unless SSO is configured. With no TERDUT_OIDC_*
setting nothing changes, so every existing install behaves as before.

Identity is (issuer, subject), never email or username: those are mutable
at the provider and a recycled address must not inherit an account. An
existing user is linked by email only when the provider marks it verified,
or TERDUT_OIDC_TRUST_EMAIL is set, which Authentik needs.

Group grants are marked source='oidc' on team_members and users, and the
sync changes only those rows. Hand-made memberships and administrators
are left alone, and the sync bypasses the last-owner and last-admin guards
because the provider is the source of truth for what it grants. Editing
managed access by hand is refused with 409, since the next sign-in would
undo it. The web UI badges it as SSO and disables the controls.

Groups are read only at sign-in, so an SSO session carries a hard ceiling
(sessions.max_expires_at, 12h by default) that sliding never extends.
There is no refresh token, which means API keys of somebody removed at the
provider stay valid until an administrator disables the user. That is
accepted and documented, not fixed.

A client with no browser, the TUI over SSH, signs in with a device code
run by terdut itself (POST /api/oidc/device and /device/token), so the
terminal never talks to the provider and ends up with the ordinary
terdut_session cookie. Only a browser session can approve a code; an API
key cannot. /device?code= sends a signed-out visitor through sign-in and
back, which is what oidc_logins.next is for.

oauth2 is pinned to v0.36.0: v0.37 needs Go 1.26 and the Dockerfile
builds on 1.25.

Migrations 011 and 012 add tables and defaulted columns only.
2026-09-26 21:37:40 +02:00
Niklas Ye c5be55dcbc Set the chart's placeholder version to 0.28.1
CI / chart (push) Successful in 2s
CI / security (push) Successful in 15s
CI / test (push) Successful in 2m51s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 31s
Release / image (push) Successful in 59s
Release / scan-image (push) Successful in 3s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 36c00ac and 9bf4c92, so a
tree heading for v0.28.1 doesn't say 0.28.0.
2026-09-26 10:21:54 +02:00
Niklas Ye b2c3868619 Fix the web UI stuck on its loading spinner
format.js defined isoWeek twice. 9d1df2b added a second copy for the
week numbers on the rota without noticing the first, which already
exported the same function. A duplicate function declaration is legal in
a plain script but an early SyntaxError in an ES module, so the browser
refused format.js, every module importing it, and with them app.js. Its
boot() is what hides the spinner, so nothing ever did.

Keeps the first definition, whose comment explains the Thursday rule, and
drops the second. Both give ISO 8601 numbers; the one kept was checked
against 2026-01-01 (week 1), 2026-09-28 (40), 2026-12-31 (53) and
2024-12-30 (1). oncall.js and team.js import the name unchanged.

The suite could not see this: it has no JS, and node --check reads a .js
file as a script, where the redeclaration passes. Checking each file as a
module (.mjs) does catch it.
2026-09-26 10:21:54 +02:00
Niklas Ye 36c00acf62 Set the chart's placeholder version to 0.28.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m1s
Release / test (push) Successful in 5s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 23s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 9bf4c92 and 2b396d2, so a
tree heading for v0.28.0 doesn't say 0.27.0.
2026-09-26 09:19:35 +02:00
Niklas Ye 9d1df2b611 Show week numbers on the rota, and assign a whole week from them
The rota grid starts each row with its ISO week number, and for an owner
the number is a button: one tap opens a sheet for that week, showing who
holds each of its seven days, and puts one person on all of them. A rota
is usually handed out by the week, and seven taps on seven days was the
only way to do it short of the range form.

ISO 8601 numbering, because the grid already runs Monday to Sunday: week
1 is the one holding the year's first Thursday, which is taken from the
Thursday of the row so the year boundaries come out right (2025-12-29 is
week 1 of 2026, 2020-12-31 is week 53).

Days already past are left alone. Who was on call last Tuesday is a fact,
and "the whole week" should not rewrite it, so a half-elapsed week covers
the days still to come and the sheet says so; a week that is entirely
over has nothing to assign. By default the assignment replaces whoever
holds those days, as the day sheet does and the sheet states, and a
checkbox limits it to the days nobody has yet. The overhang into the
neighbouring month is part of the same week and is included.

Web UI only: the existing schedule endpoint already takes a list of
dates and a replace flag, so nothing changed on the server and there is
nothing to mirror in terdut-tui.
2026-09-26 09:19:35 +02:00
Niklas Ye 9bf4c92bfe Set the chart's placeholder version to 0.27.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m59s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 19s
Release / image (push) Successful in 54s
Release / scan-image (push) Successful in 27s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 2b396d2 and dc92f51, so a
tree heading for v0.27.0 doesn't say 0.26.0.
2026-09-26 09:04:54 +02:00
Niklas Ye e616c82646 Show team members as a list, with who is on call and who cannot be paged
Team -> Members was a two-column table and an inline add form. It is now
a table in the style of Switches, Sources and Escalation: a status
badge, the member, their role, their next rota day, when they were last
active and when they joined. Adding a member and changing a role moved
into sheets, and removing one asks first.

The badge is the one that matters at 03:00: On call if the rota has them
today, Reachable if they have an ntfy topic, and Can't be paged when a
page to them would go nowhere -- no topic, or a disabled account -- with
the reason under their name. Not being pageable wins over being on call,
since an on-call person nobody can reach is the case worth seeing before
an incident finds it. The rules are the notifier's own. The topic itself
is never in the response, only whether one is set.

Last active is the newer of a member's newest session and API-key use,
and is shown to every member of the team like the rest of the list.
Rota days are UTC dates, and the page formats them as such so a day
cannot show up as the one before.

Removing a member leaves the rota days already assigned to them alone,
which the confirm says, so they are reassigned from the Rota tab rather
than silently dropped.

Demoting the last owner is now refused with 409, as removing them
already was: it was the same outcome by another route, a team with
nobody who can edit it.

API: GET /members gains status, on_call, next_shift, pageable, problem
and last_active_at; additive, no migration, and terdut-tui needs
nothing. POST /members answers 409 for the last-owner demotion.
2026-09-26 09:04:54 +02:00
Niklas Ye 2b396d22d6 Set the chart's placeholder version to 0.26.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m56s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 22s
Release / image (push) Successful in 57s
Release / scan-image (push) Successful in 4s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as dc92f51 and e8d45f9, so a
tree heading for v0.26.0 doesn't say 0.25.0.
2026-09-26 08:28:24 +02:00
Niklas Ye 1f1faa437c Show the escalation ladder as a list, with who it would page and where it is
Team -> Escalation was the draft form on the page, which showed the
ladder only as inputs. It is now a table in the style of Switches and
Sources: a row per level with a status badge, who it pages, the wait
before the next level, and the open incidents currently waiting on it.
Below it, the repeat count, the fallback topic and when the ladder last
escalated (linking the incident). The editor moved into an "Edit ladder"
sheet, so a poll of the page underneath can no longer throw away half an
edit, and the page-level draft state went with it.

Targets are resolved to who they mean today, and the badge says what
would actually happen: Ready, Escalating (an unanswered incident has
climbed to level 2 or higher), or Pages nobody. The last is the one worth
seeing before an incident finds it: an empty rota, a person with no ntfy
topic or a disabled account each make a rung a silence with a number on
it, and the target says which. The rules are pageLevel's own, so the
page cannot promise a page the notifier would skip.

"Last escalated" comes from the escalated timeline events that already
exist, so there is no migration. Acknowledging or resolving takes an
incident off the ladder, so Escalating clears then while the history
stays.

API: GET /escalation gains status and waiting per level, username,
reachable and problem per target, and last_escalated_at and
last_escalated_incident_id. Output only and additive; PUT is unchanged
and terdut-tui needs nothing.
2026-09-26 08:28:24 +02:00
Niklas Ye dc92f51cf8 Set the chart's placeholder version to 0.25.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 31s
CI / test (push) Successful in 3m20s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 20s
Release / image (push) Successful in 1m2s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as e8d45f9 and 3ee8583, so a
tree heading for v0.25.0 doesn't say 0.24.0.
2026-09-26 07:52:07 +02:00
Niklas Ye d675f8ec9b List alert sources with their status and last arrival on Team -> Sources
Like Team -> Switches, the page is now a table: a status badge (Active
if the key posted within a day, Quiet if it has but not lately, Never
used), when it last posted a webhook, when an alert last arrived on it,
how many distinct alerts it refreshed in the last 24 hours, and when it
was created. Adding a source moved into a "New source" sheet, and owners
can rename one from its row.

"Last alert" and the count needed alerts to remember which source they
came in on, which they never did, so migration 010 adds
alerts.integration_id and every accepted payload stamps it. Last sender
wins when two sources post the same fingerprint. It is not backfilled: a
NULL says "before this was recorded" rather than guessing, and it heals
by itself as Alertmanager re-sends each alert every repeat_interval.
Revoking a source keeps its alerts, unattributed.

Last webhook and last alert are separate on purpose: a payload with
nothing usable in it stamps the first and not the second. The Quiet
threshold is a fixed day, a colour and not an alarm, since silence that
should page is what dead man's switches are for.

The counts are indexed subqueries (alerts_integration_idx) rather than a
join, which would read every alert a source ever delivered.

API: the integrations list gains status, last_alert_at and alerts_24h,
and PATCH /api/teams/{id}/integrations/{id} renames. Both are additive;
terdut-tui needs nothing.
2026-09-26 07:52:07 +02:00
48 changed files with 4682 additions and 247 deletions
+6
View File
@@ -16,6 +16,12 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -ldflags="-w -s -X main.version=${VERSION}" -o /terdut ./cmd/terdut
FROM scratch
# scratch has no trust store, and a Go binary on it fails every HTTPS call with
# "x509: certificate signed by unknown authority". Nothing needed one until single
# sign-on: discovery and the token exchange are HTTPS calls to the identity provider.
# The bundle is the builder's, copied by name so a missing file fails the build
# rather than shipping an image that cannot sign anybody in.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /terdut /terdut
EXPOSE 8080
ENTRYPOINT ["/terdut"]
+122 -10
View File
@@ -132,6 +132,97 @@ the one place membership is edited from the person's side — the Team tab answe
"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
TERDUT_OIDC_GROUP_MAPPINGS='[{"group":"sre","team":"SRE","role":"member"},{"group":"sre-leads","team":"SRE","role":"owner"}]'
```
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 the mappings; where several groups grant the same team the highest role
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 mapping grants a *higher* role, who is raised and from then
on managed. Editing managed access by hand (`POST` or `DELETE` on a team's
members, revoking an SSO-granted administrator) is refused with `409`, since the
next sign-in would undo it.
**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
```bash
@@ -229,6 +320,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_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_GROUP_MAPPINGS` | — | JSON list of `{"group","team","role"}` (`role` is `owner` or `member`). Teams that do not exist are created |
| `TERDUT_OIDC_SESSION_MAX_AGE` | `12h` | Hard ceiling on a session made by an SSO sign-in |
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
@@ -236,7 +338,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, and single sign-on via `oidc.*` and `passwordLogin`.
---
@@ -590,7 +692,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/oidc/login`, `/api/oidc/callback`, `/api/oidc/device` and `/api/oidc/device/token`
require either an API key:
```
Authorization: Bearer <api-key>
@@ -650,7 +754,14 @@ incident exists is itself something only its team should learn.
| 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"}`. No session needed |
| `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` |
| `POST` | `/api/logout` | Ends the session and clears the cookie |
| `GET` | `/api/me` | The caller: `{user, has_password}` |
@@ -671,7 +782,7 @@ on anybody's.
| `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 |
@@ -715,16 +826,17 @@ administrator who is not in the team gets the same `404` as anybody else.
| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner |
| `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}/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 |
| `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[]}`. Empty levels means the team has none |
| `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/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 |
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.24.0
appVersion: "v0.24.0"
version: 0.29.1
appVersion: "v0.29.1"
+43 -2
View File
@@ -61,8 +61,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 +69,49 @@ 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 }}
{{- 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 }}
{{- if .Values.oidc.groupMappings }}
- name: TERDUT_OIDC_GROUP_MAPPINGS
value: {{ .Values.oidc.groupMappings | toJson | quote }}
{{- end }}
{{- end }}
livenessProbe:
httpGet:
path: /healthz
+50
View File
@@ -107,6 +107,56 @@ 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
# 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: ""
# Team roles from groups. A team that does not exist is created. Where several
# groups grant the same team the highest role wins.
# - group: sre
# team: SRE
# role: member # member or owner
groupMappings: []
# 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
+3
View File
@@ -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 {
+3
View File
@@ -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
+6
View File
@@ -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=
+15 -10
View File
@@ -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,11 +90,12 @@ 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) {
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 {
respond(w, http.StatusBadRequest, errResp("invalid payload"))
@@ -104,7 +105,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 +115,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
@@ -129,7 +131,7 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64,
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
}
@@ -186,7 +188,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 deadmanSet, 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 +245,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman deadmanSet, teamID in
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 +260,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman deadmanSet, teamID in
-- 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 +272,7 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman deadmanSet, teamID in
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
}
+9 -1
View File
@@ -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))
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
+20 -4
View File
@@ -143,15 +143,31 @@ func hashPassword(pw string) (string, error) {
// 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, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
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
}
@@ -159,7 +175,7 @@ func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
MaxAge: int(life.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
+282
View File
@@ -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(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(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})
}
}
+347
View File
@@ -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)
}
}
}
+185 -1
View File
@@ -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"`
+119
View File
@@ -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)
}
}
+154
View File
@@ -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)
}
}
+7 -2
View File
@@ -132,8 +132,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
+458
View File
@@ -0,0 +1,458 @@
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
)
// 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"`
}
return func(w http.ResponseWriter, r *http.Request) {
resp := response{PasswordLogin: !cfg.DisablePasswordLogin}
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(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(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
}
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.
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 != "" {
log.Printf("oidc: provider returned error %q: %s", e, q.Get("error_description"))
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 (%s) is in none of the allowed groups", identity.Username, identity.Subject)
ssoRedirect(w, r, ssoNotAllowed)
return
}
userID, err := signInSSO(r.Context(), db, cfg, identity, grants)
if err != nil {
var se ssoError
if errors.As(err, &se) {
log.Printf("oidc: refused %q (%s): %v", identity.Username, identity.Subject, se)
ssoRedirect(w, r, se)
return
}
log.Printf("oidc: sign in %q: %v", identity.Username, err)
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) (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); err != nil {
return 0, err
}
return userID, tx.Commit()
}
// 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):
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.
func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants) 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(g.Teams))
for name, role := range g.Teams {
if _, err := tx.ExecContext(ctx,
"INSERT INTO teams (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", name); err != nil {
return err
}
var teamID int64
if err := tx.QueryRowContext(ctx, "SELECT id FROM teams WHERE name = $1", name).Scan(&teamID); err != nil {
return err
}
granted = append(granted, teamID)
// A row the sync owns follows the groups in both directions. One added by
// hand is only raised: a member the owner made an owner by hand is not
// demoted because the mapping says member.
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
}
+775
View File
@@ -0,0 +1,775 @@
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, with the mapping the tests
// share: terdut-users may sign in, terdut-admins administer, and the sre groups
// grant roles in the SRE team.
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",
GroupMappings: []config.GroupMapping{
{Group: "sre", Team: "SRE", Role: "member"},
{Group: "sre-leads", Team: "SRE", Role: "owner"},
{Group: "platform", Team: "Platform", Role: "member"},
},
SessionMaxAge: 12 * time.Hour,
}
return c
}
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)
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)
}
}
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)
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)
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.exec(t, "INSERT INTO teams (name) VALUES ('SRE')")
s.exec(t, `INSERT INTO team_members (team_id, user_id, role)
VALUES ((SELECT id FROM teams WHERE name = 'SRE'), (SELECT id FROM users WHERE username = 'alice'), 'owner')`)
signInSSO(t, idp, ssoBrowser(t, s), alice) // the mapping only says member
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)
}
}
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)
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)
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)
}
}
+30 -2
View File
@@ -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"
@@ -20,6 +21,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// two would let a burst of sign-ups lock somebody out of logging in.
loginLimit := newLoginLimiter()
signupLimiter := newLoginLimiter()
oidcLimit := newLoginLimiter()
r := chi.NewRouter()
r.Use(middleware.Logger)
@@ -50,18 +52,43 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// an invite link is good, so the form can say so before somebody picks a
// password.
r.Get("/api/signup", handleSignupInfo(db))
r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
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, loginLimit, 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.
@@ -151,6 +178,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// Integrations: where a team's alerts come in, and the key that says so.
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
r.Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
// The rota is per team. /api/schedule/current is the exception: it
+2 -2
View File
@@ -315,7 +315,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
// 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
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
@@ -330,7 +330,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
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 {
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
}
+171
View File
@@ -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)
}
}
+244 -31
View File
@@ -21,7 +21,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
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 +36,7 @@ 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
}
@@ -83,7 +83,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
}
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
@@ -98,7 +98,7 @@ func handleUserTeams(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
}
@@ -210,8 +210,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)
@@ -223,26 +255,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 {
@@ -281,6 +343,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)
@@ -316,6 +400,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"))
@@ -340,6 +432,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, `
@@ -355,8 +461,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)
@@ -367,28 +507,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 {
@@ -457,6 +614,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)
@@ -493,24 +698,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")
+16 -4
View File
@@ -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
}
@@ -329,8 +329,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 +361,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"))
+174
View File
@@ -1,7 +1,12 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"
)
@@ -59,8 +64,78 @@ 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
}
// 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
// GroupMappings grants team roles. A user in Group gets Role in Team.
GroupMappings []GroupMapping
// SessionMaxAge is the hard ceiling on a session made by an SSO login. The
// login is the only moment groups are re-read, so this is how long a change
// in the provider may take to reach terdut.
SessionMaxAge time.Duration
// parseErr is a malformed TERDUT_OIDC_GROUP_MAPPINGS, reported by Validate:
// Load cannot fail, and a mapping that was silently dropped would grant
// less access than the operator wrote down.
parseErr error
}
// GroupMapping grants Role in Team to members of Group.
type GroupMapping struct {
Group string `json:"group"`
Team string `json:"team"`
Role string `json:"role"`
}
// Enabled reports whether single sign-on is configured.
func (o OIDC) Enabled() bool { return o.Issuer != "" }
func Load() Config {
addr := os.Getenv("TERDUT_ADDR")
if addr == "" {
@@ -89,9 +164,108 @@ 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(),
}
}
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),
}
if raw := strings.TrimSpace(os.Getenv("TERDUT_OIDC_GROUP_MAPPINGS")); raw != "" {
if err := json.Unmarshal([]byte(raw), &o.GroupMappings); err != nil {
o.parseErr = fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS: %w", err)
}
}
return o
}
// 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.parseErr != nil {
return o.parseErr
}
if !o.Enabled() {
if c.DisablePasswordLogin {
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
}
if len(o.GroupMappings) > 0 || o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
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")
}
for i, m := range o.GroupMappings {
if m.Group == "" || m.Team == "" {
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: group and team are required", i)
}
if m.Role != "owner" && m.Role != "member" {
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: role must be owner or member, got %q", i, m.Role)
}
}
if c.DisablePasswordLogin && len(o.GroupMappings) == 0 && o.AdminGroup == "" {
return errors.New("TERDUT_PASSWORD_LOGIN=false with no OIDC group grants leaves nobody able to do anything")
}
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.
+89
View File
@@ -0,0 +1,89 @@
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"},
{"bad mapping json", func(m map[string]string) { m["TERDUT_OIDC_GROUP_MAPPINGS"] = "{nope" }, "GROUP_MAPPINGS"},
{"bad mapping role", func(m map[string]string) {
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","team":"t","role":"admin"}]`
}, "role must be"},
{"mapping needs team", func(m map[string]string) {
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","role":"member"}]`
}, "group and team"},
{"good mapping", func(m map[string]string) {
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","team":"t","role":"owner"}]`
}, ""},
{"password off without sso", func(m map[string]string) {
clear(m)
m["TERDUT_PASSWORD_LOGIN"] = "false"
}, "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", "TERDUT_OIDC_GROUP_MAPPINGS",
} {
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")
}
}
@@ -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;
+60
View File
@@ -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 '/';
+8
View File
@@ -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
+6
View File
@@ -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 {
+81
View File
@@ -0,0 +1,81 @@
// 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 access a set of groups confers.
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
// Teams maps team name to role. Where several groups grant the same team the
// highest role wins, so belonging to both a members group and an owners
// group makes somebody an owner rather than whichever mapping came last.
Teams map[string]string
}
// ComputeGrants evaluates the configured mappings against groups.
func ComputeGrants(cfg config.OIDC, groups []string) Grants {
in := make(map[string]bool, len(groups))
for _, g := range groups {
in[g] = true
}
g := Grants{Teams: map[string]string{}}
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]
for _, m := range cfg.GroupMappings {
if !in[m.Group] {
continue
}
if rank(m.Role) > rank(g.Teams[m.Team]) {
g.Teams[m.Team] = m.Role
}
}
return g
}
// 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) }
+82
View File
@@ -0,0 +1,82 @@
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",
GroupMappings: []config.GroupMapping{
{Group: "sre", Team: "SRE", Role: "member"},
{Group: "sre-leads", Team: "SRE", Role: "owner"},
{Group: "platform", Team: "Platform", Role: "member"},
},
}
}
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, Teams: map[string]string{}},
},
{
name: "allowed but no grants",
groups: []string{"terdut-users"},
want: Grants{Admitted: true, Teams: map[string]string{}},
},
{
name: "admin group grants admin",
groups: []string{"terdut-users", "terdut-admins"},
want: Grants{Admitted: true, Admin: true, Teams: map[string]string{}},
},
{
name: "team roles from several groups",
groups: []string{"terdut-users", "sre", "platform"},
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "member", "Platform": "member"}},
},
{
name: "highest role wins whatever the order",
groups: []string{"sre-leads", "terdut-users", "sre"},
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "owner"}},
},
}
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")
}
}
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)
}
}
+173
View File
@@ -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
}
+86
View File
@@ -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)
}
}
}
+50 -8
View File
@@ -137,6 +137,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); }
@@ -173,7 +180,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; }
@@ -327,6 +334,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); }
@@ -363,7 +380,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.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 { background: var(--surface-2); color: var(--muted); }
.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); }
@@ -695,16 +717,16 @@ kbd {
.disabled-row td { opacity: 0.55; }
.btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); }
/* --- dead man's switches -------------------------------------------------
/* --- 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. */
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.table-scroll { overflow-x: auto; margin-top: 12px; }
.switch-table th, .switch-table td { white-space: nowrap; }
.switch-table td:nth-child(2) { white-space: normal; min-width: 12em; }
.switch-table .source-row td { border-bottom-style: dashed; }
.switch-table .source-row td:first-child { padding-left: 16px; }
.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 .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; }
@@ -768,7 +790,7 @@ kbd {
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 { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; padding: 10px; }
.rota-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; }
.rota-wd {
padding-bottom: 4px; text-align: center;
color: var(--muted); font-size: 11px; font-weight: 700;
@@ -782,6 +804,21 @@ kbd {
}
button.rota-day { cursor: pointer; }
button.rota-day:hover { background: var(--surface-2); }
/* 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 {
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(--accent); 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 { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.rota-day.today { background: var(--accent-soft); }
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
@@ -825,6 +862,11 @@ button.rota-day:hover { background: var(--surface-2); }
.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. */
.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. */
+25 -14
View File
@@ -25,20 +25,29 @@
<img src="/icon.svg" alt="" width="40" height="40">
<h1>terdut</h1>
</div>
<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>
<!-- 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
@@ -142,6 +151,8 @@
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>
+19 -2
View File
@@ -26,8 +26,7 @@ function render() {
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyForm(user),
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
passwordForm(user, hasPassword),
...passwordSection(user, hasPassword),
h('div', { class: 'only-desktop' },
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
@@ -119,6 +118,24 @@ function notifyForm(user) {
return form;
}
// 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: hasPassword ? 'Change password' : 'Set a 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 });
+9 -2
View File
@@ -13,7 +13,7 @@
// gate.
import * as api from './api.js';
import { h, clear, spinner, confirm, menuCard } 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');
@@ -197,7 +197,9 @@ function usersCard() {
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
@@ -206,6 +208,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', {
@@ -255,6 +260,8 @@ function invitePointer() {
);
}
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?`,
+7 -2
View File
@@ -11,7 +11,7 @@
// gate.
import * as api from './api.js';
import { h, clear, spinner, confirm, toast, icon } from './ui.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';
@@ -166,17 +166,22 @@ function membersCard() {
// 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', text: m.role }),
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)),
}),
+11 -2
View File
@@ -11,7 +11,7 @@
// enforcement of them.
import * as api from './api.js';
import { h, clear, spinner, confirm, toast, icon } from './ui.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';
@@ -96,6 +96,7 @@ function identityCard() {
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' },
@@ -110,6 +111,9 @@ function identityCard() {
!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', {
@@ -162,16 +166,21 @@ function teamsCard() {
// 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', text: t.role }),
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)),
}),
+7
View File
@@ -59,8 +59,13 @@ 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 } });
@@ -133,6 +138,8 @@ export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/mem
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}`);
+88 -6
View File
@@ -14,6 +14,7 @@ import * as team from './team.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);
@@ -30,6 +31,8 @@ const SECTIONS = {
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 mobile hamburger menu's contents — the same sections the desktop
@@ -61,7 +64,7 @@ function parseRoute(pathname) {
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') return { section: name };
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more' || name === 'device') return { section: name };
return { section: 'queue', incident: null };
}
@@ -227,6 +230,7 @@ async function boot() {
$('login-form').addEventListener('submit', onLogin);
$('signup-form').addEventListener('submit', onSignup);
$('menu-btn').addEventListener('click', openNavMenu);
ssoErrorCode = takeSSOError();
// /signup is the one route that works without a session.
if (location.pathname.replace(/\/$/, '') === '/signup') {
@@ -236,6 +240,9 @@ async function boot() {
}
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();
// The Admin tab exists only for an administrator. Somebody who types /admin
@@ -331,17 +338,87 @@ async function onSignup(e) {
}
}
function showLogin() {
// 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.',
}[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();
$('boot').hidden = true;
$('app').hidden = true;
$('login').hidden = false;
$('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('.form-error').hidden = true;
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) => {
@@ -354,9 +431,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);
@@ -380,6 +461,7 @@ export async function signOut() {
}
function showApp() {
ssoErrorCode = null;
$('boot').hidden = true;
$('login').hidden = true;
$('app').hidden = false;
+88
View File
@@ -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' }));
}
+10 -9
View File
@@ -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',
+3
View File
@@ -5,6 +5,9 @@ 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
};
+467 -131
View File
@@ -18,9 +18,9 @@
// than no form, but it is not the thing enforcing anything.
import * as api from './api.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip } from './ui.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js';
import { state, currentTeam, users as allUsers, myID } from './state.js';
import { isoDate, addDays, mondayOf, initial, ago, when, duration } from './format.js';
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
const view = () => document.getElementById('view-team');
@@ -52,12 +52,10 @@ let freshKey = null; // an integration key, shown once, until the view is left
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. The
// ladder draft goes with it: it is an edit of the page being left.
// rather than being shown under the new heading until the fetch lands.
if (next !== tab) {
tab = next;
data = null;
draft = null;
}
if (!data) clear(view(), subnav(), spinner());
refresh();
@@ -185,7 +183,6 @@ function teamPicker() {
teamID = Number(select.value);
data = null;
freshKey = null;
draft = null;
refresh();
});
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
@@ -243,6 +240,7 @@ function overview() {
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
const dayShortFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' });
const longDayFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'long', day: 'numeric', month: 'long',
});
@@ -284,11 +282,14 @@ function scheduleCard() {
const key = isoDate(d);
const e = byDate.get(key);
const inMonth = d.getMonth() === month;
// Every row starts with its week number, which is also the way to fill the
// whole week at once.
if (i % 7 === 0) cells.push(weekCell(d, byDate, today));
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
cells.push(dayCell(d, key, e, inMonth, today));
}
const heads = [];
const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })];
for (let i = 0; i < 7; i++) {
// Any Monday will do; this one is a Monday.
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
@@ -326,6 +327,23 @@ function scheduleCard() {
];
}
// The ISO week number at the start of a row. For an owner it is a button: one
// tap fills the week, which is the way a rota is usually handed out — a person
// takes a week, not seven separate days.
function weekCell(monday, byDate, today) {
const n = isoWeek(monday);
const current = isoDate(monday) <= today && today < isoDate(addDays(monday, 7));
const cls = `rota-week${current ? ' current' : ''}`;
const label = `Week ${n}`;
return isOwner()
? h('button', {
class: cls, type: 'button', text: String(n),
title: `${label} · assign somebody for the whole week`, 'aria-label': label,
onclick: () => weekSheet(monday, byDate),
})
: h('div', { class: cls, title: label, text: String(n) });
}
function dayCell(d, key, e, inMonth, today) {
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
.filter(Boolean).join(' ');
@@ -381,6 +399,73 @@ function coverNote(byDate) {
' left this month with nobody on call.');
}
// One week, in the sheet: who holds each day of it, and one person to put on
// all of them. Days already gone are left alone — who was on call last Tuesday
// is a fact, and "the whole week" should not rewrite it — and the week's
// overhang into the next month is included, since it is the same week.
function weekSheet(monday, byDate) {
const today = isoDate(new Date());
const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i));
const keys = days.map(isoDate);
const ahead = keys.filter((k) => k >= today);
const range = `${dayShortFmt.format(days[0])} – ${dayShortFmt.format(days[6])}`;
const who = memberSelect();
const onlyEmpty = h('input', { type: 'checkbox' });
const problem = h('p', { class: 'load-error', hidden: true });
const holders = h('div', { class: 'week-holders' }, days.map((d, i) => {
const e = byDate.get(keys[i]);
return h('span', {
class: `week-holder${keys[i] < today ? ' past' : ''}`,
title: `${keys[i]} · ${e ? e.username : 'nobody'}`,
},
h('span', { class: 'rota-num', text: weekdayFmt.format(d) }),
e
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
: h('span', { class: 'rota-chip none' }));
}));
openSheet(() => [
h('h2', { class: 'sheet-title', text: `Week ${isoWeek(monday)}` }),
h('p', { class: 'sheet-text', text: range }),
holders,
ahead.length
? [
h('label', { class: 'sheet-pick' }, 'On call ', who),
h('label', { class: 'checkbox' }, onlyEmpty, ' Only fill days nobody has yet'),
h('p', { class: 'muted small' },
ahead.length < 7
? `Days already past are left alone, so this covers the ${ahead.length} still to come. `
: '',
'Anybody already on those days is replaced unless you tick the box.'),
]
: h('p', { class: 'muted', text: 'This whole week is already over.' }),
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet() }),
ahead.length > 0 && h('button', {
class: 'btn btn-primary', type: 'button', autofocus: true, text: 'Assign week',
onclick: () => {
const dates = onlyEmpty.checked ? ahead.filter((k) => !byDate.has(k)) : ahead;
if (!who.value) {
problem.textContent = 'There is nobody in this team to assign.';
problem.hidden = false;
return;
}
if (!dates.length) {
problem.textContent = 'Every day still to come already has somebody.';
problem.hidden = false;
return;
}
closeSheet();
act(() => api.assignSchedule(teamID, Number(who.value), dates, !onlyEmpty.checked));
},
}),
),
]);
}
// One day, in the sheet: who has it, who should, and the way to empty it. This
// is where the per-row Clear button went — the grid has no room for thirty of
// them, and the day you want to change is the one you just tapped.
@@ -457,57 +542,124 @@ function memberSelect(selected) {
// --- escalation ------------------------------------------------------------
// The ladder is edited as a whole and sent as a whole, because the API replaces
// it wholesale: the levels are an order, and patching one rung would leave the
// numbering of the others undecided.
let draft = null;
const LEVEL_STATUS = {
ready: { label: 'Ready', hint: 'Somebody here can be woken.' },
escalating: { label: 'Escalating', hint: 'An unanswered incident has climbed to this level.' },
unreachable: { label: 'Pages nobody', hint: 'Nobody on this level can be woken right now.' },
};
const levelBadge = (status) => statusBadge(LEVEL_STATUS, status, 'ready');
// One target as the list shows it: who it means today, and why it would not
// wake them if it would not.
function targetLine(t) {
const label = t.kind === 'oncall'
? `On call${t.username ? ` · ${t.username}` : ''}`
: (t.username || 'Unknown person');
return h('div', { class: 'target-line' },
h('span', { text: label }),
t.problem && h('span', { class: 'target-problem', text: t.problem }));
}
function escalationCard() {
const esc = data.escalation;
if (!draft) {
draft = {
repeat_count: esc.repeat_count || 0,
fallback_topic: esc.fallback_topic || '',
levels: (esc.levels || []).map((l) => ({
timeout_seconds: l.timeout_seconds,
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
const esc = data.escalation || {};
const levels = esc.levels || [];
const rows = levels.map((l) => h('tr', {},
h('td', {}, h('strong', { text: `Level ${l.position}` })),
h('td', {}, levelBadge(l.status)),
h('td', { class: 'wrap' }, ...l.targets.map(targetLine)),
h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }),
h('td', { class: 'small' }, l.waiting?.length
? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])
: h('span', { class: 'muted', text: '—' })),
));
const facts = [];
if (levels.length) {
const n = esc.repeat_count || 0;
if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`);
facts.push(esc.fallback_topic
? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.']
: 'No fallback topic: after the last level the chain just ends.');
facts.push(esc.last_escalated_at
? ['Last escalated ',
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
' on ', h('a', { href: `/incidents/${esc.last_escalated_incident_id}`, text: `#${esc.last_escalated_incident_id}` }), '.']
: 'Nothing has needed to escalate yet.');
}
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Escalation' }),
isOwner() && h('button', {
class: 'btn', type: 'button', onclick: openLadderEditor,
text: levels.length ? 'Edit ladder' : 'Set up ladder',
})),
};
}
h('p', { class: 'muted small' },
'When a level’s wait passes and nobody has acknowledged, the next level is ',
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
levels.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Level' }), h('th', { text: 'Status' }), h('th', { text: 'Pages' }),
h('th', { text: 'Then after' }), h('th', { text: 'Waiting now' }))),
h('tbody', {}, rows)))
: h('p', { class: 'muted' },
'No ladder. An unacknowledged incident re-pages the same person every ',
'reminder interval and nobody else is woken.'),
...facts.map((f) => h('p', { class: 'muted small' }, f)),
);
}
const body = [];
if (!draft.levels.length) {
body.push(h('p', { class: 'muted' },
'No ladder. An unacknowledged incident re-pages the same person every ',
'reminder interval and nobody else is woken.'));
}
// The ladder is edited as a whole and sent as a whole, because the API replaces
// it wholesale: the levels are an order, and patching one rung would leave the
// numbering of the others undecided. The draft lives in the sheet, so a poll of
// the page underneath cannot throw away half an edit.
function openLadderEditor() {
const esc = data.escalation || {};
const draft = {
repeat_count: esc.repeat_count || 0,
fallback_topic: esc.fallback_topic || '',
levels: (esc.levels || []).map((l) => ({
timeout_seconds: l.timeout_seconds,
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
})),
};
draft.levels.forEach((level, i) => {
body.push(h('div', { class: 'ladder-level' },
h('div', { class: 'ladder-head' },
h('strong', { text: `Level ${i + 1}` }),
isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: () => { draft.levels.splice(i, 1); render(); },
})),
h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => {
level.timeout_seconds = secs;
}), ' before the next level'),
h('div', { class: 'ladder-targets' },
...level.targets.map((t, ti) => targetRow(level, t, ti)),
isOwner() && h('button', {
class: 'btn-sm', type: 'button', text: '+ target',
onclick: () => { level.targets.push({ kind: 'oncall' }); render(); },
})),
));
});
const body = h('div', { class: 'ladder-editor' });
const problem = h('p', { class: 'load-error', hidden: true });
if (isOwner()) {
body.push(h('button', {
const paint = () => {
const parts = [];
if (!draft.levels.length) {
parts.push(h('p', { class: 'muted small' }, 'No levels yet. Add the first one.'));
}
draft.levels.forEach((level, i) => {
parts.push(h('div', { class: 'ladder-level' },
h('div', { class: 'ladder-head' },
h('strong', { text: `Level ${i + 1}` }),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: () => { draft.levels.splice(i, 1); paint(); },
})),
h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => {
level.timeout_seconds = secs;
}), ' before the next level'),
h('div', { class: 'ladder-targets' },
...level.targets.map((t, ti) => targetRow(level, t, ti, paint)),
h('button', {
class: 'btn-sm', type: 'button', text: '+ target',
onclick: () => { level.targets.push({ kind: 'oncall' }); paint(); },
})),
));
});
parts.push(h('button', {
class: 'btn-sm', type: 'button', text: '+ level',
onclick: () => {
draft.levels.push({ timeout_seconds: 300, targets: [{ kind: 'oncall' }] });
render();
paint();
},
}));
@@ -520,31 +672,43 @@ function escalationCard() {
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
oninput: (e) => { draft.fallback_topic = e.target.value; },
});
body.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
body.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
body.push(h('button', {
class: 'btn', type: 'button', text: 'Save ladder',
onclick: () => act(() => api.setEscalation(teamID, draft), { resetDraft: true }),
}));
}
parts.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
parts.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
clear(body, ...parts);
};
paint();
return h('div', { class: 'card' },
h('h2', { text: 'Escalation' }),
h('p', { class: 'muted small' },
'When a level’s wait passes and nobody has acknowledged, the next level is ',
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
...body,
);
const save = h('button', { class: 'btn btn-primary', type: 'button', text: 'Save ladder' });
save.addEventListener('click', async () => {
try {
await api.setEscalation(teamID, draft);
} catch (err) {
problem.textContent = err.message;
problem.hidden = false;
return;
}
closeSheet(true);
refresh();
});
openSheet(() => [
h('h2', { class: 'sheet-title', text: 'Edit ladder' }),
body,
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
save),
]);
}
function targetRow(level, target, index) {
function targetRow(level, target, index, repaint) {
const kind = h('select', {},
h('option', { value: 'oncall', text: 'Whoever is on call', selected: target.kind === 'oncall' }),
h('option', { value: 'user', text: 'A specific person', selected: target.kind === 'user' }));
kind.addEventListener('change', () => {
target.kind = kind.value;
target.user_id = kind.value === 'user' ? (data.members[0] || {}).user_id : undefined;
render();
repaint();
});
const who = target.kind === 'user'
@@ -555,10 +719,10 @@ function targetRow(level, target, index) {
}
return h('div', { class: 'target-row' }, kind, who,
isOwner() && h('button', {
h('button', {
class: 'btn-sm danger', type: 'button', text: '×',
title: 'Remove this target',
onclick: () => { level.targets.splice(index, 1); render(); },
onclick: () => { level.targets.splice(index, 1); repaint(); },
}));
}
@@ -576,33 +740,54 @@ function minutesInput(seconds, onChange) {
function integrationsCard() {
const rows = (data.integrations || []).map((i) =>
h('tr', {},
h('td', {}, h('strong', { text: i.name })),
h('td', { class: 'muted small', text: i.kind }),
h('td', { class: 'muted small', text: i.last_used_at ? 'in use' : 'never used' }),
h('td', {}, isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Revoke',
onclick: async () => {
if (!(await confirm({
title: `Revoke ${i.name}?`,
text: 'Anything posting with this key stops delivering immediately.',
confirmLabel: 'Revoke',
danger: true,
}))) return;
act(() => api.deleteIntegration(teamID, i.id));
},
})),
h('td', {}, sourceBadge(i.status)),
h('td', { class: 'wrap' },
h('strong', { text: i.name }),
h('div', { class: 'muted small', text: i.kind })),
// When the key last posted, and when an alert last arrived on it. They
// differ: a payload with nothing usable in it stamps only the first.
h('td', { class: 'muted small' }, timeCell(i.last_used_at)),
h('td', { class: 'muted small' }, timeCell(i.last_alert_at)),
h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours',
text: String(i.alerts_24h ?? 0) }),
h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })),
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button', text: 'Rename', onclick: () => openRenameSource(i),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Revoke',
onclick: async () => {
if (!(await confirm({
title: `Revoke ${i.name}?`,
text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.',
confirmLabel: 'Revoke',
danger: true,
}))) return;
act(() => api.deleteIntegration(teamID, i.id));
},
}))),
));
return h('div', { class: 'card' },
h('h2', { text: 'Alert sources' }),
h('div', { class: 'card-head' },
h('h2', { text: 'Alert sources' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'New source', onclick: openNewSource,
})),
h('p', { class: 'muted small' },
'Alerts arrive on an integration key, which says both that the sender may ',
'post and which team the alerts belong to.'),
rows.length
? h('table', { class: 'admin-table' }, h('tbody', {}, rows))
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
freshKey && newKeyPanel(),
isOwner() && !freshKey && newIntegrationForm(),
rows.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Source' }),
h('th', { text: 'Last webhook' }), h('th', { text: 'Last alert' }),
h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }), h('th'))),
h('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
);
}
@@ -633,38 +818,77 @@ function newKeyPanel() {
);
}
function newIntegrationForm() {
const name = h('input', { type: 'text', placeholder: 'prod alertmanager', required: true });
const form = h('form', { class: 'inline-form' }, name,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
// A sheet with one name field, for adding a source and for renaming one: the two
// differ only in what they call and what they put in the box.
function openNameSheet({ title, submit, value, run }) {
const name = h('input', {
type: 'text', placeholder: 'prod alertmanager', required: true, value, autofocus: true,
});
const problem = h('p', { class: 'load-error', hidden: true });
const form = h('form', { class: 'stacked-form' },
h('label', {}, 'Name ', name),
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
h('button', { class: 'btn btn-primary', type: 'submit', text: submit })));
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
freshKey = await api.createIntegration(teamID, name.value.trim());
await refresh();
await run(name.value.trim());
} catch (err) {
error = err.message;
render();
problem.textContent = err.message;
problem.hidden = false;
return;
}
closeSheet(true);
refresh();
});
openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]);
}
function openNewSource() {
openNameSheet({
title: 'New source', submit: 'Add source', value: '',
// The key comes back once, and the card shows it until dismissed.
run: async (name) => { freshKey = await api.createIntegration(teamID, name); },
});
}
function openRenameSource(i) {
openNameSheet({
title: `Rename ${i.name}`, submit: 'Rename', value: i.name,
run: (name) => api.renameIntegration(teamID, i.id, name),
});
return form;
}
// --- dead man's switches ---------------------------------------------------
// Status badges, shared by the Sources and Switches lists: a table of label and
// hint per status, and one function to draw it. Module-level, so the two cards
// can be defined in either order.
const SWITCH_STATUS = {
healthy: { label: 'Healthy', hint: 'Heard from within its timeout.' },
dead: { label: 'Dead', hint: 'Silent for longer than its timeout.' },
dormant: { label: 'Dormant', hint: 'Nothing has matched yet, so there is nothing to lose.' },
};
function switchBadge(status) {
const s = SWITCH_STATUS[status] || SWITCH_STATUS.dormant;
const SOURCE_STATUS = {
active: { label: 'Active', hint: 'Posted within the last day.' },
quiet: { label: 'Quiet', hint: 'Has posted, but not in the last day. Nothing firing is a fine reason.' },
never: { label: 'Never used', hint: 'Nothing has been posted with this key yet.' },
};
function statusBadge(table, status, fallback) {
const s = table[status] || table[fallback];
const el = badge(s.label, `st-${status}`);
el.title = s.hint;
return el;
}
const switchBadge = (status) => statusBadge(SWITCH_STATUS, status, 'dormant');
const sourceBadge = (status) => statusBadge(SOURCE_STATUS, status, 'never');
const timeCell = (iso) => iso
? h('span', { title: when(iso), text: ago(iso) })
: h('span', { class: 'muted', text: 'never' });
@@ -681,7 +905,7 @@ const triggeredCell = (iso, incidentID) => {
function switchRows(sw) {
const main = h('tr', {},
h('td', {}, switchBadge(sw.status)),
h('td', {},
h('td', { class: 'wrap' },
h('strong', { text: sw.name }),
sw.name !== sw.matcher && h('div', { class: 'muted small' }, h('code', { text: sw.matcher }))),
h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)),
@@ -733,7 +957,7 @@ function deadmanCard() {
'quiet for longer than the switch’s timeout opens an incident.'),
switches.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table switch-table' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Switch' }),
h('th', { text: 'Last heartbeat' }), h('th', { text: 'Last triggered' }),
@@ -794,41 +1018,155 @@ function openNewSwitch() {
// --- members ---------------------------------------------------------------
function membersCard() {
const rows = (data.members || []).map((m) =>
h('tr', {},
h('td', {}, h('strong', { text: m.username })),
h('td', { class: 'muted small', text: m.role }),
h('td', {}, isOwner() && h('button', {
class: 'btn-sm', type: 'button',
text: m.role === 'owner' ? 'Make member' : 'Make owner',
onclick: () => act(() =>
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
}), isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
})),
));
const MEMBER_STATUS = {
oncall: { label: 'On call', hint: 'The rota has them today.' },
reachable: { label: 'Reachable', hint: 'Has an ntfy topic, so a page would reach them.' },
unpageable: { label: 'Can’t be paged', hint: 'A page to them would go nowhere.' },
};
const memberBadge = (m) => {
const el = statusBadge(MEMBER_STATUS, m.status, 'reachable');
if (m.problem) el.title = `${MEMBER_STATUS.unpageable.hint} ${m.problem}.`;
return el;
};
// Rota days are UTC dates with no time in them; formatting one in the viewer's
// zone could show the day before.
const shiftFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC',
});
const shiftDay = (ymd) => shiftFmt.format(new Date(`${ymd}T00:00:00Z`));
function shiftCell(m) {
if (m.on_call) {
return h('span', { text: m.next_shift ? `today, then ${shiftDay(m.next_shift)}` : 'today' });
}
return m.next_shift
? h('span', { text: shiftDay(m.next_shift) })
: h('span', { text: 'not scheduled' });
}
function membersCard() {
const members = data.members || [];
const owners = members.filter((m) => m.role === 'owner').length;
const rows = members.map((m) => {
const lastOwner = m.role === 'owner' && owners === 1;
return h('tr', {},
h('td', {}, memberBadge(m)),
h('td', { class: 'wrap' },
h('strong', { text: m.username }),
m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }),
m.problem && h('div', { class: 'target-problem', text: m.problem })),
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
h('td', { class: 'muted small' }, shiftCell(m)),
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
h('td', { class: 'muted small' },
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button', text: 'Edit',
// The server refuses to edit a membership the groups grant.
disabled: m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED : null,
onclick: () => openEditMember(m),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
disabled: lastOwner || m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED
: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
onclick: async () => {
if (!(await confirm({
title: `Remove ${m.username}?`,
text: 'They lose access to this team. Rota days already assigned to them are not '
+ 'changed, so reassign those from the Rota tab.',
confirmLabel: 'Remove',
danger: true,
}))) return;
act(() => api.removeTeamMember(teamID, m.user_id));
},
}))),
);
});
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Members' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Add member', onclick: openAddMember,
})),
h('p', { class: 'muted small' },
'Owners set up the team; members work its incidents. Somebody who can’t be ',
'paged is worth fixing before their next shift.'),
members.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Member' }), h('th', { text: 'Role' }),
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }),
h('th'))),
h('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
);
}
// One sheet for both jobs a member's row has: who, and as what. Adding is
// choosing a person and a role; editing is the same with the person fixed. The
// API is one call either way — POST upserts the role.
function openMemberSheet({ title, submit, person, role, run }) {
const roleSelect = h('select', {},
...['member', 'owner'].map((r) => h('option', { value: r, text: r, selected: r === role })));
const problem = h('p', { class: 'load-error', hidden: true });
const form = h('form', { class: 'stacked-form' },
person.label,
h('label', {}, 'Role ', roleSelect),
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
h('button', { class: 'btn btn-primary', type: 'submit', text: submit })));
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await run(person.userID(), roleSelect.value);
} catch (err) {
problem.textContent = err.message;
problem.hidden = false;
return;
}
closeSheet(true);
refresh();
});
openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]);
}
function openAddMember() {
const inTeam = new Set((data.members || []).map((m) => m.user_id));
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));
openMemberSheet({
title: 'Add member', submit: 'Add member', role: 'member',
person: {
label: candidates.length
? h('label', {}, 'Person ', pick)
: h('p', { class: 'muted', text: 'Everybody with an account is already in this team.' }),
userID: () => Number(pick.value),
},
run: (userID, role) => {
if (!userID) throw new Error('Nobody to add');
return api.addTeamMember(teamID, userID, role);
},
});
}
return h('div', { class: 'card' },
h('h2', { text: 'Members' }),
h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
isOwner() && candidates.length > 0 && form,
);
function openEditMember(m) {
openMemberSheet({
title: `Edit ${m.username}`, submit: 'Save', role: m.role,
person: { label: h('p', { class: 'muted small', text: m.username }), userID: () => m.user_id },
run: (userID, role) => api.addTeamMember(teamID, userID, role),
});
}
// --- plumbing --------------------------------------------------------------
@@ -836,14 +1174,12 @@ function membersCard() {
// act runs a write and reloads. Errors are shown rather than thrown away: a
// 409 from the last-owner guard or the schedule's conflict rule is the server
// explaining itself, and the reader needs to see it.
async function act(fn, { resetDraft = false } = {}) {
async function act(fn) {
try {
await fn();
error = null;
if (resetDraft) draft = null;
} catch (err) {
error = err.message;
}
if (!resetDraft) draft = null;
await refresh();
}
+9
View File
@@ -170,6 +170,15 @@ export function badge(text, cls = '') {
return h('span', { class: `badge ${cls}`, text });
}
// Access granted by the identity provider's groups. The server refuses to edit
// it by hand (it would be undone at the next sign-in), so the controls that
// would try are disabled with this as their reason.
export const SSO_MANAGED = 'Managed by single sign-on. Change the person’s groups in the identity provider.';
export function ssoBadge(text = 'SSO') {
return h('span', { class: 'row-team sso', title: SSO_MANAGED, text });
}
export function labelChip(k, v) {
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
}