Compare commits

..

24 Commits

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

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

The password section's heading no longer says "Change password" or
"Set a password" itself, since that verb now lives on the summary; it
just says "Password", matching the existing SSO-off case.
2026-09-27 22:04:59 +02:00
Niklas Ye 949d6595ba Set the chart's placeholder version to 0.31.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m48s
Release / test (push) Successful in 5s
Release / binaries (push) Successful in 41s
Release / chart (push) Successful in 2s
Release / image (push) Successful in 1m3s
Release / scan-image (push) Successful in 5s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as e5b4df7 and 97a4814, so a
tree heading for v0.31.0 doesn't say 0.30.0.
2026-09-27 18:23:43 +02:00
Niklas Ye 33356ca978 Add a global, colour-coded team selector to the nav
state.js's currentTeam() was hard-coded to teams[0] and never really meant
"the team currently selected" — team.js's settings page and queue.js's
filter chips each kept their own separate, unsynchronized notion of "which
team" instead, so picking one on one page had no effect on the other.

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

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

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

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

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

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

Password login and everything that talks only to Postgres were unaffected.
2026-09-26 22:05:33 +02:00
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
Niklas Ye e8d45f9d3d Set the chart's placeholder version to 0.24.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m55s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 1m0s
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 3ee8583 and d2cdcc9, so a
tree heading for v0.24.0 doesn't say 0.23.0.
2026-09-25 23:40:55 +02:00
Niklas Ye f3918b863c List dead man's switches with their status on Team -> Switches
The page was a bare form: it did not say which switches existed or
whether they were alive. It now lists them, each with a Healthy, Dead or
Dormant badge, when its heartbeat was last heard and when it last opened
an incident (linked while that incident is open). A matcher that several
clusters satisfy is broken down per cluster, since a live cluster must
not hide a dead one. The form moved into a "New switch" sheet, and each
row has a Remove with a confirm.

That needed a switch to be a thing, so switches are rows now
(migration 009) with their own name, matcher, timeout and severity,
instead of one string with one team-wide timeout in deadman_configs.
Existing configuration is split into one row per matcher; a team whose
timeout was zero simply has none. The sweeper and the status endpoint
share one death rule (deadmanAlert.dead), so the page cannot disagree
with the pager. Incident group keys are unchanged, so incidents that
are open across the upgrade keep working.

The environment defaults (TERDUT_DEADMAN_*) are seeded into teams once
per install, recorded in settings, so a team that deletes its last
switch does not get it back on the next restart. Installs that already
had per-team rows are marked as seeded by the migration.

Removing a switch stops the watching but leaves an incident it already
opened open until someone resolves it.

API: GET/PUT /api/teams/{id}/deadman are replaced by
GET/POST /deadman/switches and DELETE /deadman/switches/{switchID}.
terdut-tui does not call them, so nothing to mirror there.
2026-09-25 23:40:55 +02:00
Niklas Ye 3ee8583f6f Set the chart's placeholder version to 0.23.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m35s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 21s
Release / image (push) Successful in 57s
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 d2cdcc9 and 71d7e18, so a
tree heading for v0.23.0 doesn't say 0.22.1.
2026-09-25 19:12:33 +02:00
Niklas Ye 591d5b8df0 Copy an incident to the clipboard as Markdown
A button in the incident header (also `y`, and "Copy incident" in the
more menu) puts everything the page knows on the clipboard, for pasting
into a chat or an agent prompt with no integration involved.

The text carries the facts, every alert with all its labels and
annotations (the page only shows summary or description), the timeline
with notes in full, and the "Seen before" resolution notes. Times are
ISO 8601 and users are named rather than "you", since relative and
first-person wording is ambiguous once pasted elsewhere.

The async clipboard API needs a secure context and this server is often
reached over plain HTTP, so it falls back to execCommand.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-09-25 19:12:33 +02:00
58 changed files with 6348 additions and 655 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 go build -ldflags="-w -s -X main.version=${VERSION}" -o /terdut ./cmd/terdut
FROM scratch 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 COPY --from=builder /terdut /terdut
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/terdut"] ENTRYPOINT ["/terdut"]
+159 -24
View File
@@ -132,6 +132,110 @@ 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 "who is in this team", and answering "which teams is this person in" there means
visiting each team in turn. visiting each team in turn.
### Single sign-on (OIDC)
terdut can sign people in through any OpenID Connect provider; the examples use
[Authentik](https://goauthentik.io/). Groups at the provider decide who may sign
in, which teams they belong to and whether they administer the install, much as
Grafana's OAuth role and org mapping does. Password login keeps working alongside
it unless you turn it off.
**At the provider**, create an OAuth2/OpenID provider and an application for it:
a *confidential* client, redirect URI `<TERDUT_PUBLIC_URL>/api/oidc/callback`, and
the `openid`, `profile` and `email` scopes. The issuer is the application's, e.g.
`https://auth.example.com/application/o/terdut/`. Then set:
```sh
TERDUT_PUBLIC_URL=https://terdut.example.com
TERDUT_OIDC_ISSUER=https://auth.example.com/application/o/terdut/
TERDUT_OIDC_CLIENT_ID=terdut
TERDUT_OIDC_CLIENT_SECRET=...
TERDUT_OIDC_ALLOWED_GROUPS=terdut-users,terdut-admins
TERDUT_OIDC_ADMIN_GROUP=terdut-admins
```
Which team a group grants is not server-wide config: each team names its own
group(s), set by that team's own owner (or an administrator) from its Members
tab, or `PUT /api/teams/{teamID}/oidc-groups {"member_group":"sre","owner_group":"sre-leads"}`.
A team must already exist before a group can grant access to it — the sync
never creates one.
The web UI's sign-in page shows a "Sign in with <name>" button (a plain link to
`/api/oidc/login`) above the password form, or instead of it when
`TERDUT_PASSWORD_LOGIN=false`; it asks `GET /api/auth/config` what the server offers
(`password_login`, `oidc.enabled`, `oidc.name`). A refused sign-in comes back to that
page with the reason spelled out. Access the groups grant is badged **SSO** on the
Team, Admin and per-user pages, with its edit and remove controls disabled, and the
Account page does not offer to set a password nobody could use.
**What a sign-in does**
1. *Who.* The provider's `(issuer, subject)` is the identity. The first time, a
user is found by email — only when the provider marks it verified, or
`TERDUT_OIDC_TRUST_EMAIL` is set — or created with no password. A username taken
by somebody else gets a numeric suffix (`alice-2`). Username and email follow the
provider at each sign-in. Authentik reports `email_verified` as false unless
configured otherwise, so linking existing users usually needs
`TERDUT_OIDC_TRUST_EMAIL=true`.
2. *Whether.* With `TERDUT_OIDC_ALLOWED_GROUPS` set, somebody in none of them is
refused and nothing is created.
3. *What.* The administrator flag follows `TERDUT_OIDC_ADMIN_GROUP`. Team roles
follow each team's own `oidc_member_group`/`oidc_owner_group`; where both of a
team's groups match, the owner group wins.
**Managed access.** What the sync grants is marked as managed by single sign-on,
and only that is ever changed by it. It is added at sign-in, and removed at the
next sign-in after the group is gone, even if that leaves a team without an owner
(an administrator can always repair a team) — the provider is the source of truth
for what it grants, so the last-owner and last-administrator guards do not apply.
Memberships and administrators added by hand are left alone; the exception is a
hand-added member whose team's own group grants a *higher* role, who is raised and
from then on managed. Editing managed access by hand (`POST` or `DELETE` on a
team's members, revoking an SSO-granted administrator) is refused with `409`, since
the next sign-in would undo it.
> **Upgrading past migration 013: reconfigure every team's groups.**
> `TERDUT_OIDC_GROUP_MAPPINGS` is gone, and the sync no longer creates a team by
> name. Group-to-team-role mapping is now each team's own setting — an owner sets
> it from the Members tab, or `PUT /api/teams/{teamID}/oidc-groups`. Until a team's
> owner does that, an OIDC-sourced membership in it is dropped at that user's next
> SSO sign-in, the same as any other loss of group access. Set every team's groups
> before affected users next sign in, to avoid a visible gap in access.
**How fast changes arrive.** Groups are read only at sign-in. A session made by an
SSO sign-in has a hard ceiling (`TERDUT_OIDC_SESSION_MAX_AGE`, default 12h) that
sliding never extends, so a change at the provider reaches terdut within that time.
Password sessions are unaffected.
> **API keys are not revoked when somebody is removed at the provider.** terdut
> holds no refresh token and never asks the provider again, so a person removed
> from every allowed group loses their sessions within `TERDUT_OIDC_SESSION_MAX_AGE`
> and cannot sign in again, but keeps any API key they made (the TUI and scripts use
> them) until an administrator disables the user in terdut.
**Signing in from a terminal.** A client with no browser of its own, such as the
TUI over SSH, signs in with a device code, run by terdut itself so the terminal
never talks to the provider:
1. The terminal calls `POST /api/oidc/device` and shows the person a link
(`<TERDUT_PUBLIC_URL>/device?code=XXXX-XXXX`) and the code.
2. On any device the person opens the link, signs in (by the provider or by
password, whatever the login page offers), sees the code and the account, and
presses **Approve**. Only a browser session can approve; an API key cannot.
3. The terminal polls `POST /api/oidc/device/token` every 5 seconds and is given the
ordinary `terdut_session` cookie once. A person who signs in through the provider
gets the same `TERDUT_OIDC_SESSION_MAX_AGE` ceiling on the terminal's session as
on their browser's.
A login expires after 10 minutes. `GET /api/auth/config` reports `device_login`.
**If the provider is down**, terdut still starts (discovery is fetched on first
use) and password login is the way in. With `TERDUT_PASSWORD_LOGIN=false` that way
is closed: set it back to `true`. The first administrator comes from the bootstrap
endpoint, and stays a manual administrator that no group can revoke; on an SSO-only
install set `bootstrap.enabled: false` in the chart if you don't want that account,
or keep it and never give it a password.
### Docker ### Docker
```bash ```bash
@@ -229,6 +333,16 @@ over an administrator's edit.
| `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call | | `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_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_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_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. Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
@@ -236,7 +350,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 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. 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`.
--- ---
@@ -513,19 +627,27 @@ nothing unless something downstream notices it stop. That is what
`TERDUT_DEADMAN_MATCHERS` defaults to. `TERDUT_DEADMAN_MATCHERS` defaults to.
**Switches belong to a team**, which decides which of its own alerts are **Switches belong to a team**, which decides which of its own alerts are
heartbeats and how long a silence has to last. An owner sets them through heartbeats and how long a silence has to last. Each **switch** is a row of its
`PUT /api/teams/{teamID}/deadman`; a missed heartbeat opens an incident in the own — a name, one matcher, a timeout and a severity — so switches in one team
team whose integration received it. can have different deadlines. An owner adds and removes them on **Team →
Switches**, which lists each with a status (**healthy**, **dead**, or
**dormant** until its first heartbeat), when it was last heard from, and when it
last opened an incident; a matcher that several clusters satisfy is broken down
per cluster. The API is `POST`/`DELETE /api/teams/{teamID}/deadman/switches`. A
missed heartbeat opens an incident in the team whose integration received it.
Removing a switch stops the watching; an incident it already opened stays open
until somebody resolves it.
The environment variables are the starting point, not the setting: at startup The environment variables are the starting point, not the setting: the **first**
every team **without** a configuration of its own is given one from them, and an time the server starts, every team is given a switch per default matcher from
owner's later edit is never overwritten by a redeploy. A team created after them, once. After that a team's switches are its own — an owner's edit or
that starts watching nothing until its owner says otherwise — inheriting an deletion is never put back by a redeploy. A team created later starts watching
install-wide heartbeat would page a new team about a source it has never heard nothing until its owner says otherwise — inheriting an install-wide heartbeat
of. would page a new team about a source it has never heard of.
A matcher is a set of exact label conditions, one of which must be the A matcher is a set of exact label conditions, one of which must be the
`alertname`, in the same format the environment variable uses: `alertname`, in the format the environment variable uses (one matcher per switch; the
variable takes several, separated by `;`):
``` ```
alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat
@@ -582,7 +704,9 @@ of the last heartbeat, and the heartbeat's labels are on the incident's
### Authentication ### Authentication
All endpoints except `/api/bootstrap`, `/api/integrations/{key}/alertmanager`, 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> Authorization: Bearer <api-key>
@@ -642,7 +766,14 @@ incident exists is itself something only its team should learn.
| Method | Path | Description | | 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 | | `POST` | `/api/logout` | Ends the session and clears the cookie |
| `GET` | `/api/me` | The caller: `{user, has_password}` | | `GET` | `/api/me` | The caller: `{user, has_password}` |
@@ -663,7 +794,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 | | `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 | | `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 | | `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}/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}/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 | | `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 |
@@ -707,19 +838,23 @@ 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 | | `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 | | `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 | | `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 | | `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"}` | | `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 | | `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 | | `GET` | `/api/teams/{teamID}/oidc-groups` | member | Which groups control this team's membership: `{"member_group","owner_group"}`. An empty string means no group grants that role here |
| `PUT` | `/api/teams/{teamID}/oidc-groups` | **owner** | Set them. An empty string clears a binding |
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys. Each carries `status` (`active` if its key posted within 24h, `quiet` if it has but not lately, `never`), `last_used_at` (last webhook, usable or not), `last_alert_at` (when an alert last arrived on it) and `alerts_24h` (distinct alerts it refreshed in the last day). Alerts delivered before the source was recorded (migration 010) have none, so the last two fill in as Alertmanager re-sends them |
| `PATCH` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Rename `{"name"}`. The key does not change |
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once | | `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 | | `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 | | `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 | | `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 | | `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
| `GET` | `/api/teams/{teamID}/deadman` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` | | `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 |
| `PUT` | `/api/teams/{teamID}/deadman` | **owner** | Replace it. `400` when no matcher names an `alertname`, because a switch that silently watches nothing is the failure this feature exists to prevent | | `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 |
| `DELETE` | `/api/teams/{teamID}/deadman/switches/{switchID}` | **owner** | Stop watching. An incident it opened stays open. `404` for a switch of another team |
### Notifications ### Notifications
@@ -964,8 +1099,8 @@ What changes, and will need attention:
**Dead man's switches moved too.** `TERDUT_DEADMAN_MATCHERS`, `_TIMEOUT` and **Dead man's switches moved too.** `TERDUT_DEADMAN_MATCHERS`, `_TIMEOUT` and
`_SEVERITY` are no longer the setting; they are the default each existing team `_SEVERITY` are no longer the setting; they are the default each existing team
is seeded with at startup, after which an owner edits them per team through is seeded with at startup, after which an owner manages them per team through
`PUT /api/teams/{teamID}/deadman` and a redeploy never overwrites that. `/api/teams/{teamID}/deadman/switches` and a redeploy never overwrites that.
Nothing else about an incident changes, and incidents never move between teams: Nothing else about an incident changes, and incidents never move between teams:
an alert belongs to whichever team's key it arrived on. an alert belongs to whichever team's key it arrived on.
+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: # 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 # image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing. # metadata and drives nothing.
version: 0.22.1 version: 0.32.0
appVersion: "v0.22.1" appVersion: "v0.32.0"
+39 -2
View File
@@ -61,8 +61,6 @@ spec:
value: "{{ .Values.notify.fallbackTopic }}" value: "{{ .Values.notify.fallbackTopic }}"
- name: TERDUT_NOTIFY_REPEAT - name: TERDUT_NOTIFY_REPEAT
value: "{{ .Values.notify.repeatEvery }}" value: "{{ .Values.notify.repeatEvery }}"
- name: TERDUT_PUBLIC_URL
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
{{- if .Values.notify.tokenSecret.name }} {{- if .Values.notify.tokenSecret.name }}
- name: TERDUT_NTFY_TOKEN - name: TERDUT_NTFY_TOKEN
valueFrom: valueFrom:
@@ -71,6 +69,45 @@ spec:
key: {{ .Values.notify.tokenSecret.key }} key: {{ .Values.notify.tokenSecret.key }}
{{- end }} {{- end }}
{{- 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 }}
{{- end }}
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
+48
View File
@@ -107,6 +107,54 @@ notify:
name: "" name: ""
key: token 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: ""
# Which group grants a team's membership and ownership is each team's own
# setting now, not chart config: an owner sets it from the Members tab, or
# PUT /api/teams/{teamID}/oidc-groups. A team must already exist for a group
# to grant access to it.
# Hard ceiling on a session made by a single sign-on login.
sessionMaxAge: 12h
# Backups are no longer this chart's business. The SQLite database lived on a PVC # 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 # 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 # 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() { func main() {
cfg := config.Load() cfg := config.Load()
if err := cfg.Validate(); err != nil {
log.Fatalf("config: %v", err)
}
database, err := db.Open(cfg.DSN) database, err := db.Open(cfg.DSN)
if err != nil { if err != nil {
+3
View File
@@ -3,13 +3,16 @@ module git.ryuvia.com/niklas/terdut-server
go 1.25.9 go 1.25.9
require ( require (
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgx/v5 v5.11.0 github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.55.0 golang.org/x/crypto v0.55.0
golang.org/x/oauth2 v0.36.0
) )
require ( require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 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 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 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= 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 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= 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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+16 -11
View File
@@ -78,7 +78,7 @@ type ingested struct {
// post, and which team the alerts belong to. // post, and which team the alerts belong to.
func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc { func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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 err != nil {
if errors.Is(err, errUnknownIntegration) { if errors.Is(err, errUnknownIntegration) {
// 401 and not 404: the path is real, the key is not, and a // 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")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return 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 var payload amPayload
if err := decodeJSON(r, &payload); err != nil { if err := decodeJSON(r, &payload); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid payload")) 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 // 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 // we failed to store is more useful than an error it cannot act on — so
// failures are logged, not surfaced. // 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) 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. // 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 // 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. // 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) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return err return err
@@ -124,12 +126,12 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64,
// Which arriving alerts are heartbeats is the team's own answer, read // Which arriving alerts are heartbeats is the team's own answer, read
// inside the transaction so an owner editing it mid-payload cannot split // inside the transaction so an owner editing it mid-payload cannot split
// one webhook across two interpretations. // one webhook across two interpretations.
deadman, err := deadmanConfigForTeam(ctx, tx, teamID) deadman, err := deadmanSetForTeam(ctx, tx, teamID)
if err != nil { if err != nil {
return err return err
} }
accepted, err := upsertAlerts(ctx, tx, deadman, teamID, payload.Alerts) accepted, err := upsertAlerts(ctx, tx, deadman, src, payload.Alerts)
if err != nil { if err != nil {
return err 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 // upsertAlerts stores each alert of a payload and reports what changed. Payloads
// the ordering guard rejected are left out entirely. // the ordering guard rejected are left out entirely.
func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID int64, alerts []amAlert) ([]ingested, error) { func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman deadmanSet, src alertSource, alerts []amAlert) ([]ingested, error) {
teamID := src.teamID
now := time.Now().Unix() now := time.Now().Unix()
accepted := make([]ingested, 0, len(alerts)) accepted := make([]ingested, 0, len(alerts))
@@ -242,8 +245,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO alerts INSERT INTO alerts
(team_id, fingerprint, name, status, labels, annotations, starts_at, ends_at, (team_id, fingerprint, name, status, labels, annotations, starts_at, ends_at,
generator_url, received_at, resolution_source) generator_url, received_at, resolution_source, integration_id)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11, $12)
ON CONFLICT (team_id, fingerprint) DO UPDATE SET ON CONFLICT (team_id, fingerprint) DO UPDATE SET
status = excluded.status, status = excluded.status,
labels = excluded.labels, labels = excluded.labels,
@@ -257,6 +260,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID
-- is a breaking API change — see models.Alert.ReceivedAt. -- is a breaking API change — see models.Alert.ReceivedAt.
received_at = excluded.received_at, received_at = excluded.received_at,
resolution_source = excluded.resolution_source, 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. -- A re-fire makes the alert current again, so it leaves the archive.
archived_at = CASE WHEN excluded.status = 'firing' archived_at = CASE WHEN excluded.status = 'firing'
THEN NULL ELSE alerts.archived_at END THEN NULL ELSE alerts.archived_at END
@@ -267,7 +272,7 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID
teamID, a.Fingerprint, name, a.Status, teamID, a.Fingerprint, name, a.Status,
string(labelsJSON), string(annotationsJSON), string(labelsJSON), string(annotationsJSON),
a.StartsAt.Unix(), endsAtUnix, a.StartsAt.Unix(), endsAtUnix,
a.GeneratorURL, now, resolutionSource, a.GeneratorURL, now, resolutionSource, src.integrationID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
+20 -14
View File
@@ -15,6 +15,7 @@ import (
"time" "time"
"git.ryuvia.com/niklas/terdut-server/internal/api" "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 // 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 { if len(notify) > 0 {
cfg = 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) database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig())) srv := httptest.NewServer(api.NewRouter(database, cfg, conf))
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"}) body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
@@ -88,27 +96,25 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
return s return s
} }
// setTeamDeadman configures the default team's switches over the API, rendering // setTeamDeadman gives the default team one switch per configured matcher, over
// the matchers back into the string form the endpoint takes. // the API, the way an owner would add them.
func setTeamDeadman(t *testing.T, s *ts, cfg api.DeadmanConfig) { func setTeamDeadman(t *testing.T, s *ts, cfg api.DeadmanConfig) {
t.Helper() t.Helper()
matchers := make([]string, 0, len(cfg.Matchers))
for _, m := range cfg.Matchers { for _, m := range cfg.Matchers {
parts := []string{"alertname=" + m.Name} parts := []string{"alertname=" + m.Name}
for k, v := range m.Labels { for k, v := range m.Labels {
parts = append(parts, k+"="+v) parts = append(parts, k+"="+v)
} }
sort.Strings(parts[1:]) sort.Strings(parts[1:])
matchers = append(matchers, strings.Join(parts, ",")) resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", map[string]any{
} "matcher": strings.Join(parts, ","),
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman", map[string]any{ "timeout_seconds": int64(cfg.Timeout.Seconds()),
"matchers": strings.Join(matchers, "; "), "severity": cfg.Severity,
"timeout_seconds": int64(cfg.Timeout.Seconds()), })
"severity": cfg.Severity, resp.Body.Close()
}) if resp.StatusCode != http.StatusCreated {
defer resp.Body.Close() t.Fatalf("add a dead man's switch: %d", resp.StatusCode)
if resp.StatusCode != http.StatusOK { }
t.Fatalf("configure the team's dead man's switches: %d", resp.StatusCode)
} }
} }
+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 // 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. // 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 { 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() raw, tokenHash, err := randomToken()
if err != nil { if err != nil {
return err return err
} }
now := time.Now() 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(), ` if _, err := db.ExecContext(r.Context(), `
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent) 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)`, VALUES ($1, $2, $3, $4, $5, $6, $7)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil { tokenHash, userID, now.Unix(), now.Unix(), now.Add(life).Unix(), ceiling, r.UserAgent()); err != nil {
return err return err
} }
@@ -159,7 +175,7 @@ func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int
Name: sessionCookie, Name: sessionCookie,
Value: raw, Value: raw,
Path: "/", Path: "/",
MaxAge: int(sessionTTL.Seconds()), MaxAge: int(life.Seconds()),
HttpOnly: true, HttpOnly: true,
Secure: cookieSecure(publicURL, r), Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
+354 -174
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt"
"log" "log"
"sort" "sort"
"strings" "strings"
@@ -39,6 +41,17 @@ func (m DeadmanMatcher) String() string {
return m.Name + " (" + strings.Join(parts, ", ") + ")" return m.Name + " (" + strings.Join(parts, ", ") + ")"
} }
// config renders the matcher in the form parseDeadmanMatcher reads, which is
// what a switch row stores: `alertname=Watchdog,cluster=prod`.
func (m DeadmanMatcher) config() string {
parts := make([]string, 0, len(m.Labels))
for k, v := range m.Labels {
parts = append(parts, k+"="+v)
}
sort.Strings(parts)
return strings.Join(append([]string{"alertname=" + m.Name}, parts...), ",")
}
// matches reports whether an alert's labels satisfy every condition. // matches reports whether an alert's labels satisfy every condition.
func (m DeadmanMatcher) matches(labels map[string]string) bool { func (m DeadmanMatcher) matches(labels map[string]string) bool {
if labels["alertname"] != m.Name { if labels["alertname"] != m.Name {
@@ -52,12 +65,10 @@ func (m DeadmanMatcher) matches(labels map[string]string) bool {
return true return true
} }
// DeadmanConfig inverts the handling of the alerts it matches: receiving one // DeadmanConfig is the server-wide default a team's switches are seeded from:
// opens nothing, and the absence of one opens an incident. // the environment's matchers, timeout and severity. Switches themselves are rows
// // of a team's own — see DeadmanSwitch — and this is only how a fresh install
// The unit of monitoring is the fingerprint, not the matcher — two clusters // starts out.
// sending the same heartbeat alertname are two independent switches, so one
// healthy cluster cannot mask a dead one.
type DeadmanConfig struct { type DeadmanConfig struct {
Matchers []DeadmanMatcher Matchers []DeadmanMatcher
@@ -76,41 +87,81 @@ type DeadmanConfig struct {
// enabled reports whether there is anything to watch. // enabled reports whether there is anything to watch.
func (c DeadmanConfig) enabled() bool { return c.Timeout > 0 && len(c.Matchers) > 0 } func (c DeadmanConfig) enabled() bool { return c.Timeout > 0 && len(c.Matchers) > 0 }
// match returns the first matcher an alert satisfies. // DeadmanSwitch inverts the handling of the alerts it matches: receiving one
func (c DeadmanConfig) match(labels map[string]string) (DeadmanMatcher, bool) { // opens nothing, and the absence of one opens an incident.
if !c.enabled() { //
return DeadmanMatcher{}, false // The unit of monitoring is the fingerprint, not the switch — two clusters
} // sending the same heartbeat alertname are two independent heartbeats under one
for _, m := range c.Matchers { // switch, so one healthy cluster cannot mask a dead one.
if m.matches(labels) { type DeadmanSwitch struct {
return m, true ID int64
} Name string
} Matcher DeadmanMatcher
return DeadmanMatcher{}, false
// Timeout is how long a heartbeat may go unheard before it is declared dead.
Timeout time.Duration
// Severity is what the incident opens at.
Severity string
} }
// isDeadman is match without the matcher, for the ingest path. // deadmanSet is one team's switches.
func (c DeadmanConfig) isDeadman(labels map[string]string) bool { type deadmanSet []DeadmanSwitch
_, ok := c.match(labels)
// match returns the first switch an alert satisfies.
func (d deadmanSet) match(labels map[string]string) (DeadmanSwitch, bool) {
for _, sw := range d {
if sw.Matcher.matches(labels) {
return sw, true
}
}
return DeadmanSwitch{}, false
}
// isDeadman is match without the switch, for the ingest path.
func (d deadmanSet) isDeadman(labels map[string]string) bool {
_, ok := d.match(labels)
return ok return ok
} }
// names lists the distinct alertnames worth loading from the database. // names lists the distinct alertnames worth loading from the database.
func (c DeadmanConfig) names() []string { func (d deadmanSet) names() []string {
seen := map[string]bool{} seen := map[string]bool{}
out := make([]string, 0, len(c.Matchers)) out := make([]string, 0, len(d))
for _, m := range c.Matchers { for _, sw := range d {
if !seen[m.Name] { if !seen[sw.Matcher.Name] {
seen[m.Name] = true seen[sw.Matcher.Name] = true
out = append(out, m.Name) out = append(out, sw.Matcher.Name)
} }
} }
return out return out
} }
// parseDeadmanMatcher reads one matcher from its configured form: "," separates
// the conditions and "=" is exact label equality — `alertname=Watchdog,cluster=prod`.
// The error says what is wrong with it, in words a form can show.
func parseDeadmanMatcher(entry string) (DeadmanMatcher, error) {
m := DeadmanMatcher{Labels: map[string]string{}}
for _, cond := range strings.Split(strings.TrimSpace(entry), ",") {
k, v, ok := strings.Cut(cond, "=")
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if !ok || k == "" || v == "" {
return DeadmanMatcher{}, fmt.Errorf("%q is not label=value", strings.TrimSpace(cond))
}
if k == "alertname" {
m.Name = v
continue
}
m.Labels[k] = v
}
if m.Name == "" {
return DeadmanMatcher{}, errors.New("no alertname condition")
}
return m, nil
}
// ParseDeadmanConfig reads the matcher list from its configured form: // ParseDeadmanConfig reads the matcher list from its configured form:
// ";" separates matchers, "," separates the conditions within one, and "=" is // ";" separates matchers, and each is parsed as parseDeadmanMatcher does.
// exact label equality — `alertname=Watchdog,cluster=prod; alertname=Heartbeat`.
// //
// A malformed or alertname-less entry is dropped rather than fatal, following // A malformed or alertname-less entry is dropped rather than fatal, following
// config.duration's rule that one bad tuning knob should not take the server // config.duration's rule that one bad tuning knob should not take the server
@@ -125,28 +176,9 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
if entry == "" { if entry == "" {
continue continue
} }
m, err := parseDeadmanMatcher(entry)
m := DeadmanMatcher{Labels: map[string]string{}} if err != nil {
malformed := false log.Printf("deadman: ignoring matcher %q: %v", entry, err)
for _, cond := range strings.Split(entry, ",") {
k, v, ok := strings.Cut(cond, "=")
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if !ok || k == "" || v == "" {
log.Printf("deadman: ignoring matcher %q: %q is not label=value", entry, strings.TrimSpace(cond))
malformed = true
break
}
if k == "alertname" {
m.Name = v
continue
}
m.Labels[k] = v
}
if malformed {
continue
}
if m.Name == "" {
log.Printf("deadman: ignoring matcher %q: no alertname condition", entry)
continue continue
} }
cfg.Matchers = append(cfg.Matchers, m) cfg.Matchers = append(cfg.Matchers, m)
@@ -162,23 +194,35 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
for _, m := range cfg.Matchers { for _, m := range cfg.Matchers {
rendered = append(rendered, m.String()) rendered = append(rendered, m.String())
} }
log.Printf("deadman: watching %s, timeout %s, severity %s", log.Printf("deadman: default for new teams: %s, timeout %s, severity %s",
strings.Join(rendered, "; "), timeout, severity) strings.Join(rendered, "; "), timeout, severity)
} }
return cfg return cfg
} }
// deadmanAlert is one switch: the alert row carrying its last heartbeat. // deadmanAlert is one heartbeat: the alert row carrying its last sighting, and
// the switch that claimed it.
type deadmanAlert struct { type deadmanAlert struct {
id int64 id int64
teamID int64 teamID int64
fingerprint string fingerprint string
labels map[string]string labels map[string]string
matcher DeadmanMatcher sw DeadmanSwitch
resolved bool resolved bool
receivedAt int64 receivedAt int64
} }
// dead is the one rule for a silent heartbeat, shared by the sweeper that pages
// on it and the status the Switches page shows, so the page cannot disagree
// with the pager.
//
// An explicit resolved from Alertmanager is a stronger death signal than mere
// absence: the sender is telling us the heartbeat stopped, so there is nothing
// left to wait out.
func (a deadmanAlert) dead(now time.Time) bool {
return a.resolved || a.receivedAt < now.Add(-a.sw.Timeout).Unix()
}
// groupKey is the switch's identity as an incident. Per fingerprint, so each // groupKey is the switch's identity as an incident. Per fingerprint, so each
// source is tracked on its own. // source is tracked on its own.
func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerprint } func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerprint }
@@ -189,13 +233,13 @@ func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerpr
// It returns the ids of the alerts it owns, because the generic staleness // It returns the ids of the alerts it owns, because the generic staleness
// expiry must leave them alone — staleAfter and ends_at would otherwise resolve // expiry must leave them alone — staleAfter and ends_at would otherwise resolve
// a heartbeat long before its own, much tighter, timeout ever fired. // a heartbeat long before its own, much tighter, timeout ever fired.
// Each team is swept against its own configuration: its own matchers, its own // Each team is swept against its own switches, each with its own matcher,
// timeout, its own severity. A team watching nothing is skipped entirely, which // timeout and severity. A team watching nothing is skipped entirely, which is
// is most of them. // most of them.
func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int64]bool { func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int64]bool {
owned := map[int64]bool{} owned := map[int64]bool{}
configs, err := deadmanConfigs(ctx, db) configs, err := deadmanSets(ctx, db)
if err != nil { if err != nil {
log.Printf("deadman: load configs: %v", err) log.Printf("deadman: load configs: %v", err)
return owned return owned
@@ -203,39 +247,35 @@ func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int6
now := time.Now() now := time.Now()
for teamID, cfg := range configs { for teamID, cfg := range configs {
switches, err := deadmanAlerts(ctx, db, teamID, cfg) heartbeats, err := deadmanAlerts(ctx, db, teamID, cfg)
if err != nil { if err != nil {
log.Printf("deadman: load switches for team %d: %v", teamID, err) log.Printf("deadman: load heartbeats for team %d: %v", teamID, err)
continue continue
} }
cutoff := now.Add(-cfg.Timeout).Unix()
for _, sw := range switches { for _, hb := range heartbeats {
owned[sw.id] = true owned[hb.id] = true
// An explicit resolved from Alertmanager is a stronger death signal if hb.dead(now) {
// than mere absence: the sender is telling us the heartbeat if err := deadmanDied(ctx, db, notify, hb, now); err != nil {
// stopped, so there is nothing left to wait out. log.Printf("deadman: open incident for %s: %v", hb.sw.Matcher.Name, err)
if sw.resolved || sw.receivedAt < cutoff {
if err := deadmanDied(ctx, db, cfg, notify, sw, now); err != nil {
log.Printf("deadman: open incident for %s: %v", sw.matcher.Name, err)
} }
continue continue
} }
if err := deadmanRecovered(ctx, db, sw); err != nil { if err := deadmanRecovered(ctx, db, hb); err != nil {
log.Printf("deadman: resolve incident for %s: %v", sw.matcher.Name, err) log.Printf("deadman: resolve incident for %s: %v", hb.sw.Matcher.Name, err)
} }
} }
} }
return owned return owned
} }
// deadmanAlerts loads every alert row that a matcher claims. The candidate query // deadmanAlerts loads every alert row that one of a team's switches claims. The candidate query
// is narrowed by alertname so it rides alerts_name_idx; the rest of the matching // is narrowed by alertname so it rides alerts_name_idx; the rest of the matching
// happens in Go, which keeps one implementation of the rules. The rows are read // happens in Go, which keeps one implementation of the rules. The rows are read
// in full before the caller writes, so the writes do not run against an open // in full before the caller writes, so the writes do not run against an open
// cursor over the same table. // cursor over the same table.
func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanConfig) ([]deadmanAlert, error) { func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg deadmanSet) ([]deadmanAlert, error) {
names := cfg.names() names := cfg.names()
args := &sqlArgs{} args := &sqlArgs{}
nameList := make([]any, len(names)) nameList := make([]any, len(names))
@@ -263,11 +303,11 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanCon
} }
json.Unmarshal([]byte(labelsJSON), &a.labels) //nolint:errcheck json.Unmarshal([]byte(labelsJSON), &a.labels) //nolint:errcheck
m, ok := cfg.match(a.labels) sw, ok := cfg.match(a.labels)
if !ok { if !ok {
continue continue
} }
a.matcher = m a.sw = sw
a.resolved = status == "resolved" a.resolved = status == "resolved"
out = append(out, a) out = append(out, a)
} }
@@ -284,16 +324,16 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanCon
// incidentForGroup), and a source that is gone for good is a one-time page // incidentForGroup), and a source that is gone for good is a one-time page
// rather than a nag. Only a heartbeat that comes back and dies again earns a new // rather than a nag. Only a heartbeat that comes back and dies again earns a new
// incident. // incident.
func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify NotifyConfig, sw deadmanAlert, now time.Time) error { func deadmanDied(ctx context.Context, db *sql.DB, notify NotifyConfig, hb deadmanAlert, now time.Time) error {
var lastTriggered, open int64 var lastTriggered, open int64
if err := db.QueryRowContext(ctx, ` if err := db.QueryRowContext(ctx, `
SELECT COALESCE(MAX(triggered_at), 0), SELECT COALESCE(MAX(triggered_at), 0),
COUNT(*) FILTER (WHERE resolved_at IS NULL) COUNT(*) FILTER (WHERE resolved_at IS NULL)
FROM incidents WHERE team_id = $1 AND group_key = $2`, FROM incidents WHERE team_id = $1 AND group_key = $2`,
sw.teamID, sw.groupKey()).Scan(&lastTriggered, &open); err != nil { hb.teamID, hb.groupKey()).Scan(&lastTriggered, &open); err != nil {
return err return err
} }
if open > 0 || sw.receivedAt <= lastTriggered { if open > 0 || hb.receivedAt <= lastTriggered {
return nil return nil
} }
@@ -306,18 +346,18 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
// A heartbeat nobody has heard from is not firing, and saying otherwise in // A heartbeat nobody has heard from is not firing, and saying otherwise in
// the alert list would be a lie. An Alertmanager-sourced resolution keeps its // the alert list would be a lie. An Alertmanager-sourced resolution keeps its
// own source: it told us the truth first. // own source: it told us the truth first.
if !sw.resolved { if !hb.resolved {
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
UPDATE alerts UPDATE alerts
SET status = 'resolved', SET status = 'resolved',
resolution_source = $1, resolution_source = $1,
ends_at = COALESCE(ends_at, `+nowEpoch+`) ends_at = COALESCE(ends_at, `+nowEpoch+`)
WHERE id = $2 AND status = 'firing'`, resolutionDeadman, sw.id); err != nil { WHERE id = $2 AND status = 'firing'`, resolutionDeadman, hb.id); err != nil {
return err return err
} }
} }
severity := cfg.Severity severity := hb.sw.Severity
var sev *string var sev *string
if severity != "" { if severity != "" {
sev = &severity sev = &severity
@@ -325,14 +365,14 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
// The incident opens in the team whose integration received the heartbeat: // The incident opens in the team whose integration received the heartbeat:
// the switch belongs to whoever is watching that source, not to the install. // the switch belongs to whoever is watching that source, not to the install.
incidentID, err := openIncident(ctx, tx, notify, sw.teamID, sw.groupKey(), incidentID, err := openIncident(ctx, tx, notify, hb.teamID, hb.groupKey(),
"No heartbeat from "+sw.matcher.String(), sw.labels, sev) "No heartbeat from "+hb.sw.Matcher.String(), hb.labels, sev)
if err != nil { if err != nil {
return err return err
} }
alertID := sw.id alertID := hb.id
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(sw.receivedAt, 0))) + " ago" detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(hb.receivedAt, 0))) + " ago"
if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, &alertID, &detail); err != nil { if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, &alertID, &detail); err != nil {
return err return err
} }
@@ -340,7 +380,7 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return err return err
} }
log.Printf("deadman: %s went silent, opened incident %d", sw.matcher.String(), incidentID) log.Printf("deadman: %s went silent, opened incident %d", hb.sw.Matcher.String(), incidentID)
return nil return nil
} }
@@ -350,12 +390,12 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
// member alerts (linking the heartbeat would have the settled-incident cascade // member alerts (linking the heartbeat would have the settled-incident cascade
// close it on the very same sweep that opened it), so the alert-driven cascade // close it on the very same sweep that opened it), so the alert-driven cascade
// ignores it entirely and recovery is the only automatic way out. // ignores it entirely and recovery is the only automatic way out.
func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error { func deadmanRecovered(ctx context.Context, db *sql.DB, hb deadmanAlert) error {
var incidentID int64 var incidentID int64
switch err := db.QueryRowContext(ctx, ` switch err := db.QueryRowContext(ctx, `
SELECT id FROM incidents SELECT id FROM incidents
WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL`, WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL`,
sw.teamID, sw.groupKey()).Scan(&incidentID); { hb.teamID, hb.groupKey()).Scan(&incidentID); {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil return nil
case err != nil: case err != nil:
@@ -387,116 +427,256 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return err return err
} }
log.Printf("deadman: %s is back, resolved incident %d", sw.matcher.String(), incidentID) log.Printf("deadman: %s is back, resolved incident %d", hb.sw.Matcher.String(), incidentID)
return nil return nil
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Per-team configuration // A team's switches
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// deadmanConfigForTeam reads one team's switches. A team with no row, or with const deadmanSwitchColumns = "id, team_id, name, matcher, timeout_seconds, severity"
// nothing configured, gets a disabled config — which is the right answer rather
// than an error: most teams watch no heartbeat at all.
func deadmanConfigForTeam(ctx context.Context, q querier, teamID int64) (DeadmanConfig, error) {
var matchers, severity string
var timeout int64
err := q.QueryRowContext(ctx,
"SELECT matchers, timeout_seconds, severity FROM deadman_configs WHERE team_id = $1",
teamID).Scan(&matchers, &timeout, &severity)
if err == sql.ErrNoRows {
return DeadmanConfig{}, nil
}
if err != nil {
return DeadmanConfig{}, err
}
return parseDeadmanQuietly(matchers, time.Duration(timeout)*time.Second, severity), nil
}
// deadmanConfigs reads every team's switches in one query, for the sweeper. // scanDeadmanSwitches reads switch rows into per-team sets. A row whose matcher
func deadmanConfigs(ctx context.Context, db *sql.DB) (map[int64]DeadmanConfig, error) { // no longer parses is skipped rather than fatal: the API refuses to store one,
rows, err := db.QueryContext(ctx, // so it can only mean a hand edit, and one bad row must not stop the others
"SELECT team_id, matchers, timeout_seconds, severity FROM deadman_configs") // from being watched.
if err != nil { func scanDeadmanSwitches(rows *sql.Rows) (map[int64]deadmanSet, error) {
return nil, err
}
defer rows.Close() defer rows.Close()
out := map[int64]deadmanSet{}
out := map[int64]DeadmanConfig{}
for rows.Next() { for rows.Next() {
var sw DeadmanSwitch
var teamID, timeout int64 var teamID, timeout int64
var matchers, severity string var matcher string
if err := rows.Scan(&teamID, &matchers, &timeout, &severity); err != nil { if err := rows.Scan(&sw.ID, &teamID, &sw.Name, &matcher, &timeout, &sw.Severity); err != nil {
return nil, err return nil, err
} }
cfg := parseDeadmanQuietly(matchers, time.Duration(timeout)*time.Second, severity) m, err := parseDeadmanMatcher(matcher)
if cfg.enabled() { if err != nil {
out[teamID] = cfg log.Printf("deadman: switch %d has an unusable matcher %q: %v", sw.ID, matcher, err)
continue
} }
sw.Matcher = m
sw.Timeout = time.Duration(timeout) * time.Second
out[teamID] = append(out[teamID], sw)
} }
return out, rows.Err() return out, rows.Err()
} }
// SeedDeadmanConfigs gives every team without a row the server's environment // deadmanSetForTeam reads one team's switches. A team with none gets an empty
// configuration, so the install that upgrades into per-team switches keeps // set — which is the right answer rather than an error: most teams watch no
// watching exactly what it was watching before. // heartbeat at all.
func deadmanSetForTeam(ctx context.Context, q querier, teamID int64) (deadmanSet, error) {
rows, err := q.QueryContext(ctx,
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches WHERE team_id = $1 ORDER BY id", teamID)
if err != nil {
return nil, err
}
sets, err := scanDeadmanSwitches(rows)
return sets[teamID], err
}
// deadmanSets reads every team's switches in one query, for the sweeper.
func deadmanSets(ctx context.Context, db *sql.DB) (map[int64]deadmanSet, error) {
rows, err := db.QueryContext(ctx,
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches ORDER BY id")
if err != nil {
return nil, err
}
return scanDeadmanSwitches(rows)
}
// deadmanSeededKey is the settings row that records the environment defaults
// were handed out. Without it, a team that deleted its last switch would get
// the default back on the next restart.
const deadmanSeededKey = "deadman_seeded"
// SeedDeadmanConfigs gives every team the server's environment defaults as
// switches, exactly once per install, so a fresh install watches Watchdog
// without anybody setting it up.
// //
// Idempotent, and never overwrites: once a team has a row it owns its own // Once seeded it never runs again: a team's switches are its own, and a redeploy
// configuration, and a redeploy must not quietly put the environment's value // must not quietly put the environment's value back over an owner's edit or
// back over an owner's edit. // deletion. Installs that upgraded from per-team configuration were already
// seeded, which migration 009 records.
// //
// A team created after startup gets no row and therefore watches nothing until // A team created after that gets none and watches nothing until its owner says
// its owner says otherwise. That is deliberate: inheriting an install-wide // otherwise. That is deliberate: inheriting an install-wide heartbeat would page
// heartbeat would page a new team about a source it has never heard of, and a // a new team about a source it has never heard of, and a switch nobody chose is
// switch nobody chose is the kind that gets muted rather than fixed. // the kind that gets muted rather than fixed.
func SeedDeadmanConfigs(ctx context.Context, db *sql.DB, cfg DeadmanConfig) error { func SeedDeadmanConfigs(ctx context.Context, db *sql.DB, cfg DeadmanConfig) error {
matchers := make([]string, 0, len(cfg.Matchers)) if !cfg.enabled() {
return nil
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
res, err := tx.ExecContext(ctx,
"INSERT INTO settings (key, value) VALUES ($1, '1') ON CONFLICT (key) DO NOTHING",
deadmanSeededKey)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil
}
for _, m := range cfg.Matchers { for _, m := range cfg.Matchers {
parts := []string{"alertname=" + m.Name} if _, err := tx.ExecContext(ctx, `
for k, v := range m.Labels { INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
parts = append(parts, k+"="+v) SELECT id, $1, $1, $2, $3 FROM teams`,
m.config(), int64(cfg.Timeout.Seconds()), cfg.Severity); err != nil {
return err
} }
sort.Strings(parts[1:])
matchers = append(matchers, strings.Join(parts, ","))
} }
return tx.Commit()
_, err := db.ExecContext(ctx, `
INSERT INTO deadman_configs (team_id, matchers, timeout_seconds, severity)
SELECT id, $1, $2, $3 FROM teams
ON CONFLICT (team_id) DO NOTHING`,
strings.Join(matchers, "; "), int64(cfg.Timeout.Seconds()), cfg.Severity)
return err
} }
// parseDeadmanQuietly is ParseDeadmanConfig without the startup logging: a // ---------------------------------------------------------------------------
// team's configuration is read on every sweep and every webhook, and logging it // Status
// each time would bury everything else. // ---------------------------------------------------------------------------
func parseDeadmanQuietly(matchers string, timeout time.Duration, severity string) DeadmanConfig {
cfg := DeadmanConfig{Timeout: timeout, Severity: severity} const (
for _, entry := range strings.Split(matchers, ";") { switchHealthy = "healthy"
entry = strings.TrimSpace(entry) switchDead = "dead"
if entry == "" { switchDormant = "dormant"
continue )
}
m := DeadmanMatcher{Labels: map[string]string{}} // deadmanSource is one heartbeat under a switch: a fingerprint that matched.
malformed := false type deadmanSource struct {
for _, cond := range strings.Split(entry, ",") { Fingerprint string `json:"fingerprint"`
k, v, ok := strings.Cut(cond, "=") Labels map[string]string `json:"labels"`
k, v = strings.TrimSpace(k), strings.TrimSpace(v) Status string `json:"status"`
if !ok || k == "" || v == "" { LastHeartbeatAt time.Time `json:"last_heartbeat_at"`
malformed = true LastTriggeredAt *time.Time `json:"last_triggered_at"`
break IncidentID *int64 `json:"incident_id"`
} }
if k == "alertname" {
m.Name = v // deadmanSwitchStatus is a switch as the Switches page shows it.
continue type deadmanSwitchStatus struct {
} ID int64 `json:"id"`
m.Labels[k] = v Name string `json:"name"`
} Matcher string `json:"matcher"`
if malformed || m.Name == "" { TimeoutSeconds int64 `json:"timeout_seconds"`
continue Severity string `json:"severity"`
}
cfg.Matchers = append(cfg.Matchers, m) // Status is dead when any source is, dormant when none has ever been heard
} // from, healthy otherwise — a live cluster must not hide a dead one.
return cfg Status string `json:"status"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
LastTriggeredAt *time.Time `json:"last_triggered_at"`
OpenIncidentID *int64 `json:"open_incident_id"`
Sources []deadmanSource `json:"sources"`
}
// deadmanStatuses reports every switch of a team with what its heartbeats are
// doing. The liveness verdict is deadmanAlert.dead, the sweeper's own.
func deadmanStatuses(ctx context.Context, db *sql.DB, teamID int64, set deadmanSet, now time.Time) ([]deadmanSwitchStatus, error) {
out := make([]deadmanSwitchStatus, 0, len(set))
if len(set) == 0 {
return out, nil
}
heartbeats, err := deadmanAlerts(ctx, db, teamID, set)
if err != nil {
return nil, err
}
// One query for every switch's incident history, keyed the way the sweeper
// keys it.
type history struct {
triggeredAt int64
openID int64
}
incidents := map[string]history{}
rows, err := db.QueryContext(ctx, `
SELECT group_key, MAX(triggered_at), COALESCE(MAX(id) FILTER (WHERE resolved_at IS NULL), 0)
FROM incidents
WHERE team_id = $1 AND group_key LIKE $2
GROUP BY group_key`, teamID, deadmanGroupPrefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var key string
var h history
if err := rows.Scan(&key, &h.triggeredAt, &h.openID); err != nil {
return nil, err
}
incidents[key] = h
}
if err := rows.Err(); err != nil {
return nil, err
}
bySwitch := map[int64][]deadmanAlert{}
for _, hb := range heartbeats {
bySwitch[hb.sw.ID] = append(bySwitch[hb.sw.ID], hb)
}
later := func(cur *time.Time, unix int64) *time.Time {
t := time.Unix(unix, 0).UTC()
if cur == nil || t.After(*cur) {
return &t
}
return cur
}
for _, sw := range set {
st := deadmanSwitchStatus{
ID: sw.ID, Name: sw.Name, Matcher: sw.Matcher.config(),
TimeoutSeconds: int64(sw.Timeout.Seconds()), Severity: sw.Severity,
Status: switchDormant, Sources: []deadmanSource{},
}
for _, hb := range bySwitch[sw.ID] {
src := deadmanSource{
Fingerprint: hb.fingerprint,
Labels: hb.labels,
Status: switchHealthy,
LastHeartbeatAt: time.Unix(hb.receivedAt, 0).UTC(),
}
if hb.dead(now) {
src.Status = switchDead
}
if h, ok := incidents[hb.groupKey()]; ok {
t := time.Unix(h.triggeredAt, 0).UTC()
src.LastTriggeredAt = &t
st.LastTriggeredAt = later(st.LastTriggeredAt, h.triggeredAt)
if h.openID != 0 {
id := h.openID
src.IncidentID = &id
if st.OpenIncidentID == nil || id > *st.OpenIncidentID {
st.OpenIncidentID = &id
}
}
}
st.LastHeartbeatAt = later(st.LastHeartbeatAt, hb.receivedAt)
st.Sources = append(st.Sources, src)
switch {
case src.Status == switchDead:
st.Status = switchDead
case st.Status == switchDormant:
st.Status = switchHealthy
}
}
// Dead ones first, then by fingerprint: what needs attention leads, and
// the order does not shuffle between refreshes.
sort.Slice(st.Sources, func(i, j int) bool {
a, b := st.Sources[i], st.Sources[j]
if (a.Status == switchDead) != (b.Status == switchDead) {
return a.Status == switchDead
}
return a.Fingerprint < b.Fingerprint
})
out = append(out, st)
}
return out, nil
} }
+166 -16
View File
@@ -1,6 +1,7 @@
package api_test package api_test
import ( import (
"context"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
@@ -483,13 +484,13 @@ func TestDeadman_ConfigurationIsPerTeam(t *testing.T) {
unwatched := newTeam(t, s, "unwatched") unwatched := newTeam(t, s, "unwatched")
// Only the first team calls Watchdog a heartbeat. // Only the first team calls Watchdog a heartbeat.
resp := s.req(t, http.MethodPut, "/api/teams/"+id64(watched.id)+"/deadman", map[string]any{ resp := s.req(t, http.MethodPost, "/api/teams/"+id64(watched.id)+"/deadman/switches", map[string]any{
"matchers": "alertname=Watchdog", "matcher": "alertname=Watchdog",
"timeout_seconds": 3600, "timeout_seconds": 3600,
"severity": "critical", "severity": "critical",
}) })
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusCreated {
t.Fatalf("configure the watched team: %d", resp.StatusCode) t.Fatalf("configure the watched team: %d", resp.StatusCode)
} }
@@ -550,9 +551,9 @@ func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys", decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
map[string]string{"name": "test"}), &key) map[string]string{"name": "test"}), &key)
req, _ := http.NewRequest(http.MethodPut, req, _ := http.NewRequest(http.MethodPost,
s.URL+"/api/teams/"+id64(team.id)+"/deadman", s.URL+"/api/teams/"+id64(team.id)+"/deadman/switches",
strings.NewReader(`{"matchers":"alertname=Watchdog","timeout_seconds":60}`)) strings.NewReader(`{"matcher":"alertname=Watchdog","timeout_seconds":60}`))
req.Header.Set("Authorization", "Bearer "+key.Key) req.Header.Set("Authorization", "Bearer "+key.Key)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
@@ -564,7 +565,7 @@ func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
t.Errorf("a member editing the switches: expected 403, got %d", resp.StatusCode) t.Errorf("a member editing the switches: expected 403, got %d", resp.StatusCode)
} }
read, _ := http.NewRequest(http.MethodGet, s.URL+"/api/teams/"+id64(team.id)+"/deadman", nil) read, _ := http.NewRequest(http.MethodGet, s.URL+"/api/teams/"+id64(team.id)+"/deadman/switches", nil)
read.Header.Set("Authorization", "Bearer "+key.Key) read.Header.Set("Authorization", "Bearer "+key.Key)
got, err := http.DefaultClient.Do(read) got, err := http.DefaultClient.Do(read)
if err != nil { if err != nil {
@@ -577,16 +578,165 @@ func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
} }
// A matcher with no alertname watches nothing, silently, which is the failure // A matcher with no alertname watches nothing, silently, which is the failure
// this feature exists to prevent — so it is refused at the door. // this feature exists to prevent — so it is refused at the door, along with the
func TestDeadman_UnusableMatchersAreRejected(t *testing.T) { // other things that would make a switch unable to fire.
func TestDeadman_UnusableSwitchesAreRejected(t *testing.T) {
s, _ := deadmanTS(t, deadmanCfg()) s, _ := deadmanTS(t, deadmanCfg())
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman", map[string]any{ for name, body := range map[string]map[string]any{
"matchers": "cluster=prod", "no alertname": {"matcher": "cluster=prod", "timeout_seconds": 900},
"timeout_seconds": 900, "malformed": {"matcher": "alertname=Watchdog,garbage", "timeout_seconds": 900},
}) "several": {"matcher": "alertname=A; alertname=B", "timeout_seconds": 900},
resp.Body.Close() "zero timeout": {"matcher": "alertname=Watchdog", "timeout_seconds": 0},
if resp.StatusCode != http.StatusBadRequest { "bad severity": {"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "loud"},
t.Errorf("expected 400 for a matcher with no alertname, got %d", resp.StatusCode) "empty matcher": {"matcher": "", "timeout_seconds": 900},
} {
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", body)
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("%s: expected 400, got %d", name, resp.StatusCode)
}
}
}
// ---------------------------------------------------------------------------
// The switch list
// ---------------------------------------------------------------------------
// listSwitches reads the default team's switches as the Switches page does.
func listSwitches(t *testing.T, s *ts) []map[string]any {
t.Helper()
return list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/deadman/switches", nil))
}
// A switch is healthy while its heartbeat is fresh, dead once it is silent, and
// dormant until the first one arrives.
func TestDeadman_ListReportsStatus(t *testing.T) {
s, _ := deadmanTS(t, api.ParseDeadmanConfig("alertname=Watchdog; alertname=NeverSent", time.Hour, "critical"))
got := listSwitches(t, s)
if len(got) != 2 {
t.Fatalf("expected 2 switches, got %d", len(got))
}
for _, sw := range got {
if sw["status"] != "dormant" || sw["last_heartbeat_at"] != nil || sw["last_triggered_at"] != nil {
t.Errorf("a switch nobody has heard from should be dormant and blank, got %v", sw)
}
}
heartbeat(t, s, "fp-watchdog", nil)
got = listSwitches(t, s)
if got[0]["status"] != "healthy" || got[0]["last_heartbeat_at"] == nil {
t.Errorf("a fresh heartbeat should be healthy with a timestamp, got %v", got[0])
}
if got[1]["status"] != "dormant" {
t.Errorf("the other switch is still dormant, got %v", got[1]["status"])
}
silence(t, s, "fp-watchdog", 2*time.Hour)
sweep(t, s, noArchive)
got = listSwitches(t, s)
if got[0]["status"] != "dead" {
t.Fatalf("a silent heartbeat should be dead, got %v", got[0]["status"])
}
if got[0]["last_triggered_at"] == nil || got[0]["open_incident_id"] == nil {
t.Errorf("a dead switch should show when it triggered and its open incident, got %v", got[0])
}
}
// One matcher, several clusters: the switch is as bad as its worst heartbeat and
// each heartbeat is listed on its own.
func TestDeadman_ListBreaksDownByFingerprint(t *testing.T) {
s, _ := deadmanTS(t, deadmanCfg())
heartbeat(t, s, "fp-a", map[string]string{"cluster": "a"})
heartbeat(t, s, "fp-b", map[string]string{"cluster": "b"})
silence(t, s, "fp-b", 2*time.Hour)
sw := listSwitches(t, s)[0]
if sw["status"] != "dead" {
t.Errorf("one dead cluster makes the switch dead, got %v", sw["status"])
}
sources := sw["sources"].([]any)
if len(sources) != 2 {
t.Fatalf("expected 2 sources, got %d", len(sources))
}
first, second := sources[0].(map[string]any), sources[1].(map[string]any)
if first["fingerprint"] != "fp-b" || first["status"] != "dead" || second["status"] != "healthy" {
t.Errorf("the dead source should lead, got %v then %v", first, second)
}
}
// Every switch keeps its own deadline.
func TestDeadman_TimeoutsArePerSwitch(t *testing.T) {
s, _ := deadmanTS(t, deadmanCfg())
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", map[string]any{
"matcher": "alertname=Edge", "timeout_seconds": 300,
})
resp.Body.Close()
heartbeat(t, s, "fp-watchdog", nil)
postWebhook(t, s, []map[string]any{
amAlert("fp-edge", "Edge", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
}, `{}:{alertname="Edge"}`)
// Ten minutes of silence: past the Edge switch's five, inside Watchdog's hour.
silence(t, s, "fp-watchdog", 10*time.Minute)
silence(t, s, "fp-edge", 10*time.Minute)
got := listSwitches(t, s)
if got[0]["status"] != "healthy" || got[1]["status"] != "dead" {
t.Errorf("want Watchdog healthy and Edge dead, got %v and %v", got[0]["status"], got[1]["status"])
}
}
// Deleting is an owner's, is scoped to the team, and leaves what the switch
// already opened alone.
func TestDeadman_DeleteIsScopedToTheTeam(t *testing.T) {
s, _ := deadmanTS(t, deadmanCfg())
other := newTeam(t, s, "other")
id := int64(listSwitches(t, s)[0]["id"].(float64))
// Another team's owner cannot reach it.
resp := other.call(http.MethodDelete, "/api/teams/"+id64(other.id)+"/deadman/switches/"+id64(id), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("deleting another team's switch: expected 404, got %d", resp.StatusCode)
}
if got := len(listSwitches(t, s)); got != 1 {
t.Fatalf("the switch should have survived, %d left", got)
}
resp = s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(id), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("deleting: expected 204, got %d", resp.StatusCode)
}
if got := len(listSwitches(t, s)); got != 0 {
t.Errorf("expected no switches, got %d", got)
}
}
// The environment's defaults are handed out once and then belong to the teams.
func TestDeadman_SeedRunsOnce(t *testing.T) {
s := newTS(t)
cfg := api.ParseDeadmanConfig("alertname=Watchdog", time.Hour, "critical")
if err := api.SeedDeadmanConfigs(context.Background(), s.db, cfg); err != nil {
t.Fatalf("seed: %v", err)
}
if got := len(listSwitches(t, s)); got != 1 {
t.Fatalf("the first seed should add the default, got %d switches", got)
}
// The owner deletes it; a restart must not put it back.
id := int64(listSwitches(t, s)[0]["id"].(float64))
s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(id), nil).Body.Close()
if err := api.SeedDeadmanConfigs(context.Background(), s.db, cfg); err != nil {
t.Fatalf("seed again: %v", err)
}
if got := len(listSwitches(t, s)); got != 0 {
t.Errorf("a second seed resurrected %d switch(es)", got)
} }
} }
+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")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return 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 { type escalationLevelJSON struct {
Position int64 `json:"position"` Position int64 `json:"position"`
TimeoutSeconds int64 `json:"timeout_seconds"` 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) 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 { if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
db.ExecContext(ctx, // LEAST keeps a capped session (a single sign-on login) from sliding
"UPDATE sessions SET last_seen_at = $1, expires_at = $2 WHERE id = $3", // 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) now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
} }
return sessionID, userID, true return sessionID, userID, true
+484
View File
@@ -0,0 +1,484 @@
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
}
teamGroups, err := loadTeamGroups(r.Context(), db)
if err != nil {
log.Printf("oidc: load team groups: %v", err)
ssoRedirect(w, r, ssoFailed)
return
}
teamGrants := oidc.ComputeTeamGrants(teamGroups, identity.Groups)
userID, err := signInSSO(r.Context(), db, cfg, identity, grants, teamGrants)
if err != nil {
var se ssoError
if errors.As(err, &se) {
log.Printf("oidc: refused %q (%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, teamRoles map[int64]string) (int64, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback() //nolint:errcheck
userID, err := resolveSSOUser(ctx, tx, cfg, id)
if err != nil {
return 0, err
}
var disabled bool
if err := tx.QueryRowContext(ctx,
"SELECT disabled_at IS NOT NULL FROM users WHERE id = $1", userID).Scan(&disabled); err != nil {
return 0, err
}
if disabled {
return 0, ssoDisabled
}
if err := syncGrants(ctx, tx, userID, g, teamRoles); err != nil {
return 0, err
}
return userID, tx.Commit()
}
// loadTeamGroups reads every team's own OIDC group binding, for the sync to
// evaluate against one user's groups at a time. Teams are few, so this reads
// the whole table rather than filtering it.
func loadTeamGroups(ctx context.Context, db *sql.DB) ([]oidc.TeamGroup, error) {
rows, err := db.QueryContext(ctx,
"SELECT id, COALESCE(oidc_member_group, ''), COALESCE(oidc_owner_group, '') FROM teams")
if err != nil {
return nil, err
}
defer rows.Close()
var out []oidc.TeamGroup
for rows.Next() {
var tg oidc.TeamGroup
if err := rows.Scan(&tg.TeamID, &tg.MemberGroup, &tg.OwnerGroup); err != nil {
return nil, err
}
out = append(out, tg)
}
return out, rows.Err()
}
// resolveSSOUser finds the user an identity belongs to, linking or creating one
// when this is its first sign-in.
//
// The order matters. The (issuer, subject) pair is the identity; email is only
// a way to recognise an existing local account the first time. Once linked, a
// changed email at the provider must not move the account to somebody else.
func resolveSSOUser(ctx context.Context, tx *sql.Tx, cfg config.OIDC, id *oidc.Identity) (int64, error) {
now := time.Now().Unix()
var userID int64
err := tx.QueryRowContext(ctx,
"SELECT user_id FROM user_identities WHERE issuer = $1 AND subject = $2",
id.Issuer, id.Subject).Scan(&userID)
if err == nil {
if _, err := tx.ExecContext(ctx,
"UPDATE user_identities SET last_login_at = $1 WHERE issuer = $2 AND subject = $3",
now, id.Issuer, id.Subject); err != nil {
return 0, err
}
return userID, refreshProfile(ctx, tx, userID, id)
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, err
}
// First sign-in with this identity.
if id.Email == "" {
return 0, ssoNoEmail
}
err = tx.QueryRowContext(ctx,
"SELECT id FROM users WHERE lower(email) = lower($1)", id.Email).Scan(&userID)
switch {
case err == nil:
if !id.EmailVerified && !cfg.TrustEmail {
return 0, ssoEmailConflict
}
// A local account that already has an identity from this issuer is a
// different person at the provider using a recycled address. Linking
// them would hand one person's account to another.
var linked bool
if err := tx.QueryRowContext(ctx,
"SELECT EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1 AND issuer = $2)",
userID, id.Issuer).Scan(&linked); err != nil {
return 0, err
}
if linked {
return 0, ssoEmailConflict
}
case errors.Is(err, sql.ErrNoRows):
userID, err = createSSOUser(ctx, tx, id)
if err != nil {
return 0, err
}
default:
return 0, err
}
if _, err := tx.ExecContext(ctx,
"INSERT INTO user_identities (user_id, issuer, subject) VALUES ($1, $2, $3)",
userID, id.Issuer, id.Subject); err != nil {
return 0, err
}
return userID, nil
}
// createSSOUser inserts a user with no password. The username is the provider's,
// made unique with a numeric suffix when somebody local already has it.
func createSSOUser(ctx context.Context, tx *sql.Tx, id *oidc.Identity) (int64, error) {
base := strings.TrimSpace(id.Username)
if base == "" {
base, _, _ = strings.Cut(id.Email, "@")
}
if base == "" {
base = "user"
}
for n := 1; n <= 100; n++ {
name := base
if n > 1 {
name = base + "-" + strconv.Itoa(n)
}
var userID int64
err := tx.QueryRowContext(ctx, `
INSERT INTO users (username, email) VALUES ($1, $2)
ON CONFLICT (username) DO NOTHING RETURNING id`,
name, id.Email).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
continue // taken; try the next suffix
}
return userID, err
}
return 0, errors.New("no free username for " + base)
}
// refreshProfile brings a linked user's username and email in line with the
// provider. Each update is skipped, not failed, when another user already holds
// the value: both columns are unique, and a sign-in must not break over a name.
func refreshProfile(ctx context.Context, tx *sql.Tx, userID int64, id *oidc.Identity) error {
if id.Username != "" {
if _, err := tx.ExecContext(ctx, `
UPDATE users SET username = $1
WHERE id = $2 AND username <> $1
AND NOT EXISTS (SELECT 1 FROM users WHERE username = $1)`,
id.Username, userID); err != nil {
return err
}
}
if id.Email != "" {
if _, err := tx.ExecContext(ctx, `
UPDATE users SET email = $1
WHERE id = $2 AND email <> $1
AND NOT EXISTS (SELECT 1 FROM users WHERE lower(email) = lower($1))`,
id.Email, userID); err != nil {
return err
}
}
return nil
}
// syncGrants makes the user's OIDC-sourced access match what their groups grant
// now, and touches nothing else.
//
// Rows the sync owns are marked source 'oidc'. It adds them, changes their role
// and removes them. The last-owner and last-administrator guards do not apply:
// they exist to stop a person's mistake, and the provider is the source of truth
// for the access it grants, so a team or an install can be left without an
// SSO-granted owner. Administrators can always repair a team, and the bootstrap
// administrator is a manual one. Rows added by hand are 'manual', and the sync
// only ever raises them (turning them into 'oidc' rows), never lowers or removes
// them.
//
// teamRoles is keyed by team ID, not name: a team must already exist, with its
// own oidc_member_group/oidc_owner_group set by its owner, before a group can
// grant access to it. The sync never creates a team.
func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants, teamRoles map[int64]string) error {
// Administrator. A manual administrator stays one whatever the groups say.
if g.Admin {
if _, err := tx.ExecContext(ctx,
"UPDATE users SET is_admin = true, admin_source = 'oidc' WHERE id = $1 AND NOT is_admin",
userID); err != nil {
return err
}
} else if _, err := tx.ExecContext(ctx,
"UPDATE users SET is_admin = false, admin_source = 'manual' WHERE id = $1 AND admin_source = 'oidc'",
userID); err != nil {
return err
}
// Teams. The result of the loop is the set of teams the groups grant.
granted := make([]int64, 0, len(teamRoles))
for teamID, role := range teamRoles {
granted = append(granted, teamID)
// A row the sync owns follows the groups in both directions. One added by
// hand is only raised: a member the owner made an owner by hand is not
// demoted because the group says member.
if _, err := tx.ExecContext(ctx, `
INSERT INTO team_members (team_id, user_id, role, source)
VALUES ($1, $2, $3, 'oidc')
ON CONFLICT (team_id, user_id) DO UPDATE
SET role = excluded.role, source = 'oidc'
WHERE team_members.source = 'oidc'
OR (excluded.role = $4 AND team_members.role = $5)`,
teamID, userID, role, models.RoleOwner, models.RoleMember); err != nil {
return err
}
}
// Access the groups no longer grant. granted is never nil, or the ALL
// comparison would be against NULL and delete nothing.
_, err := tx.ExecContext(ctx,
"DELETE FROM team_members WHERE user_id = $1 AND source = 'oidc' AND team_id <> ALL($2)",
userID, granted)
return err
}
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"database/sql"
"net/http"
)
// teamOIDCGroups is one team's own OIDC binding: which group, if any, grants
// member access and which grants owner access. The same shape answers GET and
// is accepted by PUT. An empty string means no group grants that role here.
type teamOIDCGroups struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
// handleGetTeamOIDCGroups answers which groups control a team's membership.
// Member-gated like the member list itself: this is part of "who is in the
// team and why", not a setting only an owner should be able to see.
func handleGetTeamOIDCGroups(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamMember(w, r, teamID) {
return
}
var g teamOIDCGroups
err := db.QueryRowContext(r.Context(),
"SELECT COALESCE(oidc_member_group, ''), COALESCE(oidc_owner_group, '') FROM teams WHERE id = $1",
teamID).Scan(&g.MemberGroup, &g.OwnerGroup)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, g)
}
}
// handleSetTeamOIDCGroups sets which groups control a team's membership.
//
// Owner-gated, the same as the schedule, the integrations and the escalation
// ladder: this decides who can end up in the team, which is exactly the kind
// of thing only the team's own owner (or an administrator repairing it) should
// be able to change. An empty string clears a binding.
func handleSetTeamOIDCGroups(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
var req teamOIDCGroups
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if _, err := db.ExecContext(r.Context(), `
UPDATE teams
SET oidc_member_group = NULLIF($1, ''),
oidc_owner_group = NULLIF($2, '')
WHERE id = $3`,
req.MemberGroup, req.OwnerGroup, teamID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+815
View File
@@ -0,0 +1,815 @@
package api_test
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/config"
)
// fakeIdP is just enough of an OpenID Connect provider for terdut to sign
// somebody in against: discovery, a key set and a token endpoint that checks the
// PKCE verifier. There is no authorize endpoint; the tests read the URL terdut
// redirects to and play the part of the browser and the person themselves.
type fakeIdP struct {
*httptest.Server
key *rsa.PrivateKey
mu sync.Mutex
codes map[string]pendingCode
}
type pendingCode struct {
claims map[string]any
challenge string
}
const (
idpClientID = "terdut"
idpClientSecret = "s3cret"
)
func newFakeIdP(t *testing.T) *fakeIdP {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
f := &fakeIdP{key: key, codes: map[string]pendingCode{}}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"issuer": f.URL,
"authorization_endpoint": f.URL + "/authorize",
"token_endpoint": f.URL + "/token",
"jwks_uri": f.URL + "/jwks",
"id_token_signing_alg_values_supported": []string{"RS256"},
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
b64 := base64.RawURLEncoding.EncodeToString
json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{
"kty": "RSA", "kid": "k1", "use": "sig", "alg": "RS256",
"n": b64(key.N.Bytes()),
"e": b64(big.NewInt(int64(key.E)).Bytes()),
}}})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
user, pass, basic := r.BasicAuth()
if !basic {
user, pass = r.PostForm.Get("client_id"), r.PostForm.Get("client_secret")
}
if user != idpClientID || pass != idpClientSecret {
http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized)
return
}
f.mu.Lock()
p, ok := f.codes[r.PostForm.Get("code")]
delete(f.codes, r.PostForm.Get("code")) // single use, like a real provider
f.mu.Unlock()
sum := sha256.Sum256([]byte(r.PostForm.Get("code_verifier")))
if !ok || base64.RawURLEncoding.EncodeToString(sum[:]) != p.challenge {
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
return
}
// oauth2 picks the parser from the content type; without this it reads
// the body as a form, finds no token and retries, spending the code.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "unused", "token_type": "Bearer", "expires_in": 300,
"id_token": f.sign(t, p.claims),
})
})
f.Server = httptest.NewServer(mux)
t.Cleanup(f.Close)
return f
}
// sign returns claims as an RS256 JWT.
func (f *fakeIdP) sign(t *testing.T, claims map[string]any) string {
t.Helper()
enc := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
signing := enc(map[string]string{"alg": "RS256", "kid": "k1", "typ": "JWT"}) + "." + enc(claims)
sum := sha256.Sum256([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:])
if err != nil {
t.Fatal(err)
}
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// idpUser is who signs in, as the provider describes them.
type idpUser struct {
sub, username, email string
unverified bool
groups []string
badNonce bool
}
// ssoConfig is a terdut configuration wired to idp: terdut-users may sign in,
// terdut-admins administer. Which groups grant which team is not config
// anymore — it is each team's own oidc_member_group/oidc_owner_group, so a
// test that needs one seeds it with seedTeam.
func ssoConfig(idp *fakeIdP) config.Config {
c := testConfig()
c.OIDC = config.OIDC{
Issuer: idp.URL,
ClientID: idpClientID,
ClientSecret: idpClientSecret,
Name: "Authentik",
Scopes: []string{"openid", "profile", "email"},
UsernameClaim: "preferred_username",
EmailClaim: "email",
GroupsClaim: "groups",
AllowedGroups: []string{"terdut-users"},
AdminGroup: "terdut-admins",
SessionMaxAge: 12 * time.Hour,
}
return c
}
// seedTeam creates a team with an OIDC group binding, the way an owner would
// set one from the Members tab. Teams are no longer created by the sync
// itself, so a test whose groups should grant something needs the team to
// already exist. An empty group means that role is not granted by one.
func (s *ts) seedTeam(t *testing.T, name, memberGroup, ownerGroup string) int64 {
t.Helper()
var id int64
err := s.db.QueryRow(`
INSERT INTO teams (name, oidc_member_group, oidc_owner_group)
VALUES ($1, NULLIF($2, ''), NULLIF($3, '')) RETURNING id`,
name, memberGroup, ownerGroup).Scan(&id)
if err != nil {
t.Fatalf("seed team %q: %v", name, err)
}
return id
}
func newSSOTS(t *testing.T, idp *fakeIdP, tweak ...func(*config.Config)) *ts {
t.Helper()
c := ssoConfig(idp)
for _, f := range tweak {
f(&c)
}
return newTSWith(t, api.DeadmanConfig{}, api.NotifyConfig{PublicURL: "http://terdut.test"}, c)
}
// ssoBrowser is a browser that does not follow redirects, so a test can read
// where each step sends it.
func ssoBrowser(t *testing.T, s *ts) *browser {
t.Helper()
b := newBrowser(t, s.URL)
b.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return b
}
// startLogin visits /api/oidc/login and returns what terdut asked the provider
// for: the state, nonce and PKCE challenge.
func startLogin(t *testing.T, idp *fakeIdP, b *browser) (state, nonce, challenge string) {
t.Helper()
resp := b.do(t, http.MethodGet, "/api/oidc/login", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("login start: %d", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil || !strings.HasPrefix(loc.String(), idp.URL+"/authorize") {
t.Fatalf("login redirected to %q, want the provider", resp.Header.Get("Location"))
}
q := loc.Query()
if q.Get("code_challenge_method") != "S256" || q.Get("client_id") != idpClientID ||
q.Get("redirect_uri") != "http://terdut.test/api/oidc/callback" || q.Get("response_type") != "code" {
t.Fatalf("unexpected authorization request: %v", q)
}
return q.Get("state"), q.Get("nonce"), q.Get("code_challenge")
}
// issueCode has the provider authenticate u and hand back an authorization code.
func (f *fakeIdP) issueCode(u idpUser, nonce, challenge string) string {
if u.badNonce {
nonce = "not-the-nonce"
}
claims := map[string]any{
"iss": f.URL, "sub": u.sub, "aud": idpClientID,
"iat": time.Now().Unix(), "exp": time.Now().Add(5 * time.Minute).Unix(),
"nonce": nonce,
"preferred_username": u.username,
"email": u.email,
"email_verified": !u.unverified,
"groups": u.groups,
}
f.mu.Lock()
defer f.mu.Unlock()
code := fmt.Sprintf("code-%d", len(f.codes)+int(time.Now().UnixNano()%1e6))
f.codes[code] = pendingCode{claims: claims, challenge: challenge}
return code
}
// callback delivers the provider's answer to terdut and returns where terdut
// sends the browser next.
func callback(t *testing.T, b *browser, code, state string) string {
t.Helper()
resp := b.do(t, http.MethodGet, "/api/oidc/callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("callback: %d", resp.StatusCode)
}
return resp.Header.Get("Location")
}
// signInSSO runs a whole sign-in and returns the Location the callback ended on.
func signInSSO(t *testing.T, idp *fakeIdP, b *browser, u idpUser) string {
t.Helper()
state, nonce, challenge := startLogin(t, idp, b)
return callback(t, b, idp.issueCode(u, nonce, challenge), state)
}
var alice = idpUser{sub: "sub-alice", username: "alice", email: "alice@example.com", groups: []string{"terdut-users", "sre"}}
func withGroups(u idpUser, groups ...string) idpUser {
u.groups = groups
return u
}
// meOf reads /api/me over the browser's session.
func meOf(t *testing.T, b *browser) (status int, username string, isAdmin, hasPassword bool) {
t.Helper()
resp := b.do(t, http.MethodGet, "/api/me", nil)
defer resp.Body.Close()
var me struct {
User struct {
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
} `json:"user"`
HasPassword bool `json:"has_password"`
}
json.NewDecoder(resp.Body).Decode(&me)
return resp.StatusCode, me.User.Username, me.User.IsAdmin, me.HasPassword
}
// memberships lists a user's teams as name -> "role/source".
func (s *ts) memberships(t *testing.T, username string) map[string]string {
t.Helper()
rows, err := s.db.Query(`
SELECT t.name, m.role, m.source FROM team_members m
JOIN teams t ON t.id = m.team_id JOIN users u ON u.id = m.user_id
WHERE u.username = $1`, username)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var name, role, source string
rows.Scan(&name, &role, &source)
out[name] = role + "/" + source
}
return out
}
func sameMap(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "sre-leads")
s.seedTeam(t, "Platform", "platform", "")
b := ssoBrowser(t, s)
if loc := signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "platform")); loc != "/" {
t.Fatalf("signed in and was sent to %q, want /", loc)
}
status, name, isAdmin, hasPassword := meOf(t, b)
if status != http.StatusOK || name != "alice" || isAdmin || hasPassword {
t.Fatalf("me: status %d user %q admin %v has_password %v", status, name, isAdmin, hasPassword)
}
want := map[string]string{"SRE": "member/oidc", "Platform": "member/oidc"}
if got := s.memberships(t, "alice"); !sameMap(got, want) {
t.Errorf("memberships %v, want %v", got, want)
}
}
// A group matching no team's own binding grants nothing and creates nothing:
// unlike the old global mapping, the sync never creates a team by name.
func TestSSO_NoAutoCreateTeam(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
var before int
s.db.QueryRow("SELECT COUNT(*) FROM teams").Scan(&before)
signInSSO(t, idp, ssoBrowser(t, s), alice) // groups include "sre"; no team names it
if got := s.memberships(t, "alice"); len(got) != 0 {
t.Errorf("memberships %v, want none: no team's oidc_member_group/oidc_owner_group is set", got)
}
var after int
s.db.QueryRow("SELECT COUNT(*) FROM teams").Scan(&after)
if after != before {
t.Errorf("team count %d -> %d, want no team created", before, after)
}
}
func TestSSO_RefusedOutsideAllowedGroups(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
b := ssoBrowser(t, s)
loc := signInSSO(t, idp, b, withGroups(alice, "sre", "terdut-admins"))
if loc != "/?sso_error=not_allowed" {
t.Fatalf("sent to %q, want the not_allowed error", loc)
}
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
t.Errorf("a refused sign-in must not leave a session: /api/me %d", status)
}
var n int
s.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = 'alice'").Scan(&n)
if n != 0 {
t.Error("a refused sign-in must not create the user")
}
}
func TestSSO_AdminFollowsTheAdminGroup(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "terdut-admins"))
var isAdmin bool
var source string
read := func() {
s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'alice'").Scan(&isAdmin, &source)
}
if read(); !isAdmin || source != "oidc" {
t.Fatalf("after admin sign-in: admin %v source %q", isAdmin, source)
}
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users"))
if read(); isAdmin || source != "manual" {
t.Errorf("after losing the group: admin %v source %q, want revoked and manual", isAdmin, source)
}
}
func TestSSO_ManualAdminIsNeverRevoked(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true })
// The bootstrap administrator is a manual one. Signing in through the
// provider without the admin group must not take that away.
signInSSO(t, idp, ssoBrowser(t, s), idpUser{sub: "sub-admin", username: "admin", email: "admin@test.com", groups: []string{"terdut-users"}})
var isAdmin bool
var source string
s.db.QueryRow("SELECT is_admin, admin_source FROM users WHERE username = 'admin'").Scan(&isAdmin, &source)
if !isAdmin || source != "manual" {
t.Errorf("admin %v source %q, want still a manual admin", isAdmin, source)
}
}
func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "sre-leads")
signInSSO(t, idp, ssoBrowser(t, s), alice)
// Somebody adds alice to another team by hand.
s.exec(t, "INSERT INTO teams (name) VALUES ('Hand')")
s.exec(t, `INSERT INTO team_members (team_id, user_id, role)
SELECT (SELECT id FROM teams WHERE name = 'Hand'), id, 'member' FROM users WHERE username = 'alice'`)
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users"))
want := map[string]string{"Hand": "member/manual"}
if got := s.memberships(t, "alice"); !sameMap(got, want) {
t.Errorf("memberships %v, want %v: the SRE row is the sync's to remove, Hand is not", got, want)
}
}
func TestSSO_HighestRoleWinsAndRoleChangesFollow(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "sre-leads")
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "sre-leads"))
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/oidc"}) {
t.Errorf("both groups: %v, want owner", got)
}
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre"))
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) {
t.Errorf("lead group dropped: %v, want member", got)
}
}
func TestSSO_ManualMemberIsRaisedNeverLowered(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
// alice exists locally, is a manual owner of SRE, and is linked by email.
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'alice@example.com')")
s.seedTeam(t, "SRE", "sre", "")
s.exec(t, `INSERT INTO team_members (team_id, user_id, role)
VALUES ((SELECT id FROM teams WHERE name = 'SRE'), (SELECT id FROM users WHERE username = 'alice'), 'owner')`)
signInSSO(t, idp, ssoBrowser(t, s), alice) // the group only grants member
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/manual"}) {
t.Errorf("%v: a hand-made owner must not be lowered by a member mapping", got)
}
}
func TestSSO_LinksExistingUserByVerifiedEmail(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'Alice@Example.com')")
b := ssoBrowser(t, s)
signInSSO(t, idp, b, alice)
if _, name, _, _ := meOf(t, b); name != "alice-local" {
t.Errorf("signed in as %q, want the existing local user", name)
}
var users, identities int
s.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&users)
s.db.QueryRow("SELECT COUNT(*) FROM user_identities").Scan(&identities)
if users != 2 || identities != 1 { // admin + alice-local
t.Errorf("%d users, %d identities: linking must not create a second user", users, identities)
}
}
func TestSSO_UnverifiedEmailIsNotLinkedUnlessTrusted(t *testing.T) {
idp := newFakeIdP(t)
unverified := alice
unverified.unverified = true
s := newSSOTS(t, idp)
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')")
if loc := signInSSO(t, idp, ssoBrowser(t, s), unverified); loc != "/?sso_error=email_conflict" {
t.Errorf("unverified email: sent to %q, want email_conflict", loc)
}
trusting := newSSOTS(t, idp, func(c *config.Config) { c.OIDC.TrustEmail = true })
trusting.exec(t, "INSERT INTO users (username, email) VALUES ('alice-local', 'alice@example.com')")
b := ssoBrowser(t, trusting)
if loc := signInSSO(t, idp, b, unverified); loc != "/" {
t.Fatalf("trusted email: sent to %q, want /", loc)
}
if _, name, _, _ := meOf(t, b); name != "alice-local" {
t.Errorf("signed in as %q, want the existing local user", name)
}
}
func TestSSO_RecycledEmailDoesNotTakeOverALinkedAccount(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
signInSSO(t, idp, ssoBrowser(t, s), alice)
// A different person at the provider, same address.
other := alice
other.sub = "sub-someone-else"
if loc := signInSSO(t, idp, ssoBrowser(t, s), other); loc != "/?sso_error=email_conflict" {
t.Errorf("sent to %q, want email_conflict", loc)
}
}
func TestSSO_UsernameCollisionGetsASuffix(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'someone-else@example.com')")
b := ssoBrowser(t, s)
signInSSO(t, idp, b, alice)
if _, name, _, _ := meOf(t, b); name != "alice-2" {
t.Errorf("username %q, want alice-2", name)
}
}
func TestSSO_ProfileFollowsTheProvider(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
signInSSO(t, idp, ssoBrowser(t, s), alice)
renamed := alice
renamed.username, renamed.email = "alice.smith", "alice.smith@example.com"
b := ssoBrowser(t, s)
signInSSO(t, idp, b, renamed)
if _, name, _, _ := meOf(t, b); name != "alice.smith" {
t.Errorf("username %q, want the provider's new one", name)
}
var email string
s.db.QueryRow("SELECT email FROM users WHERE username = 'alice.smith'").Scan(&email)
if email != "alice.smith@example.com" {
t.Errorf("email %q", email)
}
}
func TestSSO_DisabledUserIsRefused(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
signInSSO(t, idp, ssoBrowser(t, s), alice)
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'")
b := ssoBrowser(t, s)
if loc := signInSSO(t, idp, b, alice); loc != "/?sso_error=disabled" {
t.Errorf("sent to %q, want disabled", loc)
}
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
t.Errorf("/api/me %d, want 401", status)
}
}
func TestSSO_NoEmailIsRefused(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
noEmail := alice
noEmail.email = ""
if loc := signInSSO(t, idp, ssoBrowser(t, s), noEmail); loc != "/?sso_error=no_email" {
t.Errorf("sent to %q, want no_email", loc)
}
}
func TestSSO_SessionIsCappedAndDoesNotSlidePastTheCap(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
b := ssoBrowser(t, s)
signInSSO(t, idp, b, alice)
var expires, ceiling int64
s.db.QueryRow(`SELECT expires_at, max_expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&expires, &ceiling)
inTwelveHours := time.Now().Add(12 * time.Hour).Unix()
if ceiling < inTwelveHours-60 || ceiling > inTwelveHours+60 || expires != ceiling {
t.Fatalf("expires %d ceiling %d, want both about %d", expires, ceiling, inTwelveHours)
}
// Age the session so the next request would slide it, with a ceiling well
// inside the ordinary 30 days.
s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200")
if status, _, _, _ := meOf(t, b); status != http.StatusOK {
t.Fatalf("/api/me %d", status)
}
var after int64
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after)
if after > ceiling {
t.Errorf("expiry slid to %d, past the ceiling %d", after, ceiling)
}
}
func TestSSO_PasswordSessionsStillSlideWithoutACeiling(t *testing.T) {
s := newTS(t)
b := signedIn(t, s)
var ceiling *int64
s.db.QueryRow(`SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&ceiling)
if ceiling != nil {
t.Errorf("a password session has a ceiling %d, want none", *ceiling)
}
s.exec(t, "UPDATE sessions SET last_seen_at = last_seen_at - 7200, expires_at = expires_at - 7200")
var before, after int64
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&before)
meOf(t, b)
s.db.QueryRow(`SELECT expires_at FROM sessions ORDER BY id DESC LIMIT 1`).Scan(&after)
if after <= before {
t.Errorf("expiry %d -> %d, want it to slide forward", before, after)
}
}
func TestSSO_StateIsSingleUseAndBoundToTheBrowser(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
// Replaying a callback finds no state.
b := ssoBrowser(t, s)
state, nonce, challenge := startLogin(t, idp, b)
code := idp.issueCode(alice, nonce, challenge)
if loc := callback(t, b, code, state); loc != "/" {
t.Fatalf("first callback sent to %q", loc)
}
if loc := callback(t, b, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" {
t.Errorf("replayed state: sent to %q, want expired", loc)
}
// A callback from a browser that did not start the login is refused, which
// is what stops a login being planted on somebody else.
victim := ssoBrowser(t, s)
state, nonce, challenge = startLogin(t, idp, ssoBrowser(t, s)) // the attacker's
if loc := callback(t, victim, idp.issueCode(alice, nonce, challenge), state); loc != "/?sso_error=expired" {
t.Errorf("foreign browser: sent to %q, want expired", loc)
}
if status, _, _, _ := meOf(t, victim); status != http.StatusUnauthorized {
t.Errorf("the victim has a session: /api/me %d", status)
}
}
func TestSSO_WrongNonceIsRefused(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
bad := alice
bad.badNonce = true
b := ssoBrowser(t, s)
if loc := signInSSO(t, idp, b, bad); loc != "/?sso_error=failed" {
t.Errorf("sent to %q, want failed", loc)
}
if status, _, _, _ := meOf(t, b); status != http.StatusUnauthorized {
t.Errorf("/api/me %d, want 401", status)
}
}
func TestSSO_ProviderErrorGoesBackToTheUI(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
b := ssoBrowser(t, s)
resp := b.do(t, http.MethodGet, "/api/oidc/callback?error=access_denied", nil)
resp.Body.Close()
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=denied" {
t.Errorf("%d to %q, want a redirect to denied", resp.StatusCode, loc)
}
}
func TestSSO_ManagedAccessCannotBeEditedByHand(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "")
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "terdut-admins"))
var aliceID, sreID int64
s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID)
s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID)
teamPath := fmt.Sprintf("/api/teams/%d/members", sreID)
// The bootstrap admin is a system administrator, so may manage SRE.
for _, c := range []struct {
name, method, path string
body any
}{
{"role change", http.MethodPost, teamPath, map[string]any{"user_id": aliceID, "role": "owner"}},
{"removal", http.MethodDelete, fmt.Sprintf("%s/%d", teamPath, aliceID), nil},
{"admin revoke", http.MethodPut, fmt.Sprintf("/api/users/%d/admin", aliceID), map[string]any{"is_admin": false}},
} {
resp := s.req(t, c.method, c.path, c.body)
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("%s: %d, want 409", c.name, resp.StatusCode)
}
}
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "member/oidc"}) {
t.Errorf("memberships changed by a refused edit: %v", got)
}
}
func TestSSO_PasswordLoginCanBeSwitchedOff(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp, func(c *config.Config) { c.DisablePasswordLogin = true })
b := newBrowser(t, s.URL)
resp := b.login(t, "admin", "whatever-password")
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("login: %d, want 403", resp.StatusCode)
}
resp = b.do(t, http.MethodPost, "/api/signup", map[string]string{"username": "x", "email": "x@example.com", "password": "correct horse battery"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("signup: %d, want 403", resp.StatusCode)
}
var cfg struct {
PasswordLogin bool `json:"password_login"`
OIDC struct {
Enabled bool `json:"enabled"`
Name string `json:"name"`
} `json:"oidc"`
}
resp = b.do(t, http.MethodGet, "/api/auth/config", nil)
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&cfg)
if cfg.PasswordLogin || !cfg.OIDC.Enabled || cfg.OIDC.Name != "Authentik" {
t.Errorf("auth config: %+v", cfg)
}
}
func TestAuthConfig_DefaultsToPasswordOnly(t *testing.T) {
s := newTS(t)
var cfg struct {
PasswordLogin bool `json:"password_login"`
OIDC struct {
Enabled bool `json:"enabled"`
} `json:"oidc"`
}
resp := newBrowser(t, s.URL).do(t, http.MethodGet, "/api/auth/config", nil)
defer resp.Body.Close()
json.NewDecoder(resp.Body).Decode(&cfg)
if !cfg.PasswordLogin || cfg.OIDC.Enabled {
t.Errorf("auth config: %+v", cfg)
}
// With SSO off the routes do not exist, rather than answering with an error
// page a person could land on.
resp = newBrowser(t, s.URL).do(t, http.MethodGet, "/api/oidc/login", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("/api/oidc/login with SSO off: %d, want 404", resp.StatusCode)
}
}
func TestSSO_UnreachableProviderRedirectsWithAnError(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
idp.Close() // the provider goes down after terdut has started
b := ssoBrowser(t, s)
resp := b.do(t, http.MethodGet, "/api/oidc/login", nil)
resp.Body.Close()
if loc := resp.Header.Get("Location"); resp.StatusCode != http.StatusFound || loc != "/?sso_error=unavailable" {
t.Errorf("%d to %q, want a redirect to unavailable", resp.StatusCode, loc)
}
}
func TestSSO_APIShowsWhereAccessCameFrom(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "")
b := ssoBrowser(t, s)
signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "terdut-admins"))
var aliceID, sreID int64
s.db.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&aliceID)
s.db.QueryRow("SELECT id FROM teams WHERE name = 'SRE'").Scan(&sreID)
// Users: alice's administrator flag is the groups', the bootstrap admin's is not.
var users []struct {
Username string `json:"username"`
AdminSource string `json:"admin_source"`
}
decode(t, s.req(t, http.MethodGet, "/api/users", nil), &users)
got := map[string]string{}
for _, u := range users {
got[u.Username] = u.AdminSource
}
if got["alice"] != "oidc" || got["admin"] != "manual" {
t.Errorf("admin_source by user: %v", got)
}
// The team's own member list, as a member sees it.
var members []struct {
Username string `json:"username"`
Source string `json:"source"`
}
resp := b.do(t, http.MethodGet, fmt.Sprintf("/api/teams/%d/members", sreID), nil)
decode(t, resp, &members)
if len(members) != 1 || members[0].Username != "alice" || members[0].Source != "oidc" {
t.Errorf("team members: %+v", members)
}
// The administrator's view of the same team, and of alice's teams.
var adminTeam struct {
Members []struct {
Username string `json:"username"`
Source string `json:"source"`
} `json:"members"`
}
decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/admin/teams/%d", sreID), nil), &adminTeam)
if len(adminTeam.Members) != 1 || adminTeam.Members[0].Source != "oidc" {
t.Errorf("admin team members: %+v", adminTeam.Members)
}
var teams []struct {
Name string `json:"name"`
Source string `json:"source"`
}
decode(t, s.req(t, http.MethodGet, fmt.Sprintf("/api/users/%d/teams", aliceID), nil), &teams)
if len(teams) != 1 || teams[0].Name != "SRE" || teams[0].Source != "oidc" {
t.Errorf("user teams: %+v", teams)
}
// The bootstrap admin's own membership is manual.
var mine []struct {
Source string `json:"source"`
}
decode(t, s.req(t, http.MethodGet, "/api/users/1/teams", nil), &mine)
if len(mine) == 0 || mine[0].Source != "manual" {
t.Errorf("bootstrap admin's teams: %+v", mine)
}
}
+38 -4
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"git.ryuvia.com/niklas/terdut-server/internal/config" "git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/oidc"
"git.ryuvia.com/niklas/terdut-server/internal/web" "git.ryuvia.com/niklas/terdut-server/internal/web"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "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. // two would let a burst of sign-ups lock somebody out of logging in.
loginLimit := newLoginLimiter() loginLimit := newLoginLimiter()
signupLimiter := newLoginLimiter() signupLimiter := newLoginLimiter()
oidcLimit := newLoginLimiter()
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Logger) 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 // an invite link is good, so the form can say so before somebody picks a
// password. // password.
r.Get("/api/signup", handleSignupInfo(db)) 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, // Signing in to the web UI. Login trades a password for a session cookie,
// which AuthMiddleware accepts in place of an API key. // 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)) 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. // All other /api routes require a valid API key.
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(db)) r.Use(AuthMiddleware(db))
r.Get("/api/me", handleMe(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)) r.Put("/api/me/onboarding", handleDismissOnboarding(db))
// Proves the topic works, which is the only part of "notifications are // Proves the topic works, which is the only part of "notifications are
// set up" that the person holding the phone can confirm. // set up" that the person holding the phone can confirm.
@@ -133,6 +160,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db)) r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db)) r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
// A team's own OIDC group binding: which provider groups grant member
// and owner access to it.
r.Get("/api/teams/{teamID}/oidc-groups", handleGetTeamOIDCGroups(db))
r.Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(db))
// Invite links into this team. // Invite links into this team.
r.Get("/api/teams/{teamID}/invites", handleListInvites(db)) r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL)) r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
@@ -144,12 +176,14 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// A team's own dead man's switches: which of its alerts are heartbeats, // A team's own dead man's switches: which of its alerts are heartbeats,
// and how long a silence has to last before somebody is paged. // and how long a silence has to last before somebody is paged.
r.Get("/api/teams/{teamID}/deadman", handleGetTeamDeadman(db)) r.Get("/api/teams/{teamID}/deadman/switches", handleListTeamDeadman(db))
r.Put("/api/teams/{teamID}/deadman", handleSetTeamDeadman(db)) r.Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
r.Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
// Integrations: where a team's alerts come in, and the key that says so. // Integrations: where a team's alerts come in, and the key that says so.
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db)) r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL)) r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
r.Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db)) r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
// The rota is per team. /api/schedule/current is the exception: it // The rota is per team. /api/schedule/current is the exception: it
+18 -6
View File
@@ -237,6 +237,14 @@ type adminTeam struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Members int64 `json:"members"` Members int64 `json:"members"`
OpenIncidents int64 `json:"open_incidents"` OpenIncidents int64 `json:"open_incidents"`
// OIDCMemberGroup and OIDCOwnerGroup are the team's own group binding,
// read-only here: an administrator can see why a team's OIDC-sourced
// membership looks the way it does without being able to change it out
// from under the team's owner. Setting it is PUT
// /api/teams/{teamID}/oidc-groups, owner-only.
OIDCMemberGroup string `json:"oidc_member_group,omitempty"`
OIDCOwnerGroup string `json:"oidc_owner_group,omitempty"`
} }
// handleAdminListTeams lists every team on the server, with its size. The // handleAdminListTeams lists every team on the server, with its size. The
@@ -248,7 +256,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
SELECT t.id, t.name, t.created_at, SELECT t.id, t.name, t.created_at,
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id), (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
(SELECT COUNT(*) FROM incidents i (SELECT COUNT(*) FROM incidents i
WHERE i.team_id = t.id AND i.resolved_at IS NULL) WHERE i.team_id = t.id AND i.resolved_at IS NULL),
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
FROM teams t FROM teams t
ORDER BY t.name`) ORDER BY t.name`)
if err != nil { if err != nil {
@@ -261,7 +270,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
for rows.Next() { for rows.Next() {
var t adminTeam var t adminTeam
var created int64 var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); err != nil { if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
&t.OIDCMemberGroup, &t.OIDCOwnerGroup); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
@@ -298,10 +308,12 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
SELECT t.id, t.name, t.created_at, SELECT t.id, t.name, t.created_at,
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id), (SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
(SELECT COUNT(*) FROM incidents i (SELECT COUNT(*) FROM incidents i
WHERE i.team_id = t.id AND i.resolved_at IS NULL) WHERE i.team_id = t.id AND i.resolved_at IS NULL),
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
FROM teams t FROM teams t
WHERE t.id = $1`, teamID). WHERE t.id = $1`, teamID).
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents) Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
&t.OIDCMemberGroup, &t.OIDCOwnerGroup)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("not found")) respond(w, http.StatusNotFound, errResp("not found"))
return return
@@ -315,7 +327,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
// Same query and same ordering as handleListTeamMembers, so the two // Same query and same ordering as handleListTeamMembers, so the two
// answers to "who is in this team" cannot disagree about the answer. // answers to "who is in this team" cannot disagree about the answer.
rows, err := db.QueryContext(r.Context(), ` 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 FROM team_members m
JOIN users u ON u.id = m.user_id JOIN users u ON u.id = m.user_id
WHERE m.team_id = $1 WHERE m.team_id = $1
@@ -330,7 +342,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
for rows.Next() { for rows.Next() {
var m models.TeamMember var m models.TeamMember
var joined int64 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")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return 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)
}
}
+336 -80
View File
@@ -21,7 +21,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
caller, _ := userFromContext(r.Context()) caller, _ := userFromContext(r.Context())
rows, err := db.QueryContext(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 FROM teams t
JOIN team_members m ON m.team_id = t.id JOIN team_members m ON m.team_id = t.id
WHERE m.user_id = $1 WHERE m.user_id = $1
@@ -36,7 +36,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
for rows.Next() { for rows.Next() {
var t models.Team var t models.Team
var created int64 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")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
@@ -83,7 +83,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
} }
rows, err := db.QueryContext(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 FROM teams t
JOIN team_members m ON m.team_id = t.id JOIN team_members m ON m.team_id = t.id
WHERE m.user_id = $1 WHERE m.user_id = $1
@@ -98,7 +98,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
for rows.Next() { for rows.Next() {
var t models.Team var t models.Team
var created int64 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")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
@@ -210,8 +210,40 @@ func handleDeleteTeam(db *sql.DB) http.HandlerFunc {
} }
} }
// handleListTeamMembers names everybody in a team. Visible to any member: you // Member statuses, as the Members page colours them.
// can see who else is on the rota you are on. 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 { func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r) teamID, ok := teamParam(w, r)
@@ -223,26 +255,56 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
} }
rows, err := db.QueryContext(r.Context(), ` 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 FROM team_members m
JOIN users u ON u.id = m.user_id JOIN users u ON u.id = m.user_id
WHERE m.team_id = $1 WHERE m.team_id = $1
ORDER BY u.username`, teamID) ORDER BY u.username`, teamID, todayUTC())
if err != nil { if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
defer rows.Close() defer rows.Close()
members := []models.TeamMember{} members := []memberStatus{}
for rows.Next() { for rows.Next() {
var m models.TeamMember var m memberStatus
var joined int64 var joined, lastActive int64
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil { 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")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
m.JoinedAt = time.Unix(joined, 0).UTC() 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) members = append(members, m)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -281,6 +343,28 @@ func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
return 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(), ` _, err := db.ExecContext(r.Context(), `
INSERT INTO team_members (team_id, user_id, role) INSERT INTO team_members (team_id, user_id, role)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
@@ -316,6 +400,14 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
return 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) last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
if err != nil { if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) 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) { func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
var last bool var last bool
err := db.QueryRowContext(ctx, ` err := db.QueryRowContext(ctx, `
@@ -355,8 +461,42 @@ func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (boo
// Integrations // Integrations
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// handleListIntegrations lists a team's integrations. Never the keys: those // sourceQuietAfter is how long a source may go without posting before the
// exist in plaintext only in the response that created them. // 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 { func handleListIntegrations(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r) teamID, ok := teamParam(w, r)
@@ -367,28 +507,45 @@ func handleListIntegrations(db *sql.DB) http.HandlerFunc {
return return
} }
now := time.Now()
rows, err := db.QueryContext(r.Context(), ` rows, err := db.QueryContext(r.Context(), `
SELECT id, team_id, kind, name, created_at, last_used_at SELECT i.id, i.team_id, i.kind, i.name, i.created_at, i.last_used_at,
FROM integrations -- Scalar subqueries, not a join and GROUP BY: each is a
WHERE team_id = $1 -- single range over alerts_integration_idx, where the join
ORDER BY id`, teamID) -- 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 { if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
defer rows.Close() defer rows.Close()
integrations := []models.Integration{} integrations := []integrationStatus{}
for rows.Next() { for rows.Next() {
var i models.Integration var i integrationStatus
var created int64 var created int64
var lastUsed *int64 var lastUsed, lastAlert *int64
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed); err != nil { 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")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
i.CreatedAt = time.Unix(created, 0).UTC() i.CreatedAt = time.Unix(created, 0).UTC()
i.LastUsedAt = unixPtr(lastUsed) 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) integrations = append(integrations, i)
} }
if err := rows.Err(); err != nil { 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 { func handleDeleteIntegration(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r) teamID, ok := teamParam(w, r)
@@ -493,24 +698,32 @@ func integrationPath(key, kind string) string {
return "/api/integrations/" + key + "/" + kind 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 // last use. An unknown key is not an error worth distinguishing: the caller is
// told nothing beyond "no". // told nothing beyond "no".
func teamIDForKey(ctx context.Context, db *sql.DB, key string) (int64, error) { func sourceForKey(ctx context.Context, db *sql.DB, key string) (alertSource, error) {
var teamID int64 var src alertSource
err := db.QueryRowContext(ctx, 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) { if errors.Is(err, sql.ErrNoRows) {
return 0, errUnknownIntegration return alertSource{}, errUnknownIntegration
} }
if err != nil { if err != nil {
return 0, err return alertSource{}, err
} }
// Best effort, like an API key's: a failed stamp must not reject an alert. // Best effort, like an API key's: a failed stamp must not reject an alert.
db.ExecContext(ctx, //nolint:errcheck db.ExecContext(ctx, //nolint:errcheck
"UPDATE integrations SET last_used_at = $1 WHERE key_hash = $2", "UPDATE integrations SET last_used_at = $1 WHERE id = $2",
time.Now().Unix(), hashToken(key)) time.Now().Unix(), src.integrationID)
return teamID, nil return src, nil
} }
var errUnknownIntegration = errors.New("unknown integration key") var errUnknownIntegration = errors.New("unknown integration key")
@@ -539,17 +752,24 @@ func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) {
// A team's dead man's switches // A team's dead man's switches
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// deadmanResponse is the wire shape of a team's switch configuration. The // deadmanSwitchRequest is what creating a switch takes. The timeout is seconds,
// timeout is seconds rather than a duration string, because that is what the // because that is what the column holds and what arithmetic is done on; a client
// column holds and what arithmetic is done on; a client renders it. // renders it.
type deadmanResponse struct { type deadmanSwitchRequest struct {
TeamID int64 `json:"team_id"` Name string `json:"name"`
Matchers string `json:"matchers"` Matcher string `json:"matcher"`
TimeoutSeconds int64 `json:"timeout_seconds"` TimeoutSeconds int64 `json:"timeout_seconds"`
Severity string `json:"severity"` Severity string `json:"severity"`
} }
func handleGetTeamDeadman(db *sql.DB) http.HandlerFunc { // deadmanSeverities are the severities an incident can open at.
var deadmanSeverities = map[string]bool{"critical": true, "error": true, "warning": true, "info": true}
// handleListTeamDeadman lists a team's switches with what each one's heartbeats
// are doing. A team with none gets an empty list, which is a configuration and
// not an absence: answering 404 would make "off" indistinguishable from "this
// server does not do this".
func handleListTeamDeadman(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r) teamID, ok := teamParam(w, r)
if !ok { if !ok {
@@ -559,27 +779,26 @@ func handleGetTeamDeadman(db *sql.DB) http.HandlerFunc {
return return
} }
out := deadmanResponse{TeamID: teamID, Severity: "critical"} set, err := deadmanSetForTeam(r.Context(), db, teamID)
err := db.QueryRowContext(r.Context(), if err != nil {
"SELECT matchers, timeout_seconds, severity FROM deadman_configs WHERE team_id = $1", respond(w, http.StatusInternalServerError, errResp("internal error"))
teamID).Scan(&out.Matchers, &out.TimeoutSeconds, &out.Severity) return
if err != nil && !errors.Is(err, sql.ErrNoRows) { }
out, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
// A team with no row watches nothing, which is a configuration and not
// an absence: answering 404 would make "off" indistinguishable from
// "this server does not do this".
respond(w, http.StatusOK, out) respond(w, http.StatusOK, out)
} }
} }
// handleSetTeamDeadman replaces a team's switch configuration. // handleCreateTeamDeadman adds one switch.
// //
// Validated by parsing: a matcher string that survives ParseDeadmanConfig with // Validated by parsing: a matcher with no alertname is rejected rather than
// nothing usable in it is rejected rather than stored, because a switch that // stored, because a switch that silently watches nothing is the failure this
// silently watches nothing is the failure this feature exists to prevent. // feature exists to prevent.
func handleSetTeamDeadman(db *sql.DB) http.HandlerFunc { func handleCreateTeamDeadman(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r) teamID, ok := teamParam(w, r)
if !ok { if !ok {
@@ -589,50 +808,87 @@ func handleSetTeamDeadman(db *sql.DB) http.HandlerFunc {
return return
} }
var req struct { var req deadmanSwitchRequest
Matchers string `json:"matchers"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Severity string `json:"severity"`
}
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body")) respond(w, http.StatusBadRequest, errResp("invalid request body"))
return return
} }
req.Matchers = strings.TrimSpace(req.Matchers) req.Matcher = strings.TrimSpace(req.Matcher)
req.Name = strings.TrimSpace(req.Name)
if req.Severity == "" { if req.Severity == "" {
req.Severity = "critical" req.Severity = "critical"
} }
if req.TimeoutSeconds < 0 { if !deadmanSeverities[req.Severity] {
respond(w, http.StatusBadRequest, errResp("timeout_seconds must not be negative")) respond(w, http.StatusBadRequest, errResp("severity must be critical, error, warning or info"))
return return
} }
if req.Matchers != "" { if req.TimeoutSeconds <= 0 {
parsed := parseDeadmanQuietly(req.Matchers, time.Duration(req.TimeoutSeconds)*time.Second, req.Severity) respond(w, http.StatusBadRequest, errResp("timeout_seconds must be positive"))
if len(parsed.Matchers) == 0 { return
respond(w, http.StatusBadRequest, errResp( }
"no usable matchers: each must name an alertname, as in alertname=Watchdog,cluster=prod")) if strings.Contains(req.Matcher, ";") {
return respond(w, http.StatusBadRequest, errResp("one matcher per switch: add another switch instead of separating with ;"))
} return
}
m, err := parseDeadmanMatcher(req.Matcher)
if err != nil {
respond(w, http.StatusBadRequest, errResp(
"unusable matcher ("+err.Error()+"): each must name an alertname, as in alertname=Watchdog,cluster=prod"))
return
}
if req.Name == "" {
req.Name = m.config()
}
if len(req.Name) > 100 {
respond(w, http.StatusBadRequest, errResp("name is too long"))
return
} }
if _, err := db.ExecContext(r.Context(), ` var id int64
INSERT INTO deadman_configs (team_id, matchers, timeout_seconds, severity, updated_at) if err := db.QueryRowContext(r.Context(), `
VALUES ($1, $2, $3, $4, `+nowEpoch+`) INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
ON CONFLICT (team_id) DO UPDATE SET VALUES ($1, $2, $3, $4, $5) RETURNING id`,
matchers = excluded.matchers, teamID, req.Name, m.config(), req.TimeoutSeconds, req.Severity).Scan(&id); err != nil {
timeout_seconds = excluded.timeout_seconds,
severity = excluded.severity,
updated_at = excluded.updated_at`,
teamID, req.Matchers, req.TimeoutSeconds, req.Severity); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
respond(w, http.StatusOK, deadmanResponse{ respond(w, http.StatusCreated, deadmanSwitchStatus{
TeamID: teamID, ID: id, Name: req.Name, Matcher: m.config(),
Matchers: req.Matchers, TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
TimeoutSeconds: req.TimeoutSeconds, Status: switchDormant, Sources: []deadmanSource{},
Severity: req.Severity,
}) })
} }
} }
// handleDeleteTeamDeadman removes a switch. An incident it already opened stays
// open until somebody resolves it: deleting the switch says "stop watching", not
// "the problem is gone".
func handleDeleteTeamDeadman(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
switchID, err := strconv.ParseInt(chi.URLParam(r, "switchID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid switch id"))
return
}
res, err := db.ExecContext(r.Context(),
"DELETE FROM deadman_switches WHERE id = $1 AND team_id = $2", switchID, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("switch not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+106
View File
@@ -327,6 +327,112 @@ func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
} }
} }
// A team's own OIDC group binding follows the same rule as its schedule and
// its integrations: an owner sets it, a member may only read it, an outsider
// learns nothing, and an administrator can still reach it to repair a team
// whose owner has left.
func TestTeamOIDCGroups_OwnerOnlyToEdit(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "sre") // team.call authenticates as its owner
// A plain member of the same team.
var plain struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": "plain", "email": "plain@test.com"}), &plain)
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
map[string]any{"user_id": plain.ID, "role": "member"})
resp.Body.Close()
var key struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(plain.ID)+"/api-keys",
map[string]string{"name": "test"}), &key)
memberCall := func(method, path string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, s.URL+path, r)
req.Header.Set("Authorization", "Bearer "+key.Key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
}
// A member of a different team altogether.
_, outsiderCall := member(t, s, "outsider")
path := "/api/teams/" + id64(team.id) + "/oidc-groups"
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "sre", "owner_group": "sre-leads"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("owner PUT: %d, want 204", resp.StatusCode)
}
var got struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &got)
if got.MemberGroup != "sre" || got.OwnerGroup != "sre-leads" {
t.Errorf("owner GET after PUT: %+v", got)
}
resp = memberCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("member GET: %d, want 200", resp.StatusCode)
}
resp = memberCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("member PUT: %d, want 403", resp.StatusCode)
}
// 404, not 403: whether the team exists is itself something only its
// members should learn.
resp = outsiderCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider GET: %d, want 404", resp.StatusCode)
}
resp = outsiderCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider PUT: %d, want 404", resp.StatusCode)
}
// An administrator who is not a member may still set it, the same bypass
// that lets one repair a team whose owner has left.
resp = s.req(t, http.MethodPut, path, map[string]string{"member_group": "sre2"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("admin PUT: %d, want 204", resp.StatusCode)
}
// An empty string clears a binding, stored as NULL rather than the literal
// empty string, so an empty group claim can never accidentally match it.
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "", "owner_group": ""})
resp.Body.Close()
var cleared struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &cleared)
if cleared.MemberGroup != "" || cleared.OwnerGroup != "" {
t.Errorf("cleared: %+v", cleared)
}
}
// A team is not somewhere an outsider can look, whatever they know about it. // A team is not somewhere an outsider can look, whatever they know about it.
func TestTeams_OutsiderSeesNothing(t *testing.T) { func TestTeams_OutsiderSeesNothing(t *testing.T) {
s := newTS(t) s := newTS(t)
+16 -4
View File
@@ -96,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
func handleListUsers(db *sql.DB) http.HandlerFunc { func handleListUsers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(), 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 { if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
@@ -108,7 +108,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
var u models.User var u models.User
var ts int64 var ts int64
var disabled *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")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
@@ -329,8 +329,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var ts int64 var ts int64
var disabled *int64 var disabled *int64
err := db.QueryRowContext(ctx, err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users WHERE id = $1", id). "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, &disabled) Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled)
if err != nil { if err != nil {
return u, err return u, err
} }
@@ -361,6 +361,18 @@ func handleSetAdmin(db *sql.DB) http.HandlerFunc {
} }
if !*req.IsAdmin { 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()) caller, _ := userFromContext(r.Context())
if caller.ID == id { if caller.ID == id {
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access")) respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))
+146
View File
@@ -1,7 +1,11 @@
package config package config
import ( import (
"errors"
"fmt"
"net/url"
"os" "os"
"strings"
"time" "time"
) )
@@ -59,8 +63,63 @@ type Config struct {
// NotifyRepeat is how long an incident may sit unacknowledged before it is // NotifyRepeat is how long an incident may sit unacknowledged before it is
// notified again. Zero disables reminders. // notified again. Zero disables reminders.
NotifyRepeat time.Duration 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
// SessionMaxAge is the hard ceiling on a session made by an SSO login. The
// login is the only moment groups are re-read, so this is how long a change
// in the provider may take to reach terdut.
SessionMaxAge time.Duration
}
// Enabled reports whether single sign-on is configured.
func (o OIDC) Enabled() bool { return o.Issuer != "" }
func Load() Config { func Load() Config {
addr := os.Getenv("TERDUT_ADDR") addr := os.Getenv("TERDUT_ADDR")
if addr == "" { if addr == "" {
@@ -89,9 +148,96 @@ func Load() Config {
NtfyFallbackTopic: os.Getenv("TERDUT_NTFY_FALLBACK_TOPIC"), NtfyFallbackTopic: os.Getenv("TERDUT_NTFY_FALLBACK_TOPIC"),
PublicURL: os.Getenv("TERDUT_PUBLIC_URL"), PublicURL: os.Getenv("TERDUT_PUBLIC_URL"),
NotifyRepeat: duration("TERDUT_NOTIFY_REPEAT", 15*time.Minute), 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),
}
return o
}
// Validate reports a configuration the server should refuse to start with.
// Single sign-on is the only part that can be inconsistent: a half-configured
// provider would come up and then fail every login, which is harder to notice
// than not starting.
func (c Config) Validate() error {
o := c.OIDC
if !o.Enabled() {
if c.DisablePasswordLogin {
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
}
if o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
return errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not")
}
return nil
}
if u, err := url.Parse(o.Issuer); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("TERDUT_OIDC_ISSUER %q is not a URL", o.Issuer)
}
if o.ClientID == "" || o.ClientSecret == "" {
return errors.New("TERDUT_OIDC_CLIENT_ID and TERDUT_OIDC_CLIENT_SECRET are required with TERDUT_OIDC_ISSUER")
}
if c.PublicURL == "" {
return errors.New("TERDUT_PUBLIC_URL is required with TERDUT_OIDC_ISSUER: it is the base of the redirect URI")
}
if o.SessionMaxAge <= 0 {
return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive")
}
// Team grants are no longer visible here: they live on each team's own
// oidc_member_group/oidc_owner_group columns, set by that team's owner, not
// in config Validate can see at startup. The one thing left to guard against
// is an install nobody can administer at all.
if c.DisablePasswordLogin && o.AdminGroup == "" {
return errors.New("TERDUT_PASSWORD_LOGIN=false with no TERDUT_OIDC_ADMIN_GROUP leaves nobody able to administer the install")
}
return nil
}
func str(env, def string) string {
if s := strings.TrimSpace(os.Getenv(env)); s != "" {
return s
}
return def
}
// list reads a comma- or space-separated env var.
func list(env, def string) []string {
s := os.Getenv(env)
if strings.TrimSpace(s) == "" {
s = def
}
return strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' })
}
// boolean reads a true/false env var. An unrecognised value takes the default,
// so the two flags read this way (password login on, trusting email off) both
// fail towards the cautious setting.
func boolean(env string, def bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(env))) {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
}
return def
}
// duration reads a time.ParseDuration-formatted env var. An unset or // 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 // unparseable value falls back to def rather than failing startup: a typo in one
// tuning knob should not take the server down. // tuning knob should not take the server down.
+79
View File
@@ -0,0 +1,79 @@
package config
import (
"strings"
"testing"
)
func TestValidate(t *testing.T) {
base := func() map[string]string {
return map[string]string{
"TERDUT_PUBLIC_URL": "https://terdut.example.com",
"TERDUT_OIDC_ISSUER": "https://auth.example.com/application/o/terdut/",
"TERDUT_OIDC_CLIENT_ID": "id",
"TERDUT_OIDC_CLIENT_SECRET": "secret",
}
}
tests := []struct {
name string
env func(map[string]string)
wantErr string // substring; empty means valid
}{
{"off by default", func(m map[string]string) { clear(m) }, ""},
{"minimal sso", func(m map[string]string) {}, ""},
{"groups without issuer", func(m map[string]string) {
clear(m)
m["TERDUT_OIDC_ADMIN_GROUP"] = "admins"
}, "ISSUER is not"},
{"missing secret", func(m map[string]string) { delete(m, "TERDUT_OIDC_CLIENT_SECRET") }, "CLIENT_SECRET"},
{"missing public url", func(m map[string]string) { delete(m, "TERDUT_PUBLIC_URL") }, "PUBLIC_URL"},
{"bad issuer", func(m map[string]string) { m["TERDUT_OIDC_ISSUER"] = "not a url" }, "not a URL"},
{"password off without sso", func(m map[string]string) {
clear(m)
m["TERDUT_PASSWORD_LOGIN"] = "false"
}, "no way to sign in"},
{"password off with sso but no grants", func(m map[string]string) {
m["TERDUT_PASSWORD_LOGIN"] = "false"
}, "nobody able"},
{"password off with admin group", func(m map[string]string) {
m["TERDUT_PASSWORD_LOGIN"] = "false"
m["TERDUT_OIDC_ADMIN_GROUP"] = "admins"
}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := base()
tt.env(env)
for _, k := range []string{
"TERDUT_PUBLIC_URL", "TERDUT_PASSWORD_LOGIN", "TERDUT_OIDC_ISSUER", "TERDUT_OIDC_CLIENT_ID",
"TERDUT_OIDC_CLIENT_SECRET", "TERDUT_OIDC_ADMIN_GROUP",
} {
t.Setenv(k, env[k])
}
err := Load().Validate()
switch {
case tt.wantErr == "" && err != nil:
t.Errorf("unexpected error: %v", err)
case tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)):
t.Errorf("error %v, want one containing %q", err, tt.wantErr)
}
})
}
}
func TestLoad_OIDCDefaults(t *testing.T) {
t.Setenv("TERDUT_OIDC_ISSUER", "https://auth.example.com/")
o := Load().OIDC
if o.UsernameClaim != "preferred_username" || o.EmailClaim != "email" || o.GroupsClaim != "groups" {
t.Errorf("claim defaults: %+v", o)
}
if strings.Join(o.Scopes, " ") != "openid profile email" {
t.Errorf("scopes: %v", o.Scopes)
}
if o.SessionMaxAge.Hours() != 12 {
t.Errorf("max age: %v", o.SessionMaxAge)
}
if Load().DisablePasswordLogin {
t.Error("password login should be on by default")
}
}
@@ -0,0 +1,54 @@
-- Dead man's switches become rows of their own.
--
-- 004 kept a team's switches in one string with one timeout and one severity,
-- which was enough to configure them and not enough to show them: there was no
-- thing to list, nothing to hang a status on, and every switch in a team had to
-- share a deadline. A row per switch gives each its own name, matcher, timeout
-- and severity, and gives the Team → Switches page something to be a list of.
--
-- The matcher keeps the syntax the string used, one matcher per row:
-- `alertname=Watchdog,cluster=prod`. The unit of monitoring is still the
-- fingerprint, so a matcher that many clusters satisfy is still one switch row
-- watching several independent heartbeats.
CREATE TABLE deadman_switches (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
-- What the owner calls it. Defaults to the matcher when they do not say.
name TEXT NOT NULL,
-- "," separates the label conditions, "=" is exact equality, and alertname is
-- mandatory: it is what keeps the sweeper's candidate query on an index.
matcher TEXT NOT NULL,
-- Seconds of silence before the switch is declared dead. Never zero: a switch
-- that cannot fire is deleted, not disabled.
timeout_seconds BIGINT NOT NULL CHECK (timeout_seconds > 0),
-- The severity its incidents open at. See 004 for why they carry their own.
severity TEXT NOT NULL DEFAULT 'critical',
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
);
CREATE INDEX deadman_switches_team_idx ON deadman_switches (team_id);
-- Carry every team's configuration over, one row per matcher. A team whose
-- timeout was zero had switches turned off, which is now "no rows".
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
SELECT c.team_id, btrim(m), btrim(m), c.timeout_seconds, c.severity
FROM deadman_configs c,
LATERAL regexp_split_to_table(c.matchers, ';') AS m
WHERE c.timeout_seconds > 0
AND btrim(m) <> ''
ORDER BY c.team_id;
-- The server seeds environment defaults into teams once, and remembers that it
-- did. An install that had a row per team was already seeded; without this
-- marker the first start after upgrading would seed teams that had switched
-- theirs off.
INSERT INTO settings (key, value)
SELECT 'deadman_seeded', '1'
WHERE EXISTS (SELECT 1 FROM deadman_configs);
DROP TABLE deadman_configs;
@@ -0,0 +1,21 @@
-- Which alert source an alert last arrived on.
--
-- Team -> Sources shows when each source last posted, which integrations
-- already knew (last_used_at, stamped on every webhook). What it could not say
-- was what a source delivered: an alert never recorded the key it came in on, so
-- "prod alertmanager" and "staging alertmanager" were indistinguishable once
-- inside. This column is that link, and lets the page show each source's last
-- alert and how many alerts it has kept fresh over the past day.
--
-- Last sender wins: every accepted payload restamps it, the way it advances
-- received_at. Two sources posting the same fingerprint into one team is
-- already one alert, and it is attributed to whichever spoke last.
--
-- Nullable, and not backfilled. Alerts that arrived before this migration have
-- no source, and NULL says so honestly rather than guessing. It heals by itself:
-- Alertmanager re-sends every alert each repeat_interval, and each re-send is an
-- accepted payload. Deleting a source keeps its alerts, unattributed.
ALTER TABLE alerts ADD COLUMN integration_id BIGINT REFERENCES integrations(id) ON DELETE SET NULL;
CREATE INDEX alerts_integration_idx ON alerts (integration_id, received_at)
WHERE integration_id IS NOT NULL;
+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 '/';
@@ -0,0 +1,26 @@
-- Per-team OIDC group configuration, replacing the global
-- TERDUT_OIDC_GROUP_MAPPINGS env var.
--
-- Group -> team -> role used to be one global list an operator set for the
-- whole install, matched against a team by name, and the sync would create
-- the team if no team by that name existed yet. That put the decision of
-- which group controls a team in the server's environment rather than the
-- team's own hands, meant changing it needed an env var edit and a restart,
-- and let a typo in a team name silently create a stray team.
--
-- Each team now names, itself, which group grants membership and which
-- grants ownership. Nullable: most teams need neither. No uniqueness
-- constraint on either column — two teams may legitimately watch the same
-- provider group (a broad team and a narrower one both keyed off overlapping
-- groups is a choice for their owners to make, not one the schema should
-- refuse).
--
-- BREAKING CHANGE, deliberately not auto-migrated: TERDUT_OIDC_GROUP_MAPPINGS
-- stops being read as of this version, and the sync no longer creates a team
-- by name. Every team's group binding must be set again through
-- PUT /api/teams/{teamID}/oidc-groups. Until an owner does that, an
-- OIDC-sourced membership in that team is dropped at that user's next SSO
-- sign-in, the same way any other loss of group access is handled. See the
-- README's OIDC section.
ALTER TABLE teams ADD COLUMN oidc_member_group TEXT;
ALTER TABLE teams ADD COLUMN oidc_owner_group TEXT;
+8
View File
@@ -13,6 +13,10 @@ type Team struct {
// listed for a particular person. Empty when nobody in particular is // listed for a particular person. Empty when nobody in particular is
// asking, as in the admin listing. // asking, as in the admin listing.
Role string `json:"role,omitempty"` 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 // Team roles. An owner configures the team — its schedule, its integrations and
@@ -29,6 +33,10 @@ type TeamMember struct {
Username string `json:"username"` Username string `json:"username"`
Role string `json:"role"` Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"` 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 // 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 // is too old to have the field", and the web UI decides what to show from
// it. // it.
IsAdmin bool `json:"is_admin"` 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 { type APIKey struct {
+107
View File
@@ -0,0 +1,107 @@
// Package oidc signs users in through an OpenID Connect provider and turns the
// groups it reports into the access terdut grants.
//
// The package knows nothing about the database or HTTP handlers: Grants is a
// pure function of configuration and groups, and Provider is the protocol. The
// api package joins them to users, teams and sessions.
package oidc
import (
"git.ryuvia.com/niklas/terdut-server/internal/config"
)
// Role names match models.RoleOwner and RoleMember. They are restated here so
// the package stays free of the models import; config.Validate has already
// refused anything else.
const (
roleOwner = "owner"
roleMember = "member"
)
// Grants is the account-wide access a set of groups confers. Team access is a
// separate question — see TeamGroup and ComputeTeamGrants — because it is
// configured per team in the database, not in this package's cfg.
type Grants struct {
// Admitted is false when AllowedGroups is set and the user is in none of
// them. Nothing else in the struct means anything then.
Admitted bool
// Admin is whether the user is in the admin group.
Admin bool
}
// ComputeGrants evaluates the account-wide configuration against groups.
func ComputeGrants(cfg config.OIDC, groups []string) Grants {
in := make(map[string]bool, len(groups))
for _, g := range groups {
in[g] = true
}
var g Grants
g.Admitted = len(cfg.AllowedGroups) == 0
for _, allowed := range cfg.AllowedGroups {
if in[allowed] {
g.Admitted = true
break
}
}
if !g.Admitted {
return g
}
g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup]
return g
}
// TeamGroup is one team's own OIDC binding: which group, if any, grants
// member access to it and which grants owner access, as read from
// teams.oidc_member_group / teams.oidc_owner_group.
type TeamGroup struct {
TeamID int64
MemberGroup string // "" means no group grants member access here.
OwnerGroup string // "" means no group grants owner access here.
}
// ComputeTeamGrants evaluates every team's own group binding against groups,
// and returns the role each team grants, keyed by team ID. A team absent from
// the result is not granted at all. Where a team's member and owner groups
// both match, the owner group wins — the same "highest role wins" rule that
// applied across the old global mapping list applies here across one team's
// two fields, so belonging to both groups makes somebody an owner rather than
// whichever field happened to be checked last.
func ComputeTeamGrants(teamGroups []TeamGroup, groups []string) map[int64]string {
in := make(map[string]bool, len(groups))
for _, g := range groups {
in[g] = true
}
out := map[int64]string{}
for _, tg := range teamGroups {
role := ""
if tg.MemberGroup != "" && in[tg.MemberGroup] {
role = roleMember
}
if tg.OwnerGroup != "" && in[tg.OwnerGroup] && rank(roleOwner) > rank(role) {
role = roleOwner
}
if role != "" {
out[tg.TeamID] = role
}
}
return out
}
// rank orders roles; an unknown or absent role ranks lowest.
func rank(role string) int {
switch role {
case roleOwner:
return 2
case roleMember:
return 1
}
return 0
}
// HigherRole reports whether role a outranks role b.
func HigherRole(a, b string) bool { return rank(a) > rank(b) }
+140
View File
@@ -0,0 +1,140 @@
package oidc
import (
"reflect"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/config"
)
func testCfg() config.OIDC {
return config.OIDC{
AllowedGroups: []string{"terdut-users"},
AdminGroup: "terdut-admins",
}
}
func TestComputeGrants(t *testing.T) {
tests := []struct {
name string
groups []string
want Grants
}{
{
name: "not in an allowed group is refused",
groups: []string{"sre", "terdut-admins"},
want: Grants{Admitted: false},
},
{
name: "allowed but no grants",
groups: []string{"terdut-users"},
want: Grants{Admitted: true},
},
{
name: "admin group grants admin",
groups: []string{"terdut-users", "terdut-admins"},
want: Grants{Admitted: true, Admin: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ComputeGrants(testCfg(), tt.groups)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %+v, want %+v", got, tt.want)
}
})
}
}
func TestComputeGrants_NoAllowedGroupsAdmitsEveryone(t *testing.T) {
cfg := testCfg()
cfg.AllowedGroups = nil
if g := ComputeGrants(cfg, nil); !g.Admitted {
t.Error("with no allowed groups configured, everybody the provider authenticates is admitted")
}
}
// testTeamGroups is one SRE team keyed off two groups (a member group and a
// higher owner group) and one Platform team keyed off a member group only —
// the same shape the old global TERDUT_OIDC_GROUP_MAPPINGS example used.
func testTeamGroups() []TeamGroup {
return []TeamGroup{
{TeamID: 1, MemberGroup: "sre", OwnerGroup: "sre-leads"},
{TeamID: 2, MemberGroup: "platform"},
}
}
func TestComputeTeamGrants(t *testing.T) {
tests := []struct {
name string
teamGroups []TeamGroup
groups []string
want map[int64]string
}{
{
name: "no matching group grants nothing",
teamGroups: testTeamGroups(),
groups: []string{"terdut-users"},
want: map[int64]string{},
},
{
name: "member group grants member",
teamGroups: testTeamGroups(),
groups: []string{"sre"},
want: map[int64]string{1: roleMember},
},
{
name: "owner group grants owner",
teamGroups: testTeamGroups(),
groups: []string{"sre-leads"},
want: map[int64]string{1: roleOwner},
},
{
name: "in both of a team's groups, owner wins",
teamGroups: testTeamGroups(),
groups: []string{"sre", "sre-leads"},
want: map[int64]string{1: roleOwner},
},
{
name: "several teams from several groups",
teamGroups: testTeamGroups(),
groups: []string{"sre", "platform"},
want: map[int64]string{1: roleMember, 2: roleMember},
},
{
name: "two teams may share a group",
teamGroups: []TeamGroup{
{TeamID: 1, MemberGroup: "sre"},
{TeamID: 2, MemberGroup: "sre"},
},
groups: []string{"sre"},
want: map[int64]string{1: roleMember, 2: roleMember},
},
{
name: "a team with neither field set is never granted",
teamGroups: []TeamGroup{{TeamID: 1}},
groups: []string{"sre", "sre-leads", "platform"},
want: map[int64]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ComputeTeamGrants(tt.teamGroups, tt.groups)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %+v, want %+v", got, tt.want)
}
})
}
}
func TestStringList(t *testing.T) {
if got := stringList([]any{"a", "", 3, "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Errorf("list: %v", got)
}
if got := stringList("solo"); !reflect.DeepEqual(got, []string{"solo"}) {
t.Errorf("single string: %v", got)
}
if got := stringList(nil); got != nil {
t.Errorf("nil: %v", got)
}
}
+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
}
+26
View File
@@ -0,0 +1,26 @@
package web
import (
"io/fs"
"strings"
"testing"
)
func TestCopyIncidentIsEmbedded(t *testing.T) {
sub, err := fs.Sub(files, "static")
if err != nil {
t.Fatal(err)
}
for file, want := range map[string]string{
"js/incident.js": "copyIncident",
"js/ui.js": "copy:",
} {
b, err := fs.ReadFile(sub, file)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), want) {
t.Errorf("%s lacks %s", file, want)
}
}
}
+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)
}
}
}
+94 -4
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 { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.login-brand h1 { font-size: 24px; letter-spacing: -0.01em; } .login-brand h1 { font-size: 24px; letter-spacing: -0.01em; }
.login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; } .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 { display: grid; gap: 6px; }
label > span { font-size: 13px; font-weight: 600; color: var(--muted); } 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; transition: background 0.12s, border-color 0.12s, opacity 0.12s;
} }
.btn:hover { background: var(--surface-hover); } .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 { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.btn-primary:hover { background: var(--accent); filter: brightness(1.06); } .btn-primary:hover { background: var(--accent); filter: brightness(1.06); }
.btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; } .btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; }
@@ -254,6 +261,33 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; font-size: 11px; font-weight: 700; line-height: 18px; text-align: center;
} }
/* ---------- team selector ---------- */
/* The global control for which team the app is scoped to. Hidden (via the
`hidden` attribute, set from teamselector.js) for anybody in fewer than two
teams, the same rule every other team-aware control in this file follows. */
.nav-team-selector,
.team-selector-mobile {
display: inline-flex; align-items: center; gap: 8px;
border: 1px solid var(--border-strong); border-radius: 999px;
background: var(--surface); color: var(--text);
font-size: 13px; font-weight: 600; cursor: pointer;
padding: 4px 12px; max-width: 100%;
}
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A team's identity colour — not a status, so never the severity palette. Six
colours, then they repeat; teamColorClass() in format.js picks one by the
team's id, the same rcN convention the rota's per-person chips use. */
.team-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); }
.team-dot.rc1 { background: var(--accent); }
.team-dot.rc2 { background: var(--ok); }
.team-dot.rc3 { background: var(--snooze); }
.team-dot.rc4 { background: var(--warn); }
.team-dot.rc5 { background: var(--teal); }
.team-dot.rc6 { background: var(--pink); }
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); } .view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
.view-page { padding-left: 16px; padding-right: 16px; } .view-page { padding-left: 16px; padding-right: 16px; }
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; } .view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
@@ -275,6 +309,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
} }
.chips::-webkit-scrollbar { display: none; } .chips::-webkit-scrollbar { display: none; }
.chip { .chip {
display: inline-flex; align-items: center; gap: 6px;
flex: none; flex: none;
min-height: 34px; padding: 0 12px; min-height: 34px; padding: 0 12px;
border: 1px solid var(--border-strong); border-radius: 999px; border: 1px solid var(--border-strong); border-radius: 999px;
@@ -327,6 +362,16 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
background: var(--surface-2); border: 1px solid var(--border); background: var(--surface-2); border: 1px solid var(--border);
color: var(--muted); font-size: 12px; white-space: nowrap; 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); } .row.resolved .row-title { color: var(--muted); }
.sev-critical { --sev: var(--crit); } .sev-critical { --sev: var(--crit); }
@@ -359,7 +404,16 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); } .badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); } .badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); } .badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
.badge.st-resolved { background: var(--ok-soft); color: var(--ok); } .badge.st-resolved, .badge.st-healthy { background: var(--ok-soft); color: var(--ok); }
.badge.st-dead { background: var(--crit-soft); color: var(--crit); }
/* Dormant is the plain badge on purpose: nothing has gone wrong and nothing has
gone right, which is what the muted default already says. */
.badge.st-dormant, .badge.st-never { background: var(--surface-2); color: var(--muted); }
.badge.st-active { background: var(--ok-soft); color: var(--ok); }
.badge.st-quiet, .badge.st-escalating { background: var(--warn-soft); color: var(--warn); }
.badge.st-ready, .badge.st-oncall { background: var(--ok-soft); color: var(--ok); }
.badge.st-reachable { background: var(--surface-2); color: var(--muted); }
.badge.st-unreachable, .badge.st-unpageable { background: var(--crit-soft); color: var(--crit); }
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); } .badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); } .badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
.badge.sev-info { background: var(--info-soft); color: var(--info); } .badge.sev-info { background: var(--info-soft); color: var(--info); }
@@ -377,6 +431,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
-webkit-backdrop-filter: saturate(1.4) blur(12px); -webkit-backdrop-filter: saturate(1.4) blur(12px);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.detail-head .copy { margin-left: auto; }
.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; } .detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; } .detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } .detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
@@ -583,6 +639,8 @@ details[open] > summary { margin-bottom: 8px; }
.account-name { font-size: 18px; font-weight: 750; } .account-name { font-size: 18px; font-weight: 750; }
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; } .account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.pw-form { display: grid; gap: 12px; padding: 16px; } .pw-form { display: grid; gap: 12px; padding: 16px; }
.account-fold { border-top: 1px solid var(--border); padding-top: 12px; }
.account-fold .stacked-form { margin-top: 8px; }
.form-ok { .form-ok {
margin: 0; padding: 10px 12px; margin: 0; padding: 10px 12px;
background: var(--ok-soft); color: var(--ok); background: var(--ok-soft); color: var(--ok);
@@ -617,6 +675,7 @@ kbd {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em; padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
} }
.nav-team-selector { margin: -8px 10px 14px; width: calc(100% - 20px); }
.nav-link { .nav-link {
flex-direction: row; justify-content: flex-start; gap: 12px; flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm); min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
@@ -689,6 +748,18 @@ kbd {
.disabled-row td { opacity: 0.55; } .disabled-row td { opacity: 0.55; }
.btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); } .btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); }
/* --- status lists: alert sources and dead man's switches -----------------
Six columns do not fit a phone, so the table scrolls inside its card rather
than the page. A heartbeat under a switch with several is indented, the way
the escalation ladder indents its levels. */
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.table-scroll { overflow-x: auto; margin-top: 12px; }
.status-table th, .status-table td { white-space: nowrap; }
.status-table td.wrap { white-space: normal; min-width: 12em; }
.status-table .source-row td { border-bottom-style: dashed; }
.status-table .source-row td:first-child { padding-left: 16px; }
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.inline-form { display: flex; gap: 8px; margin-top: 12px; } .inline-form { display: flex; gap: 8px; margin-top: 12px; }
.inline-form input { flex: 1; min-width: 0; } .inline-form input { flex: 1; min-width: 0; }
@@ -744,13 +815,12 @@ kbd {
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; } .stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
.stacked-form label.checkbox { gap: 8px; } .stacked-form label.checkbox { gap: 8px; }
.stacked-form input.wide { min-width: min(420px, 100%); } .stacked-form input.wide { min-width: min(420px, 100%); }
.team-picker { margin-top: 8px; max-width: 100%; }
/* The rota, a month at a time. A name is too wide to print thirty times and /* The rota, a month at a time. A name is too wide to print thirty times and
too alike down a column to read, so a day carries an initial in that 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 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. */ 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 { .rota-wd {
padding-bottom: 4px; text-align: center; padding-bottom: 4px; text-align: center;
color: var(--muted); font-size: 11px; font-weight: 700; color: var(--muted); font-size: 11px; font-weight: 700;
@@ -764,6 +834,21 @@ kbd {
} }
button.rota-day { cursor: pointer; } button.rota-day { cursor: pointer; }
button.rota-day:hover { background: var(--surface-2); } 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-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.rota-day.today { background: var(--accent-soft); } .rota-day.today { background: var(--accent-soft); }
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; } .rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
@@ -807,6 +892,11 @@ button.rota-day:hover { background: var(--surface-2); }
.ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } .ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } .ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
.target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } .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 /* An integration key is shown exactly once, so it should look like something
to act on rather than another row of text. */ to act on rather than another row of text. */
+29 -14
View File
@@ -25,20 +25,29 @@
<img src="/icon.svg" alt="" width="40" height="40"> <img src="/icon.svg" alt="" width="40" height="40">
<h1>terdut</h1> <h1>terdut</h1>
</div> </div>
<label> <!-- Why a sign-in failed, when the identity provider sent the browser back
<span>Username</span> here with ?sso_error=. Kept apart from the password form's own error. -->
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required> <p class="form-error" id="sso-error" role="alert" hidden></p>
</label> <!-- A plain link, not a fetch: the browser has to navigate to the provider,
<label> and the page's CSP allows no connection to anywhere else. -->
<span>Password</span> <a class="btn btn-primary btn-block" id="sso-link" href="/api/oidc/login" hidden>Sign in with SSO</a>
<input name="password" type="password" autocomplete="current-password" required> <p class="login-divider" id="login-or" hidden><span>or</span></p>
</label> <div class="login-password" id="password-login">
<p class="form-error" role="alert" hidden></p> <label>
<button class="btn btn-primary btn-block" type="submit">Sign in</button> <span>Username</span>
<p class="login-hint">No password yet? Ask an admin to set one, or run <input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
<code>PUT /api/users/{id}/password</code> with your API key.</p> </label>
<p class="login-hint" id="signup-link" hidden> <label>
No account? <a href="/signup">Create one</a>.</p> <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> </form>
<!-- Sign-up. Shown instead of the login card at /signup, and only offers <!-- Sign-up. Shown instead of the login card at /signup, and only offers
@@ -77,6 +86,9 @@
<img src="/icon.svg" alt="" width="28" height="28"> <img src="/icon.svg" alt="" width="28" height="28">
<span>terdut</span> <span>terdut</span>
</a> </a>
<!-- Which team the app is scoped to. Hidden unless the signed-in user is
in more than one; teamselector.js fills it in and wires the click. -->
<button class="nav-team-selector" id="team-selector" type="button" hidden></button>
<a class="nav-link" href="/" data-section="queue" aria-label="Queue"> <a class="nav-link" href="/" data-section="queue" aria-label="Queue">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span class="nav-label">Queue</span> <span class="nav-label">Queue</span>
@@ -117,6 +129,7 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
<span class="nav-badge menu-btn-badge" data-badge hidden></span> <span class="nav-badge menu-btn-badge" data-badge hidden></span>
</button> </button>
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
<h1 class="topbar-title" id="topbar-title">Queue</h1> <h1 class="topbar-title" id="topbar-title">Queue</h1>
</div> </div>
<span class="open-pill" id="open-pill" hidden></span> <span class="open-pill" id="open-pill" hidden></span>
@@ -142,6 +155,8 @@
which the Team tab cannot show for a team you are not a member of. --> 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-adminteam" class="view view-page" data-view="adminteam" hidden></section>
<section id="view-more" class="view view-page" data-view="more" 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> </div>
<dialog id="sheet" class="sheet"></dialog> <dialog id="sheet" class="sheet"></dialog>
+71 -42
View File
@@ -26,8 +26,7 @@ function render() {
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })), h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyForm(user), notifyForm(user),
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })), ...passwordSection(user, hasPassword),
passwordForm(user, hasPassword),
h('div', { class: 'only-desktop' }, h('div', { class: 'only-desktop' },
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })), h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
@@ -44,9 +43,12 @@ function render() {
// //
// The topic is the whole address — the server it is published to is the // The topic is the whole address — the server it is published to is the
// install's one ntfy, set in the deployment and not something a user picks. // install's one ntfy, set in the deployment and not something a user picks.
function notifyStatus(topic) {
return topic ? `Topic: ${topic}` : 'No topic set — pages go to the team’s fallback topic.';
}
function notifyForm(user) { function notifyForm(user) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const topic = h('input', { const topic = h('input', {
name: 'ntfy_topic', type: 'text', autocomplete: 'off', name: 'ntfy_topic', type: 'text', autocomplete: 'off',
autocapitalize: 'none', spellcheck: false, autocapitalize: 'none', spellcheck: false,
@@ -55,29 +57,7 @@ function notifyForm(user) {
}); });
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' }); const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
// Only offered once a topic is saved: the test publishes to whatever the const form = h('form', { class: 'stacked-form' },
// server has stored, not to whatever is half-typed in the field.
const test = h('button', {
class: 'btn', type: 'button', text: 'Send a test push',
hidden: !user.ntfy_topic,
onclick: async () => {
err.hidden = true;
ok.hidden = true;
test.disabled = true;
try {
await api.testNotification();
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
ok.hidden = false;
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
test.disabled = false;
}
},
});
const form = h('form', { class: 'card pw-form' },
h('label', {}, h('label', {},
h('span', { text: 'ntfy topic' }), h('span', { text: 'ntfy topic' }),
topic), topic),
@@ -90,25 +70,46 @@ function notifyForm(user) {
h('p', { class: 'muted small' }, h('p', { class: 'muted small' },
'Anyone who knows the topic can read your pages and publish to it, so ', 'Anyone who knows the topic can read your pages and publish to it, so ',
'pick something unguessable rather than your name.'), 'pick something unguessable rather than your name.'),
err, ok, err,
h('div', { class: 'row-actions' }, submit, test), submit,
); );
const status = h('p', { class: 'muted', text: notifyStatus(user.ntfy_topic) });
const summary = h('summary', { text: user.ntfy_topic ? 'Change topic' : 'Set a topic' });
const details = h('details', { class: 'account-fold' }, summary, form);
// Only offered once a topic is saved: the test publishes to whatever the
// server has stored, not to whatever is half-typed in the field.
const test = h('button', {
class: 'btn', type: 'button', text: 'Send a test push',
hidden: !user.ntfy_topic,
onclick: async () => {
test.disabled = true;
try {
await api.testNotification();
toast('Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.');
} catch (ex) {
toast(ex.message, 'error');
} finally {
test.disabled = false;
}
},
});
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
err.hidden = true; err.hidden = true;
ok.hidden = true;
submit.disabled = true; submit.disabled = true;
try { try {
const updated = await api.setNotifyTarget(user.id, topic.value.trim()); const updated = await api.setNotifyTarget(user.id, topic.value.trim());
// Keep the cached user in step, so the onboarding checklist stops // Keep the cached user in step, so the onboarding checklist stops
// asking for this and the test button appears without a reload. // asking for this and the test button appears without a reload.
state.me.user = updated; state.me.user = updated;
ok.textContent = updated.ntfy_topic status.textContent = notifyStatus(updated.ntfy_topic);
? 'Topic saved.' summary.textContent = updated.ntfy_topic ? 'Change topic' : 'Set a topic';
: 'Topic cleared. Your pages go to the team’s fallback topic.';
ok.hidden = false;
test.hidden = !updated.ntfy_topic; test.hidden = !updated.ntfy_topic;
details.open = false;
toast(updated.ntfy_topic ? 'Topic saved' : 'Topic cleared. Your pages go to the team’s fallback topic.');
} catch (ex) { } catch (ex) {
err.textContent = ex.message; err.textContent = ex.message;
err.hidden = false; err.hidden = false;
@@ -116,12 +117,33 @@ function notifyForm(user) {
submit.disabled = false; submit.disabled = false;
} }
}); });
return form;
return h('div', { class: 'card pw-form' },
status,
h('div', { class: 'row-actions' }, test),
details);
}
// With password login switched off a password opens nothing, so somebody who
// has none is not asked to make one. Somebody who does keeps the form: it is
// how they change or get rid of a credential the server still remembers.
function passwordSection(user, hasPassword) {
if (!hasPassword && state.auth.password_login === false) {
const name = state.auth.oidc?.name || 'single sign-on';
return [
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
h('div', { class: 'card' },
h('p', { class: 'muted', text: `You sign in with ${name}, and this server has turned password login off.` })),
];
}
return [
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
passwordForm(user, hasPassword),
];
} }
function passwordForm(user, hasPassword) { function passwordForm(user, hasPassword) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true }); const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
const current = hasPassword const current = hasPassword
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true }) ? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
: null; : null;
@@ -131,18 +153,24 @@ function passwordForm(user, hasPassword) {
// A hidden username field lets password managers file the new password // A hidden username field lets password managers file the new password
// under the right account. // under the right account.
const form = h('form', { class: 'card pw-form', autocomplete: 'on' }, const form = h('form', { class: 'stacked-form', autocomplete: 'on' },
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }), h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
current && h('label', {}, h('span', { text: 'Current password' }), current), current && h('label', {}, h('span', { text: 'Current password' }), current),
h('label', {}, h('span', { text: 'New password' }), next), h('label', {}, h('span', { text: 'New password' }), next),
h('label', {}, h('span', { text: 'Repeat new password' }), again), h('label', {}, h('span', { text: 'Repeat new password' }), again),
err, ok, submit, err, submit,
); );
const status = h('p', {
class: 'muted',
text: hasPassword ? 'Password set.' : 'No password set — sign-in needs one of the other methods.',
});
const summary = h('summary', { text: hasPassword ? 'Change password' : 'Set a password' });
const details = h('details', { class: 'account-fold' }, summary, form);
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
err.hidden = true; err.hidden = true;
ok.hidden = true;
if (next.value !== again.value) { if (next.value !== again.value) {
err.textContent = 'The new passwords do not match.'; err.textContent = 'The new passwords do not match.';
err.hidden = false; err.hidden = false;
@@ -154,13 +182,14 @@ function passwordForm(user, hasPassword) {
state.me.has_password = true; state.me.has_password = true;
form.reset(); form.reset();
if (!current) { if (!current) {
// From now on the form needs the current-password field. // From now on the form needs the current-password field, and a fresh
// render already comes up with the fold closed.
render(); render();
toast('Password saved'); toast('Password saved');
return; return;
} }
ok.textContent = 'Password saved. Other devices have been signed out.'; details.open = false;
ok.hidden = false; toast('Password saved. Other devices have been signed out.');
} catch (ex) { } catch (ex) {
err.textContent = ex.message; err.textContent = ex.message;
err.hidden = false; err.hidden = false;
@@ -168,7 +197,7 @@ function passwordForm(user, hasPassword) {
submit.disabled = false; submit.disabled = false;
} }
}); });
return form; return h('div', { class: 'card pw-form' }, status, details);
} }
function shortcuts() { function shortcuts() {
+9 -2
View File
@@ -13,7 +13,7 @@
// gate. // gate.
import * as api from './api.js'; 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'; import { state, myID } from './state.js';
const view = () => document.getElementById('view-admin'); const view = () => document.getElementById('view-admin');
@@ -197,7 +197,9 @@ function usersCard() {
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }), u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
self && h('span', { class: 'you', text: 'you' })), self && h('span', { class: 'you', text: 'you' })),
h('td', { class: 'muted', text: u.email }), 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', {}, h('td', {},
// Neither action is offered for your own account: the server refuses // Neither action is offered for your own account: the server refuses
// both, and an enabled-looking button that always fails is worse than // both, and an enabled-looking button that always fails is worse than
@@ -206,6 +208,9 @@ function usersCard() {
class: 'btn-sm', class: 'btn-sm',
type: 'button', type: 'button',
text: u.is_admin ? 'Revoke admin' : 'Make admin', 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), onclick: () => setAdmin(u, !u.is_admin),
}), }),
!self && h('button', { !self && h('button', {
@@ -255,6 +260,8 @@ function invitePointer() {
); );
} }
const ssoAdmin = (u) => u.is_admin && u.admin_source === 'oidc';
async function setAdmin(user, next) { async function setAdmin(user, next) {
if (next && !(await confirm({ if (next && !(await confirm({
title: `Make ${user.username} an administrator?`, title: `Make ${user.username} an administrator?`,
+14 -2
View File
@@ -11,7 +11,7 @@
// gate. // gate.
import * as api from './api.js'; 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 { state } from './state.js';
import { navigate } from './app.js'; import { navigate } from './app.js';
import { when } from './format.js'; import { when } from './format.js';
@@ -146,6 +146,13 @@ function identityCard() {
fact('Created', when(t.created_at)), fact('Created', when(t.created_at)),
fact('Members', String(t.members)), fact('Members', String(t.members)),
fact('Open incidents', String(t.open_incidents)), fact('Open incidents', String(t.open_incidents)),
// Read-only here: an administrator can see why a team's OIDC-sourced
// membership looks the way it does, but setting it is the team's own
// owner's call, from the Team tab.
...(state.auth?.oidc?.enabled ? [
fact('OIDC member group', t.oidc_member_group || '—'),
fact('OIDC owner group', t.oidc_owner_group || '—'),
] : []),
), ),
form, err, ok, form, err, ok,
); );
@@ -166,17 +173,22 @@ function membersCard() {
// Unlike the Team tab's own member list, the name is a link: that // Unlike the Team tab's own member list, the name is a link: that
// person's page is where the rest of them lives. // 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', {}, 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('td', { class: 'row-actions' },
h('button', { h('button', {
class: 'btn-sm', type: 'button', class: 'btn-sm', type: 'button',
text: m.role === 'owner' ? 'Make member' : 'Make owner', 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. // The same endpoint both ways: adding is an upsert on the role.
onclick: () => act(() => onclick: () => act(() =>
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')), api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
}), }),
h('button', { h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove', 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. // The server refuses the last owner with a 409, which act() shows.
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)), onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
}), }),
+11 -2
View File
@@ -11,7 +11,7 @@
// enforcement of them. // enforcement of them.
import * as api from './api.js'; 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 { state, myID } from './state.js';
import { navigate } from './app.js'; import { navigate } from './app.js';
import { when } from './format.js'; import { when } from './format.js';
@@ -96,6 +96,7 @@ function identityCard() {
h('div', { class: 'user-head' }, h('div', { class: 'user-head' },
h('h2', { text: u.username }), h('h2', { text: u.username }),
u.is_admin && h('span', { class: 'row-team', text: 'admin' }), 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' }), u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
self && h('span', { class: 'you', text: 'you' })), self && h('span', { class: 'you', text: 'you' })),
h('dl', { class: 'user-facts' }, h('dl', { class: 'user-facts' },
@@ -110,6 +111,9 @@ function identityCard() {
!self && h('button', { !self && h('button', {
class: 'btn', type: 'button', class: 'btn', type: 'button',
text: u.is_admin ? 'Revoke admin' : 'Make admin', 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), onclick: () => setAdmin(!u.is_admin),
}), }),
!self && h('button', { !self && h('button', {
@@ -162,16 +166,21 @@ function teamsCard() {
// Not a link: the Team tab always shows the viewer's own team, so // 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. // sending them there from somebody else's membership would be a lie.
h('td', {}, h('strong', { text: t.name })), 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('td', { class: 'row-actions' },
h('button', { h('button', {
class: 'btn-sm', type: 'button', class: 'btn-sm', type: 'button',
text: t.role === 'owner' ? 'Make member' : 'Make owner', 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(() => onclick: () => act(() =>
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')), api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
}), }),
h('button', { h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove', 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. // The server refuses the last owner with a 409, which act() shows.
onclick: () => act(() => api.removeTeamMember(t.id, userID)), onclick: () => act(() => api.removeTeamMember(t.id, userID)),
}), }),
+16 -2
View File
@@ -59,8 +59,13 @@ async function call(method, path, { query, body, signal } = {}) {
// session // session
export const me = () => call('GET', '/me'); 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 login = (username, password) => call('POST', '/login', { body: { username, password } });
export const logout = () => call('POST', '/logout'); 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) => export const setPassword = (userID, password, currentPassword) =>
call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } }); call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } });
@@ -130,14 +135,23 @@ export const addTeamMember = (id, userID, role) =>
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } }); call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`); export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
// Which OIDC groups grant member and owner access to this team.
export const oidcGroups = (id) => call('GET', `/teams/${id}/oidc-groups`);
export const setOidcGroups = (id, body) => call('PUT', `/teams/${id}/oidc-groups`, { body });
export const integrations = (id) => call('GET', `/teams/${id}/integrations`); export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
export const createIntegration = (id, name) => export const createIntegration = (id, name) =>
call('POST', `/teams/${id}/integrations`, { body: { 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) => export const deleteIntegration = (id, integrationID) =>
call('DELETE', `/teams/${id}/integrations/${integrationID}`); call('DELETE', `/teams/${id}/integrations/${integrationID}`);
export const deadman = (id) => call('GET', `/teams/${id}/deadman`); export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
export const setDeadman = (id, body) => call('PUT', `/teams/${id}/deadman`, { body }); export const createDeadmanSwitch = (id, body) =>
call('POST', `/teams/${id}/deadman/switches`, { body });
export const deleteDeadmanSwitch = (id, switchID) =>
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
export const escalation = (id) => call('GET', `/teams/${id}/escalation`); export const escalation = (id) => call('GET', `/teams/${id}/escalation`);
export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body }); export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body });
+92 -6
View File
@@ -11,9 +11,11 @@ import * as alerts from './alerts.js';
import * as stats from './stats.js'; import * as stats from './stats.js';
import * as account from './account.js'; import * as account from './account.js';
import * as team from './team.js'; import * as team from './team.js';
import * as teamselector from './teamselector.js';
import * as admin from './admin.js'; import * as admin from './admin.js';
import * as adminuser from './adminuser.js'; import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js'; import * as adminteam from './adminteam.js';
import * as device from './device.js';
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@@ -30,6 +32,8 @@ const SECTIONS = {
adminuser: { title: 'User', view: adminuser, nav: 'admin' }, adminuser: { title: 'User', view: adminuser, nav: 'admin' },
adminteam: { title: 'Team', view: adminteam, nav: 'admin' }, adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
more: { title: 'Account', view: account }, 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 // The mobile hamburger menu's contents — the same sections the desktop
@@ -61,7 +65,7 @@ function parseRoute(pathname) {
if (t) return { section: 'admin', tab: t.tab }; if (t) return { section: 'admin', tab: t.tab };
const tt = team.TABS.find((x) => x.path === `/${name}`); const tt = team.TABS.find((x) => x.path === `/${name}`);
if (tt) return { section: 'team', tab: tt.tab }; 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 }; return { section: 'queue', incident: null };
} }
@@ -227,6 +231,8 @@ async function boot() {
$('login-form').addEventListener('submit', onLogin); $('login-form').addEventListener('submit', onLogin);
$('signup-form').addEventListener('submit', onSignup); $('signup-form').addEventListener('submit', onSignup);
$('menu-btn').addEventListener('click', openNavMenu); $('menu-btn').addEventListener('click', openNavMenu);
teamselector.init();
ssoErrorCode = takeSSOError();
// /signup is the one route that works without a session. // /signup is the one route that works without a session.
if (location.pathname.replace(/\/$/, '') === '/signup') { if (location.pathname.replace(/\/$/, '') === '/signup') {
@@ -236,8 +242,12 @@ async function boot() {
} }
try { 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(); state.me = await api.me();
await loadTeams(); await loadTeams();
teamselector.render();
// The Admin tab exists only for an administrator. Somebody who types /admin // The Admin tab exists only for an administrator. Somebody who types /admin
// anyway gets the view's own "ask an administrator" card, not a blank page. // anyway gets the view's own "ask an administrator" card, not a blank page.
$('nav-admin').hidden = !state.me?.user?.is_admin; $('nav-admin').hidden = !state.me?.user?.is_admin;
@@ -321,6 +331,7 @@ async function onSignup(e) {
history.replaceState({ depth: 0 }, '', '/'); history.replaceState({ depth: 0 }, '', '/');
route = parseRoute('/'); route = parseRoute('/');
await loadTeams(); await loadTeams();
teamselector.render();
$('nav-admin').hidden = !state.me?.user?.is_admin; $('nav-admin').hidden = !state.me?.user?.is_admin;
showApp(); showApp();
} catch (ex) { } catch (ex) {
@@ -331,17 +342,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(); poll.stop();
ui.closeSheet(null); ui.closeSheet(null);
reset(); reset();
$('boot').hidden = true;
$('app').hidden = true; $('app').hidden = true;
$('login').hidden = false;
$('signup-form').hidden = true; $('signup-form').hidden = true;
// Ask before showing anything, so the form does not flash the password
// fields at somebody whose server has turned them off.
const auth = await loadAuthConfig();
const sso = auth.oidc?.enabled ? auth.oidc : null;
const passwords = auth.password_login !== false;
const link = $('sso-link');
link.hidden = !sso;
if (sso) {
link.textContent = `Sign in with ${sso.name || 'SSO'}`;
// Come back to the page that was asked for: a link to /device?code=... has
// to survive the trip through the provider. The server only honours paths
// on this server, and ignores the front page.
const here = location.pathname + location.search;
link.href = here === '/' ? '/api/oidc/login' : `/api/oidc/login?next=${encodeURIComponent(here)}`;
}
$('login-or').hidden = !(sso && passwords);
$('password-login').hidden = !passwords;
const ssoErr = $('sso-error');
ssoErr.hidden = ssoErrorCode === null;
if (ssoErrorCode !== null) ssoErr.textContent = ssoErrorText(ssoErrorCode, sso?.name);
$('boot').hidden = true;
$('login').hidden = false;
$('login-form').hidden = false; $('login-form').hidden = false;
const form = $('login-form'); 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 // 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. // invite-only server should be told, not sent to a form that refuses them.
api.signupInfo().then((info) => { api.signupInfo().then((info) => {
@@ -354,9 +435,13 @@ function showLogin() {
async function onLogin(e) { async function onLogin(e) {
e.preventDefault(); e.preventDefault();
const form = e.currentTarget; 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]'); const btn = form.querySelector('button[type=submit]');
err.hidden = true; 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; btn.disabled = true;
try { try {
state.me = await api.login(form.username.value.trim(), form.password.value); state.me = await api.login(form.username.value.trim(), form.password.value);
@@ -380,6 +465,7 @@ export async function signOut() {
} }
function showApp() { function showApp() {
ssoErrorCode = null;
$('boot').hidden = true; $('boot').hidden = true;
$('login').hidden = true; $('login').hidden = true;
$('app').hidden = false; $('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' }));
}
+18 -9
View File
@@ -66,21 +66,22 @@ export function mondayOf(d) {
return r; 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) { export function addDays(d, n) {
const r = new Date(d); const r = new Date(d);
r.setDate(r.getDate() + n); r.setDate(r.getDate() + n);
return r; 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 = { export const STATUS_LABEL = {
triggered: 'Triggered', triggered: 'Triggered',
acknowledged: 'Acknowledged', acknowledged: 'Acknowledged',
@@ -97,6 +98,14 @@ export function severityClass(sev) {
return ''; return '';
} }
// A stable identity colour for a team, so the same team always reads the same
// colour without the server needing to store one. Teams have no colour field;
// this hashes the id into the six-colour rcN palette app.css already has for
// the rota's per-person chips (a team is not a status, so never severity).
export function teamColorClass(teamID) {
return `rc${(((teamID % 6) + 6) % 6) + 1}`;
}
// A one-line summary of the group labels, without the one the title already shows. // A one-line summary of the group labels, without the one the title already shows.
export function labelSummary(labels, skip = 'alertname') { export function labelSummary(labels, skip = 'alertname') {
return Object.entries(labels || {}) return Object.entries(labels || {})
+102 -2
View File
@@ -66,6 +66,8 @@ function render() {
h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back }, h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back },
icon('back')), icon('back')),
h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }), h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }),
inc && h('button', { class: 'btn btn-ghost btn-icon copy', type: 'button', 'aria-label': 'Copy incident', title: 'Copy incident (y)', onclick: copyIncident },
icon('copy')),
); );
if (!inc) { if (!inc) {
@@ -186,8 +188,9 @@ function alertItem(a) {
// ---------- timeline ---------- // ---------- timeline ----------
function eventText(ev) { // named spells users out instead of "you", for text that leaves this page.
const person = ev.user_id != null ? who(ev.user_id, ev.username) : null; function eventText(ev, named = false) {
const person = ev.user_id != null ? (named ? ev.username || 'someone' : who(ev.user_id, ev.username)) : null;
const strong = (t) => h('span', { class: 'who', text: t || 'someone' }); const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
const alertName = () => { const alertName = () => {
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id); const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
@@ -263,6 +266,100 @@ function timelineItem(ev) {
); );
} }
// ---------- copy ----------
const fence = (rows) => ['```', ...rows, '```'];
const pairs = (obj) => Object.entries(obj || {}).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`);
// incidentMarkdown is everything on this page as text that reads well in a chat
// or an agent prompt. Times are ISO 8601, since "3 min ago" means nothing once
// it has been pasted somewhere else.
function incidentMarkdown() {
const out = [`# Incident #${inc.id}: ${inc.title}`, ''];
const add = (k, v) => { if (v != null && v !== '') out.push(`- ${k}: ${v}`); };
add('Status', inc.status);
add('Severity', inc.severity);
add('Team', inc.team_name);
add('Assigned to', inc.assigned_to_id != null ? inc.assigned_to || 'someone' : 'unassigned');
add('Triggered', inc.triggered_at);
if (inc.acknowledged_at) add('Acknowledged', `${inc.acknowledged_at} by ${inc.acknowledged_by || 'someone'}`);
if (isOpen() && isFuture(inc.snoozed_until)) add('Snoozed until', inc.snoozed_until);
if (inc.escalation_level > 0) add('Escalation level', inc.escalation_level);
if (inc.resolved_at) add('Resolved', `${inc.resolved_at} (${inc.resolution_source === 'manual' ? 'manually' : 'all alerts stopped firing'})`);
if (inc.archived_at) add('Archived', inc.archived_at);
const group = pairs(inc.group_labels);
if (group.length) out.push('- Grouped by:', ...group.map((g) => ` - ${g}`));
const alerts = inc.alerts || [];
out.push('', `## Alerts (${alerts.length})`);
for (const a of alerts) {
out.push('', `### ${a.name} (${a.status})`);
out.push(`- Started: ${a.starts_at}`);
if (a.status === 'resolved' && a.ends_at) out.push(`- Ended: ${a.ends_at}`);
if (a.generator_url) out.push(`- Source: ${a.generator_url}`);
const labels = pairs(a.labels);
if (labels.length) out.push('', 'Labels:', ...fence(labels));
const annotations = Object.entries(a.annotations || {}).sort(([x], [y]) => x.localeCompare(y));
if (annotations.length) out.push('', 'Annotations:', ...fence(annotations.map(([k, v]) => `${k}: ${v}`)));
}
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
if (sorted.length) {
out.push('', '## Timeline', '');
for (const ev of sorted) {
const text = eventText(ev, true).map((f) => (f instanceof Node ? f.textContent : f)).join('');
out.push(`- ${ev.created_at} ${text}`);
if (isNote(ev) && ev.detail) {
const label = ev.type === 'resolution_note' ? ' (what fixed it)' : '';
out.push(...(label ? [label] : []), ...ev.detail.split('\n').map((l) => ` > ${l}`));
}
}
}
if (similarList.length) {
out.push('', '## Seen before', '', 'Earlier incidents with the same signature:');
for (const s of similarList) {
out.push(`- #${s.id} ${s.title} (resolved ${s.resolved_at})`);
for (const n of s.resolution_notes || []) {
out.push(' - What fixed it:', ...(n.detail || '').split('\n').map((l) => ` > ${l}`));
}
}
}
out.push('', `_Copied from Terminal Duty at ${new Date().toISOString()}_`, '');
return out.join('\n');
}
// writeClipboard falls back to execCommand: the async API needs a secure
// context, and this server is often reached over plain HTTP.
async function writeClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// fall through
}
const ta = h('textarea', { readonly: true, 'aria-hidden': 'true', class: 'clip-buffer' });
ta.value = text;
document.body.append(ta);
ta.select();
try {
if (!document.execCommand('copy')) throw new Error('copy refused');
} finally {
ta.remove();
}
}
async function copyIncident() {
if (!inc) return;
try {
await writeClipboard(incidentMarkdown());
toast('Copied incident');
} catch {
toast('Could not copy', 'error');
}
}
// ---------- actions ---------- // ---------- actions ----------
const isOpen = () => inc.status !== 'resolved'; const isOpen = () => inc.status !== 'resolved';
@@ -470,10 +567,12 @@ async function moreMenu() {
items.push(item('user', 'Assign…', assign)); items.push(item('user', 'Assign…', assign));
items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze)); items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze));
items.push(item('note', 'Add note…', addNote)); items.push(item('note', 'Add note…', addNote));
items.push(item('copy', 'Copy incident', copyIncident));
items.push(h('li', { class: 'menu-sep', role: 'separator' })); items.push(h('li', { class: 'menu-sep', role: 'separator' }));
items.push(item('checkCircle', 'Resolve…', resolve, 'danger')); items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
} else { } else {
items.push(item('note', 'Add note…', addNote)); items.push(item('note', 'Add note…', addNote));
items.push(item('copy', 'Copy incident', copyIncident));
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive)); items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
} }
@@ -502,6 +601,7 @@ export function key(e) {
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true; case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
case 'Z': if (isSnoozed()) unsnooze(); return true; case 'Z': if (isSnoozed()) unsnooze(); return true;
case 'c': addNote(); return true; case 'c': addNote(); return true;
case 'y': copyIncident(); return true;
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true; case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
default: return false; default: return false;
} }
+20 -32
View File
@@ -2,8 +2,8 @@
import * as api from './api.js'; import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js'; import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary } from './format.js'; import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
import { state, myID } from './state.js'; import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js'; import * as onboarding from './onboarding.js';
import { navigate } from './app.js'; import { navigate } from './app.js';
@@ -27,34 +27,21 @@ const EMPTY = {
}; };
onboarding.onRerender(() => renderList()); onboarding.onRerender(() => renderList());
// The queue used to keep its own team filter (a per-tab sessionStorage value,
// out of step with team.js's own picker); both now defer to the global
// selector's shared state, so re-render whenever it changes.
onTeamChange(() => {
renderChips();
refresh({ fresh: true });
});
let filter = loadFilter(); let filter = loadFilter();
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
let items = null; // null while loading let items = null; // null while loading
let error = null; let error = null;
let selected = null; let selected = null;
let cursor = -1; // keyboard position in the list let cursor = -1; // keyboard position in the list
let built = false; let built = false;
function loadTeamFilter() {
try {
return sessionStorage.getItem('terdut.queue.team') || '';
} catch {
return '';
}
}
function setTeamFilter(id) {
teamFilter = id;
try {
sessionStorage.setItem('terdut.queue.team', id);
} catch {
/* storage unavailable */
}
renderChips();
refresh({ fresh: true });
}
function loadFilter() { function loadFilter() {
try { try {
const f = sessionStorage.getItem('terdut.queue.filter'); const f = sessionStorage.getItem('terdut.queue.filter');
@@ -89,8 +76,8 @@ export async function refresh({ fresh = false } = {}) {
// The open list is already fetched for the badges; no need to ask twice. // The open list is already fetched for the badges; no need to ask twice.
// The cached open queue covers every team, so it can only be reused when // The cached open queue covers every team, so it can only be reused when
// no team filter is applied. // no team filter is applied.
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query; const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query;
const cached = filter === 'open' && !fresh && !teamFilter; const cached = filter === 'open' && !fresh && state.selectedTeamID == null;
const result = cached ? state.open : await api.incidents(query); const result = cached ? state.open : await api.incidents(query);
await onboarding.load(); await onboarding.load();
if (requested !== filter) return; if (requested !== filter) return;
@@ -136,19 +123,20 @@ function renderChips() {
class: 'chip', class: 'chip',
type: 'button', type: 'button',
role: 'tab', role: 'tab',
'aria-selected': String(teamFilter === ''), 'aria-selected': String(state.selectedTeamID == null),
onclick: () => setTeamFilter(''), onclick: () => setSelectedTeam(null),
text: 'All teams', }, h('span', { class: 'team-dot' }), ' All teams'));
}));
for (const team of state.teams) { for (const team of state.teams) {
chips.push(h('button', { chips.push(h('button', {
class: 'chip', class: 'chip',
type: 'button', type: 'button',
role: 'tab', role: 'tab',
'aria-selected': String(teamFilter === String(team.id)), 'aria-selected': String(team.id === state.selectedTeamID),
onclick: () => setTeamFilter(String(team.id)), onclick: () => setSelectedTeam(team.id),
text: team.name, },
})); h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
' ' + team.name,
));
} }
} }
+53 -3
View File
@@ -5,14 +5,58 @@ import * as api from './api.js';
export const state = { export const state = {
me: null, // { user, has_password } 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 open: [], // the default queue: open, not snoozed
teams: [], // the teams the viewer belongs to, each with their role teams: [], // the teams the viewer belongs to, each with their role
// Which team the whole app is scoped to right now; null means "All teams".
// Set only through setSelectedTeam below, never assigned directly, so every
// view stays in sync and the choice is remembered across reloads.
selectedTeamID: loadSelectedTeam(),
}; };
// The team whose schedule and settings the views act on. A viewer in one team — const SELECTED_TEAM_KEY = 'terdut.selectedTeam';
// which is everybody until somebody makes a second — never has to choose.
function loadSelectedTeam() {
try {
const raw = localStorage.getItem(SELECTED_TEAM_KEY);
return raw ? Number(raw) : null;
} catch {
return null; // storage unavailable, or nothing saved yet
}
}
// Callbacks to run whenever the selected team changes, so every view that
// cares — the queue's filter, the Team settings page, the selector's own
// trigger — stays in sync without a general event bus, following the one
// precedent for this in the codebase: onboarding.js's onRerender.
const teamListeners = [];
export function onTeamChange(cb) {
teamListeners.push(cb);
}
// setSelectedTeam changes which team the app is scoped to (id, or null for
// "All teams"), persists it — a durable preference, unlike the per-tab
// sessionStorage filter this replaces — and tells every registered listener.
export function setSelectedTeam(id) {
state.selectedTeamID = id;
try {
if (id == null) localStorage.removeItem(SELECTED_TEAM_KEY);
else localStorage.setItem(SELECTED_TEAM_KEY, String(id));
} catch {
/* storage unavailable */
}
for (const cb of teamListeners) cb();
}
// The team whose schedule and settings the views act on: the selected team,
// falling back to the first one the viewer belongs to — which is everybody's
// only team until somebody makes a second, or the stored selection naming a
// team the account has since left.
export function currentTeam() { export function currentTeam() {
return state.teams[0] || null; const teams = state.teams || [];
return teams.find((t) => t.id === state.selectedTeamID) || teams[0] || null;
} }
export function myID() { export function myID() {
@@ -33,6 +77,12 @@ export async function users() {
export async function loadTeams() { export async function loadTeams() {
state.teams = await api.teams(); state.teams = await api.teams();
// A stored id that no longer names one of the account's teams — left it, or
// this is simply a different account signed in on the same browser — is as
// good as unset.
if (state.selectedTeamID != null && !state.teams.some((t) => t.id === state.selectedTeamID)) {
state.selectedTeamID = null;
}
return state.teams; return state.teams;
} }
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
// The global team selector: a small control, once per layout (the desktop
// sidebar and the mobile topbar each have their own button in index.html),
// showing the current team's colour and name — or "All teams" — and opening a
// sheet to switch. Shown only once there is more than one team to choose
// between, the same rule every other team-aware control in this app follows;
// see state.js's currentTeam() for why nobody with just one ever has to.
import { h, openSheet, closeSheet } from './ui.js';
import { state, currentTeam, setSelectedTeam, onTeamChange } from './state.js';
import { teamColorClass } from './format.js';
// Not a real team id (ids are positive), so it can never collide with one —
// the value closeSheet resolves with for "All teams", distinct from the null
// a dismissed sheet resolves with.
const ALL_TEAMS = '__all__';
const buttons = () => [
document.getElementById('team-selector'),
document.getElementById('team-selector-mobile'),
].filter(Boolean);
// init wires the buttons once, at boot. render (below) is what actually fills
// them in and is called again by state.js whenever the selection changes.
export function init() {
for (const btn of buttons()) btn.addEventListener('click', open);
onTeamChange(render);
}
export function render() {
const multiTeam = (state.teams || []).length > 1;
const team = currentTeam();
const label = team ? team.name : 'All teams';
const dotClass = team ? `team-dot ${teamColorClass(team.id)}` : 'team-dot';
for (const btn of buttons()) {
btn.hidden = !multiTeam;
btn.replaceChildren(
h('span', { class: dotClass }),
h('span', { class: 'team-selector-label', text: label }),
);
}
}
function open() {
const teams = state.teams || [];
openSheet(() => [
h('h2', { class: 'sheet-title', text: 'Switch team' }),
h('ul', { class: 'menu', role: 'menu' },
h('li', {}, h('button', {
class: 'menu-item', type: 'button', role: 'menuitemradio',
'aria-checked': String(state.selectedTeamID == null),
onclick: () => closeSheet(ALL_TEAMS),
}, h('span', { class: 'team-dot' }), ' All teams')),
teams.map((t) => h('li', {}, h('button', {
class: 'menu-item', type: 'button', role: 'menuitemradio',
'aria-checked': String(t.id === state.selectedTeamID),
onclick: () => closeSheet(t.id),
}, h('span', { class: `team-dot ${teamColorClass(t.id)}` }), ' ' + t.name))),
),
]).then((choice) => {
if (choice == null) return; // dismissed: backdrop, escape, or cancel
setSelectedTeam(choice === ALL_TEAMS ? null : choice);
});
}
+10
View File
@@ -48,6 +48,7 @@ const ICONS = {
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'], note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'], archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'], flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
copy: ['rect:9,9,11,11,2', 'M5 15V6a2 2 0 0 1 2-2h9'],
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'], trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
chevronLeft: ['M15 18l-6-6 6-6'], chevronLeft: ['M15 18l-6-6 6-6'],
chevronRight: ['M9 6l6 6-6 6'], chevronRight: ['M9 6l6 6-6 6'],
@@ -169,6 +170,15 @@ export function badge(text, cls = '') {
return h('span', { class: `badge ${cls}`, text }); 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) { export function labelChip(k, v) {
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v })); return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
} }