Compare commits

...

14 Commits

Author SHA1 Message Date
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
48 changed files with 4417 additions and 176 deletions
+6
View File
@@ -16,6 +16,12 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -ldflags="-w -s -X main.version=${VERSION}" -o /terdut ./cmd/terdut
FROM scratch
# scratch has no trust store, and a Go binary on it fails every HTTPS call with
# "x509: certificate signed by unknown authority". Nothing needed one until single
# sign-on: discovery and the token exchange are HTTPS calls to the identity provider.
# The bundle is the builder's, copied by name so a missing file fails the build
# rather than shipping an image that cannot sign anybody in.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /terdut /terdut
EXPOSE 8080
ENTRYPOINT ["/terdut"]
+132 -7
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
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
```bash
@@ -229,6 +333,16 @@ over an administrator's edit.
| `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call |
| `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server: the notification's link into the web UI, its Acknowledge button, and whether the session cookie is `Secure` |
| `TERDUT_NOTIFY_REPEAT` | `15m` | **seed.** How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats |
| `TERDUT_PASSWORD_LOGIN` | `true` | `false` refuses password login and password sign-up (`403`), leaving single sign-on the only way in. Refused at startup unless SSO is configured |
| `TERDUT_OIDC_ISSUER` | — | Turns single sign-on on. The provider's issuer URL; discovery is read from `<issuer>/.well-known/openid-configuration`. See [Single sign-on](#single-sign-on-oidc) |
| `TERDUT_OIDC_CLIENT_ID` / `TERDUT_OIDC_CLIENT_SECRET` | — | **Required with an issuer.** The confidential client registered at the provider. Keep the secret in a Secret, not in values |
| `TERDUT_OIDC_NAME` | `SSO` | What the sign-in button calls the provider |
| `TERDUT_OIDC_SCOPES` | `openid profile email` | Scopes requested, comma or space separated. Authentik puts `groups` behind `profile` |
| `TERDUT_OIDC_USERNAME_CLAIM` / `_EMAIL_CLAIM` / `_GROUPS_CLAIM` | `preferred_username` / `email` / `groups` | ID token claims read for the username, email and groups |
| `TERDUT_OIDC_TRUST_EMAIL` | `false` | Link a first sign-in to an existing local user by email even if the provider does not mark the address verified |
| `TERDUT_OIDC_ALLOWED_GROUPS` | — | Comma-separated. Only people in one of these may sign in. Empty admits everybody the provider authenticates |
| `TERDUT_OIDC_ADMIN_GROUP` | — | Members are system administrators |
| `TERDUT_OIDC_SESSION_MAX_AGE` | `12h` | Hard ceiling on a session made by an SSO sign-in |
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
@@ -236,7 +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
deadline you set deliberately, and the heartbeat's route is configured to beat faster than it.
In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and `sweeper.archiveAfter`, dead man's switches via the `deadman.*` values, and notifications via the `notify.*` values.
In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and `sweeper.archiveAfter`, dead man's switches via the `deadman.*` values, notifications via the `notify.*` values, and single sign-on via `oidc.*` and `passwordLogin`.
---
@@ -590,7 +704,9 @@ of the last heartbeat, and the heartbeat's labels are on the incident's
### Authentication
All endpoints except `/api/bootstrap`, `/api/integrations/{key}/alertmanager`,
`/api/notify/ack/{token}`, `/api/login` and `/api/logout` require either an API key:
`/api/notify/ack/{token}`, `/api/login`, `/api/logout`, `/api/auth/config`,
`/api/oidc/login`, `/api/oidc/callback`, `/api/oidc/device` and `/api/oidc/device/token`
require either an API key:
```
Authorization: Bearer <api-key>
@@ -650,7 +766,14 @@ incident exists is itself something only its team should learn.
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures |
| `GET` | `/api/auth/config` | How to sign in: `{"password_login", "oidc": {"enabled","name"}, "device_login"}`. No session needed |
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures; `403` when `TERDUT_PASSWORD_LOGIN=false` |
| `GET` | `/api/oidc/login` | Starts a single sign-on sign-in: redirects the browser to the provider. `?next=/path` is where to land afterwards; only a path on this server is honoured. Only exists when SSO is configured |
| `POST` | `/api/oidc/device` | Starts a device login: returns `{device_code, user_code, verification_url, interval, expires_in}`. Only exists when SSO is configured |
| `POST` | `/api/oidc/device/token` | `{"device_code"}` → `202 {"status":"pending"}`, then `200` with the session cookie once approved (once only). `410` with `{"error":"expired"}` or `{"error":"denied"}`; `429 {"error":"slow_down"}` if polled faster than `interval` |
| `POST` | `/api/oidc/device/approve` | **session** — `{"user_code"}`. Approves a pending device login as the caller. `403` for an API key; `404` for an unknown, expired or already decided code |
| `POST` | `/api/oidc/device/deny` | **session** — `{"user_code"}`. Refuses it |
| `GET` | `/api/oidc/callback` | Where the provider sends the browser back. Sets the session cookie and redirects to `/`, or to `/?sso_error=<code>` — one of `denied`, `expired`, `failed`, `unavailable`, `not_allowed`, `no_email`, `email_conflict`, `disabled` |
| `POST` | `/api/logout` | Ends the session and clears the cookie |
| `GET` | `/api/me` | The caller: `{user, has_password}` |
@@ -671,7 +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 |
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself, the last administrator, or an administrator granted by single sign-on |
| `PUT` | `/api/users/{id}/disabled` | **admin** | Take an account out of use, or put it back `{"disabled"}`. `409` for yourself or the last administrator |
| `PUT` | `/api/users/{id}/notify` | self or admin | Set push notification target `{"ntfy_topic"}` — empty string clears it |
| `PUT` | `/api/users/{id}/password` | self or admin | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions |
@@ -715,9 +838,11 @@ administrator who is not in the team gets the same `404` as anybody else.
| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner |
| `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken |
| `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents |
| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team |
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` |
| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner |
| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team, with `status` (`oncall` if the rota has them today, `unpageable` when a page to them would go nowhere — even if they are on call — else `reachable`), `on_call`, `next_shift` (first rota day after today), `pageable` and `problem` (`has no ntfy topic` / `account is disabled`; never the topic itself) and `last_active_at` (their newest session or API-key use). Every member sees the same list |
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}`. `409` when it would demote the last owner, or the membership is managed by single sign-on |
| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner, or a membership managed by single sign-on |
| `GET` | `/api/teams/{teamID}/oidc-groups` | member | Which groups control this team's membership: `{"member_group","owner_group"}`. An empty string means no group grants that role here |
| `PUT` | `/api/teams/{teamID}/oidc-groups` | **owner** | Set them. An empty string clears a binding |
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys. Each carries `status` (`active` if its key posted within 24h, `quiet` if it has but not lately, `never`), `last_used_at` (last webhook, usable or not), `last_alert_at` (when an alert last arrived on it) and `alerts_24h` (distinct alerts it refreshed in the last day). Alerts delivered before the source was recorded (migration 010) have none, so the last two fill in as Alertmanager re-sends them |
| `PATCH` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Rename `{"name"}`. The key does not change |
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.26.0
appVersion: "v0.26.0"
version: 0.31.0
appVersion: "v0.31.0"
+39 -2
View File
@@ -61,8 +61,6 @@ spec:
value: "{{ .Values.notify.fallbackTopic }}"
- name: TERDUT_NOTIFY_REPEAT
value: "{{ .Values.notify.repeatEvery }}"
- name: TERDUT_PUBLIC_URL
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
{{- if .Values.notify.tokenSecret.name }}
- name: TERDUT_NTFY_TOKEN
valueFrom:
@@ -71,6 +69,45 @@ spec:
key: {{ .Values.notify.tokenSecret.key }}
{{- end }}
{{- end }}
# Set whether or not ntfy is: single sign-on builds its redirect URI
# from it, and sessions use it to decide the cookie's Secure flag.
- name: TERDUT_PUBLIC_URL
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
- name: TERDUT_PASSWORD_LOGIN
value: {{ .Values.passwordLogin | quote }}
{{- if .Values.oidc.enabled }}
- name: TERDUT_OIDC_ISSUER
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
- name: TERDUT_OIDC_CLIENT_ID
value: {{ required "oidc.clientId is required when oidc.enabled" .Values.oidc.clientId | quote }}
- name: TERDUT_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ required "oidc.clientSecret.name is required when oidc.enabled" .Values.oidc.clientSecret.name }}
key: {{ .Values.oidc.clientSecret.key }}
- name: TERDUT_OIDC_NAME
value: {{ .Values.oidc.name | quote }}
- name: TERDUT_OIDC_SCOPES
value: {{ .Values.oidc.scopes | quote }}
- name: TERDUT_OIDC_USERNAME_CLAIM
value: {{ .Values.oidc.usernameClaim | quote }}
- name: TERDUT_OIDC_EMAIL_CLAIM
value: {{ .Values.oidc.emailClaim | quote }}
- name: TERDUT_OIDC_GROUPS_CLAIM
value: {{ .Values.oidc.groupsClaim | quote }}
- name: TERDUT_OIDC_TRUST_EMAIL
value: {{ .Values.oidc.trustEmail | quote }}
- name: TERDUT_OIDC_SESSION_MAX_AGE
value: {{ .Values.oidc.sessionMaxAge | quote }}
{{- if .Values.oidc.allowedGroups }}
- name: TERDUT_OIDC_ALLOWED_GROUPS
value: {{ join "," .Values.oidc.allowedGroups | quote }}
{{- end }}
{{- if .Values.oidc.adminGroup }}
- name: TERDUT_OIDC_ADMIN_GROUP
value: {{ .Values.oidc.adminGroup | quote }}
{{- end }}
{{- end }}
livenessProbe:
httpGet:
path: /healthz
+48
View File
@@ -107,6 +107,54 @@ notify:
name: ""
key: token
# Whether a user may sign in, or sign up, with a password. Turn it off once
# single sign-on works, to make it the only way in; turn it back on (and
# redeploy) if the identity provider is down and somebody has to get in.
passwordLogin: true
# Single sign-on through an OpenID Connect provider such as Authentik.
#
# At the provider, create an OAuth2/OpenID application whose redirect URI is
# <notify.publicUrl>/api/oidc/callback
# (publicUrl defaults to https://<networking.hostname>), a confidential client, and
# put the client secret in an existing Secret named by clientSecret below.
#
# Groups from the provider decide what a person can do. Access it grants is
# marked as managed by single sign-on and is re-read at every sign-in; anything
# added by hand in terdut is left alone. Changes in the provider take effect at
# the person's next sign-in, at most sessionMaxAge later. API keys are NOT
# revoked when somebody is removed at the provider: disable the user in terdut too.
oidc:
enabled: false
# Issuer URL. For Authentik: https://<authentik>/application/o/<app-slug>/
issuer: ""
clientId: ""
clientSecret:
name: ""
key: client-secret
# What the sign-in button calls the provider.
name: SSO
# Authentik puts the groups claim behind the profile scope.
scopes: "openid profile email"
usernameClaim: preferred_username
emailClaim: email
groupsClaim: groups
# Link a first sign-in to an existing local user with the same email even when
# the provider does not mark the address verified. Authentik reports
# email_verified as false unless configured otherwise.
trustEmail: false
# Only people in one of these groups may sign in. Empty admits everybody the
# provider authenticates, and access control is left to the provider.
allowedGroups: []
# Members of this group are system administrators.
adminGroup: ""
# Which group grants a team's membership and ownership is each team's own
# setting now, not chart config: an owner sets it from the Members tab, or
# PUT /api/teams/{teamID}/oidc-groups. A team must already exist for a group
# to grant access to it.
# Hard ceiling on a session made by a single sign-on login.
sessionMaxAge: 12h
# Backups are no longer this chart's business. The SQLite database lived on a PVC
# beside the app, so it needed a sidecar with a sqlite3 module for k8up to exec a
# dump in; Postgres is backed up where it runs, through a k8up.io/backupcommand
+3
View File
@@ -17,6 +17,9 @@ var version = "dev"
func main() {
cfg := config.Load()
if err := cfg.Validate(); err != nil {
log.Fatalf("config: %v", err)
}
database, err := db.Open(cfg.DSN)
if err != nil {
+3
View File
@@ -3,13 +3,16 @@ module git.ryuvia.com/niklas/terdut-server
go 1.25.9
require (
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.2.5
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.55.0
golang.org/x/oauth2 v0.36.0
)
require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+6
View File
@@ -1,8 +1,12 @@
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -22,6 +26,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+9 -1
View File
@@ -15,6 +15,7 @@ import (
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
"git.ryuvia.com/niklas/terdut-server/internal/config"
)
// ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so
@@ -50,9 +51,16 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
if len(notify) > 0 {
cfg = notify[0]
}
return newTSWith(t, deadman, cfg, testConfig())
}
// newTSWith is newDeadmanTS with the server's own configuration supplied, for
// tests of behaviour that config switches on, such as single sign-on.
func newTSWith(t *testing.T, deadman api.DeadmanConfig, cfg api.NotifyConfig, conf config.Config) *ts {
t.Helper()
database := newTestDB(t)
srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig()))
srv := httptest.NewServer(api.NewRouter(database, cfg, conf))
t.Cleanup(srv.Close)
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
+20 -4
View File
@@ -143,15 +143,31 @@ func hashPassword(pw string) (string, error) {
// sign-up: somebody who has just chosen a password is signed in, rather than
// being sent to a form to type the same credential again.
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
return startSessionCapped(w, r, db, userID, publicURL, 0)
}
// startSessionCapped is startSession with a hard ceiling on the session's life,
// which sliding never extends. maxAge zero means no ceiling. A single sign-on
// login uses it: the login is the only moment the provider's groups are read, so
// a session that could outlive it indefinitely would keep access the provider
// has since taken away.
func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string, maxAge time.Duration) error {
raw, tokenHash, err := randomToken()
if err != nil {
return err
}
now := time.Now()
life := sessionTTL
var ceiling *int64
if maxAge > 0 {
c := now.Add(maxAge).Unix()
ceiling = &c
life = min(life, maxAge)
}
if _, err := db.ExecContext(r.Context(), `
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, max_expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(life).Unix(), ceiling, r.UserAgent()); err != nil {
return err
}
@@ -159,7 +175,7 @@ func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
MaxAge: int(life.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
+282
View File
@@ -0,0 +1,282 @@
package api
import (
"crypto/rand"
"database/sql"
"errors"
"log"
"math/big"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// The device login flow lets a client that cannot open a browser sign in: it
// shows a code, the person approves it in a browser they are signed in to, and
// the client is handed an ordinary session. See migration 012.
const (
// deviceTTL is how long a person has to get from the terminal's prompt to an
// approval.
deviceTTL = 10 * time.Minute
// deviceInterval is how often the client is told to poll. The server holds it
// to that, with a second of slack for clocks and scheduling.
deviceInterval = 5 * time.Second
// deviceStartMaxPerAddr bounds unauthenticated device logins started per
// address, since each writes a row.
deviceStartMaxPerAddr = 30
// userCodeAlphabet has no vowels, so a code cannot spell a word, and none of
// the characters that read alike (0/O, 1/I/L).
userCodeAlphabet = "BCDFGHJKMNPQRSTVWXZ23456789"
userCodeLen = 8
)
// newUserCode returns a code for a person to read, as XXXX-XXXX.
func newUserCode() (string, error) {
max := big.NewInt(int64(len(userCodeAlphabet)))
b := make([]byte, userCodeLen)
for i := range b {
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
b[i] = userCodeAlphabet[n.Int64()]
}
return string(b[:4]) + "-" + string(b[4:]), nil
}
// normalizeUserCode reduces whatever a person typed or pasted to the stored
// form, so "bcdf ghjk" and "BCDF-GHJK" name the same login. It returns "" for
// anything that cannot be a code.
func normalizeUserCode(s string) string {
var b strings.Builder
for _, r := range strings.ToUpper(s) {
if strings.ContainsRune(userCodeAlphabet, r) {
b.WriteRune(r)
}
}
code := b.String()
if len(code) != userCodeLen {
return ""
}
return code[:4] + "-" + code[4:]
}
// handleDeviceStart begins a device login: it returns the device code the
// client polls with, and the user code and URL the person is shown.
func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
addrKey := "device:" + clientAddr(r)
if limiter.blocked(addrKey, deviceStartMaxPerAddr) {
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
return
}
limiter.fail(addrKey)
deviceCode, deviceHash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
now := time.Now()
db.ExecContext(r.Context(), "DELETE FROM device_logins WHERE expires_at < $1", now.Unix())
// A collision on the user code is one in 27^8; retrying a few times makes
// it a non-event rather than a 500.
var userCode string
for range 5 {
userCode, err = newUserCode()
if err != nil {
break
}
_, err = db.ExecContext(r.Context(), `
INSERT INTO device_logins (device_hash, user_code, expires_at) VALUES ($1, $2, $3)`,
deviceHash, userCode, now.Add(deviceTTL).Unix())
if err == nil || !isUniqueViolation(err) {
break
}
}
if err != nil {
log.Printf("device login: start: %v", err)
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, map[string]any{
"device_code": deviceCode,
"user_code": userCode,
// The code is in the URL so nobody has to type it; it is shown anyway,
// for the person to check against the terminal before approving.
"verification_url": strings.TrimRight(publicURL, "/") + "/device?code=" + url.QueryEscape(userCode),
"interval": int(deviceInterval.Seconds()),
"expires_in": int(deviceTTL.Seconds()),
})
}
}
// handleDeviceDecision approves or denies a pending device login on behalf of
// the signed-in caller.
//
// It takes a session, not an API key. Approving hands a terminal the caller's
// identity, and the approval must come from a browser the person is looking at:
// the page shows the code and asks. A script with a key has no business
// approving one, and the check keeps it from being a way to mint sessions out of
// keys.
func handleDeviceDecision(db *sql.DB, approve bool) http.HandlerFunc {
status := "denied"
if approve {
status = "approved"
}
return func(w http.ResponseWriter, r *http.Request) {
if _, viaSession := sessionFromContext(r.Context()); !viaSession {
respond(w, http.StatusForbidden, errResp("sign in with the web UI to approve a device"))
return
}
var req struct {
UserCode string `json:"user_code"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
code := normalizeUserCode(req.UserCode)
if code == "" {
respond(w, http.StatusBadRequest, errResp("that is not a sign-in code"))
return
}
caller, _ := userFromContext(r.Context())
// Only a pending login can be decided, and only once: an approval cannot
// be overwritten, so a second browser cannot take a login over.
res, err := db.ExecContext(r.Context(), `
UPDATE device_logins SET status = $1, user_id = $2
WHERE user_code = $3 AND status = 'pending' AND expires_at > $4`,
status, caller.ID, code, time.Now().Unix())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("that sign-in code is unknown, expired or already used"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleDeviceToken is what the client polls. Pending answers 202; an approval
// answers 200 with the session cookie, once; anything else is 410.
func handleDeviceToken(db *sql.DB, ssoMaxAge time.Duration, publicURL string) http.HandlerFunc {
gone := func(w http.ResponseWriter, why string) {
respond(w, http.StatusGone, map[string]string{"error": why})
}
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
DeviceCode string `json:"device_code"`
}
if err := decodeJSON(r, &req); err != nil || req.DeviceCode == "" {
respond(w, http.StatusBadRequest, errResp("device_code is required"))
return
}
hash := hashToken(req.DeviceCode)
now := time.Now()
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback() //nolint:errcheck
var status string
var userID sql.NullInt64
var expires, lastPolled int64
err = tx.QueryRowContext(r.Context(), `
SELECT status, user_id, expires_at, last_polled_at FROM device_logins
WHERE device_hash = $1 FOR UPDATE`, hash).Scan(&status, &userID, &expires, &lastPolled)
if errors.Is(err, sql.ErrNoRows) || (err == nil && expires <= now.Unix()) {
gone(w, "expired")
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
switch status {
case "denied":
tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash)
tx.Commit() //nolint:errcheck
gone(w, "denied")
return
case "pending":
// Held to the interval it was given, less a second of slack.
if now.Unix()-lastPolled < int64(deviceInterval.Seconds())-1 {
w.Header().Set("Retry-After", strconv.Itoa(int(deviceInterval.Seconds())))
respond(w, http.StatusTooManyRequests, map[string]string{"error": "slow_down"})
return
}
if _, err := tx.ExecContext(r.Context(),
"UPDATE device_logins SET last_polled_at = $1 WHERE device_hash = $2", now.Unix(), hash); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusAccepted, map[string]string{"status": "pending"})
return
}
// Approved. Single use: the row goes before the session is made, so two
// racing polls cannot both be given one.
if _, err := tx.ExecContext(r.Context(), "DELETE FROM device_logins WHERE device_hash = $1", hash); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
var disabled, sso bool
if err := tx.QueryRowContext(r.Context(), `
SELECT disabled_at IS NOT NULL,
EXISTS (SELECT 1 FROM user_identities WHERE user_id = $1)
FROM users WHERE id = $1`, userID.Int64).Scan(&disabled, &sso); err != nil {
gone(w, "denied")
return
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if disabled {
gone(w, "denied")
return
}
// A session for somebody who signs in through the provider carries the
// same ceiling as their browser's would, so the terminal is not a way
// round it. Password users have none.
var maxAge time.Duration
if sso {
maxAge = ssoMaxAge
}
if err := startSessionCapped(w, r, db, userID.Int64, publicURL, maxAge); err != nil {
log.Printf("device login: start session: %v", err)
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
user, err := fetchUser(r.Context(), db, userID.Int64)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, meResponse{User: user, HasPassword: false})
}
}
+347
View File
@@ -0,0 +1,347 @@
package api_test
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
)
type deviceStart struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_url"`
Interval int `json:"interval"`
ExpiresIn int `json:"expires_in"`
}
// startDevice is the terminal asking for a login.
func startDevice(t *testing.T, s *ts) deviceStart {
t.Helper()
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil)
var d deviceStart
decode(t, resp, &d)
if d.DeviceCode == "" || d.UserCode == "" {
t.Fatalf("device start returned %+v", d)
}
return d
}
// pollDevice is the terminal polling. It returns the status, and the session
// cookie the response set, if any.
func pollDevice(t *testing.T, s *ts, code string) (int, *http.Cookie, string) {
t.Helper()
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device/token", map[string]string{"device_code": code})
defer resp.Body.Close()
var body map[string]any
json.NewDecoder(resp.Body).Decode(&body)
var cookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == "terdut_session" {
cookie = c
}
}
msg, _ := body["error"].(string)
if msg == "" {
msg, _ = body["status"].(string)
}
return resp.StatusCode, cookie, msg
}
// readyToPoll lets the next poll through: the server holds a client to the
// interval it was given, which a test has no wish to wait out.
func (s *ts) readyToPoll(t *testing.T) {
t.Helper()
s.exec(t, "UPDATE device_logins SET last_polled_at = 0")
}
func decide(t *testing.T, b *browser, what, code string) int {
t.Helper()
resp := b.do(t, http.MethodPost, "/api/oidc/device/"+what, map[string]string{"user_code": code})
resp.Body.Close()
return resp.StatusCode
}
func TestDevice_FullFlow(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
d := startDevice(t, s)
if !strings.HasPrefix(d.VerificationURL, "http://terdut.test/device?code=") ||
!strings.Contains(d.VerificationURL, url.QueryEscape(d.UserCode)) {
t.Errorf("verification url %q", d.VerificationURL)
}
if len(d.UserCode) != 9 || d.UserCode[4] != '-' || d.Interval != 5 || d.ExpiresIn != 600 {
t.Errorf("start: %+v", d)
}
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || cookie != nil || msg != "pending" {
t.Fatalf("first poll: %d %v %q, want 202 pending and no cookie", status, cookie, msg)
}
// The person signs in through the provider in some browser and approves.
person := ssoBrowser(t, s)
signInSSO(t, idp, person, alice)
if got := decide(t, person, "approve", d.UserCode); got != http.StatusNoContent {
t.Fatalf("approve: %d", got)
}
s.readyToPoll(t)
status, cookie, _ := pollDevice(t, s, d.DeviceCode)
if status != http.StatusOK || cookie == nil {
t.Fatalf("poll after approval: %d, cookie %v", status, cookie)
}
// The cookie is a working session for the person who approved.
term := newBrowser(t, s.URL)
req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
req.AddCookie(cookie)
resp, err := term.Do(req)
if err != nil {
t.Fatal(err)
}
var me struct {
User struct {
Username string `json:"username"`
} `json:"user"`
}
decode(t, resp, &me)
if me.User.Username != "alice" {
t.Errorf("session belongs to %q, want alice", me.User.Username)
}
// Single use.
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "expired" {
t.Errorf("second redemption: %d %v %q, want 410 expired", status, cookie, msg)
}
// The session was made for an SSO user, so it carries the ceiling.
var ceiling *int64
s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling)
if ceiling == nil {
t.Error("a device session for an SSO user must carry the SSO session ceiling")
}
}
func TestDevice_PasswordUserGetsNoCeiling(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
admin := signedIn(t, s) // password sign-in as the bootstrap admin
d := startDevice(t, s)
if got := decide(t, admin, "approve", d.UserCode); got != http.StatusNoContent {
t.Fatalf("approve: %d", got)
}
s.readyToPoll(t)
if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusOK || cookie == nil {
t.Fatalf("poll: %d %v", status, cookie)
}
var ceiling *int64
s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling)
if ceiling != nil {
t.Errorf("a password user's device session has a ceiling %d, want none", *ceiling)
}
}
func TestDevice_Denied(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
person := ssoBrowser(t, s)
signInSSO(t, idp, person, alice)
d := startDevice(t, s)
if got := decide(t, person, "deny", d.UserCode); got != http.StatusNoContent {
t.Fatalf("deny: %d", got)
}
s.readyToPoll(t)
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "denied" {
t.Errorf("poll: %d %v %q, want 410 denied", status, cookie, msg)
}
}
func TestDevice_DecisionNeedsABrowserSession(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
d := startDevice(t, s)
// Nobody signed in.
if got := decide(t, newBrowser(t, s.URL), "approve", d.UserCode); got != http.StatusUnauthorized {
t.Errorf("anonymous approve: %d, want 401", got)
}
// An API key is a credential for scripts, not for approving a terminal.
resp := s.req(t, http.MethodPost, "/api/oidc/device/approve", map[string]string{"user_code": d.UserCode})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("approve with an API key: %d, want 403", resp.StatusCode)
}
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || msg != "pending" {
t.Errorf("the login must still be pending: %d %q", status, msg)
}
}
func TestDevice_ApprovalIsFinal(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
first, second := ssoBrowser(t, s), ssoBrowser(t, s)
signInSSO(t, idp, first, alice)
signInSSO(t, idp, second, idpUser{sub: "sub-mallory", username: "mallory", email: "mallory@example.com", groups: []string{"terdut-users"}})
d := startDevice(t, s)
if got := decide(t, first, "approve", d.UserCode); got != http.StatusNoContent {
t.Fatalf("approve: %d", got)
}
// A second browser cannot take the login over, nor refuse it.
for _, what := range []string{"approve", "deny"} {
if got := decide(t, second, what, d.UserCode); got != http.StatusNotFound {
t.Errorf("%s after approval: %d, want 404", what, got)
}
}
s.readyToPoll(t)
_, cookie, _ := pollDevice(t, s, d.DeviceCode)
if cookie == nil {
t.Fatal("no session")
}
var name string
s.db.QueryRow("SELECT u.username FROM sessions ss JOIN users u ON u.id = ss.user_id ORDER BY ss.id DESC LIMIT 1").Scan(&name)
if name != "alice" {
t.Errorf("session for %q, want alice", name)
}
}
func TestDevice_CodeIsForgivingAboutHowItWasTyped(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
person := ssoBrowser(t, s)
signInSSO(t, idp, person, alice)
d := startDevice(t, s)
typed := strings.ToLower(strings.ReplaceAll(d.UserCode, "-", " "))
if got := decide(t, person, "approve", typed); got != http.StatusNoContent {
t.Errorf("approve %q: %d, want 204", typed, got)
}
if got := decide(t, person, "approve", "nonsense"); got != http.StatusBadRequest {
t.Errorf("approve nonsense: %d, want 400", got)
}
}
func TestDevice_ExpiredAndUnknown(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
person := ssoBrowser(t, s)
signInSSO(t, idp, person, alice)
d := startDevice(t, s)
s.exec(t, "UPDATE device_logins SET expires_at = 1")
if got := decide(t, person, "approve", d.UserCode); got != http.StatusNotFound {
t.Errorf("approve expired: %d, want 404", got)
}
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || msg != "expired" {
t.Errorf("poll expired: %d %q, want 410 expired", status, msg)
}
if status, _, msg := pollDevice(t, s, "not-a-device-code"); status != http.StatusGone || msg != "expired" {
t.Errorf("poll unknown: %d %q, want 410 expired", status, msg)
}
}
func TestDevice_PollingTooFastIsRefused(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
d := startDevice(t, s)
if status, _, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted {
t.Fatalf("first poll: %d", status)
}
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusTooManyRequests || msg != "slow_down" {
t.Errorf("immediate second poll: %d %q, want 429 slow_down", status, msg)
}
}
func TestDevice_DisabledUserGetsNoSession(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
person := ssoBrowser(t, s)
signInSSO(t, idp, person, alice)
d := startDevice(t, s)
decide(t, person, "approve", d.UserCode)
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'")
s.readyToPoll(t)
var before int
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&before)
if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil {
t.Errorf("poll: %d %v, want 410 and no cookie", status, cookie)
}
var after int
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&after)
if after != before {
t.Error("a session was created for a disabled user")
}
}
func TestDevice_OnlyExistsWithSSOConfigured(t *testing.T) {
s := newTS(t) // no SSO
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("device start with SSO off: %d, want 404", resp.StatusCode)
}
idp := newFakeIdP(t)
for _, c := range []struct {
name string
s *ts
want bool
}{{"off", s, false}, {"on", newSSOTS(t, idp), true}} {
var cfg struct {
DeviceLogin bool `json:"device_login"`
}
decode(t, newBrowser(t, c.s.URL).do(t, http.MethodGet, "/api/auth/config", nil), &cfg)
if cfg.DeviceLogin != c.want {
t.Errorf("auth config device_login with SSO %s: %v, want %v", c.name, cfg.DeviceLogin, c.want)
}
}
}
func TestDevice_StartIsRateLimited(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
b := newBrowser(t, s.URL)
var last int
for range 32 {
resp := b.do(t, http.MethodPost, "/api/oidc/device", nil)
resp.Body.Close()
last = resp.StatusCode
}
if last != http.StatusTooManyRequests {
t.Errorf("32nd start: %d, want 429", last)
}
}
// After signing in the browser is sent on to where the person was going, which
// is how somebody without a session gets from /device?code=... through the
// provider and back to it. Only paths on this server are honoured.
func TestSSO_NextIsHonouredOnlyForPathsOnThisServer(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
for _, c := range []struct{ next, want string }{
{"/device?code=BCDF-GHJK", "/device?code=BCDF-GHJK"},
{"/team/members", "/team/members"},
{"", "/"},
{"//evil.example/x", "/"},
{"/\\evil.example", "/"},
{"https://evil.example/", "/"},
{"evil.example", "/"},
{"/api/users", "/"},
{"/ok\r\nSet-Cookie: x=y", "/"},
{"/" + strings.Repeat("a", 600), "/"},
} {
b := ssoBrowser(t, s)
resp := b.do(t, http.MethodGet, "/api/oidc/login?next="+url.QueryEscape(c.next), nil)
resp.Body.Close()
loc, _ := url.Parse(resp.Header.Get("Location"))
q := loc.Query()
got := callback(t, b, idp.issueCode(alice, q.Get("nonce"), q.Get("code_challenge")), q.Get("state"))
if got != c.want {
t.Errorf("next %q: redirected to %q, want %q", c.next, got, c.want)
}
}
}
+154
View File
@@ -0,0 +1,154 @@
package api_test
import (
"net/http"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
func testNotify() api.NotifyConfig {
return api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute}
}
type memberView struct {
Username string `json:"username"`
Role string `json:"role"`
Status string `json:"status"`
OnCall bool `json:"on_call"`
NextShift *string `json:"next_shift"`
Pageable bool `json:"pageable"`
Problem string `json:"problem"`
LastActiveAt *string `json:"last_active_at"`
}
func readMembers(t *testing.T, s *ts) map[string]memberView {
t.Helper()
var list []memberView
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/members", nil), &list)
out := map[string]memberView{}
for _, m := range list {
out[m.Username] = m
}
return out
}
// The list says who is on call, who could not be woken, and who is merely
// there — and an on-call person who cannot be paged is the red one.
func TestMembers_StatusReflectsRotaAndPageability(t *testing.T) {
s, _ := notifyTS(t, testNotify()) // admin is on call today, with a topic
teamUser(t, s, "reachable", "terdut-reachable")
silent := teamUser(t, s, "silent", "terdut-silent")
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
got := readMembers(t, s)
if m := got["admin"]; m.Status != "oncall" || !m.OnCall || !m.Pageable {
t.Errorf("the person on call should read on call, got %+v", m)
}
if m := got["reachable"]; m.Status != "reachable" || m.OnCall {
t.Errorf("a member with a topic who is off the rota is reachable, got %+v", m)
}
if m := got["silent"]; m.Status != "unpageable" || m.Problem != "has no ntfy topic" {
t.Errorf("no topic means they cannot be paged, got %+v", m)
}
// Being on call does not rescue an account that cannot be woken.
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE username = 'admin'")
if m := readMembers(t, s)["admin"]; m.Status != "unpageable" || !m.OnCall {
t.Errorf("an on-call person with no topic is the red case, got %+v", m)
}
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)
if m := readMembers(t, s)["silent"]; m.Problem != "account is disabled" {
t.Errorf("a disabled account should say so, got %+v", m)
}
}
// The next shift is the next day after today, not today itself.
func TestMembers_NextShiftIsAfterToday(t *testing.T) {
s, _ := notifyTS(t, testNotify())
tomorrow := time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02")
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/schedule",
map[string]any{"user_id": 1, "dates": []string{tomorrow}})
resp.Body.Close()
m := readMembers(t, s)["admin"]
if !m.OnCall || m.NextShift == nil || *m.NextShift != tomorrow {
t.Errorf("want on call today with the next shift on %s, got %+v", tomorrow, m)
}
teamUser(t, s, "idle", "terdut-idle")
if m := readMembers(t, s)["idle"]; m.NextShift != nil {
t.Errorf("somebody not on the rota has no next shift, got %v", *m.NextShift)
}
}
// Last active is the newer of a session and an API key, and absent when neither
// has ever been used.
func TestMembers_LastActive(t *testing.T) {
s, _ := notifyTS(t, testNotify())
idle := teamUser(t, s, "idle", "terdut-idle")
if m := readMembers(t, s)["idle"]; m.LastActiveAt != nil {
t.Errorf("nobody has used idle's account, got %v", *m.LastActiveAt)
}
old := time.Now().Add(-48 * time.Hour).Unix()
s.exec(t, `INSERT INTO api_keys (user_id, key_hash, name, last_used_at) VALUES ($1, 'h1', 'k', $2)`, idle, old)
s.exec(t, `INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at)
VALUES ('h2', $1, $2, $3, $4)`, idle, old, old+3600, time.Now().Add(time.Hour).Unix())
m := readMembers(t, s)["idle"]
if m.LastActiveAt == nil {
t.Fatal("expected a last active time")
}
got, _ := time.Parse(time.RFC3339, *m.LastActiveAt)
if got.Unix() != old+3600 {
t.Errorf("last active should be the newer session (%d), got %d", old+3600, got.Unix())
}
}
// The last owner can be neither removed nor demoted; with another owner in
// place, both are fine.
func TestMembers_LastOwnerIsProtected(t *testing.T) {
s, _ := notifyTS(t, testNotify())
tm := newTeam(t, s, "red")
base := "/api/teams/" + id64(tm.id) + "/members"
// Creating a team makes the creator an owner too; step the admin out so
// "red-user" is the only one left.
resp := s.req(t, http.MethodDelete, base+"/1", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("removing the creator: %d", resp.StatusCode)
}
var members []map[string]any
decode(t, tm.call(http.MethodGet, base, nil), &members)
var owner int64
for _, m := range members {
if m["username"] == "red-user" {
owner = int64(m["user_id"].(float64))
}
}
resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"})
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("demoting the last owner: expected 409, got %d", resp.StatusCode)
}
resp = tm.call(http.MethodDelete, base+"/"+id64(owner), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Errorf("removing the last owner: expected 409, got %d", resp.StatusCode)
}
// A second owner frees the first to step down.
resp = s.req(t, http.MethodPost, base, map[string]any{"user_id": 1, "role": "owner"})
resp.Body.Close()
resp = tm.call(http.MethodPost, base, map[string]any{"user_id": owner, "role": "member"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("demoting one of two owners: expected 204, got %d", resp.StatusCode)
}
}
+7 -2
View File
@@ -132,8 +132,13 @@ func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, user
}
if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
db.ExecContext(ctx,
"UPDATE sessions SET last_seen_at = $1, expires_at = $2 WHERE id = $3",
// LEAST keeps a capped session (a single sign-on login) from sliding
// past its ceiling; with no ceiling COALESCE makes it the plain slide.
db.ExecContext(ctx, `
UPDATE sessions
SET last_seen_at = $1,
expires_at = LEAST($2::bigint, COALESCE(max_expires_at, $2::bigint))
WHERE id = $3`,
now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
}
return sessionID, userID, true
+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)
}
}
+34 -2
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"git.ryuvia.com/niklas/terdut-server/internal/config"
"git.ryuvia.com/niklas/terdut-server/internal/oidc"
"git.ryuvia.com/niklas/terdut-server/internal/web"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -20,6 +21,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// two would let a burst of sign-ups lock somebody out of logging in.
loginLimit := newLoginLimiter()
signupLimiter := newLoginLimiter()
oidcLimit := newLoginLimiter()
r := chi.NewRouter()
r.Use(middleware.Logger)
@@ -50,18 +52,43 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// an invite link is good, so the form can say so before somebody picks a
// password.
r.Get("/api/signup", handleSignupInfo(db))
r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
// How to sign in: what the login form and the TUI offer before anybody types.
r.Get("/api/auth/config", handleAuthConfig(cfg))
// Signing in to the web UI. Login trades a password for a session cookie,
// which AuthMiddleware accepts in place of an API key.
r.Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
// Single sign-on. Both routes are navigations the browser makes, to and from
// the provider, so they answer with redirects rather than JSON.
if cfg.OIDC.Enabled() {
prov := oidc.New(cfg.OIDC, notify.PublicURL)
r.Get("/api/oidc/login", handleOIDCLogin(db, prov, oidcLimit, notify.PublicURL))
r.Get("/api/oidc/callback", handleOIDCCallback(db, prov, notify.PublicURL))
// Device login, for a client with no browser of its own. Both are
// unauthenticated: the device code in the body is the credential.
r.Post("/api/oidc/device", handleDeviceStart(db, oidcLimit, notify.PublicURL))
r.Post("/api/oidc/device/token", handleDeviceToken(db, cfg.OIDC.SessionMaxAge, notify.PublicURL))
}
// All other /api routes require a valid API key.
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(db))
r.Get("/api/me", handleMe(db))
// Approving or refusing a device login is done by somebody signed in
// to a browser, and needs the same SSO configuration the flow does.
if cfg.OIDC.Enabled() {
r.Post("/api/oidc/device/approve", handleDeviceDecision(db, true))
r.Post("/api/oidc/device/deny", handleDeviceDecision(db, false))
}
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
// Proves the topic works, which is the only part of "notifications are
// set up" that the person holding the phone can confirm.
@@ -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.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.
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
+18 -6
View File
@@ -237,6 +237,14 @@ type adminTeam struct {
CreatedAt time.Time `json:"created_at"`
Members int64 `json:"members"`
OpenIncidents int64 `json:"open_incidents"`
// OIDCMemberGroup and OIDCOwnerGroup are the team's own group binding,
// read-only here: an administrator can see why a team's OIDC-sourced
// membership looks the way it does without being able to change it out
// from under the team's owner. Setting it is PUT
// /api/teams/{teamID}/oidc-groups, owner-only.
OIDCMemberGroup string `json:"oidc_member_group,omitempty"`
OIDCOwnerGroup string `json:"oidc_owner_group,omitempty"`
}
// handleAdminListTeams lists every team on the server, with its size. The
@@ -248,7 +256,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
SELECT t.id, t.name, t.created_at,
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
(SELECT COUNT(*) FROM incidents i
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
FROM teams t
ORDER BY t.name`)
if err != nil {
@@ -261,7 +270,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var t adminTeam
var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); err != nil {
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
&t.OIDCMemberGroup, &t.OIDCOwnerGroup); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -298,10 +308,12 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
SELECT t.id, t.name, t.created_at,
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
(SELECT COUNT(*) FROM incidents i
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
FROM teams t
WHERE t.id = $1`, teamID).
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents)
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
&t.OIDCMemberGroup, &t.OIDCOwnerGroup)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("not found"))
return
@@ -315,7 +327,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
// Same query and same ordering as handleListTeamMembers, so the two
// answers to "who is in this team" cannot disagree about the answer.
rows, err := db.QueryContext(r.Context(), `
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source
FROM team_members m
JOIN users u ON u.id = m.user_id
WHERE m.team_id = $1
@@ -330,7 +342,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var m models.TeamMember
var joined int64
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil {
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
+118 -12
View File
@@ -21,7 +21,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller, _ := userFromContext(r.Context())
rows, err := db.QueryContext(r.Context(), `
SELECT t.id, t.name, t.created_at, m.role
SELECT t.id, t.name, t.created_at, m.role, m.source
FROM teams t
JOIN team_members m ON m.team_id = t.id
WHERE m.user_id = $1
@@ -36,7 +36,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var t models.Team
var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -83,7 +83,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
}
rows, err := db.QueryContext(r.Context(), `
SELECT t.id, t.name, t.created_at, m.role
SELECT t.id, t.name, t.created_at, m.role, m.source
FROM teams t
JOIN team_members m ON m.team_id = t.id
WHERE m.user_id = $1
@@ -98,7 +98,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
for rows.Next() {
var t models.Team
var created int64
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -210,8 +210,40 @@ func handleDeleteTeam(db *sql.DB) http.HandlerFunc {
}
}
// handleListTeamMembers names everybody in a team. Visible to any member: you
// can see who else is on the rota you are on.
// Member statuses, as the Members page colours them.
const (
memberOnCall = "oncall"
memberReachable = "reachable"
memberUnpageable = "unpageable"
)
// memberStatus is a team member with what matters about them at 03:00: whether
// they are on call, whether a page to them would go anywhere, and whether they
// have been around. The extra fields are output only.
type memberStatus struct {
models.TeamMember
// Status is unpageable when a page to them would go nowhere — even when
// they are on call, since that is the case that matters most — on_call when
// the rota has them today, reachable otherwise.
Status string `json:"status"`
OnCall bool `json:"on_call"`
// NextShift is the first day after today the rota has them (YYYY-MM-DD).
NextShift *string `json:"next_shift,omitempty"`
// Pageable is whether they have an ntfy topic and an enabled account — the
// conditions pageLevel and the notifier skip on. Never the topic itself.
Pageable bool `json:"pageable"`
Problem string `json:"problem,omitempty"`
// LastActiveAt is the last time they used a session or an API key.
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
}
// handleListTeamMembers names everybody in a team, with their status. Visible to
// any member: you can see who else is on the rota you are on.
func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
@@ -223,26 +255,56 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
}
rows, err := db.QueryContext(r.Context(), `
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source,
u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '',
u.disabled_at IS NOT NULL,
GREATEST(
COALESCE((SELECT MAX(last_seen_at) FROM sessions WHERE user_id = u.id), 0),
COALESCE((SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id), 0)),
EXISTS (SELECT 1 FROM schedule_entries s
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date = $2),
(SELECT MIN(date) FROM schedule_entries s
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date > $2)
FROM team_members m
JOIN users u ON u.id = m.user_id
WHERE m.team_id = $1
ORDER BY u.username`, teamID)
ORDER BY u.username`, teamID, todayUTC())
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
members := []models.TeamMember{}
members := []memberStatus{}
for rows.Next() {
var m models.TeamMember
var joined int64
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil {
var m memberStatus
var joined, lastActive int64
var hasTopic, disabled bool
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source,
&hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
m.JoinedAt = time.Unix(joined, 0).UTC()
if lastActive > 0 {
t := time.Unix(lastActive, 0).UTC()
m.LastActiveAt = &t
}
switch {
case disabled:
m.Problem = "account is disabled"
case !hasTopic:
m.Problem = "has no ntfy topic"
}
m.Pageable = m.Problem == ""
switch {
case !m.Pageable:
m.Status = memberUnpageable
case m.OnCall:
m.Status = memberOnCall
default:
m.Status = memberReachable
}
members = append(members, m)
}
if err := rows.Err(); err != nil {
@@ -281,6 +343,28 @@ func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
return
}
if managed, err := isSSOManagedMember(r.Context(), db, teamID, req.UserID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
} else if managed {
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
return
}
// Demoting the last owner is removing them by another route: the team
// would have nobody who can edit it.
if req.Role == models.RoleMember {
last, err := isLastTeamOwner(r.Context(), db, teamID, req.UserID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if last {
respond(w, http.StatusConflict, errResp("cannot demote the last owner of a team"))
return
}
}
_, err := db.ExecContext(r.Context(), `
INSERT INTO team_members (team_id, user_id, role)
VALUES ($1, $2, $3)
@@ -316,6 +400,14 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
return
}
if managed, err := isSSOManagedMember(r.Context(), db, teamID, userID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
} else if managed {
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
return
}
last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
@@ -340,6 +432,20 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
}
}
// ssoManagedMsg is the refusal for editing access that single sign-on owns.
const ssoManagedMsg = "this membership is managed by single sign-on; change the user's groups in the identity provider"
// isSSOManagedMember reports whether the membership comes from the group sync.
// Editing it here would be undone at the person's next sign-in, so it is refused
// instead of appearing to work.
func isSSOManagedMember(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
var managed bool
err := db.QueryRowContext(ctx,
"SELECT EXISTS (SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND source = 'oidc')",
teamID, userID).Scan(&managed)
return managed, err
}
func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
var last bool
err := db.QueryRowContext(ctx, `
+106
View File
@@ -327,6 +327,112 @@ func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
}
}
// A team's own OIDC group binding follows the same rule as its schedule and
// its integrations: an owner sets it, a member may only read it, an outsider
// learns nothing, and an administrator can still reach it to repair a team
// whose owner has left.
func TestTeamOIDCGroups_OwnerOnlyToEdit(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "sre") // team.call authenticates as its owner
// A plain member of the same team.
var plain struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": "plain", "email": "plain@test.com"}), &plain)
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
map[string]any{"user_id": plain.ID, "role": "member"})
resp.Body.Close()
var key struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(plain.ID)+"/api-keys",
map[string]string{"name": "test"}), &key)
memberCall := func(method, path string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, s.URL+path, r)
req.Header.Set("Authorization", "Bearer "+key.Key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
}
// A member of a different team altogether.
_, outsiderCall := member(t, s, "outsider")
path := "/api/teams/" + id64(team.id) + "/oidc-groups"
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "sre", "owner_group": "sre-leads"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("owner PUT: %d, want 204", resp.StatusCode)
}
var got struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &got)
if got.MemberGroup != "sre" || got.OwnerGroup != "sre-leads" {
t.Errorf("owner GET after PUT: %+v", got)
}
resp = memberCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("member GET: %d, want 200", resp.StatusCode)
}
resp = memberCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("member PUT: %d, want 403", resp.StatusCode)
}
// 404, not 403: whether the team exists is itself something only its
// members should learn.
resp = outsiderCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider GET: %d, want 404", resp.StatusCode)
}
resp = outsiderCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider PUT: %d, want 404", resp.StatusCode)
}
// An administrator who is not a member may still set it, the same bypass
// that lets one repair a team whose owner has left.
resp = s.req(t, http.MethodPut, path, map[string]string{"member_group": "sre2"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("admin PUT: %d, want 204", resp.StatusCode)
}
// An empty string clears a binding, stored as NULL rather than the literal
// empty string, so an empty group claim can never accidentally match it.
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "", "owner_group": ""})
resp.Body.Close()
var cleared struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &cleared)
if cleared.MemberGroup != "" || cleared.OwnerGroup != "" {
t.Errorf("cleared: %+v", cleared)
}
}
// A team is not somewhere an outsider can look, whatever they know about it.
func TestTeams_OutsiderSeesNothing(t *testing.T) {
s := newTS(t)
+16 -4
View File
@@ -96,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
func handleListUsers(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(),
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users ORDER BY id")
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users ORDER BY id")
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -108,7 +108,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
var u models.User
var ts int64
var disabled *int64
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled); err != nil {
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -329,8 +329,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
var ts int64
var disabled *int64
err := db.QueryRowContext(ctx,
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled)
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled)
if err != nil {
return u, err
}
@@ -361,6 +361,18 @@ func handleSetAdmin(db *sql.DB) http.HandlerFunc {
}
if !*req.IsAdmin {
var managed bool
if err := db.QueryRowContext(r.Context(),
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin AND admin_source = 'oidc')",
id).Scan(&managed); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if managed {
respond(w, http.StatusConflict, errResp("administrator access is managed by single sign-on; change the user's groups in the identity provider"))
return
}
caller, _ := userFromContext(r.Context())
if caller.ID == id {
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))
+146
View File
@@ -1,7 +1,11 @@
package config
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"
)
@@ -59,8 +63,63 @@ type Config struct {
// NotifyRepeat is how long an incident may sit unacknowledged before it is
// notified again. Zero disables reminders.
NotifyRepeat time.Duration
// DisablePasswordLogin refuses signing in, or signing up, with a password.
// It is how an install moves to SSO only, and turning it back off is the way
// in when the identity provider is down. Stated negatively so that the zero
// Config, which is what a test or a new caller builds, keeps passwords working.
DisablePasswordLogin bool
// OIDC configures single sign-on. The zero value, with no Issuer, is off.
OIDC OIDC
}
// OIDC is the single sign-on configuration. Groups from the provider decide
// who may sign in, which teams they belong to, and whether they administer the
// install, in the manner of Grafana's org and role mapping.
type OIDC struct {
// Issuer is the provider's issuer URL. Discovery is fetched from
// <Issuer>/.well-known/openid-configuration. For Authentik this is the
// application's issuer, e.g. https://auth.example.com/application/o/terdut/.
// Empty turns single sign-on off.
Issuer string
ClientID string
ClientSecret string
// Name is what the sign-in button calls the provider.
Name string
// Scopes to request. The groups claim normally needs "profile" on Authentik.
Scopes []string
// UsernameClaim, EmailClaim and GroupsClaim name the ID token claims read.
UsernameClaim string
EmailClaim string
GroupsClaim string
// TrustEmail links a sign-in to an existing local user by email even when the
// provider does not vouch that the address is verified. Authentik reports
// email_verified false unless told otherwise, and an install that runs its
// own provider has already decided that its addresses can be trusted.
TrustEmail bool
// AllowedGroups gates sign-in: somebody in none of them is refused, however
// well the provider authenticated them. Empty admits everybody the provider
// authenticates, and access control is left to the provider.
AllowedGroups []string
// AdminGroup grants the system administrator flag while the user is in it.
AdminGroup string
// SessionMaxAge is the hard ceiling on a session made by an SSO login. The
// login is the only moment groups are re-read, so this is how long a change
// in the provider may take to reach terdut.
SessionMaxAge time.Duration
}
// Enabled reports whether single sign-on is configured.
func (o OIDC) Enabled() bool { return o.Issuer != "" }
func Load() Config {
addr := os.Getenv("TERDUT_ADDR")
if addr == "" {
@@ -89,9 +148,96 @@ func Load() Config {
NtfyFallbackTopic: os.Getenv("TERDUT_NTFY_FALLBACK_TOPIC"),
PublicURL: os.Getenv("TERDUT_PUBLIC_URL"),
NotifyRepeat: duration("TERDUT_NOTIFY_REPEAT", 15*time.Minute),
DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true),
OIDC: loadOIDC(),
}
}
func loadOIDC() OIDC {
o := OIDC{
Issuer: strings.TrimSpace(os.Getenv("TERDUT_OIDC_ISSUER")),
ClientID: os.Getenv("TERDUT_OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("TERDUT_OIDC_CLIENT_SECRET"),
Name: str("TERDUT_OIDC_NAME", "SSO"),
Scopes: list("TERDUT_OIDC_SCOPES", "openid profile email"),
UsernameClaim: str("TERDUT_OIDC_USERNAME_CLAIM", "preferred_username"),
EmailClaim: str("TERDUT_OIDC_EMAIL_CLAIM", "email"),
GroupsClaim: str("TERDUT_OIDC_GROUPS_CLAIM", "groups"),
TrustEmail: boolean("TERDUT_OIDC_TRUST_EMAIL", false),
AllowedGroups: list("TERDUT_OIDC_ALLOWED_GROUPS", ""),
AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"),
SessionMaxAge: duration("TERDUT_OIDC_SESSION_MAX_AGE", 12*time.Hour),
}
return o
}
// Validate reports a configuration the server should refuse to start with.
// Single sign-on is the only part that can be inconsistent: a half-configured
// provider would come up and then fail every login, which is harder to notice
// than not starting.
func (c Config) Validate() error {
o := c.OIDC
if !o.Enabled() {
if c.DisablePasswordLogin {
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
}
if o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
return errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not")
}
return nil
}
if u, err := url.Parse(o.Issuer); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("TERDUT_OIDC_ISSUER %q is not a URL", o.Issuer)
}
if o.ClientID == "" || o.ClientSecret == "" {
return errors.New("TERDUT_OIDC_CLIENT_ID and TERDUT_OIDC_CLIENT_SECRET are required with TERDUT_OIDC_ISSUER")
}
if c.PublicURL == "" {
return errors.New("TERDUT_PUBLIC_URL is required with TERDUT_OIDC_ISSUER: it is the base of the redirect URI")
}
if o.SessionMaxAge <= 0 {
return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive")
}
// Team grants are no longer visible here: they live on each team's own
// oidc_member_group/oidc_owner_group columns, set by that team's owner, not
// in config Validate can see at startup. The one thing left to guard against
// is an install nobody can administer at all.
if c.DisablePasswordLogin && o.AdminGroup == "" {
return errors.New("TERDUT_PASSWORD_LOGIN=false with no TERDUT_OIDC_ADMIN_GROUP leaves nobody able to administer the install")
}
return nil
}
func str(env, def string) string {
if s := strings.TrimSpace(os.Getenv(env)); s != "" {
return s
}
return def
}
// list reads a comma- or space-separated env var.
func list(env, def string) []string {
s := os.Getenv(env)
if strings.TrimSpace(s) == "" {
s = def
}
return strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' })
}
// boolean reads a true/false env var. An unrecognised value takes the default,
// so the two flags read this way (password login on, trusting email off) both
// fail towards the cautious setting.
func boolean(env string, def bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(env))) {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
}
return def
}
// duration reads a time.ParseDuration-formatted env var. An unset or
// unparseable value falls back to def rather than failing startup: a typo in one
// tuning knob should not take the server down.
+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")
}
}
+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
// asking, as in the admin listing.
Role string `json:"role,omitempty"`
// Source says who granted Role, on the endpoint that lists one user's teams:
// "manual", or "oidc" when the identity provider's groups did.
Source string `json:"source,omitempty"`
}
// Team roles. An owner configures the team — its schedule, its integrations and
@@ -29,6 +33,10 @@ type TeamMember struct {
Username string `json:"username"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
// Source is who granted the membership: "manual", or "oidc" when the
// identity provider's groups did and only they can change it.
Source string `json:"source"`
}
// Integration is how alerts get in, and the only thing that says which team an
+6
View File
@@ -23,6 +23,12 @@ type User struct {
// is too old to have the field", and the web UI decides what to show from
// it.
IsAdmin bool `json:"is_admin"`
// AdminSource is who granted the flag: "manual" or "oidc". An "oidc"
// administrator follows the identity provider's groups, so the UI shows it as
// managed there and the API refuses to revoke it by hand. Only set on the
// user endpoints that show it.
AdminSource string `json:"admin_source,omitempty"`
}
type APIKey struct {
+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
}
+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)
}
}
}
+66 -5
View File
@@ -137,6 +137,13 @@ h1, h2, h3 { margin: 0; line-height: 1.25; }
.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.login-brand h1 { font-size: 24px; letter-spacing: -0.01em; }
.login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; }
.login-password { display: grid; gap: 14px; }
/* "or" between the single sign-on button and the password form. */
.login-divider {
display: flex; align-items: center; gap: 10px; margin: 0;
color: var(--faint); font-size: 13px;
}
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
label { display: grid; gap: 6px; }
label > span { font-size: 13px; font-weight: 600; color: var(--muted); }
@@ -173,7 +180,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
}
.btn:hover { background: var(--surface-hover); }
.btn:disabled { opacity: 0.55; cursor: default; }
.btn:disabled, .btn-sm:disabled { opacity: 0.55; cursor: default; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.btn-primary:hover { background: var(--accent); filter: brightness(1.06); }
.btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; }
@@ -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;
}
/* ---------- team selector ---------- */
/* The global control for which team the app is scoped to. Hidden (via the
`hidden` attribute, set from teamselector.js) for anybody in fewer than two
teams, the same rule every other team-aware control in this file follows. */
.nav-team-selector,
.team-selector-mobile {
display: inline-flex; align-items: center; gap: 8px;
border: 1px solid var(--border-strong); border-radius: 999px;
background: var(--surface); color: var(--text);
font-size: 13px; font-weight: 600; cursor: pointer;
padding: 4px 12px; max-width: 100%;
}
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* A team's identity colour — not a status, so never the severity palette. Six
colours, then they repeat; teamColorClass() in format.js picks one by the
team's id, the same rcN convention the rota's per-person chips use. */
.team-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); }
.team-dot.rc1 { background: var(--accent); }
.team-dot.rc2 { background: var(--ok); }
.team-dot.rc3 { background: var(--snooze); }
.team-dot.rc4 { background: var(--warn); }
.team-dot.rc5 { background: var(--teal); }
.team-dot.rc6 { background: var(--pink); }
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
.view-page { padding-left: 16px; padding-right: 16px; }
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
@@ -275,6 +309,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
}
.chips::-webkit-scrollbar { display: none; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
flex: none;
min-height: 34px; padding: 0 12px;
border: 1px solid var(--border-strong); border-radius: 999px;
@@ -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);
color: var(--muted); font-size: 12px; white-space: nowrap;
}
/* The page a terminal's sign-in prompt links to. */
.device-card { max-width: 420px; margin: 24px auto; padding: 20px; display: grid; gap: 14px; }
.device-code {
margin: 0; padding: 14px; text-align: center;
font: 700 30px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
letter-spacing: 0.12em;
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
}
/* Access the identity provider's groups grant. The tint says "not yours to edit here". */
.row-team.sso { margin-left: 6px; background: var(--accent-soft); border-color: transparent; color: var(--accent); }
.row.resolved .row-title { color: var(--muted); }
.sev-critical { --sev: var(--crit); }
@@ -366,8 +411,9 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.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 { background: var(--ok-soft); color: var(--ok); }
.badge.st-unreachable { background: var(--crit-soft); color: var(--crit); }
.badge.st-ready, .badge.st-oncall { background: var(--ok-soft); color: var(--ok); }
.badge.st-reachable { background: var(--surface-2); color: var(--muted); }
.badge.st-unreachable, .badge.st-unpageable { background: var(--crit-soft); color: var(--crit); }
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
.badge.sev-info { background: var(--info-soft); color: var(--info); }
@@ -627,6 +673,7 @@ kbd {
display: flex; align-items: center; gap: 10px;
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
}
.nav-team-selector { margin: -8px 10px 14px; width: calc(100% - 20px); }
.nav-link {
flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
@@ -766,13 +813,12 @@ kbd {
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
.stacked-form label.checkbox { gap: 8px; }
.stacked-form input.wide { min-width: min(420px, 100%); }
.team-picker { margin-top: 8px; max-width: 100%; }
/* The rota, a month at a time. A name is too wide to print thirty times and
too alike down a column to read, so a day carries an initial in that
person's colour and the legend underneath says whose. A shift is then a run
of one colour, which is the shape the question actually has. */
.rota-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; padding: 10px; }
.rota-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; }
.rota-wd {
padding-bottom: 4px; text-align: center;
color: var(--muted); font-size: 11px; font-weight: 700;
@@ -786,6 +832,21 @@ kbd {
}
button.rota-day { cursor: pointer; }
button.rota-day:hover { background: var(--surface-2); }
/* The week number starts each row. Quiet by default, because it is a label
first; an owner's tap on it is the second thing it does. */
.rota-week {
display: grid; place-items: center;
border: 0; border-radius: var(--radius-sm); background: none;
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
color: var(--faint);
}
button.rota-week { cursor: pointer; }
button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.rota-week.current { color: var(--accent); font-weight: 700; }
/* The sheet's row of who holds each day of the week. */
.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; }
.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
.week-holder.past { opacity: 0.55; }
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.rota-day.today { background: var(--accent-soft); }
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
+15
View File
@@ -25,6 +25,14 @@
<img src="/icon.svg" alt="" width="40" height="40">
<h1>terdut</h1>
</div>
<!-- Why a sign-in failed, when the identity provider sent the browser back
here with ?sso_error=. Kept apart from the password form's own error. -->
<p class="form-error" id="sso-error" role="alert" hidden></p>
<!-- A plain link, not a fetch: the browser has to navigate to the provider,
and the page's CSP allows no connection to anywhere else. -->
<a class="btn btn-primary btn-block" id="sso-link" href="/api/oidc/login" hidden>Sign in with SSO</a>
<p class="login-divider" id="login-or" hidden><span>or</span></p>
<div class="login-password" id="password-login">
<label>
<span>Username</span>
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
@@ -39,6 +47,7 @@
<code>PUT /api/users/{id}/password</code> with your API key.</p>
<p class="login-hint" id="signup-link" hidden>
No account? <a href="/signup">Create one</a>.</p>
</div>
</form>
<!-- Sign-up. Shown instead of the login card at /signup, and only offers
@@ -77,6 +86,9 @@
<img src="/icon.svg" alt="" width="28" height="28">
<span>terdut</span>
</a>
<!-- Which team the app is scoped to. Hidden unless the signed-in user is
in more than one; teamselector.js fills it in and wires the click. -->
<button class="nav-team-selector" id="team-selector" type="button" hidden></button>
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span class="nav-label">Queue</span>
@@ -117,6 +129,7 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
</button>
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
<h1 class="topbar-title" id="topbar-title">Queue</h1>
</div>
<span class="open-pill" id="open-pill" hidden></span>
@@ -142,6 +155,8 @@
which the Team tab cannot show for a team you are not a member of. -->
<section id="view-adminteam" class="view view-page" data-view="adminteam" hidden></section>
<section id="view-more" class="view view-page" data-view="more" hidden></section>
<!-- Approve a terminal's sign-in, at /device?code=...: the page its prompt links to. -->
<section id="view-device" class="view view-page" data-view="device" hidden></section>
</div>
<dialog id="sheet" class="sheet"></dialog>
+19 -2
View File
@@ -26,8 +26,7 @@ function render() {
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
notifyForm(user),
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
passwordForm(user, hasPassword),
...passwordSection(user, hasPassword),
h('div', { class: 'only-desktop' },
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
@@ -119,6 +118,24 @@ function notifyForm(user) {
return form;
}
// With password login switched off a password opens nothing, so somebody who
// has none is not asked to make one. Somebody who does keeps the form: it is
// how they change or get rid of a credential the server still remembers.
function passwordSection(user, hasPassword) {
if (!hasPassword && state.auth.password_login === false) {
const name = state.auth.oidc?.name || 'single sign-on';
return [
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
h('div', { class: 'card' },
h('p', { class: 'muted', text: `You sign in with ${name}, and this server has turned password login off.` })),
];
}
return [
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
passwordForm(user, hasPassword),
];
}
function passwordForm(user, hasPassword) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
+9 -2
View File
@@ -13,7 +13,7 @@
// gate.
import * as api from './api.js';
import { h, clear, spinner, confirm, menuCard } from './ui.js';
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED } from './ui.js';
import { state, myID } from './state.js';
const view = () => document.getElementById('view-admin');
@@ -197,7 +197,9 @@ function usersCard() {
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
self && h('span', { class: 'you', text: 'you' })),
h('td', { class: 'muted', text: u.email }),
h('td', {}, u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null),
h('td', {},
u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null,
u.is_admin && u.admin_source === 'oidc' ? ssoBadge() : null),
h('td', {},
// Neither action is offered for your own account: the server refuses
// both, and an enabled-looking button that always fails is worse than
@@ -206,6 +208,9 @@ function usersCard() {
class: 'btn-sm',
type: 'button',
text: u.is_admin ? 'Revoke admin' : 'Make admin',
// The server refuses to revoke what the groups grant.
disabled: ssoAdmin(u),
title: ssoAdmin(u) ? SSO_MANAGED : null,
onclick: () => setAdmin(u, !u.is_admin),
}),
!self && h('button', {
@@ -255,6 +260,8 @@ function invitePointer() {
);
}
const ssoAdmin = (u) => u.is_admin && u.admin_source === 'oidc';
async function setAdmin(user, next) {
if (next && !(await confirm({
title: `Make ${user.username} an administrator?`,
+14 -2
View File
@@ -11,7 +11,7 @@
// gate.
import * as api from './api.js';
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
import { h, clear, spinner, confirm, toast, icon, ssoBadge, SSO_MANAGED } from './ui.js';
import { state } from './state.js';
import { navigate } from './app.js';
import { when } from './format.js';
@@ -146,6 +146,13 @@ function identityCard() {
fact('Created', when(t.created_at)),
fact('Members', String(t.members)),
fact('Open incidents', String(t.open_incidents)),
// Read-only here: an administrator can see why a team's OIDC-sourced
// membership looks the way it does, but setting it is the team's own
// owner's call, from the Team tab.
...(state.auth?.oidc?.enabled ? [
fact('OIDC member group', t.oidc_member_group || '—'),
fact('OIDC owner group', t.oidc_owner_group || '—'),
] : []),
),
form, err, ok,
);
@@ -166,17 +173,22 @@ function membersCard() {
// Unlike the Team tab's own member list, the name is a link: that
// person's page is where the rest of them lives.
h('td', {}, h('a', { class: 'row-link', href: `/admin/users/${m.user_id}`, text: m.username })),
h('td', { class: 'muted small', text: m.role }),
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
h('td', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button',
text: m.role === 'owner' ? 'Make member' : 'Make owner',
// The server refuses to edit a membership the groups grant.
disabled: m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED : null,
// The same endpoint both ways: adding is an upsert on the role.
onclick: () => act(() =>
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
disabled: m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED : null,
// The server refuses the last owner with a 409, which act() shows.
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
}),
+11 -2
View File
@@ -11,7 +11,7 @@
// enforcement of them.
import * as api from './api.js';
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
import { h, clear, spinner, confirm, toast, icon, ssoBadge, SSO_MANAGED } from './ui.js';
import { state, myID } from './state.js';
import { navigate } from './app.js';
import { when } from './format.js';
@@ -96,6 +96,7 @@ function identityCard() {
h('div', { class: 'user-head' },
h('h2', { text: u.username }),
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
u.is_admin && u.admin_source === 'oidc' && ssoBadge(),
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
self && h('span', { class: 'you', text: 'you' })),
h('dl', { class: 'user-facts' },
@@ -110,6 +111,9 @@ function identityCard() {
!self && h('button', {
class: 'btn', type: 'button',
text: u.is_admin ? 'Revoke admin' : 'Make admin',
// The server refuses to revoke what the groups grant.
disabled: u.is_admin && u.admin_source === 'oidc',
title: u.is_admin && u.admin_source === 'oidc' ? SSO_MANAGED : null,
onclick: () => setAdmin(!u.is_admin),
}),
!self && h('button', {
@@ -162,16 +166,21 @@ function teamsCard() {
// Not a link: the Team tab always shows the viewer's own team, so
// sending them there from somebody else's membership would be a lie.
h('td', {}, h('strong', { text: t.name })),
h('td', { class: 'muted small', text: t.role }),
h('td', { class: 'muted small' }, t.role, t.source === 'oidc' && ssoBadge()),
h('td', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button',
text: t.role === 'owner' ? 'Make member' : 'Make owner',
// The server refuses to edit a membership the groups grant.
disabled: t.source === 'oidc',
title: t.source === 'oidc' ? SSO_MANAGED : null,
onclick: () => act(() =>
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
disabled: t.source === 'oidc',
title: t.source === 'oidc' ? SSO_MANAGED : null,
// The server refuses the last owner with a 409, which act() shows.
onclick: () => act(() => api.removeTeamMember(t.id, userID)),
}),
+9
View File
@@ -59,8 +59,13 @@ async function call(method, path, { query, body, signal } = {}) {
// session
export const me = () => call('GET', '/me');
// How this server can be signed in to: { password_login, oidc: { enabled, name } }.
export const authConfig = () => call('GET', '/auth/config');
export const login = (username, password) => call('POST', '/login', { body: { username, password } });
export const logout = () => call('POST', '/logout');
// Approve or refuse a sign-in a terminal started; code is what it is showing.
export const approveDevice = (code) => call('POST', '/oidc/device/approve', { body: { user_code: code } });
export const denyDevice = (code) => call('POST', '/oidc/device/deny', { body: { user_code: code } });
export const setPassword = (userID, password, currentPassword) =>
call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } });
@@ -130,6 +135,10 @@ export const addTeamMember = (id, userID, role) =>
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
// Which OIDC groups grant member and owner access to this team.
export const oidcGroups = (id) => call('GET', `/teams/${id}/oidc-groups`);
export const setOidcGroups = (id, body) => call('PUT', `/teams/${id}/oidc-groups`, { body });
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
export const createIntegration = (id, name) =>
call('POST', `/teams/${id}/integrations`, { body: { name } });
+92 -6
View File
@@ -11,9 +11,11 @@ import * as alerts from './alerts.js';
import * as stats from './stats.js';
import * as account from './account.js';
import * as team from './team.js';
import * as teamselector from './teamselector.js';
import * as admin from './admin.js';
import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js';
import * as device from './device.js';
const $ = (id) => document.getElementById(id);
@@ -30,6 +32,8 @@ const SECTIONS = {
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
more: { title: 'Account', view: account },
// Reached by link from a terminal's sign-in prompt, not from the nav.
device: { title: 'Sign in a terminal', view: device },
};
// The mobile hamburger menu's contents — the same sections the desktop
@@ -61,7 +65,7 @@ function parseRoute(pathname) {
if (t) return { section: 'admin', tab: t.tab };
const tt = team.TABS.find((x) => x.path === `/${name}`);
if (tt) return { section: 'team', tab: tt.tab };
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more') return { section: name };
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more' || name === 'device') return { section: name };
return { section: 'queue', incident: null };
}
@@ -227,6 +231,8 @@ async function boot() {
$('login-form').addEventListener('submit', onLogin);
$('signup-form').addEventListener('submit', onSignup);
$('menu-btn').addEventListener('click', openNavMenu);
teamselector.init();
ssoErrorCode = takeSSOError();
// /signup is the one route that works without a session.
if (location.pathname.replace(/\/$/, '') === '/signup') {
@@ -236,8 +242,12 @@ async function boot() {
}
try {
// Before anything renders: the account view decides from it whether a
// password is worth offering to set.
await loadAuthConfig();
state.me = await api.me();
await loadTeams();
teamselector.render();
// The Admin tab exists only for an administrator. Somebody who types /admin
// anyway gets the view's own "ask an administrator" card, not a blank page.
$('nav-admin').hidden = !state.me?.user?.is_admin;
@@ -321,6 +331,7 @@ async function onSignup(e) {
history.replaceState({ depth: 0 }, '', '/');
route = parseRoute('/');
await loadTeams();
teamselector.render();
$('nav-admin').hidden = !state.me?.user?.is_admin;
showApp();
} catch (ex) {
@@ -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();
ui.closeSheet(null);
reset();
$('boot').hidden = true;
$('app').hidden = true;
$('login').hidden = false;
$('signup-form').hidden = true;
// Ask before showing anything, so the form does not flash the password
// fields at somebody whose server has turned them off.
const auth = await loadAuthConfig();
const sso = auth.oidc?.enabled ? auth.oidc : null;
const passwords = auth.password_login !== false;
const link = $('sso-link');
link.hidden = !sso;
if (sso) {
link.textContent = `Sign in with ${sso.name || 'SSO'}`;
// Come back to the page that was asked for: a link to /device?code=... has
// to survive the trip through the provider. The server only honours paths
// on this server, and ignores the front page.
const here = location.pathname + location.search;
link.href = here === '/' ? '/api/oidc/login' : `/api/oidc/login?next=${encodeURIComponent(here)}`;
}
$('login-or').hidden = !(sso && passwords);
$('password-login').hidden = !passwords;
const ssoErr = $('sso-error');
ssoErr.hidden = ssoErrorCode === null;
if (ssoErrorCode !== null) ssoErr.textContent = ssoErrorText(ssoErrorCode, sso?.name);
$('boot').hidden = true;
$('login').hidden = false;
$('login-form').hidden = false;
const form = $('login-form');
form.querySelector('.form-error').hidden = true;
form.querySelector('#password-login .form-error').hidden = true;
if (!passwords) return;
// Only offer the door that is open. Somebody without an invite on an
// invite-only server should be told, not sent to a form that refuses them.
api.signupInfo().then((info) => {
@@ -354,9 +435,13 @@ function showLogin() {
async function onLogin(e) {
e.preventDefault();
const form = e.currentTarget;
const err = form.querySelector('.form-error');
// Not the first .form-error: that one is the single sign-on message above.
const err = form.querySelector('#password-login .form-error');
const btn = form.querySelector('button[type=submit]');
err.hidden = true;
// Whatever single sign-on said is about the last attempt, not this one.
ssoErrorCode = null;
$('sso-error').hidden = true;
btn.disabled = true;
try {
state.me = await api.login(form.username.value.trim(), form.password.value);
@@ -380,6 +465,7 @@ export async function signOut() {
}
function showApp() {
ssoErrorCode = null;
$('boot').hidden = true;
$('login').hidden = true;
$('app').hidden = false;
+88
View File
@@ -0,0 +1,88 @@
// Approving a sign-in that a terminal started, at /device?code=XXXX-XXXX.
//
// The terminal (the TUI) shows a code and a link to this page. Whoever opens it
// is already signed in — by the provider or by password, whichever the login
// page offered — and is asked to approve. Approving hands that terminal a
// session for *this* account, so the page names the account and the code, and
// tells anybody who did not start this to refuse.
import * as api from './api.js';
import { h, clear } from './ui.js';
import { state } from './state.js';
import { navigate } from './app.js';
const view = () => document.getElementById('view-device');
// What has been decided for the code on screen, so a re-render does not offer
// to approve it a second time.
let outcome = null; // { code, approved }
export function show() {
render();
}
function render() {
const code = new URLSearchParams(location.search).get('code') || '';
if (!code) return clear(view(), enterCode());
if (outcome && outcome.code === code) return clear(view(), decided(outcome.approved));
return clear(view(), confirmCard(code));
}
// Reached without a code, for somebody who typed the address by hand.
function enterCode() {
const input = h('input', {
name: 'code', autocomplete: 'off', autocapitalize: 'characters', spellcheck: 'false',
placeholder: 'XXXX-XXXX', required: true,
});
const form = h('form', { class: 'card device-card' },
h('h2', { text: 'Sign in a terminal' }),
h('p', { class: 'muted', text: 'Enter the code the terminal is showing.' }),
h('label', {}, h('span', { text: 'Code' }), input),
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Continue' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
navigate(`/device?code=${encodeURIComponent(input.value.trim())}`);
});
return form;
}
function confirmCard(code) {
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
const approve = h('button', { class: 'btn btn-primary', type: 'button', text: 'Approve' });
const refuse = h('button', { class: 'btn', type: 'button', text: 'Refuse' });
const decide = (approved) => async () => {
err.hidden = true;
approve.disabled = refuse.disabled = true;
try {
await (approved ? api.approveDevice(code) : api.denyDevice(code));
outcome = { code, approved };
render();
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
approve.disabled = refuse.disabled = false;
}
};
approve.addEventListener('click', decide(true));
refuse.addEventListener('click', decide(false));
return h('div', { class: 'card device-card' },
h('h2', { text: 'Sign in a terminal?' }),
h('p', {}, 'A terminal is asking to sign in as ', h('strong', { text: state.me.user.username }),
'. Check that this code matches the one it is showing:'),
h('p', { class: 'device-code', text: code }),
h('p', { class: 'muted small',
text: 'Only approve a sign-in you started yourself. Whoever is approved here acts as you.' }),
err,
h('div', { class: 'row-actions' }, approve, refuse));
}
function decided(approved) {
return h('div', { class: 'card device-card' },
h('h2', { text: approved ? 'Approved' : 'Refused' }),
h('p', { class: 'muted', text: approved
? 'You can go back to your terminal. It signs in within a few seconds.'
: 'That terminal will not be signed in.' }),
h('a', { class: 'btn', href: '/', text: 'Go to the queue' }));
}
+18 -9
View File
@@ -66,21 +66,22 @@ export function mondayOf(d) {
return r;
}
// ISO 8601 week number: weeks start on Monday and week 1 is the one holding the
// year's first Thursday, which is what a rota that runs Monday to Sunday means
// by "week 40". Taken from the Thursday of d's week, whose year is the week's.
export function isoWeek(d) {
const thu = new Date(d.getFullYear(), d.getMonth(), d.getDate());
thu.setDate(thu.getDate() + 3 - ((thu.getDay() + 6) % 7));
const jan4 = new Date(thu.getFullYear(), 0, 4);
return 1 + Math.round(((thu - jan4) / 86400000 - 3 + ((jan4.getDay() + 6) % 7)) / 7);
}
export function addDays(d, n) {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
// ISO 8601 week number.
export function isoWeek(d) {
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const day = t.getUTCDay() || 7;
t.setUTCDate(t.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
return Math.ceil(((t - yearStart) / DAY + 1) / 7);
}
export const STATUS_LABEL = {
triggered: 'Triggered',
acknowledged: 'Acknowledged',
@@ -97,6 +98,14 @@ export function severityClass(sev) {
return '';
}
// A stable identity colour for a team, so the same team always reads the same
// colour without the server needing to store one. Teams have no colour field;
// this hashes the id into the six-colour rcN palette app.css already has for
// the rota's per-person chips (a team is not a status, so never severity).
export function teamColorClass(teamID) {
return `rc${(((teamID % 6) + 6) % 6) + 1}`;
}
// A one-line summary of the group labels, without the one the title already shows.
export function labelSummary(labels, skip = 'alertname') {
return Object.entries(labels || {})
+20 -32
View File
@@ -2,8 +2,8 @@
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
import { state, myID } from './state.js';
import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js';
@@ -27,34 +27,21 @@ const EMPTY = {
};
onboarding.onRerender(() => renderList());
// The queue used to keep its own team filter (a per-tab sessionStorage value,
// out of step with team.js's own picker); both now defer to the global
// selector's shared state, so re-render whenever it changes.
onTeamChange(() => {
renderChips();
refresh({ fresh: true });
});
let filter = loadFilter();
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
let items = null; // null while loading
let error = null;
let selected = null;
let cursor = -1; // keyboard position in the list
let built = false;
function loadTeamFilter() {
try {
return sessionStorage.getItem('terdut.queue.team') || '';
} catch {
return '';
}
}
function setTeamFilter(id) {
teamFilter = id;
try {
sessionStorage.setItem('terdut.queue.team', id);
} catch {
/* storage unavailable */
}
renderChips();
refresh({ fresh: true });
}
function loadFilter() {
try {
const f = sessionStorage.getItem('terdut.queue.filter');
@@ -89,8 +76,8 @@ export async function refresh({ fresh = false } = {}) {
// The open list is already fetched for the badges; no need to ask twice.
// The cached open queue covers every team, so it can only be reused when
// no team filter is applied.
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
const cached = filter === 'open' && !fresh && !teamFilter;
const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query;
const cached = filter === 'open' && !fresh && state.selectedTeamID == null;
const result = cached ? state.open : await api.incidents(query);
await onboarding.load();
if (requested !== filter) return;
@@ -136,19 +123,20 @@ function renderChips() {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(teamFilter === ''),
onclick: () => setTeamFilter(''),
text: 'All teams',
}));
'aria-selected': String(state.selectedTeamID == null),
onclick: () => setSelectedTeam(null),
}, h('span', { class: 'team-dot' }), ' All teams'));
for (const team of state.teams) {
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(teamFilter === String(team.id)),
onclick: () => setTeamFilter(String(team.id)),
text: team.name,
}));
'aria-selected': String(team.id === state.selectedTeamID),
onclick: () => setSelectedTeam(team.id),
},
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
' ' + team.name,
));
}
}
+53 -3
View File
@@ -5,14 +5,58 @@ import * as api from './api.js';
export const state = {
me: null, // { user, has_password }
// How the server can be signed in to. The defaults are an older server's
// answer: passwords, no single sign-on.
auth: { password_login: true, oidc: { enabled: false, name: '' } },
open: [], // the default queue: open, not snoozed
teams: [], // the teams the viewer belongs to, each with their role
// Which team the whole app is scoped to right now; null means "All teams".
// Set only through setSelectedTeam below, never assigned directly, so every
// view stays in sync and the choice is remembered across reloads.
selectedTeamID: loadSelectedTeam(),
};
// The team whose schedule and settings the views act on. A viewer in one team —
// which is everybody until somebody makes a second — never has to choose.
const SELECTED_TEAM_KEY = 'terdut.selectedTeam';
function loadSelectedTeam() {
try {
const raw = localStorage.getItem(SELECTED_TEAM_KEY);
return raw ? Number(raw) : null;
} catch {
return null; // storage unavailable, or nothing saved yet
}
}
// Callbacks to run whenever the selected team changes, so every view that
// cares — the queue's filter, the Team settings page, the selector's own
// trigger — stays in sync without a general event bus, following the one
// precedent for this in the codebase: onboarding.js's onRerender.
const teamListeners = [];
export function onTeamChange(cb) {
teamListeners.push(cb);
}
// setSelectedTeam changes which team the app is scoped to (id, or null for
// "All teams"), persists it — a durable preference, unlike the per-tab
// sessionStorage filter this replaces — and tells every registered listener.
export function setSelectedTeam(id) {
state.selectedTeamID = id;
try {
if (id == null) localStorage.removeItem(SELECTED_TEAM_KEY);
else localStorage.setItem(SELECTED_TEAM_KEY, String(id));
} catch {
/* storage unavailable */
}
for (const cb of teamListeners) cb();
}
// The team whose schedule and settings the views act on: the selected team,
// falling back to the first one the viewer belongs to — which is everybody's
// only team until somebody makes a second, or the stored selection naming a
// team the account has since left.
export function currentTeam() {
return state.teams[0] || null;
const teams = state.teams || [];
return teams.find((t) => t.id === state.selectedTeamID) || teams[0] || null;
}
export function myID() {
@@ -33,6 +77,12 @@ export async function users() {
export async function loadTeams() {
state.teams = await api.teams();
// A stored id that no longer names one of the account's teams — left it, or
// this is simply a different account signed in on the same browser — is as
// good as unset.
if (state.selectedTeamID != null && !state.teams.some((t) => t.id === state.selectedTeamID)) {
state.selectedTeamID = null;
}
return state.teams;
}
+322 -56
View File
@@ -18,9 +18,9 @@
// than no form, but it is not the thing enforcing anything.
import * as api from './api.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip } from './ui.js';
import { state, currentTeam, users as allUsers, myID } from './state.js';
import { isoDate, addDays, mondayOf, initial, ago, when, duration } from './format.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js';
import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
const view = () => document.getElementById('view-team');
@@ -41,6 +41,9 @@ export const TABS = [
{ tab: 'deadman', path: '/team/deadman', label: 'Switches', title: 'Dead man’s switches' },
];
// Cached from currentTeam() on each refresh(), for the many actions below
// (assignSchedule, addTeamMember, ...) that need a plain id rather than a
// round trip through state.
let teamID = null;
// Which sub-section is open. Remembered rather than passed, because the poll
// loop calls refresh() with no route.
@@ -49,6 +52,13 @@ let data = null; // { team, ... }; which fields are present varies by tab
let error = null;
let freshKey = null; // an integration key, shown once, until the view is left
// The global team selector is what changes which team this page shows now;
// re-fetch under whichever sub-section is open when it fires.
onTeamChange(() => {
data = null;
refresh();
});
export function show(route) {
const next = route?.tab ?? null;
// A different sub-section wants different data, so the old answer goes
@@ -61,13 +71,8 @@ export function show(route) {
refresh();
}
function selectedTeam() {
const teams = state.teams || [];
return teams.find((t) => t.id === teamID) || currentTeam();
}
export async function refresh() {
const team = selectedTeam();
const team = currentTeam();
if (!team) {
data = null;
render();
@@ -101,8 +106,12 @@ async function load(id) {
return { members, schedule };
}
if (tab === 'members') {
const [members, users] = await Promise.all([api.teamMembers(id), allUsers()]);
return { members, users };
const [members, users, oidcGroups] = await Promise.all([
api.teamMembers(id),
allUsers(),
state.auth?.oidc?.enabled ? api.oidcGroups(id) : null,
]);
return { members, users, oidcGroups };
}
if (tab === 'escalation') {
const [members, escalation] = await Promise.all([api.teamMembers(id), api.escalation(id)]);
@@ -168,25 +177,13 @@ function subnav() {
})));
}
// Only shown to somebody in more than one team, like the queue's filter chips.
// It is above the sections rather than inside one because it changes the
// subject of all six.
// Names which team's settings the six sections below belong to. It used to be
// a picker of its own for somebody in more than one team; that job now belongs
// to the global team selector in the nav, which is what onTeamChange above
// reacts to.
function teamPicker() {
if ((state.teams || []).length < 2) {
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
}
const select = h('select', { class: 'team-picker' },
...state.teams.map((t) => h('option', {
value: String(t.id), text: t.name, selected: t.id === teamID,
})));
select.addEventListener('change', () => {
teamID = Number(select.value);
data = null;
freshKey = null;
refresh();
});
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
}
// --- overview --------------------------------------------------------------
@@ -240,6 +237,7 @@ function overview() {
const monthFmt = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
const weekdayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short' });
const dayShortFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' });
const longDayFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'long', day: 'numeric', month: 'long',
});
@@ -281,11 +279,14 @@ function scheduleCard() {
const key = isoDate(d);
const e = byDate.get(key);
const inMonth = d.getMonth() === month;
// Every row starts with its week number, which is also the way to fill the
// whole week at once.
if (i % 7 === 0) cells.push(weekCell(d, byDate, today));
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
cells.push(dayCell(d, key, e, inMonth, today));
}
const heads = [];
const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })];
for (let i = 0; i < 7; i++) {
// Any Monday will do; this one is a Monday.
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
@@ -323,6 +324,23 @@ function scheduleCard() {
];
}
// The ISO week number at the start of a row. For an owner it is a button: one
// tap fills the week, which is the way a rota is usually handed out — a person
// takes a week, not seven separate days.
function weekCell(monday, byDate, today) {
const n = isoWeek(monday);
const current = isoDate(monday) <= today && today < isoDate(addDays(monday, 7));
const cls = `rota-week${current ? ' current' : ''}`;
const label = `Week ${n}`;
return isOwner()
? h('button', {
class: cls, type: 'button', text: String(n),
title: `${label} · assign somebody for the whole week`, 'aria-label': label,
onclick: () => weekSheet(monday, byDate),
})
: h('div', { class: cls, title: label, text: String(n) });
}
function dayCell(d, key, e, inMonth, today) {
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
.filter(Boolean).join(' ');
@@ -378,6 +396,73 @@ function coverNote(byDate) {
' left this month with nobody on call.');
}
// One week, in the sheet: who holds each day of it, and one person to put on
// all of them. Days already gone are left alone — who was on call last Tuesday
// is a fact, and "the whole week" should not rewrite it — and the week's
// overhang into the next month is included, since it is the same week.
function weekSheet(monday, byDate) {
const today = isoDate(new Date());
const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i));
const keys = days.map(isoDate);
const ahead = keys.filter((k) => k >= today);
const range = `${dayShortFmt.format(days[0])} – ${dayShortFmt.format(days[6])}`;
const who = memberSelect();
const onlyEmpty = h('input', { type: 'checkbox' });
const problem = h('p', { class: 'load-error', hidden: true });
const holders = h('div', { class: 'week-holders' }, days.map((d, i) => {
const e = byDate.get(keys[i]);
return h('span', {
class: `week-holder${keys[i] < today ? ' past' : ''}`,
title: `${keys[i]} · ${e ? e.username : 'nobody'}`,
},
h('span', { class: 'rota-num', text: weekdayFmt.format(d) }),
e
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
: h('span', { class: 'rota-chip none' }));
}));
openSheet(() => [
h('h2', { class: 'sheet-title', text: `Week ${isoWeek(monday)}` }),
h('p', { class: 'sheet-text', text: range }),
holders,
ahead.length
? [
h('label', { class: 'sheet-pick' }, 'On call ', who),
h('label', { class: 'checkbox' }, onlyEmpty, ' Only fill days nobody has yet'),
h('p', { class: 'muted small' },
ahead.length < 7
? `Days already past are left alone, so this covers the ${ahead.length} still to come. `
: '',
'Anybody already on those days is replaced unless you tick the box.'),
]
: h('p', { class: 'muted', text: 'This whole week is already over.' }),
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet() }),
ahead.length > 0 && h('button', {
class: 'btn btn-primary', type: 'button', autofocus: true, text: 'Assign week',
onclick: () => {
const dates = onlyEmpty.checked ? ahead.filter((k) => !byDate.has(k)) : ahead;
if (!who.value) {
problem.textContent = 'There is nobody in this team to assign.';
problem.hidden = false;
return;
}
if (!dates.length) {
problem.textContent = 'Every day still to come already has somebody.';
problem.hidden = false;
return;
}
closeSheet();
act(() => api.assignSchedule(teamID, Number(who.value), dates, !onlyEmpty.checked));
},
}),
),
]);
}
// One day, in the sheet: who has it, who should, and the way to empty it. This
// is where the per-row Clear button went — the grid has no room for thirty of
// them, and the day you want to change is the one you just tapped.
@@ -930,41 +1015,222 @@ function openNewSwitch() {
// --- members ---------------------------------------------------------------
function membersCard() {
const rows = (data.members || []).map((m) =>
h('tr', {},
h('td', {}, h('strong', { text: m.username })),
h('td', { class: 'muted small', text: m.role }),
h('td', {}, isOwner() && h('button', {
class: 'btn-sm', type: 'button',
text: m.role === 'owner' ? 'Make member' : 'Make owner',
onclick: () => act(() =>
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
}), isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
})),
));
const MEMBER_STATUS = {
oncall: { label: 'On call', hint: 'The rota has them today.' },
reachable: { label: 'Reachable', hint: 'Has an ntfy topic, so a page would reach them.' },
unpageable: { label: 'Can’t be paged', hint: 'A page to them would go nowhere.' },
};
const memberBadge = (m) => {
const el = statusBadge(MEMBER_STATUS, m.status, 'reachable');
if (m.problem) el.title = `${MEMBER_STATUS.unpageable.hint} ${m.problem}.`;
return el;
};
// Rota days are UTC dates with no time in them; formatting one in the viewer's
// zone could show the day before.
const shiftFmt = new Intl.DateTimeFormat(undefined, {
weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC',
});
const shiftDay = (ymd) => shiftFmt.format(new Date(`${ymd}T00:00:00Z`));
function shiftCell(m) {
if (m.on_call) {
return h('span', { text: m.next_shift ? `today, then ${shiftDay(m.next_shift)}` : 'today' });
}
return m.next_shift
? h('span', { text: shiftDay(m.next_shift) })
: h('span', { text: 'not scheduled' });
}
// The team's own OIDC group binding, shown only on an SSO-enabled install:
// which group grants membership and which grants ownership. Read-only text
// for a member, an edit sheet for an owner — the server enforces the same
// split on the endpoint underneath.
function oidcGroupsCard() {
if (!state.auth?.oidc?.enabled) return null;
const g = data.oidcGroups || { member_group: '', owner_group: '' };
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Single sign-on' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
})),
h('p', { class: 'muted small' },
'Members of the group below are added to this team automatically at ',
'sign-in; members of the owner group become owners. Leave a field ',
'blank to grant nothing this way.'),
h('dl', { class: 'user-facts' },
fact('Member group', g.member_group || '—'),
fact('Owner group', g.owner_group || '—'),
),
);
}
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
}
function openOidcGroupsEditor() {
const g = data.oidcGroups || { member_group: '', owner_group: '' };
const memberGroup = h('input', {
type: 'text', value: g.member_group, placeholder: 'e.g. sre', autofocus: true,
});
const ownerGroup = h('input', { type: 'text', value: g.owner_group, placeholder: 'e.g. sre-leads' });
const problem = h('p', { class: 'load-error', hidden: true });
const form = h('form', { class: 'stacked-form' },
h('label', {}, 'Member group ', memberGroup),
h('label', {}, 'Owner group ', ownerGroup),
h('p', { class: 'muted small' },
'A person in both becomes an owner. Whoever the group lists is kept in ',
'sync at their next sign-in — a member added by hand can still be made ',
'an owner, but not the other way round.'),
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save' })));
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api.setOidcGroups(teamID, {
member_group: memberGroup.value.trim(),
owner_group: ownerGroup.value.trim(),
});
} catch (err) {
problem.textContent = err.message;
problem.hidden = false;
return;
}
closeSheet(true);
refresh();
});
openSheet(() => [h('h2', { class: 'sheet-title', text: 'Single sign-on groups' }), form]);
}
function membersCard() {
const members = data.members || [];
const owners = members.filter((m) => m.role === 'owner').length;
const rows = members.map((m) => {
const lastOwner = m.role === 'owner' && owners === 1;
return h('tr', {},
h('td', {}, memberBadge(m)),
h('td', { class: 'wrap' },
h('strong', { text: m.username }),
m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }),
m.problem && h('div', { class: 'target-problem', text: m.problem })),
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
h('td', { class: 'muted small' }, shiftCell(m)),
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
h('td', { class: 'muted small' },
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button', text: 'Edit',
// The server refuses to edit a membership the groups grant.
disabled: m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED : null,
onclick: () => openEditMember(m),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
disabled: lastOwner || m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED
: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
onclick: async () => {
if (!(await confirm({
title: `Remove ${m.username}?`,
text: 'They lose access to this team. Rota days already assigned to them are not '
+ 'changed, so reassign those from the Rota tab.',
confirmLabel: 'Remove',
danger: true,
}))) return;
act(() => api.removeTeamMember(teamID, m.user_id));
},
}))),
);
});
return [oidcGroupsCard(), h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Members' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Add member', onclick: openAddMember,
})),
h('p', { class: 'muted small' },
'Owners set up the team; members work its incidents. Somebody who can’t be ',
'paged is worth fixing before their next shift.'),
members.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Member' }), h('th', { text: 'Role' }),
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }),
h('th'))),
h('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
)];
}
// One sheet for both jobs a member's row has: who, and as what. Adding is
// choosing a person and a role; editing is the same with the person fixed. The
// API is one call either way — POST upserts the role.
function openMemberSheet({ title, submit, person, role, run }) {
const roleSelect = h('select', {},
...['member', 'owner'].map((r) => h('option', { value: r, text: r, selected: r === role })));
const problem = h('p', { class: 'load-error', hidden: true });
const form = h('form', { class: 'stacked-form' },
person.label,
h('label', {}, 'Role ', roleSelect),
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
h('button', { class: 'btn btn-primary', type: 'submit', text: submit })));
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await run(person.userID(), roleSelect.value);
} catch (err) {
problem.textContent = err.message;
problem.hidden = false;
return;
}
closeSheet(true);
refresh();
});
openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]);
}
function openAddMember() {
const inTeam = new Set((data.members || []).map((m) => m.user_id));
const candidates = (data.users || []).filter((u) => !inTeam.has(u.id) && !u.disabled_at);
const pick = h('select', {},
...candidates.map((u) => h('option', { value: String(u.id), text: u.username })));
const role = h('select', {},
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
openMemberSheet({
title: 'Add member', submit: 'Add member', role: 'member',
person: {
label: candidates.length
? h('label', {}, 'Person ', pick)
: h('p', { class: 'muted', text: 'Everybody with an account is already in this team.' }),
userID: () => Number(pick.value),
},
run: (userID, role) => {
if (!userID) throw new Error('Nobody to add');
return api.addTeamMember(teamID, userID, role);
},
});
}
return h('div', { class: 'card' },
h('h2', { text: 'Members' }),
h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
isOwner() && candidates.length > 0 && form,
);
function openEditMember(m) {
openMemberSheet({
title: `Edit ${m.username}`, submit: 'Save', role: m.role,
person: { label: h('p', { class: 'muted small', text: m.username }), userID: () => m.user_id },
run: (userID, role) => api.addTeamMember(teamID, userID, role),
});
}
// --- plumbing --------------------------------------------------------------
+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);
});
}
+9
View File
@@ -170,6 +170,15 @@ export function badge(text, cls = '') {
return h('span', { class: `badge ${cls}`, text });
}
// Access granted by the identity provider's groups. The server refuses to edit
// it by hand (it would be undone at the next sign-in), so the controls that
// would try are disabled with this as their reason.
export const SSO_MANAGED = 'Managed by single sign-on. Change the person’s groups in the identity provider.';
export function ssoBadge(text = 'SSO') {
return h('span', { class: 'row-team sso', title: SSO_MANAGED, text });
}
export function labelChip(k, v) {
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
}