Compare commits
26 Commits
v0.21.0
...
949d6595ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 949d6595ba | |||
| 33356ca978 | |||
| e5b4df7c03 | |||
| 5b4683febf | |||
| 97a4814c04 | |||
| a2dc9e3b03 | |||
| 155f27ca62 | |||
| a27ff49171 | |||
| c5be55dcbc | |||
| b2c3868619 | |||
| 36c00acf62 | |||
| 9d1df2b611 | |||
| 9bf4c92bfe | |||
| e616c82646 | |||
| 2b396d22d6 | |||
| 1f1faa437c | |||
| dc92f51cf8 | |||
| d675f8ec9b | |||
| e8d45f9d3d | |||
| f3918b863c | |||
| 3ee8583f6f | |||
| 591d5b8df0 | |||
| d2cdcc9776 | |||
| 8b2789b9b2 | |||
| 71d7e1853a | |||
| 60ebb75cd2 |
@@ -16,6 +16,12 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
|||||||
go build -ldflags="-w -s -X main.version=${VERSION}" -o /terdut ./cmd/terdut
|
go build -ldflags="-w -s -X main.version=${VERSION}" -o /terdut ./cmd/terdut
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
# scratch has no trust store, and a Go binary on it fails every HTTPS call with
|
||||||
|
# "x509: certificate signed by unknown authority". Nothing needed one until single
|
||||||
|
# sign-on: discovery and the token exchange are HTTPS calls to the identity provider.
|
||||||
|
# The bundle is the builder's, copied by name so a missing file fails the build
|
||||||
|
# rather than shipping an image that cannot sign anybody in.
|
||||||
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||||
COPY --from=builder /terdut /terdut
|
COPY --from=builder /terdut /terdut
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/terdut"]
|
ENTRYPOINT ["/terdut"]
|
||||||
|
|||||||
@@ -132,6 +132,110 @@ the one place membership is edited from the person's side — the Team tab answe
|
|||||||
"who is in this team", and answering "which teams is this person in" there means
|
"who is in this team", and answering "which teams is this person in" there means
|
||||||
visiting each team in turn.
|
visiting each team in turn.
|
||||||
|
|
||||||
|
### Single sign-on (OIDC)
|
||||||
|
|
||||||
|
terdut can sign people in through any OpenID Connect provider; the examples use
|
||||||
|
[Authentik](https://goauthentik.io/). Groups at the provider decide who may sign
|
||||||
|
in, which teams they belong to and whether they administer the install, much as
|
||||||
|
Grafana's OAuth role and org mapping does. Password login keeps working alongside
|
||||||
|
it unless you turn it off.
|
||||||
|
|
||||||
|
**At the provider**, create an OAuth2/OpenID provider and an application for it:
|
||||||
|
a *confidential* client, redirect URI `<TERDUT_PUBLIC_URL>/api/oidc/callback`, and
|
||||||
|
the `openid`, `profile` and `email` scopes. The issuer is the application's, e.g.
|
||||||
|
`https://auth.example.com/application/o/terdut/`. Then set:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TERDUT_PUBLIC_URL=https://terdut.example.com
|
||||||
|
TERDUT_OIDC_ISSUER=https://auth.example.com/application/o/terdut/
|
||||||
|
TERDUT_OIDC_CLIENT_ID=terdut
|
||||||
|
TERDUT_OIDC_CLIENT_SECRET=...
|
||||||
|
TERDUT_OIDC_ALLOWED_GROUPS=terdut-users,terdut-admins
|
||||||
|
TERDUT_OIDC_ADMIN_GROUP=terdut-admins
|
||||||
|
```
|
||||||
|
|
||||||
|
Which team a group grants is not server-wide config: each team names its own
|
||||||
|
group(s), set by that team's own owner (or an administrator) from its Members
|
||||||
|
tab, or `PUT /api/teams/{teamID}/oidc-groups {"member_group":"sre","owner_group":"sre-leads"}`.
|
||||||
|
A team must already exist before a group can grant access to it — the sync
|
||||||
|
never creates one.
|
||||||
|
|
||||||
|
The web UI's sign-in page shows a "Sign in with <name>" button (a plain link to
|
||||||
|
`/api/oidc/login`) above the password form, or instead of it when
|
||||||
|
`TERDUT_PASSWORD_LOGIN=false`; it asks `GET /api/auth/config` what the server offers
|
||||||
|
(`password_login`, `oidc.enabled`, `oidc.name`). A refused sign-in comes back to that
|
||||||
|
page with the reason spelled out. Access the groups grant is badged **SSO** on the
|
||||||
|
Team, Admin and per-user pages, with its edit and remove controls disabled, and the
|
||||||
|
Account page does not offer to set a password nobody could use.
|
||||||
|
|
||||||
|
**What a sign-in does**
|
||||||
|
|
||||||
|
1. *Who.* The provider's `(issuer, subject)` is the identity. The first time, a
|
||||||
|
user is found by email — only when the provider marks it verified, or
|
||||||
|
`TERDUT_OIDC_TRUST_EMAIL` is set — or created with no password. A username taken
|
||||||
|
by somebody else gets a numeric suffix (`alice-2`). Username and email follow the
|
||||||
|
provider at each sign-in. Authentik reports `email_verified` as false unless
|
||||||
|
configured otherwise, so linking existing users usually needs
|
||||||
|
`TERDUT_OIDC_TRUST_EMAIL=true`.
|
||||||
|
2. *Whether.* With `TERDUT_OIDC_ALLOWED_GROUPS` set, somebody in none of them is
|
||||||
|
refused and nothing is created.
|
||||||
|
3. *What.* The administrator flag follows `TERDUT_OIDC_ADMIN_GROUP`. Team roles
|
||||||
|
follow each team's own `oidc_member_group`/`oidc_owner_group`; where both of a
|
||||||
|
team's groups match, the owner group wins.
|
||||||
|
|
||||||
|
**Managed access.** What the sync grants is marked as managed by single sign-on,
|
||||||
|
and only that is ever changed by it. It is added at sign-in, and removed at the
|
||||||
|
next sign-in after the group is gone, even if that leaves a team without an owner
|
||||||
|
(an administrator can always repair a team) — the provider is the source of truth
|
||||||
|
for what it grants, so the last-owner and last-administrator guards do not apply.
|
||||||
|
Memberships and administrators added by hand are left alone; the exception is a
|
||||||
|
hand-added member whose team's own group grants a *higher* role, who is raised and
|
||||||
|
from then on managed. Editing managed access by hand (`POST` or `DELETE` on a
|
||||||
|
team's members, revoking an SSO-granted administrator) is refused with `409`, since
|
||||||
|
the next sign-in would undo it.
|
||||||
|
|
||||||
|
> **Upgrading past migration 013: reconfigure every team's groups.**
|
||||||
|
> `TERDUT_OIDC_GROUP_MAPPINGS` is gone, and the sync no longer creates a team by
|
||||||
|
> name. Group-to-team-role mapping is now each team's own setting — an owner sets
|
||||||
|
> it from the Members tab, or `PUT /api/teams/{teamID}/oidc-groups`. Until a team's
|
||||||
|
> owner does that, an OIDC-sourced membership in it is dropped at that user's next
|
||||||
|
> SSO sign-in, the same as any other loss of group access. Set every team's groups
|
||||||
|
> before affected users next sign in, to avoid a visible gap in access.
|
||||||
|
|
||||||
|
**How fast changes arrive.** Groups are read only at sign-in. A session made by an
|
||||||
|
SSO sign-in has a hard ceiling (`TERDUT_OIDC_SESSION_MAX_AGE`, default 12h) that
|
||||||
|
sliding never extends, so a change at the provider reaches terdut within that time.
|
||||||
|
Password sessions are unaffected.
|
||||||
|
|
||||||
|
> **API keys are not revoked when somebody is removed at the provider.** terdut
|
||||||
|
> holds no refresh token and never asks the provider again, so a person removed
|
||||||
|
> from every allowed group loses their sessions within `TERDUT_OIDC_SESSION_MAX_AGE`
|
||||||
|
> and cannot sign in again, but keeps any API key they made (the TUI and scripts use
|
||||||
|
> them) until an administrator disables the user in terdut.
|
||||||
|
|
||||||
|
**Signing in from a terminal.** A client with no browser of its own, such as the
|
||||||
|
TUI over SSH, signs in with a device code, run by terdut itself so the terminal
|
||||||
|
never talks to the provider:
|
||||||
|
|
||||||
|
1. The terminal calls `POST /api/oidc/device` and shows the person a link
|
||||||
|
(`<TERDUT_PUBLIC_URL>/device?code=XXXX-XXXX`) and the code.
|
||||||
|
2. On any device the person opens the link, signs in (by the provider or by
|
||||||
|
password, whatever the login page offers), sees the code and the account, and
|
||||||
|
presses **Approve**. Only a browser session can approve; an API key cannot.
|
||||||
|
3. The terminal polls `POST /api/oidc/device/token` every 5 seconds and is given the
|
||||||
|
ordinary `terdut_session` cookie once. A person who signs in through the provider
|
||||||
|
gets the same `TERDUT_OIDC_SESSION_MAX_AGE` ceiling on the terminal's session as
|
||||||
|
on their browser's.
|
||||||
|
|
||||||
|
A login expires after 10 minutes. `GET /api/auth/config` reports `device_login`.
|
||||||
|
|
||||||
|
**If the provider is down**, terdut still starts (discovery is fetched on first
|
||||||
|
use) and password login is the way in. With `TERDUT_PASSWORD_LOGIN=false` that way
|
||||||
|
is closed: set it back to `true`. The first administrator comes from the bootstrap
|
||||||
|
endpoint, and stays a manual administrator that no group can revoke; on an SSO-only
|
||||||
|
install set `bootstrap.enabled: false` in the chart if you don't want that account,
|
||||||
|
or keep it and never give it a password.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -229,6 +333,16 @@ over an administrator's edit.
|
|||||||
| `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call |
|
| `TERDUT_NTFY_FALLBACK_TOPIC` | — | Topic used when nobody is on call |
|
||||||
| `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server: the notification's link into the web UI, its Acknowledge button, and whether the session cookie is `Secure` |
|
| `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server: the notification's link into the web UI, its Acknowledge button, and whether the session cookie is `Secure` |
|
||||||
| `TERDUT_NOTIFY_REPEAT` | `15m` | **seed.** How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats |
|
| `TERDUT_NOTIFY_REPEAT` | `15m` | **seed.** How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats |
|
||||||
|
| `TERDUT_PASSWORD_LOGIN` | `true` | `false` refuses password login and password sign-up (`403`), leaving single sign-on the only way in. Refused at startup unless SSO is configured |
|
||||||
|
| `TERDUT_OIDC_ISSUER` | — | Turns single sign-on on. The provider's issuer URL; discovery is read from `<issuer>/.well-known/openid-configuration`. See [Single sign-on](#single-sign-on-oidc) |
|
||||||
|
| `TERDUT_OIDC_CLIENT_ID` / `TERDUT_OIDC_CLIENT_SECRET` | — | **Required with an issuer.** The confidential client registered at the provider. Keep the secret in a Secret, not in values |
|
||||||
|
| `TERDUT_OIDC_NAME` | `SSO` | What the sign-in button calls the provider |
|
||||||
|
| `TERDUT_OIDC_SCOPES` | `openid profile email` | Scopes requested, comma or space separated. Authentik puts `groups` behind `profile` |
|
||||||
|
| `TERDUT_OIDC_USERNAME_CLAIM` / `_EMAIL_CLAIM` / `_GROUPS_CLAIM` | `preferred_username` / `email` / `groups` | ID token claims read for the username, email and groups |
|
||||||
|
| `TERDUT_OIDC_TRUST_EMAIL` | `false` | Link a first sign-in to an existing local user by email even if the provider does not mark the address verified |
|
||||||
|
| `TERDUT_OIDC_ALLOWED_GROUPS` | — | Comma-separated. Only people in one of these may sign in. Empty admits everybody the provider authenticates |
|
||||||
|
| `TERDUT_OIDC_ADMIN_GROUP` | — | Members are system administrators |
|
||||||
|
| `TERDUT_OIDC_SESSION_MAX_AGE` | `12h` | Hard ceiling on a session made by an SSO sign-in |
|
||||||
|
|
||||||
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
|
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
|
||||||
|
|
||||||
@@ -236,7 +350,7 @@ Note that `TERDUT_STALE_AFTER` and `TERDUT_DEADMAN_TIMEOUT` point in opposite di
|
|||||||
is a generous grace period around a `repeat_interval` you do not control; a dead man's switch is a
|
is a generous grace period around a `repeat_interval` you do not control; a dead man's switch is a
|
||||||
deadline you set deliberately, and the heartbeat's route is configured to beat faster than it.
|
deadline you set deliberately, and the heartbeat's route is configured to beat faster than it.
|
||||||
|
|
||||||
In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and `sweeper.archiveAfter`, dead man's switches via the `deadman.*` values, and notifications via the `notify.*` values.
|
In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and `sweeper.archiveAfter`, dead man's switches via the `deadman.*` values, notifications via the `notify.*` values, and single sign-on via `oidc.*` and `passwordLogin`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -513,19 +627,27 @@ nothing unless something downstream notices it stop. That is what
|
|||||||
`TERDUT_DEADMAN_MATCHERS` defaults to.
|
`TERDUT_DEADMAN_MATCHERS` defaults to.
|
||||||
|
|
||||||
**Switches belong to a team**, which decides which of its own alerts are
|
**Switches belong to a team**, which decides which of its own alerts are
|
||||||
heartbeats and how long a silence has to last. An owner sets them through
|
heartbeats and how long a silence has to last. Each **switch** is a row of its
|
||||||
`PUT /api/teams/{teamID}/deadman`; a missed heartbeat opens an incident in the
|
own — a name, one matcher, a timeout and a severity — so switches in one team
|
||||||
team whose integration received it.
|
can have different deadlines. An owner adds and removes them on **Team →
|
||||||
|
Switches**, which lists each with a status (**healthy**, **dead**, or
|
||||||
|
**dormant** until its first heartbeat), when it was last heard from, and when it
|
||||||
|
last opened an incident; a matcher that several clusters satisfy is broken down
|
||||||
|
per cluster. The API is `POST`/`DELETE /api/teams/{teamID}/deadman/switches`. A
|
||||||
|
missed heartbeat opens an incident in the team whose integration received it.
|
||||||
|
Removing a switch stops the watching; an incident it already opened stays open
|
||||||
|
until somebody resolves it.
|
||||||
|
|
||||||
The environment variables are the starting point, not the setting: at startup
|
The environment variables are the starting point, not the setting: the **first**
|
||||||
every team **without** a configuration of its own is given one from them, and an
|
time the server starts, every team is given a switch per default matcher from
|
||||||
owner's later edit is never overwritten by a redeploy. A team created after
|
them, once. After that a team's switches are its own — an owner's edit or
|
||||||
that starts watching nothing until its owner says otherwise — inheriting an
|
deletion is never put back by a redeploy. A team created later starts watching
|
||||||
install-wide heartbeat would page a new team about a source it has never heard
|
nothing until its owner says otherwise — inheriting an install-wide heartbeat
|
||||||
of.
|
would page a new team about a source it has never heard of.
|
||||||
|
|
||||||
A matcher is a set of exact label conditions, one of which must be the
|
A matcher is a set of exact label conditions, one of which must be the
|
||||||
`alertname`, in the same format the environment variable uses:
|
`alertname`, in the format the environment variable uses (one matcher per switch; the
|
||||||
|
variable takes several, separated by `;`):
|
||||||
|
|
||||||
```
|
```
|
||||||
alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat
|
alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat
|
||||||
@@ -582,7 +704,9 @@ of the last heartbeat, and the heartbeat's labels are on the incident's
|
|||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
All endpoints except `/api/bootstrap`, `/api/integrations/{key}/alertmanager`,
|
All endpoints except `/api/bootstrap`, `/api/integrations/{key}/alertmanager`,
|
||||||
`/api/notify/ack/{token}`, `/api/login` and `/api/logout` require either an API key:
|
`/api/notify/ack/{token}`, `/api/login`, `/api/logout`, `/api/auth/config`,
|
||||||
|
`/api/oidc/login`, `/api/oidc/callback`, `/api/oidc/device` and `/api/oidc/device/token`
|
||||||
|
require either an API key:
|
||||||
|
|
||||||
```
|
```
|
||||||
Authorization: Bearer <api-key>
|
Authorization: Bearer <api-key>
|
||||||
@@ -642,7 +766,14 @@ incident exists is itself something only its team should learn.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures |
|
| `GET` | `/api/auth/config` | How to sign in: `{"password_login", "oidc": {"enabled","name"}, "device_login"}`. No session needed |
|
||||||
|
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures; `403` when `TERDUT_PASSWORD_LOGIN=false` |
|
||||||
|
| `GET` | `/api/oidc/login` | Starts a single sign-on sign-in: redirects the browser to the provider. `?next=/path` is where to land afterwards; only a path on this server is honoured. Only exists when SSO is configured |
|
||||||
|
| `POST` | `/api/oidc/device` | Starts a device login: returns `{device_code, user_code, verification_url, interval, expires_in}`. Only exists when SSO is configured |
|
||||||
|
| `POST` | `/api/oidc/device/token` | `{"device_code"}` → `202 {"status":"pending"}`, then `200` with the session cookie once approved (once only). `410` with `{"error":"expired"}` or `{"error":"denied"}`; `429 {"error":"slow_down"}` if polled faster than `interval` |
|
||||||
|
| `POST` | `/api/oidc/device/approve` | **session** — `{"user_code"}`. Approves a pending device login as the caller. `403` for an API key; `404` for an unknown, expired or already decided code |
|
||||||
|
| `POST` | `/api/oidc/device/deny` | **session** — `{"user_code"}`. Refuses it |
|
||||||
|
| `GET` | `/api/oidc/callback` | Where the provider sends the browser back. Sets the session cookie and redirects to `/`, or to `/?sso_error=<code>` — one of `denied`, `expired`, `failed`, `unavailable`, `not_allowed`, `no_email`, `email_conflict`, `disabled` |
|
||||||
| `POST` | `/api/logout` | Ends the session and clears the cookie |
|
| `POST` | `/api/logout` | Ends the session and clears the cookie |
|
||||||
| `GET` | `/api/me` | The caller: `{user, has_password}` |
|
| `GET` | `/api/me` | The caller: `{user, has_password}` |
|
||||||
|
|
||||||
@@ -663,7 +794,7 @@ on anybody's.
|
|||||||
| `GET` | `/api/users/{id}/teams` | self or admin | The teams that user is in, each with their role. `/api/teams` is always about the caller; this one answers it about somebody else, for the admin page's per-user view. `404` for a user who does not exist, so "no teams" and "no such person" are distinguishable |
|
| `GET` | `/api/users/{id}/teams` | self or admin | The teams that user is in, each with their role. `/api/teams` is always about the caller; this one answers it about somebody else, for the admin page's per-user view. `404` for a user who does not exist, so "no teams" and "no such person" are distinguishable |
|
||||||
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
|
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
|
||||||
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
|
| `DELETE` | `/api/users/{id}` | **admin** | Delete user (cascades to keys). `409` for yourself or the last administrator |
|
||||||
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself or the last administrator |
|
| `PUT` | `/api/users/{id}/admin` | **admin** | Grant or revoke the administrator flag `{"is_admin"}`. `409` for yourself, the last administrator, or an administrator granted by single sign-on |
|
||||||
| `PUT` | `/api/users/{id}/disabled` | **admin** | Take an account out of use, or put it back `{"disabled"}`. `409` for yourself or the last administrator |
|
| `PUT` | `/api/users/{id}/disabled` | **admin** | Take an account out of use, or put it back `{"disabled"}`. `409` for yourself or the last administrator |
|
||||||
| `PUT` | `/api/users/{id}/notify` | self or admin | Set push notification target `{"ntfy_topic"}` — empty string clears it |
|
| `PUT` | `/api/users/{id}/notify` | self or admin | Set push notification target `{"ntfy_topic"}` — empty string clears it |
|
||||||
| `PUT` | `/api/users/{id}/password` | self or admin | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions |
|
| `PUT` | `/api/users/{id}/password` | self or admin | Set web UI password `{"password","current_password"}`. `current_password` is required only when changing your own existing password. Ends the user's other sessions |
|
||||||
@@ -707,19 +838,23 @@ administrator who is not in the team gets the same `404` as anybody else.
|
|||||||
| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner |
|
| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner |
|
||||||
| `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken |
|
| `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken |
|
||||||
| `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents |
|
| `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents |
|
||||||
| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team |
|
| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team, with `status` (`oncall` if the rota has them today, `unpageable` when a page to them would go nowhere — even if they are on call — else `reachable`), `on_call`, `next_shift` (first rota day after today), `pageable` and `problem` (`has no ntfy topic` / `account is disabled`; never the topic itself) and `last_active_at` (their newest session or API-key use). Every member sees the same list |
|
||||||
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}` |
|
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}`. `409` when it would demote the last owner, or the membership is managed by single sign-on |
|
||||||
| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner |
|
| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner, or a membership managed by single sign-on |
|
||||||
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys |
|
| `GET` | `/api/teams/{teamID}/oidc-groups` | member | Which groups control this team's membership: `{"member_group","owner_group"}`. An empty string means no group grants that role here |
|
||||||
|
| `PUT` | `/api/teams/{teamID}/oidc-groups` | **owner** | Set them. An empty string clears a binding |
|
||||||
|
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys. Each carries `status` (`active` if its key posted within 24h, `quiet` if it has but not lately, `never`), `last_used_at` (last webhook, usable or not), `last_alert_at` (when an alert last arrived on it) and `alerts_24h` (distinct alerts it refreshed in the last day). Alerts delivered before the source was recorded (migration 010) have none, so the last two fill in as Alertmanager re-sends them |
|
||||||
|
| `PATCH` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Rename `{"name"}`. The key does not change |
|
||||||
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
|
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
|
||||||
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration |
|
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration. Alerts it delivered stay, unattributed |
|
||||||
| `GET` | `/api/teams/{teamID}/invites` | **owner** | The team's invite links, with their uses and expiry. Never the tokens |
|
| `GET` | `/api/teams/{teamID}/invites` | **owner** | The team's invite links, with their uses and expiry. Never the tokens |
|
||||||
| `POST` | `/api/teams/{teamID}/invites` | **owner** | Mint one `{"role","max_uses"}` — the full URL is returned once |
|
| `POST` | `/api/teams/{teamID}/invites` | **owner** | Mint one `{"role","max_uses"}` — the full URL is returned once |
|
||||||
| `DELETE` | `/api/teams/{teamID}/invites/{inviteID}` | **owner** | Revoke a link before it expires |
|
| `DELETE` | `/api/teams/{teamID}/invites/{inviteID}` | **owner** | Revoke a link before it expires |
|
||||||
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none |
|
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[], last_escalated_at?, last_escalated_incident_id?}`. Empty levels means the team has none. Each level also carries `status` (`ready`, `escalating` when an unanswered incident has climbed to it, `unreachable` when nobody on it could be woken), `waiting` (ids of the open incidents on it) and, per target, `username` (who it means today — the person on call, for a rota target), `reachable` and `problem`. The extra fields are output only; `PUT` takes the plain shape |
|
||||||
| `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
|
| `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
|
||||||
| `GET` | `/api/teams/{teamID}/deadman` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` |
|
| `GET` | `/api/teams/{teamID}/deadman/switches` | member | The team's [dead man's switches](#dead-mans-switch), each `{id, name, matcher, timeout_seconds, severity, status, last_heartbeat_at, last_triggered_at, open_incident_id, sources[]}`. `status` is `healthy`, `dead` or `dormant`; `sources` has one entry per heartbeat fingerprint. Empty when the team watches nothing |
|
||||||
| `PUT` | `/api/teams/{teamID}/deadman` | **owner** | Replace it. `400` when no matcher names an `alertname`, because a switch that silently watches nothing is the failure this feature exists to prevent |
|
| `POST` | `/api/teams/{teamID}/deadman/switches` | **owner** | Add one: `{name?, matcher, timeout_seconds, severity?}`. `400` when the matcher names no `alertname` or holds several, or the timeout is not positive — a switch that silently watches nothing is the failure this feature exists to prevent |
|
||||||
|
| `DELETE` | `/api/teams/{teamID}/deadman/switches/{switchID}` | **owner** | Stop watching. An incident it opened stays open. `404` for a switch of another team |
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
@@ -964,8 +1099,8 @@ What changes, and will need attention:
|
|||||||
|
|
||||||
**Dead man's switches moved too.** `TERDUT_DEADMAN_MATCHERS`, `_TIMEOUT` and
|
**Dead man's switches moved too.** `TERDUT_DEADMAN_MATCHERS`, `_TIMEOUT` and
|
||||||
`_SEVERITY` are no longer the setting; they are the default each existing team
|
`_SEVERITY` are no longer the setting; they are the default each existing team
|
||||||
is seeded with at startup, after which an owner edits them per team through
|
is seeded with at startup, after which an owner manages them per team through
|
||||||
`PUT /api/teams/{teamID}/deadman` and a redeploy never overwrites that.
|
`/api/teams/{teamID}/deadman/switches` and a redeploy never overwrites that.
|
||||||
|
|
||||||
Nothing else about an incident changes, and incidents never move between teams:
|
Nothing else about an incident changes, and incidents never move between teams:
|
||||||
an alert belongs to whichever team's key it arrived on.
|
an alert belongs to whichever team's key it arrived on.
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.21.0
|
version: 0.31.0
|
||||||
appVersion: "v0.21.0"
|
appVersion: "v0.31.0"
|
||||||
|
|||||||
@@ -61,8 +61,6 @@ spec:
|
|||||||
value: "{{ .Values.notify.fallbackTopic }}"
|
value: "{{ .Values.notify.fallbackTopic }}"
|
||||||
- name: TERDUT_NOTIFY_REPEAT
|
- name: TERDUT_NOTIFY_REPEAT
|
||||||
value: "{{ .Values.notify.repeatEvery }}"
|
value: "{{ .Values.notify.repeatEvery }}"
|
||||||
- name: TERDUT_PUBLIC_URL
|
|
||||||
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
|
|
||||||
{{- if .Values.notify.tokenSecret.name }}
|
{{- if .Values.notify.tokenSecret.name }}
|
||||||
- name: TERDUT_NTFY_TOKEN
|
- name: TERDUT_NTFY_TOKEN
|
||||||
valueFrom:
|
valueFrom:
|
||||||
@@ -71,6 +69,45 @@ spec:
|
|||||||
key: {{ .Values.notify.tokenSecret.key }}
|
key: {{ .Values.notify.tokenSecret.key }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
# Set whether or not ntfy is: single sign-on builds its redirect URI
|
||||||
|
# from it, and sessions use it to decide the cookie's Secure flag.
|
||||||
|
- name: TERDUT_PUBLIC_URL
|
||||||
|
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
|
||||||
|
- name: TERDUT_PASSWORD_LOGIN
|
||||||
|
value: {{ .Values.passwordLogin | quote }}
|
||||||
|
{{- if .Values.oidc.enabled }}
|
||||||
|
- name: TERDUT_OIDC_ISSUER
|
||||||
|
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
|
||||||
|
- name: TERDUT_OIDC_CLIENT_ID
|
||||||
|
value: {{ required "oidc.clientId is required when oidc.enabled" .Values.oidc.clientId | quote }}
|
||||||
|
- name: TERDUT_OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ required "oidc.clientSecret.name is required when oidc.enabled" .Values.oidc.clientSecret.name }}
|
||||||
|
key: {{ .Values.oidc.clientSecret.key }}
|
||||||
|
- name: TERDUT_OIDC_NAME
|
||||||
|
value: {{ .Values.oidc.name | quote }}
|
||||||
|
- name: TERDUT_OIDC_SCOPES
|
||||||
|
value: {{ .Values.oidc.scopes | quote }}
|
||||||
|
- name: TERDUT_OIDC_USERNAME_CLAIM
|
||||||
|
value: {{ .Values.oidc.usernameClaim | quote }}
|
||||||
|
- name: TERDUT_OIDC_EMAIL_CLAIM
|
||||||
|
value: {{ .Values.oidc.emailClaim | quote }}
|
||||||
|
- name: TERDUT_OIDC_GROUPS_CLAIM
|
||||||
|
value: {{ .Values.oidc.groupsClaim | quote }}
|
||||||
|
- name: TERDUT_OIDC_TRUST_EMAIL
|
||||||
|
value: {{ .Values.oidc.trustEmail | quote }}
|
||||||
|
- name: TERDUT_OIDC_SESSION_MAX_AGE
|
||||||
|
value: {{ .Values.oidc.sessionMaxAge | quote }}
|
||||||
|
{{- if .Values.oidc.allowedGroups }}
|
||||||
|
- name: TERDUT_OIDC_ALLOWED_GROUPS
|
||||||
|
value: {{ join "," .Values.oidc.allowedGroups | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.oidc.adminGroup }}
|
||||||
|
- name: TERDUT_OIDC_ADMIN_GROUP
|
||||||
|
value: {{ .Values.oidc.adminGroup | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
|
|||||||
@@ -107,6 +107,54 @@ notify:
|
|||||||
name: ""
|
name: ""
|
||||||
key: token
|
key: token
|
||||||
|
|
||||||
|
# Whether a user may sign in, or sign up, with a password. Turn it off once
|
||||||
|
# single sign-on works, to make it the only way in; turn it back on (and
|
||||||
|
# redeploy) if the identity provider is down and somebody has to get in.
|
||||||
|
passwordLogin: true
|
||||||
|
|
||||||
|
# Single sign-on through an OpenID Connect provider such as Authentik.
|
||||||
|
#
|
||||||
|
# At the provider, create an OAuth2/OpenID application whose redirect URI is
|
||||||
|
# <notify.publicUrl>/api/oidc/callback
|
||||||
|
# (publicUrl defaults to https://<networking.hostname>), a confidential client, and
|
||||||
|
# put the client secret in an existing Secret named by clientSecret below.
|
||||||
|
#
|
||||||
|
# Groups from the provider decide what a person can do. Access it grants is
|
||||||
|
# marked as managed by single sign-on and is re-read at every sign-in; anything
|
||||||
|
# added by hand in terdut is left alone. Changes in the provider take effect at
|
||||||
|
# the person's next sign-in, at most sessionMaxAge later. API keys are NOT
|
||||||
|
# revoked when somebody is removed at the provider: disable the user in terdut too.
|
||||||
|
oidc:
|
||||||
|
enabled: false
|
||||||
|
# Issuer URL. For Authentik: https://<authentik>/application/o/<app-slug>/
|
||||||
|
issuer: ""
|
||||||
|
clientId: ""
|
||||||
|
clientSecret:
|
||||||
|
name: ""
|
||||||
|
key: client-secret
|
||||||
|
# What the sign-in button calls the provider.
|
||||||
|
name: SSO
|
||||||
|
# Authentik puts the groups claim behind the profile scope.
|
||||||
|
scopes: "openid profile email"
|
||||||
|
usernameClaim: preferred_username
|
||||||
|
emailClaim: email
|
||||||
|
groupsClaim: groups
|
||||||
|
# Link a first sign-in to an existing local user with the same email even when
|
||||||
|
# the provider does not mark the address verified. Authentik reports
|
||||||
|
# email_verified as false unless configured otherwise.
|
||||||
|
trustEmail: false
|
||||||
|
# Only people in one of these groups may sign in. Empty admits everybody the
|
||||||
|
# provider authenticates, and access control is left to the provider.
|
||||||
|
allowedGroups: []
|
||||||
|
# Members of this group are system administrators.
|
||||||
|
adminGroup: ""
|
||||||
|
# Which group grants a team's membership and ownership is each team's own
|
||||||
|
# setting now, not chart config: an owner sets it from the Members tab, or
|
||||||
|
# PUT /api/teams/{teamID}/oidc-groups. A team must already exist for a group
|
||||||
|
# to grant access to it.
|
||||||
|
# Hard ceiling on a session made by a single sign-on login.
|
||||||
|
sessionMaxAge: 12h
|
||||||
|
|
||||||
# Backups are no longer this chart's business. The SQLite database lived on a PVC
|
# Backups are no longer this chart's business. The SQLite database lived on a PVC
|
||||||
# beside the app, so it needed a sidecar with a sqlite3 module for k8up to exec a
|
# beside the app, so it needed a sidecar with a sqlite3 module for k8up to exec a
|
||||||
# dump in; Postgres is backed up where it runs, through a k8up.io/backupcommand
|
# dump in; Postgres is backed up where it runs, through a k8up.io/backupcommand
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ var version = "dev"
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
database, err := db.Open(cfg.DSN)
|
database, err := db.Open(cfg.DSN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ module git.ryuvia.com/niklas/terdut-server
|
|||||||
go 1.25.9
|
go 1.25.9
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.55.0
|
||||||
|
golang.org/x/oauth2 v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
|
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
|
||||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
@@ -22,6 +26,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ type ingested struct {
|
|||||||
// post, and which team the alerts belong to.
|
// post, and which team the alerts belong to.
|
||||||
func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc {
|
func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
teamID, err := teamIDForKey(r.Context(), db, chi.URLParam(r, "key"))
|
src, err := sourceForKey(r.Context(), db, chi.URLParam(r, "key"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errUnknownIntegration) {
|
if errors.Is(err, errUnknownIntegration) {
|
||||||
// 401 and not 404: the path is real, the key is not, and a
|
// 401 and not 404: the path is real, the key is not, and a
|
||||||
@@ -90,11 +90,12 @@ func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc
|
|||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
receiveWebhook(w, r, db, notify, teamID)
|
receiveWebhook(w, r, db, notify, src)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, teamID int64) {
|
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, src alertSource) {
|
||||||
|
teamID := src.teamID
|
||||||
var payload amPayload
|
var payload amPayload
|
||||||
if err := decodeJSON(r, &payload); err != nil {
|
if err := decodeJSON(r, &payload); err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid payload"))
|
respond(w, http.StatusBadRequest, errResp("invalid payload"))
|
||||||
@@ -104,7 +105,7 @@ func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify N
|
|||||||
// Alertmanager retries anything that is not 2xx, and a retry of a payload
|
// Alertmanager retries anything that is not 2xx, and a retry of a payload
|
||||||
// we failed to store is more useful than an error it cannot act on — so
|
// we failed to store is more useful than an error it cannot act on — so
|
||||||
// failures are logged, not surfaced.
|
// failures are logged, not surfaced.
|
||||||
if err := ingest(r.Context(), db, notify, teamID, payload); err != nil {
|
if err := ingest(r.Context(), db, notify, src, payload); err != nil {
|
||||||
log.Printf("webhook ingest (team %d, group %q): %v", teamID, payload.GroupKey, err)
|
log.Printf("webhook ingest (team %d, group %q): %v", teamID, payload.GroupKey, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +115,8 @@ func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify N
|
|||||||
// ingest stores a payload's alerts and reconciles the incident for its group.
|
// ingest stores a payload's alerts and reconciles the incident for its group.
|
||||||
// The whole payload is one transaction: an incident that opened but whose alerts
|
// The whole payload is one transaction: an incident that opened but whose alerts
|
||||||
// failed to link would be a work item nobody could act on.
|
// failed to link would be a work item nobody could act on.
|
||||||
func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64, payload amPayload) error {
|
func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, src alertSource, payload amPayload) error {
|
||||||
|
teamID := src.teamID
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -124,12 +126,12 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64,
|
|||||||
// Which arriving alerts are heartbeats is the team's own answer, read
|
// Which arriving alerts are heartbeats is the team's own answer, read
|
||||||
// inside the transaction so an owner editing it mid-payload cannot split
|
// inside the transaction so an owner editing it mid-payload cannot split
|
||||||
// one webhook across two interpretations.
|
// one webhook across two interpretations.
|
||||||
deadman, err := deadmanConfigForTeam(ctx, tx, teamID)
|
deadman, err := deadmanSetForTeam(ctx, tx, teamID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
accepted, err := upsertAlerts(ctx, tx, deadman, teamID, payload.Alerts)
|
accepted, err := upsertAlerts(ctx, tx, deadman, src, payload.Alerts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -186,7 +188,8 @@ func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64,
|
|||||||
|
|
||||||
// upsertAlerts stores each alert of a payload and reports what changed. Payloads
|
// upsertAlerts stores each alert of a payload and reports what changed. Payloads
|
||||||
// the ordering guard rejected are left out entirely.
|
// the ordering guard rejected are left out entirely.
|
||||||
func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID int64, alerts []amAlert) ([]ingested, error) {
|
func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman deadmanSet, src alertSource, alerts []amAlert) ([]ingested, error) {
|
||||||
|
teamID := src.teamID
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
accepted := make([]ingested, 0, len(alerts))
|
accepted := make([]ingested, 0, len(alerts))
|
||||||
|
|
||||||
@@ -242,8 +245,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID
|
|||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO alerts
|
INSERT INTO alerts
|
||||||
(team_id, fingerprint, name, status, labels, annotations, starts_at, ends_at,
|
(team_id, fingerprint, name, status, labels, annotations, starts_at, ends_at,
|
||||||
generator_url, received_at, resolution_source)
|
generator_url, received_at, resolution_source, integration_id)
|
||||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11, $12)
|
||||||
ON CONFLICT (team_id, fingerprint) DO UPDATE SET
|
ON CONFLICT (team_id, fingerprint) DO UPDATE SET
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
labels = excluded.labels,
|
labels = excluded.labels,
|
||||||
@@ -257,6 +260,8 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID
|
|||||||
-- is a breaking API change — see models.Alert.ReceivedAt.
|
-- is a breaking API change — see models.Alert.ReceivedAt.
|
||||||
received_at = excluded.received_at,
|
received_at = excluded.received_at,
|
||||||
resolution_source = excluded.resolution_source,
|
resolution_source = excluded.resolution_source,
|
||||||
|
-- Last sender wins; see migration 010.
|
||||||
|
integration_id = excluded.integration_id,
|
||||||
-- A re-fire makes the alert current again, so it leaves the archive.
|
-- A re-fire makes the alert current again, so it leaves the archive.
|
||||||
archived_at = CASE WHEN excluded.status = 'firing'
|
archived_at = CASE WHEN excluded.status = 'firing'
|
||||||
THEN NULL ELSE alerts.archived_at END
|
THEN NULL ELSE alerts.archived_at END
|
||||||
@@ -267,7 +272,7 @@ func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID
|
|||||||
teamID, a.Fingerprint, name, a.Status,
|
teamID, a.Fingerprint, name, a.Status,
|
||||||
string(labelsJSON), string(annotationsJSON),
|
string(labelsJSON), string(annotationsJSON),
|
||||||
a.StartsAt.Unix(), endsAtUnix,
|
a.StartsAt.Unix(), endsAtUnix,
|
||||||
a.GeneratorURL, now, resolutionSource,
|
a.GeneratorURL, now, resolutionSource, src.integrationID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -384,10 +389,10 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID in
|
|||||||
|
|
||||||
var id int64
|
var id int64
|
||||||
err = q.QueryRowContext(ctx, `
|
err = q.QueryRowContext(ctx, `
|
||||||
INSERT INTO incidents (team_id, group_key, title, group_labels, status, severity, triggered_at, assigned_to)
|
INSERT INTO incidents (team_id, group_key, title, group_labels, signature, status, severity, triggered_at, assigned_to)
|
||||||
VALUES ($1, $2, $3, $4::jsonb, 'triggered', $5, $6, $7)
|
VALUES ($1, $2, $3, $4::jsonb, $5, 'triggered', $6, $7, $8)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
teamID, groupKey, title, string(labelsJSON), severity,
|
teamID, groupKey, title, string(labelsJSON), incidentSignature(groupLabels, title), severity,
|
||||||
time.Now().Unix(), onCall).Scan(&id)
|
time.Now().Unix(), onCall).Scan(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
+20
-14
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so
|
// ts wraps httptest.Server with a pre-bootstrapped API key. db is exposed so
|
||||||
@@ -50,9 +51,16 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
|
|||||||
if len(notify) > 0 {
|
if len(notify) > 0 {
|
||||||
cfg = notify[0]
|
cfg = notify[0]
|
||||||
}
|
}
|
||||||
|
return newTSWith(t, deadman, cfg, testConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTSWith is newDeadmanTS with the server's own configuration supplied, for
|
||||||
|
// tests of behaviour that config switches on, such as single sign-on.
|
||||||
|
func newTSWith(t *testing.T, deadman api.DeadmanConfig, cfg api.NotifyConfig, conf config.Config) *ts {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
srv := httptest.NewServer(api.NewRouter(database, cfg, testConfig()))
|
srv := httptest.NewServer(api.NewRouter(database, cfg, conf))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
|
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
|
||||||
@@ -88,27 +96,25 @@ func newDeadmanTS(t *testing.T, deadman api.DeadmanConfig, notify ...api.NotifyC
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// setTeamDeadman configures the default team's switches over the API, rendering
|
// setTeamDeadman gives the default team one switch per configured matcher, over
|
||||||
// the matchers back into the string form the endpoint takes.
|
// the API, the way an owner would add them.
|
||||||
func setTeamDeadman(t *testing.T, s *ts, cfg api.DeadmanConfig) {
|
func setTeamDeadman(t *testing.T, s *ts, cfg api.DeadmanConfig) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
matchers := make([]string, 0, len(cfg.Matchers))
|
|
||||||
for _, m := range cfg.Matchers {
|
for _, m := range cfg.Matchers {
|
||||||
parts := []string{"alertname=" + m.Name}
|
parts := []string{"alertname=" + m.Name}
|
||||||
for k, v := range m.Labels {
|
for k, v := range m.Labels {
|
||||||
parts = append(parts, k+"="+v)
|
parts = append(parts, k+"="+v)
|
||||||
}
|
}
|
||||||
sort.Strings(parts[1:])
|
sort.Strings(parts[1:])
|
||||||
matchers = append(matchers, strings.Join(parts, ","))
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", map[string]any{
|
||||||
}
|
"matcher": strings.Join(parts, ","),
|
||||||
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman", map[string]any{
|
"timeout_seconds": int64(cfg.Timeout.Seconds()),
|
||||||
"matchers": strings.Join(matchers, "; "),
|
"severity": cfg.Severity,
|
||||||
"timeout_seconds": int64(cfg.Timeout.Seconds()),
|
})
|
||||||
"severity": cfg.Severity,
|
resp.Body.Close()
|
||||||
})
|
if resp.StatusCode != http.StatusCreated {
|
||||||
defer resp.Body.Close()
|
t.Fatalf("add a dead man's switch: %d", resp.StatusCode)
|
||||||
if resp.StatusCode != http.StatusOK {
|
}
|
||||||
t.Fatalf("configure the team's dead man's switches: %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -143,15 +143,31 @@ func hashPassword(pw string) (string, error) {
|
|||||||
// sign-up: somebody who has just chosen a password is signed in, rather than
|
// sign-up: somebody who has just chosen a password is signed in, rather than
|
||||||
// being sent to a form to type the same credential again.
|
// being sent to a form to type the same credential again.
|
||||||
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
|
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
|
||||||
|
return startSessionCapped(w, r, db, userID, publicURL, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSessionCapped is startSession with a hard ceiling on the session's life,
|
||||||
|
// which sliding never extends. maxAge zero means no ceiling. A single sign-on
|
||||||
|
// login uses it: the login is the only moment the provider's groups are read, so
|
||||||
|
// a session that could outlive it indefinitely would keep access the provider
|
||||||
|
// has since taken away.
|
||||||
|
func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string, maxAge time.Duration) error {
|
||||||
raw, tokenHash, err := randomToken()
|
raw, tokenHash, err := randomToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
life := sessionTTL
|
||||||
|
var ceiling *int64
|
||||||
|
if maxAge > 0 {
|
||||||
|
c := now.Add(maxAge).Unix()
|
||||||
|
ceiling = &c
|
||||||
|
life = min(life, maxAge)
|
||||||
|
}
|
||||||
if _, err := db.ExecContext(r.Context(), `
|
if _, err := db.ExecContext(r.Context(), `
|
||||||
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
|
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, max_expires_at, user_agent)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
|
tokenHash, userID, now.Unix(), now.Unix(), now.Add(life).Unix(), ceiling, r.UserAgent()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +175,7 @@ func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int
|
|||||||
Name: sessionCookie,
|
Name: sessionCookie,
|
||||||
Value: raw,
|
Value: raw,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(sessionTTL.Seconds()),
|
MaxAge: int(life.Seconds()),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: cookieSecure(publicURL, r),
|
Secure: cookieSecure(publicURL, r),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
|||||||
+354
-174
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -39,6 +41,17 @@ func (m DeadmanMatcher) String() string {
|
|||||||
return m.Name + " (" + strings.Join(parts, ", ") + ")"
|
return m.Name + " (" + strings.Join(parts, ", ") + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// config renders the matcher in the form parseDeadmanMatcher reads, which is
|
||||||
|
// what a switch row stores: `alertname=Watchdog,cluster=prod`.
|
||||||
|
func (m DeadmanMatcher) config() string {
|
||||||
|
parts := make([]string, 0, len(m.Labels))
|
||||||
|
for k, v := range m.Labels {
|
||||||
|
parts = append(parts, k+"="+v)
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
return strings.Join(append([]string{"alertname=" + m.Name}, parts...), ",")
|
||||||
|
}
|
||||||
|
|
||||||
// matches reports whether an alert's labels satisfy every condition.
|
// matches reports whether an alert's labels satisfy every condition.
|
||||||
func (m DeadmanMatcher) matches(labels map[string]string) bool {
|
func (m DeadmanMatcher) matches(labels map[string]string) bool {
|
||||||
if labels["alertname"] != m.Name {
|
if labels["alertname"] != m.Name {
|
||||||
@@ -52,12 +65,10 @@ func (m DeadmanMatcher) matches(labels map[string]string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeadmanConfig inverts the handling of the alerts it matches: receiving one
|
// DeadmanConfig is the server-wide default a team's switches are seeded from:
|
||||||
// opens nothing, and the absence of one opens an incident.
|
// the environment's matchers, timeout and severity. Switches themselves are rows
|
||||||
//
|
// of a team's own — see DeadmanSwitch — and this is only how a fresh install
|
||||||
// The unit of monitoring is the fingerprint, not the matcher — two clusters
|
// starts out.
|
||||||
// sending the same heartbeat alertname are two independent switches, so one
|
|
||||||
// healthy cluster cannot mask a dead one.
|
|
||||||
type DeadmanConfig struct {
|
type DeadmanConfig struct {
|
||||||
Matchers []DeadmanMatcher
|
Matchers []DeadmanMatcher
|
||||||
|
|
||||||
@@ -76,41 +87,81 @@ type DeadmanConfig struct {
|
|||||||
// enabled reports whether there is anything to watch.
|
// enabled reports whether there is anything to watch.
|
||||||
func (c DeadmanConfig) enabled() bool { return c.Timeout > 0 && len(c.Matchers) > 0 }
|
func (c DeadmanConfig) enabled() bool { return c.Timeout > 0 && len(c.Matchers) > 0 }
|
||||||
|
|
||||||
// match returns the first matcher an alert satisfies.
|
// DeadmanSwitch inverts the handling of the alerts it matches: receiving one
|
||||||
func (c DeadmanConfig) match(labels map[string]string) (DeadmanMatcher, bool) {
|
// opens nothing, and the absence of one opens an incident.
|
||||||
if !c.enabled() {
|
//
|
||||||
return DeadmanMatcher{}, false
|
// The unit of monitoring is the fingerprint, not the switch — two clusters
|
||||||
}
|
// sending the same heartbeat alertname are two independent heartbeats under one
|
||||||
for _, m := range c.Matchers {
|
// switch, so one healthy cluster cannot mask a dead one.
|
||||||
if m.matches(labels) {
|
type DeadmanSwitch struct {
|
||||||
return m, true
|
ID int64
|
||||||
}
|
Name string
|
||||||
}
|
Matcher DeadmanMatcher
|
||||||
return DeadmanMatcher{}, false
|
|
||||||
|
// Timeout is how long a heartbeat may go unheard before it is declared dead.
|
||||||
|
Timeout time.Duration
|
||||||
|
|
||||||
|
// Severity is what the incident opens at.
|
||||||
|
Severity string
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDeadman is match without the matcher, for the ingest path.
|
// deadmanSet is one team's switches.
|
||||||
func (c DeadmanConfig) isDeadman(labels map[string]string) bool {
|
type deadmanSet []DeadmanSwitch
|
||||||
_, ok := c.match(labels)
|
|
||||||
|
// match returns the first switch an alert satisfies.
|
||||||
|
func (d deadmanSet) match(labels map[string]string) (DeadmanSwitch, bool) {
|
||||||
|
for _, sw := range d {
|
||||||
|
if sw.Matcher.matches(labels) {
|
||||||
|
return sw, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DeadmanSwitch{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDeadman is match without the switch, for the ingest path.
|
||||||
|
func (d deadmanSet) isDeadman(labels map[string]string) bool {
|
||||||
|
_, ok := d.match(labels)
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// names lists the distinct alertnames worth loading from the database.
|
// names lists the distinct alertnames worth loading from the database.
|
||||||
func (c DeadmanConfig) names() []string {
|
func (d deadmanSet) names() []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
out := make([]string, 0, len(c.Matchers))
|
out := make([]string, 0, len(d))
|
||||||
for _, m := range c.Matchers {
|
for _, sw := range d {
|
||||||
if !seen[m.Name] {
|
if !seen[sw.Matcher.Name] {
|
||||||
seen[m.Name] = true
|
seen[sw.Matcher.Name] = true
|
||||||
out = append(out, m.Name)
|
out = append(out, sw.Matcher.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseDeadmanMatcher reads one matcher from its configured form: "," separates
|
||||||
|
// the conditions and "=" is exact label equality — `alertname=Watchdog,cluster=prod`.
|
||||||
|
// The error says what is wrong with it, in words a form can show.
|
||||||
|
func parseDeadmanMatcher(entry string) (DeadmanMatcher, error) {
|
||||||
|
m := DeadmanMatcher{Labels: map[string]string{}}
|
||||||
|
for _, cond := range strings.Split(strings.TrimSpace(entry), ",") {
|
||||||
|
k, v, ok := strings.Cut(cond, "=")
|
||||||
|
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||||
|
if !ok || k == "" || v == "" {
|
||||||
|
return DeadmanMatcher{}, fmt.Errorf("%q is not label=value", strings.TrimSpace(cond))
|
||||||
|
}
|
||||||
|
if k == "alertname" {
|
||||||
|
m.Name = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Labels[k] = v
|
||||||
|
}
|
||||||
|
if m.Name == "" {
|
||||||
|
return DeadmanMatcher{}, errors.New("no alertname condition")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ParseDeadmanConfig reads the matcher list from its configured form:
|
// ParseDeadmanConfig reads the matcher list from its configured form:
|
||||||
// ";" separates matchers, "," separates the conditions within one, and "=" is
|
// ";" separates matchers, and each is parsed as parseDeadmanMatcher does.
|
||||||
// exact label equality — `alertname=Watchdog,cluster=prod; alertname=Heartbeat`.
|
|
||||||
//
|
//
|
||||||
// A malformed or alertname-less entry is dropped rather than fatal, following
|
// A malformed or alertname-less entry is dropped rather than fatal, following
|
||||||
// config.duration's rule that one bad tuning knob should not take the server
|
// config.duration's rule that one bad tuning knob should not take the server
|
||||||
@@ -125,28 +176,9 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
|
|||||||
if entry == "" {
|
if entry == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
m, err := parseDeadmanMatcher(entry)
|
||||||
m := DeadmanMatcher{Labels: map[string]string{}}
|
if err != nil {
|
||||||
malformed := false
|
log.Printf("deadman: ignoring matcher %q: %v", entry, err)
|
||||||
for _, cond := range strings.Split(entry, ",") {
|
|
||||||
k, v, ok := strings.Cut(cond, "=")
|
|
||||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
|
||||||
if !ok || k == "" || v == "" {
|
|
||||||
log.Printf("deadman: ignoring matcher %q: %q is not label=value", entry, strings.TrimSpace(cond))
|
|
||||||
malformed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if k == "alertname" {
|
|
||||||
m.Name = v
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m.Labels[k] = v
|
|
||||||
}
|
|
||||||
if malformed {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if m.Name == "" {
|
|
||||||
log.Printf("deadman: ignoring matcher %q: no alertname condition", entry)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cfg.Matchers = append(cfg.Matchers, m)
|
cfg.Matchers = append(cfg.Matchers, m)
|
||||||
@@ -162,23 +194,35 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
|
|||||||
for _, m := range cfg.Matchers {
|
for _, m := range cfg.Matchers {
|
||||||
rendered = append(rendered, m.String())
|
rendered = append(rendered, m.String())
|
||||||
}
|
}
|
||||||
log.Printf("deadman: watching %s, timeout %s, severity %s",
|
log.Printf("deadman: default for new teams: %s, timeout %s, severity %s",
|
||||||
strings.Join(rendered, "; "), timeout, severity)
|
strings.Join(rendered, "; "), timeout, severity)
|
||||||
}
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// deadmanAlert is one switch: the alert row carrying its last heartbeat.
|
// deadmanAlert is one heartbeat: the alert row carrying its last sighting, and
|
||||||
|
// the switch that claimed it.
|
||||||
type deadmanAlert struct {
|
type deadmanAlert struct {
|
||||||
id int64
|
id int64
|
||||||
teamID int64
|
teamID int64
|
||||||
fingerprint string
|
fingerprint string
|
||||||
labels map[string]string
|
labels map[string]string
|
||||||
matcher DeadmanMatcher
|
sw DeadmanSwitch
|
||||||
resolved bool
|
resolved bool
|
||||||
receivedAt int64
|
receivedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dead is the one rule for a silent heartbeat, shared by the sweeper that pages
|
||||||
|
// on it and the status the Switches page shows, so the page cannot disagree
|
||||||
|
// with the pager.
|
||||||
|
//
|
||||||
|
// An explicit resolved from Alertmanager is a stronger death signal than mere
|
||||||
|
// absence: the sender is telling us the heartbeat stopped, so there is nothing
|
||||||
|
// left to wait out.
|
||||||
|
func (a deadmanAlert) dead(now time.Time) bool {
|
||||||
|
return a.resolved || a.receivedAt < now.Add(-a.sw.Timeout).Unix()
|
||||||
|
}
|
||||||
|
|
||||||
// groupKey is the switch's identity as an incident. Per fingerprint, so each
|
// groupKey is the switch's identity as an incident. Per fingerprint, so each
|
||||||
// source is tracked on its own.
|
// source is tracked on its own.
|
||||||
func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerprint }
|
func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerprint }
|
||||||
@@ -189,13 +233,13 @@ func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerpr
|
|||||||
// It returns the ids of the alerts it owns, because the generic staleness
|
// It returns the ids of the alerts it owns, because the generic staleness
|
||||||
// expiry must leave them alone — staleAfter and ends_at would otherwise resolve
|
// expiry must leave them alone — staleAfter and ends_at would otherwise resolve
|
||||||
// a heartbeat long before its own, much tighter, timeout ever fired.
|
// a heartbeat long before its own, much tighter, timeout ever fired.
|
||||||
// Each team is swept against its own configuration: its own matchers, its own
|
// Each team is swept against its own switches, each with its own matcher,
|
||||||
// timeout, its own severity. A team watching nothing is skipped entirely, which
|
// timeout and severity. A team watching nothing is skipped entirely, which is
|
||||||
// is most of them.
|
// most of them.
|
||||||
func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int64]bool {
|
func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int64]bool {
|
||||||
owned := map[int64]bool{}
|
owned := map[int64]bool{}
|
||||||
|
|
||||||
configs, err := deadmanConfigs(ctx, db)
|
configs, err := deadmanSets(ctx, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("deadman: load configs: %v", err)
|
log.Printf("deadman: load configs: %v", err)
|
||||||
return owned
|
return owned
|
||||||
@@ -203,39 +247,35 @@ func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int6
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for teamID, cfg := range configs {
|
for teamID, cfg := range configs {
|
||||||
switches, err := deadmanAlerts(ctx, db, teamID, cfg)
|
heartbeats, err := deadmanAlerts(ctx, db, teamID, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("deadman: load switches for team %d: %v", teamID, err)
|
log.Printf("deadman: load heartbeats for team %d: %v", teamID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cutoff := now.Add(-cfg.Timeout).Unix()
|
|
||||||
|
|
||||||
for _, sw := range switches {
|
for _, hb := range heartbeats {
|
||||||
owned[sw.id] = true
|
owned[hb.id] = true
|
||||||
|
|
||||||
// An explicit resolved from Alertmanager is a stronger death signal
|
if hb.dead(now) {
|
||||||
// than mere absence: the sender is telling us the heartbeat
|
if err := deadmanDied(ctx, db, notify, hb, now); err != nil {
|
||||||
// stopped, so there is nothing left to wait out.
|
log.Printf("deadman: open incident for %s: %v", hb.sw.Matcher.Name, err)
|
||||||
if sw.resolved || sw.receivedAt < cutoff {
|
|
||||||
if err := deadmanDied(ctx, db, cfg, notify, sw, now); err != nil {
|
|
||||||
log.Printf("deadman: open incident for %s: %v", sw.matcher.Name, err)
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := deadmanRecovered(ctx, db, sw); err != nil {
|
if err := deadmanRecovered(ctx, db, hb); err != nil {
|
||||||
log.Printf("deadman: resolve incident for %s: %v", sw.matcher.Name, err)
|
log.Printf("deadman: resolve incident for %s: %v", hb.sw.Matcher.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return owned
|
return owned
|
||||||
}
|
}
|
||||||
|
|
||||||
// deadmanAlerts loads every alert row that a matcher claims. The candidate query
|
// deadmanAlerts loads every alert row that one of a team's switches claims. The candidate query
|
||||||
// is narrowed by alertname so it rides alerts_name_idx; the rest of the matching
|
// is narrowed by alertname so it rides alerts_name_idx; the rest of the matching
|
||||||
// happens in Go, which keeps one implementation of the rules. The rows are read
|
// happens in Go, which keeps one implementation of the rules. The rows are read
|
||||||
// in full before the caller writes, so the writes do not run against an open
|
// in full before the caller writes, so the writes do not run against an open
|
||||||
// cursor over the same table.
|
// cursor over the same table.
|
||||||
func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanConfig) ([]deadmanAlert, error) {
|
func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg deadmanSet) ([]deadmanAlert, error) {
|
||||||
names := cfg.names()
|
names := cfg.names()
|
||||||
args := &sqlArgs{}
|
args := &sqlArgs{}
|
||||||
nameList := make([]any, len(names))
|
nameList := make([]any, len(names))
|
||||||
@@ -263,11 +303,11 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanCon
|
|||||||
}
|
}
|
||||||
json.Unmarshal([]byte(labelsJSON), &a.labels) //nolint:errcheck
|
json.Unmarshal([]byte(labelsJSON), &a.labels) //nolint:errcheck
|
||||||
|
|
||||||
m, ok := cfg.match(a.labels)
|
sw, ok := cfg.match(a.labels)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
a.matcher = m
|
a.sw = sw
|
||||||
a.resolved = status == "resolved"
|
a.resolved = status == "resolved"
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
}
|
}
|
||||||
@@ -284,16 +324,16 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanCon
|
|||||||
// incidentForGroup), and a source that is gone for good is a one-time page
|
// incidentForGroup), and a source that is gone for good is a one-time page
|
||||||
// rather than a nag. Only a heartbeat that comes back and dies again earns a new
|
// rather than a nag. Only a heartbeat that comes back and dies again earns a new
|
||||||
// incident.
|
// incident.
|
||||||
func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify NotifyConfig, sw deadmanAlert, now time.Time) error {
|
func deadmanDied(ctx context.Context, db *sql.DB, notify NotifyConfig, hb deadmanAlert, now time.Time) error {
|
||||||
var lastTriggered, open int64
|
var lastTriggered, open int64
|
||||||
if err := db.QueryRowContext(ctx, `
|
if err := db.QueryRowContext(ctx, `
|
||||||
SELECT COALESCE(MAX(triggered_at), 0),
|
SELECT COALESCE(MAX(triggered_at), 0),
|
||||||
COUNT(*) FILTER (WHERE resolved_at IS NULL)
|
COUNT(*) FILTER (WHERE resolved_at IS NULL)
|
||||||
FROM incidents WHERE team_id = $1 AND group_key = $2`,
|
FROM incidents WHERE team_id = $1 AND group_key = $2`,
|
||||||
sw.teamID, sw.groupKey()).Scan(&lastTriggered, &open); err != nil {
|
hb.teamID, hb.groupKey()).Scan(&lastTriggered, &open); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if open > 0 || sw.receivedAt <= lastTriggered {
|
if open > 0 || hb.receivedAt <= lastTriggered {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,18 +346,18 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
|
|||||||
// A heartbeat nobody has heard from is not firing, and saying otherwise in
|
// A heartbeat nobody has heard from is not firing, and saying otherwise in
|
||||||
// the alert list would be a lie. An Alertmanager-sourced resolution keeps its
|
// the alert list would be a lie. An Alertmanager-sourced resolution keeps its
|
||||||
// own source: it told us the truth first.
|
// own source: it told us the truth first.
|
||||||
if !sw.resolved {
|
if !hb.resolved {
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE alerts
|
UPDATE alerts
|
||||||
SET status = 'resolved',
|
SET status = 'resolved',
|
||||||
resolution_source = $1,
|
resolution_source = $1,
|
||||||
ends_at = COALESCE(ends_at, `+nowEpoch+`)
|
ends_at = COALESCE(ends_at, `+nowEpoch+`)
|
||||||
WHERE id = $2 AND status = 'firing'`, resolutionDeadman, sw.id); err != nil {
|
WHERE id = $2 AND status = 'firing'`, resolutionDeadman, hb.id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
severity := cfg.Severity
|
severity := hb.sw.Severity
|
||||||
var sev *string
|
var sev *string
|
||||||
if severity != "" {
|
if severity != "" {
|
||||||
sev = &severity
|
sev = &severity
|
||||||
@@ -325,14 +365,14 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
|
|||||||
|
|
||||||
// The incident opens in the team whose integration received the heartbeat:
|
// The incident opens in the team whose integration received the heartbeat:
|
||||||
// the switch belongs to whoever is watching that source, not to the install.
|
// the switch belongs to whoever is watching that source, not to the install.
|
||||||
incidentID, err := openIncident(ctx, tx, notify, sw.teamID, sw.groupKey(),
|
incidentID, err := openIncident(ctx, tx, notify, hb.teamID, hb.groupKey(),
|
||||||
"No heartbeat from "+sw.matcher.String(), sw.labels, sev)
|
"No heartbeat from "+hb.sw.Matcher.String(), hb.labels, sev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
alertID := sw.id
|
alertID := hb.id
|
||||||
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(sw.receivedAt, 0))) + " ago"
|
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(hb.receivedAt, 0))) + " ago"
|
||||||
if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, &alertID, &detail); err != nil {
|
if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, &alertID, &detail); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -340,7 +380,7 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
|
|||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Printf("deadman: %s went silent, opened incident %d", sw.matcher.String(), incidentID)
|
log.Printf("deadman: %s went silent, opened incident %d", hb.sw.Matcher.String(), incidentID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,12 +390,12 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
|
|||||||
// member alerts (linking the heartbeat would have the settled-incident cascade
|
// member alerts (linking the heartbeat would have the settled-incident cascade
|
||||||
// close it on the very same sweep that opened it), so the alert-driven cascade
|
// close it on the very same sweep that opened it), so the alert-driven cascade
|
||||||
// ignores it entirely and recovery is the only automatic way out.
|
// ignores it entirely and recovery is the only automatic way out.
|
||||||
func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
|
func deadmanRecovered(ctx context.Context, db *sql.DB, hb deadmanAlert) error {
|
||||||
var incidentID int64
|
var incidentID int64
|
||||||
switch err := db.QueryRowContext(ctx, `
|
switch err := db.QueryRowContext(ctx, `
|
||||||
SELECT id FROM incidents
|
SELECT id FROM incidents
|
||||||
WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL`,
|
WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL`,
|
||||||
sw.teamID, sw.groupKey()).Scan(&incidentID); {
|
hb.teamID, hb.groupKey()).Scan(&incidentID); {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return nil
|
return nil
|
||||||
case err != nil:
|
case err != nil:
|
||||||
@@ -387,116 +427,256 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
|
|||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Printf("deadman: %s is back, resolved incident %d", sw.matcher.String(), incidentID)
|
log.Printf("deadman: %s is back, resolved incident %d", hb.sw.Matcher.String(), incidentID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Per-team configuration
|
// A team's switches
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// deadmanConfigForTeam reads one team's switches. A team with no row, or with
|
const deadmanSwitchColumns = "id, team_id, name, matcher, timeout_seconds, severity"
|
||||||
// nothing configured, gets a disabled config — which is the right answer rather
|
|
||||||
// than an error: most teams watch no heartbeat at all.
|
|
||||||
func deadmanConfigForTeam(ctx context.Context, q querier, teamID int64) (DeadmanConfig, error) {
|
|
||||||
var matchers, severity string
|
|
||||||
var timeout int64
|
|
||||||
err := q.QueryRowContext(ctx,
|
|
||||||
"SELECT matchers, timeout_seconds, severity FROM deadman_configs WHERE team_id = $1",
|
|
||||||
teamID).Scan(&matchers, &timeout, &severity)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return DeadmanConfig{}, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return DeadmanConfig{}, err
|
|
||||||
}
|
|
||||||
return parseDeadmanQuietly(matchers, time.Duration(timeout)*time.Second, severity), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// deadmanConfigs reads every team's switches in one query, for the sweeper.
|
// scanDeadmanSwitches reads switch rows into per-team sets. A row whose matcher
|
||||||
func deadmanConfigs(ctx context.Context, db *sql.DB) (map[int64]DeadmanConfig, error) {
|
// no longer parses is skipped rather than fatal: the API refuses to store one,
|
||||||
rows, err := db.QueryContext(ctx,
|
// so it can only mean a hand edit, and one bad row must not stop the others
|
||||||
"SELECT team_id, matchers, timeout_seconds, severity FROM deadman_configs")
|
// from being watched.
|
||||||
if err != nil {
|
func scanDeadmanSwitches(rows *sql.Rows) (map[int64]deadmanSet, error) {
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
out := map[int64]deadmanSet{}
|
||||||
out := map[int64]DeadmanConfig{}
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
|
var sw DeadmanSwitch
|
||||||
var teamID, timeout int64
|
var teamID, timeout int64
|
||||||
var matchers, severity string
|
var matcher string
|
||||||
if err := rows.Scan(&teamID, &matchers, &timeout, &severity); err != nil {
|
if err := rows.Scan(&sw.ID, &teamID, &sw.Name, &matcher, &timeout, &sw.Severity); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cfg := parseDeadmanQuietly(matchers, time.Duration(timeout)*time.Second, severity)
|
m, err := parseDeadmanMatcher(matcher)
|
||||||
if cfg.enabled() {
|
if err != nil {
|
||||||
out[teamID] = cfg
|
log.Printf("deadman: switch %d has an unusable matcher %q: %v", sw.ID, matcher, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
sw.Matcher = m
|
||||||
|
sw.Timeout = time.Duration(timeout) * time.Second
|
||||||
|
out[teamID] = append(out[teamID], sw)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedDeadmanConfigs gives every team without a row the server's environment
|
// deadmanSetForTeam reads one team's switches. A team with none gets an empty
|
||||||
// configuration, so the install that upgrades into per-team switches keeps
|
// set — which is the right answer rather than an error: most teams watch no
|
||||||
// watching exactly what it was watching before.
|
// heartbeat at all.
|
||||||
|
func deadmanSetForTeam(ctx context.Context, q querier, teamID int64) (deadmanSet, error) {
|
||||||
|
rows, err := q.QueryContext(ctx,
|
||||||
|
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches WHERE team_id = $1 ORDER BY id", teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sets, err := scanDeadmanSwitches(rows)
|
||||||
|
return sets[teamID], err
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanSets reads every team's switches in one query, for the sweeper.
|
||||||
|
func deadmanSets(ctx context.Context, db *sql.DB) (map[int64]deadmanSet, error) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches ORDER BY id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return scanDeadmanSwitches(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanSeededKey is the settings row that records the environment defaults
|
||||||
|
// were handed out. Without it, a team that deleted its last switch would get
|
||||||
|
// the default back on the next restart.
|
||||||
|
const deadmanSeededKey = "deadman_seeded"
|
||||||
|
|
||||||
|
// SeedDeadmanConfigs gives every team the server's environment defaults as
|
||||||
|
// switches, exactly once per install, so a fresh install watches Watchdog
|
||||||
|
// without anybody setting it up.
|
||||||
//
|
//
|
||||||
// Idempotent, and never overwrites: once a team has a row it owns its own
|
// Once seeded it never runs again: a team's switches are its own, and a redeploy
|
||||||
// configuration, and a redeploy must not quietly put the environment's value
|
// must not quietly put the environment's value back over an owner's edit or
|
||||||
// back over an owner's edit.
|
// deletion. Installs that upgraded from per-team configuration were already
|
||||||
|
// seeded, which migration 009 records.
|
||||||
//
|
//
|
||||||
// A team created after startup gets no row and therefore watches nothing until
|
// A team created after that gets none and watches nothing until its owner says
|
||||||
// its owner says otherwise. That is deliberate: inheriting an install-wide
|
// otherwise. That is deliberate: inheriting an install-wide heartbeat would page
|
||||||
// heartbeat would page a new team about a source it has never heard of, and a
|
// a new team about a source it has never heard of, and a switch nobody chose is
|
||||||
// switch nobody chose is the kind that gets muted rather than fixed.
|
// the kind that gets muted rather than fixed.
|
||||||
func SeedDeadmanConfigs(ctx context.Context, db *sql.DB, cfg DeadmanConfig) error {
|
func SeedDeadmanConfigs(ctx context.Context, db *sql.DB, cfg DeadmanConfig) error {
|
||||||
matchers := make([]string, 0, len(cfg.Matchers))
|
if !cfg.enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck
|
||||||
|
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO settings (key, value) VALUES ($1, '1') ON CONFLICT (key) DO NOTHING",
|
||||||
|
deadmanSeededKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
for _, m := range cfg.Matchers {
|
for _, m := range cfg.Matchers {
|
||||||
parts := []string{"alertname=" + m.Name}
|
if _, err := tx.ExecContext(ctx, `
|
||||||
for k, v := range m.Labels {
|
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
|
||||||
parts = append(parts, k+"="+v)
|
SELECT id, $1, $1, $2, $3 FROM teams`,
|
||||||
|
m.config(), int64(cfg.Timeout.Seconds()), cfg.Severity); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
sort.Strings(parts[1:])
|
|
||||||
matchers = append(matchers, strings.Join(parts, ","))
|
|
||||||
}
|
}
|
||||||
|
return tx.Commit()
|
||||||
_, err := db.ExecContext(ctx, `
|
|
||||||
INSERT INTO deadman_configs (team_id, matchers, timeout_seconds, severity)
|
|
||||||
SELECT id, $1, $2, $3 FROM teams
|
|
||||||
ON CONFLICT (team_id) DO NOTHING`,
|
|
||||||
strings.Join(matchers, "; "), int64(cfg.Timeout.Seconds()), cfg.Severity)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDeadmanQuietly is ParseDeadmanConfig without the startup logging: a
|
// ---------------------------------------------------------------------------
|
||||||
// team's configuration is read on every sweep and every webhook, and logging it
|
// Status
|
||||||
// each time would bury everything else.
|
// ---------------------------------------------------------------------------
|
||||||
func parseDeadmanQuietly(matchers string, timeout time.Duration, severity string) DeadmanConfig {
|
|
||||||
cfg := DeadmanConfig{Timeout: timeout, Severity: severity}
|
const (
|
||||||
for _, entry := range strings.Split(matchers, ";") {
|
switchHealthy = "healthy"
|
||||||
entry = strings.TrimSpace(entry)
|
switchDead = "dead"
|
||||||
if entry == "" {
|
switchDormant = "dormant"
|
||||||
continue
|
)
|
||||||
}
|
|
||||||
m := DeadmanMatcher{Labels: map[string]string{}}
|
// deadmanSource is one heartbeat under a switch: a fingerprint that matched.
|
||||||
malformed := false
|
type deadmanSource struct {
|
||||||
for _, cond := range strings.Split(entry, ",") {
|
Fingerprint string `json:"fingerprint"`
|
||||||
k, v, ok := strings.Cut(cond, "=")
|
Labels map[string]string `json:"labels"`
|
||||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
Status string `json:"status"`
|
||||||
if !ok || k == "" || v == "" {
|
LastHeartbeatAt time.Time `json:"last_heartbeat_at"`
|
||||||
malformed = true
|
LastTriggeredAt *time.Time `json:"last_triggered_at"`
|
||||||
break
|
IncidentID *int64 `json:"incident_id"`
|
||||||
}
|
}
|
||||||
if k == "alertname" {
|
|
||||||
m.Name = v
|
// deadmanSwitchStatus is a switch as the Switches page shows it.
|
||||||
continue
|
type deadmanSwitchStatus struct {
|
||||||
}
|
ID int64 `json:"id"`
|
||||||
m.Labels[k] = v
|
Name string `json:"name"`
|
||||||
}
|
Matcher string `json:"matcher"`
|
||||||
if malformed || m.Name == "" {
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
continue
|
Severity string `json:"severity"`
|
||||||
}
|
|
||||||
cfg.Matchers = append(cfg.Matchers, m)
|
// Status is dead when any source is, dormant when none has ever been heard
|
||||||
}
|
// from, healthy otherwise — a live cluster must not hide a dead one.
|
||||||
return cfg
|
Status string `json:"status"`
|
||||||
|
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
|
||||||
|
LastTriggeredAt *time.Time `json:"last_triggered_at"`
|
||||||
|
OpenIncidentID *int64 `json:"open_incident_id"`
|
||||||
|
Sources []deadmanSource `json:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanStatuses reports every switch of a team with what its heartbeats are
|
||||||
|
// doing. The liveness verdict is deadmanAlert.dead, the sweeper's own.
|
||||||
|
func deadmanStatuses(ctx context.Context, db *sql.DB, teamID int64, set deadmanSet, now time.Time) ([]deadmanSwitchStatus, error) {
|
||||||
|
out := make([]deadmanSwitchStatus, 0, len(set))
|
||||||
|
if len(set) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeats, err := deadmanAlerts(ctx, db, teamID, set)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// One query for every switch's incident history, keyed the way the sweeper
|
||||||
|
// keys it.
|
||||||
|
type history struct {
|
||||||
|
triggeredAt int64
|
||||||
|
openID int64
|
||||||
|
}
|
||||||
|
incidents := map[string]history{}
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT group_key, MAX(triggered_at), COALESCE(MAX(id) FILTER (WHERE resolved_at IS NULL), 0)
|
||||||
|
FROM incidents
|
||||||
|
WHERE team_id = $1 AND group_key LIKE $2
|
||||||
|
GROUP BY group_key`, teamID, deadmanGroupPrefix+"%")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
var h history
|
||||||
|
if err := rows.Scan(&key, &h.triggeredAt, &h.openID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
incidents[key] = h
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bySwitch := map[int64][]deadmanAlert{}
|
||||||
|
for _, hb := range heartbeats {
|
||||||
|
bySwitch[hb.sw.ID] = append(bySwitch[hb.sw.ID], hb)
|
||||||
|
}
|
||||||
|
|
||||||
|
later := func(cur *time.Time, unix int64) *time.Time {
|
||||||
|
t := time.Unix(unix, 0).UTC()
|
||||||
|
if cur == nil || t.After(*cur) {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sw := range set {
|
||||||
|
st := deadmanSwitchStatus{
|
||||||
|
ID: sw.ID, Name: sw.Name, Matcher: sw.Matcher.config(),
|
||||||
|
TimeoutSeconds: int64(sw.Timeout.Seconds()), Severity: sw.Severity,
|
||||||
|
Status: switchDormant, Sources: []deadmanSource{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hb := range bySwitch[sw.ID] {
|
||||||
|
src := deadmanSource{
|
||||||
|
Fingerprint: hb.fingerprint,
|
||||||
|
Labels: hb.labels,
|
||||||
|
Status: switchHealthy,
|
||||||
|
LastHeartbeatAt: time.Unix(hb.receivedAt, 0).UTC(),
|
||||||
|
}
|
||||||
|
if hb.dead(now) {
|
||||||
|
src.Status = switchDead
|
||||||
|
}
|
||||||
|
if h, ok := incidents[hb.groupKey()]; ok {
|
||||||
|
t := time.Unix(h.triggeredAt, 0).UTC()
|
||||||
|
src.LastTriggeredAt = &t
|
||||||
|
st.LastTriggeredAt = later(st.LastTriggeredAt, h.triggeredAt)
|
||||||
|
if h.openID != 0 {
|
||||||
|
id := h.openID
|
||||||
|
src.IncidentID = &id
|
||||||
|
if st.OpenIncidentID == nil || id > *st.OpenIncidentID {
|
||||||
|
st.OpenIncidentID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.LastHeartbeatAt = later(st.LastHeartbeatAt, hb.receivedAt)
|
||||||
|
st.Sources = append(st.Sources, src)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case src.Status == switchDead:
|
||||||
|
st.Status = switchDead
|
||||||
|
case st.Status == switchDormant:
|
||||||
|
st.Status = switchHealthy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead ones first, then by fingerprint: what needs attention leads, and
|
||||||
|
// the order does not shuffle between refreshes.
|
||||||
|
sort.Slice(st.Sources, func(i, j int) bool {
|
||||||
|
a, b := st.Sources[i], st.Sources[j]
|
||||||
|
if (a.Status == switchDead) != (b.Status == switchDead) {
|
||||||
|
return a.Status == switchDead
|
||||||
|
}
|
||||||
|
return a.Fingerprint < b.Fingerprint
|
||||||
|
})
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+166
-16
@@ -1,6 +1,7 @@
|
|||||||
package api_test
|
package api_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -483,13 +484,13 @@ func TestDeadman_ConfigurationIsPerTeam(t *testing.T) {
|
|||||||
unwatched := newTeam(t, s, "unwatched")
|
unwatched := newTeam(t, s, "unwatched")
|
||||||
|
|
||||||
// Only the first team calls Watchdog a heartbeat.
|
// Only the first team calls Watchdog a heartbeat.
|
||||||
resp := s.req(t, http.MethodPut, "/api/teams/"+id64(watched.id)+"/deadman", map[string]any{
|
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(watched.id)+"/deadman/switches", map[string]any{
|
||||||
"matchers": "alertname=Watchdog",
|
"matcher": "alertname=Watchdog",
|
||||||
"timeout_seconds": 3600,
|
"timeout_seconds": 3600,
|
||||||
"severity": "critical",
|
"severity": "critical",
|
||||||
})
|
})
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
t.Fatalf("configure the watched team: %d", resp.StatusCode)
|
t.Fatalf("configure the watched team: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,9 +551,9 @@ func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
|
|||||||
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(user.ID)+"/api-keys",
|
||||||
map[string]string{"name": "test"}), &key)
|
map[string]string{"name": "test"}), &key)
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodPut,
|
req, _ := http.NewRequest(http.MethodPost,
|
||||||
s.URL+"/api/teams/"+id64(team.id)+"/deadman",
|
s.URL+"/api/teams/"+id64(team.id)+"/deadman/switches",
|
||||||
strings.NewReader(`{"matchers":"alertname=Watchdog","timeout_seconds":60}`))
|
strings.NewReader(`{"matcher":"alertname=Watchdog","timeout_seconds":60}`))
|
||||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
@@ -564,7 +565,7 @@ func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
|
|||||||
t.Errorf("a member editing the switches: expected 403, got %d", resp.StatusCode)
|
t.Errorf("a member editing the switches: expected 403, got %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
read, _ := http.NewRequest(http.MethodGet, s.URL+"/api/teams/"+id64(team.id)+"/deadman", nil)
|
read, _ := http.NewRequest(http.MethodGet, s.URL+"/api/teams/"+id64(team.id)+"/deadman/switches", nil)
|
||||||
read.Header.Set("Authorization", "Bearer "+key.Key)
|
read.Header.Set("Authorization", "Bearer "+key.Key)
|
||||||
got, err := http.DefaultClient.Do(read)
|
got, err := http.DefaultClient.Do(read)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -577,16 +578,165 @@ func TestDeadman_ConfigurationIsOwnerOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A matcher with no alertname watches nothing, silently, which is the failure
|
// A matcher with no alertname watches nothing, silently, which is the failure
|
||||||
// this feature exists to prevent — so it is refused at the door.
|
// this feature exists to prevent — so it is refused at the door, along with the
|
||||||
func TestDeadman_UnusableMatchersAreRejected(t *testing.T) {
|
// other things that would make a switch unable to fire.
|
||||||
|
func TestDeadman_UnusableSwitchesAreRejected(t *testing.T) {
|
||||||
s, _ := deadmanTS(t, deadmanCfg())
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman", map[string]any{
|
for name, body := range map[string]map[string]any{
|
||||||
"matchers": "cluster=prod",
|
"no alertname": {"matcher": "cluster=prod", "timeout_seconds": 900},
|
||||||
"timeout_seconds": 900,
|
"malformed": {"matcher": "alertname=Watchdog,garbage", "timeout_seconds": 900},
|
||||||
})
|
"several": {"matcher": "alertname=A; alertname=B", "timeout_seconds": 900},
|
||||||
resp.Body.Close()
|
"zero timeout": {"matcher": "alertname=Watchdog", "timeout_seconds": 0},
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
"bad severity": {"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "loud"},
|
||||||
t.Errorf("expected 400 for a matcher with no alertname, got %d", resp.StatusCode)
|
"empty matcher": {"matcher": "", "timeout_seconds": 900},
|
||||||
|
} {
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: expected 400, got %d", name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The switch list
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// listSwitches reads the default team's switches as the Switches page does.
|
||||||
|
func listSwitches(t *testing.T, s *ts) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
return list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/deadman/switches", nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A switch is healthy while its heartbeat is fresh, dead once it is silent, and
|
||||||
|
// dormant until the first one arrives.
|
||||||
|
func TestDeadman_ListReportsStatus(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, api.ParseDeadmanConfig("alertname=Watchdog; alertname=NeverSent", time.Hour, "critical"))
|
||||||
|
|
||||||
|
got := listSwitches(t, s)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2 switches, got %d", len(got))
|
||||||
|
}
|
||||||
|
for _, sw := range got {
|
||||||
|
if sw["status"] != "dormant" || sw["last_heartbeat_at"] != nil || sw["last_triggered_at"] != nil {
|
||||||
|
t.Errorf("a switch nobody has heard from should be dormant and blank, got %v", sw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
got = listSwitches(t, s)
|
||||||
|
if got[0]["status"] != "healthy" || got[0]["last_heartbeat_at"] == nil {
|
||||||
|
t.Errorf("a fresh heartbeat should be healthy with a timestamp, got %v", got[0])
|
||||||
|
}
|
||||||
|
if got[1]["status"] != "dormant" {
|
||||||
|
t.Errorf("the other switch is still dormant, got %v", got[1]["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
silence(t, s, "fp-watchdog", 2*time.Hour)
|
||||||
|
sweep(t, s, noArchive)
|
||||||
|
got = listSwitches(t, s)
|
||||||
|
if got[0]["status"] != "dead" {
|
||||||
|
t.Fatalf("a silent heartbeat should be dead, got %v", got[0]["status"])
|
||||||
|
}
|
||||||
|
if got[0]["last_triggered_at"] == nil || got[0]["open_incident_id"] == nil {
|
||||||
|
t.Errorf("a dead switch should show when it triggered and its open incident, got %v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One matcher, several clusters: the switch is as bad as its worst heartbeat and
|
||||||
|
// each heartbeat is listed on its own.
|
||||||
|
func TestDeadman_ListBreaksDownByFingerprint(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-a", map[string]string{"cluster": "a"})
|
||||||
|
heartbeat(t, s, "fp-b", map[string]string{"cluster": "b"})
|
||||||
|
silence(t, s, "fp-b", 2*time.Hour)
|
||||||
|
|
||||||
|
sw := listSwitches(t, s)[0]
|
||||||
|
if sw["status"] != "dead" {
|
||||||
|
t.Errorf("one dead cluster makes the switch dead, got %v", sw["status"])
|
||||||
|
}
|
||||||
|
sources := sw["sources"].([]any)
|
||||||
|
if len(sources) != 2 {
|
||||||
|
t.Fatalf("expected 2 sources, got %d", len(sources))
|
||||||
|
}
|
||||||
|
first, second := sources[0].(map[string]any), sources[1].(map[string]any)
|
||||||
|
if first["fingerprint"] != "fp-b" || first["status"] != "dead" || second["status"] != "healthy" {
|
||||||
|
t.Errorf("the dead source should lead, got %v then %v", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every switch keeps its own deadline.
|
||||||
|
func TestDeadman_TimeoutsArePerSwitch(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches", map[string]any{
|
||||||
|
"matcher": "alertname=Edge", "timeout_seconds": 300,
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
heartbeat(t, s, "fp-watchdog", nil)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-edge", "Edge", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
}, `{}:{alertname="Edge"}`)
|
||||||
|
|
||||||
|
// Ten minutes of silence: past the Edge switch's five, inside Watchdog's hour.
|
||||||
|
silence(t, s, "fp-watchdog", 10*time.Minute)
|
||||||
|
silence(t, s, "fp-edge", 10*time.Minute)
|
||||||
|
|
||||||
|
got := listSwitches(t, s)
|
||||||
|
if got[0]["status"] != "healthy" || got[1]["status"] != "dead" {
|
||||||
|
t.Errorf("want Watchdog healthy and Edge dead, got %v and %v", got[0]["status"], got[1]["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting is an owner's, is scoped to the team, and leaves what the switch
|
||||||
|
// already opened alone.
|
||||||
|
func TestDeadman_DeleteIsScopedToTheTeam(t *testing.T) {
|
||||||
|
s, _ := deadmanTS(t, deadmanCfg())
|
||||||
|
other := newTeam(t, s, "other")
|
||||||
|
|
||||||
|
id := int64(listSwitches(t, s)[0]["id"].(float64))
|
||||||
|
|
||||||
|
// Another team's owner cannot reach it.
|
||||||
|
resp := other.call(http.MethodDelete, "/api/teams/"+id64(other.id)+"/deadman/switches/"+id64(id), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("deleting another team's switch: expected 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := len(listSwitches(t, s)); got != 1 {
|
||||||
|
t.Fatalf("the switch should have survived, %d left", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(id), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("deleting: expected 204, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := len(listSwitches(t, s)); got != 0 {
|
||||||
|
t.Errorf("expected no switches, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The environment's defaults are handed out once and then belong to the teams.
|
||||||
|
func TestDeadman_SeedRunsOnce(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
cfg := api.ParseDeadmanConfig("alertname=Watchdog", time.Hour, "critical")
|
||||||
|
|
||||||
|
if err := api.SeedDeadmanConfigs(context.Background(), s.db, cfg); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(listSwitches(t, s)); got != 1 {
|
||||||
|
t.Fatalf("the first seed should add the default, got %d switches", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner deletes it; a restart must not put it back.
|
||||||
|
id := int64(listSwitches(t, s)[0]["id"].(float64))
|
||||||
|
s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(id), nil).Body.Close()
|
||||||
|
if err := api.SeedDeadmanConfigs(context.Background(), s.db, cfg); err != nil {
|
||||||
|
t.Fatalf("seed again: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(listSwitches(t, s)); got != 0 {
|
||||||
|
t.Errorf("a second seed resurrected %d switch(es)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deviceStart struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
VerificationURL string `json:"verification_url"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// startDevice is the terminal asking for a login.
|
||||||
|
func startDevice(t *testing.T, s *ts) deviceStart {
|
||||||
|
t.Helper()
|
||||||
|
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil)
|
||||||
|
var d deviceStart
|
||||||
|
decode(t, resp, &d)
|
||||||
|
if d.DeviceCode == "" || d.UserCode == "" {
|
||||||
|
t.Fatalf("device start returned %+v", d)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollDevice is the terminal polling. It returns the status, and the session
|
||||||
|
// cookie the response set, if any.
|
||||||
|
func pollDevice(t *testing.T, s *ts, code string) (int, *http.Cookie, string) {
|
||||||
|
t.Helper()
|
||||||
|
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device/token", map[string]string{"device_code": code})
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var body map[string]any
|
||||||
|
json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
var cookie *http.Cookie
|
||||||
|
for _, c := range resp.Cookies() {
|
||||||
|
if c.Name == "terdut_session" {
|
||||||
|
cookie = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg, _ := body["error"].(string)
|
||||||
|
if msg == "" {
|
||||||
|
msg, _ = body["status"].(string)
|
||||||
|
}
|
||||||
|
return resp.StatusCode, cookie, msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// readyToPoll lets the next poll through: the server holds a client to the
|
||||||
|
// interval it was given, which a test has no wish to wait out.
|
||||||
|
func (s *ts) readyToPoll(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
s.exec(t, "UPDATE device_logins SET last_polled_at = 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decide(t *testing.T, b *browser, what, code string) int {
|
||||||
|
t.Helper()
|
||||||
|
resp := b.do(t, http.MethodPost, "/api/oidc/device/"+what, map[string]string{"user_code": code})
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_FullFlow(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
if !strings.HasPrefix(d.VerificationURL, "http://terdut.test/device?code=") ||
|
||||||
|
!strings.Contains(d.VerificationURL, url.QueryEscape(d.UserCode)) {
|
||||||
|
t.Errorf("verification url %q", d.VerificationURL)
|
||||||
|
}
|
||||||
|
if len(d.UserCode) != 9 || d.UserCode[4] != '-' || d.Interval != 5 || d.ExpiresIn != 600 {
|
||||||
|
t.Errorf("start: %+v", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || cookie != nil || msg != "pending" {
|
||||||
|
t.Fatalf("first poll: %d %v %q, want 202 pending and no cookie", status, cookie, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The person signs in through the provider in some browser and approves.
|
||||||
|
person := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, person, alice)
|
||||||
|
if got := decide(t, person, "approve", d.UserCode); got != http.StatusNoContent {
|
||||||
|
t.Fatalf("approve: %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.readyToPoll(t)
|
||||||
|
status, cookie, _ := pollDevice(t, s, d.DeviceCode)
|
||||||
|
if status != http.StatusOK || cookie == nil {
|
||||||
|
t.Fatalf("poll after approval: %d, cookie %v", status, cookie)
|
||||||
|
}
|
||||||
|
// The cookie is a working session for the person who approved.
|
||||||
|
term := newBrowser(t, s.URL)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
resp, err := term.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var me struct {
|
||||||
|
User struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
decode(t, resp, &me)
|
||||||
|
if me.User.Username != "alice" {
|
||||||
|
t.Errorf("session belongs to %q, want alice", me.User.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single use.
|
||||||
|
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "expired" {
|
||||||
|
t.Errorf("second redemption: %d %v %q, want 410 expired", status, cookie, msg)
|
||||||
|
}
|
||||||
|
// The session was made for an SSO user, so it carries the ceiling.
|
||||||
|
var ceiling *int64
|
||||||
|
s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling)
|
||||||
|
if ceiling == nil {
|
||||||
|
t.Error("a device session for an SSO user must carry the SSO session ceiling")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_PasswordUserGetsNoCeiling(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
admin := signedIn(t, s) // password sign-in as the bootstrap admin
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
if got := decide(t, admin, "approve", d.UserCode); got != http.StatusNoContent {
|
||||||
|
t.Fatalf("approve: %d", got)
|
||||||
|
}
|
||||||
|
s.readyToPoll(t)
|
||||||
|
if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusOK || cookie == nil {
|
||||||
|
t.Fatalf("poll: %d %v", status, cookie)
|
||||||
|
}
|
||||||
|
var ceiling *int64
|
||||||
|
s.db.QueryRow("SELECT max_expires_at FROM sessions ORDER BY id DESC LIMIT 1").Scan(&ceiling)
|
||||||
|
if ceiling != nil {
|
||||||
|
t.Errorf("a password user's device session has a ceiling %d, want none", *ceiling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_Denied(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
person := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, person, alice)
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
if got := decide(t, person, "deny", d.UserCode); got != http.StatusNoContent {
|
||||||
|
t.Fatalf("deny: %d", got)
|
||||||
|
}
|
||||||
|
s.readyToPoll(t)
|
||||||
|
if status, cookie, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil || msg != "denied" {
|
||||||
|
t.Errorf("poll: %d %v %q, want 410 denied", status, cookie, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_DecisionNeedsABrowserSession(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
d := startDevice(t, s)
|
||||||
|
|
||||||
|
// Nobody signed in.
|
||||||
|
if got := decide(t, newBrowser(t, s.URL), "approve", d.UserCode); got != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous approve: %d, want 401", got)
|
||||||
|
}
|
||||||
|
// An API key is a credential for scripts, not for approving a terminal.
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/oidc/device/approve", map[string]string{"user_code": d.UserCode})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("approve with an API key: %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted || msg != "pending" {
|
||||||
|
t.Errorf("the login must still be pending: %d %q", status, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_ApprovalIsFinal(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
first, second := ssoBrowser(t, s), ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, first, alice)
|
||||||
|
signInSSO(t, idp, second, idpUser{sub: "sub-mallory", username: "mallory", email: "mallory@example.com", groups: []string{"terdut-users"}})
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
if got := decide(t, first, "approve", d.UserCode); got != http.StatusNoContent {
|
||||||
|
t.Fatalf("approve: %d", got)
|
||||||
|
}
|
||||||
|
// A second browser cannot take the login over, nor refuse it.
|
||||||
|
for _, what := range []string{"approve", "deny"} {
|
||||||
|
if got := decide(t, second, what, d.UserCode); got != http.StatusNotFound {
|
||||||
|
t.Errorf("%s after approval: %d, want 404", what, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.readyToPoll(t)
|
||||||
|
_, cookie, _ := pollDevice(t, s, d.DeviceCode)
|
||||||
|
if cookie == nil {
|
||||||
|
t.Fatal("no session")
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
s.db.QueryRow("SELECT u.username FROM sessions ss JOIN users u ON u.id = ss.user_id ORDER BY ss.id DESC LIMIT 1").Scan(&name)
|
||||||
|
if name != "alice" {
|
||||||
|
t.Errorf("session for %q, want alice", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_CodeIsForgivingAboutHowItWasTyped(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
person := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, person, alice)
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
typed := strings.ToLower(strings.ReplaceAll(d.UserCode, "-", " "))
|
||||||
|
if got := decide(t, person, "approve", typed); got != http.StatusNoContent {
|
||||||
|
t.Errorf("approve %q: %d, want 204", typed, got)
|
||||||
|
}
|
||||||
|
if got := decide(t, person, "approve", "nonsense"); got != http.StatusBadRequest {
|
||||||
|
t.Errorf("approve nonsense: %d, want 400", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_ExpiredAndUnknown(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
person := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, person, alice)
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
s.exec(t, "UPDATE device_logins SET expires_at = 1")
|
||||||
|
if got := decide(t, person, "approve", d.UserCode); got != http.StatusNotFound {
|
||||||
|
t.Errorf("approve expired: %d, want 404", got)
|
||||||
|
}
|
||||||
|
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || msg != "expired" {
|
||||||
|
t.Errorf("poll expired: %d %q, want 410 expired", status, msg)
|
||||||
|
}
|
||||||
|
if status, _, msg := pollDevice(t, s, "not-a-device-code"); status != http.StatusGone || msg != "expired" {
|
||||||
|
t.Errorf("poll unknown: %d %q, want 410 expired", status, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_PollingTooFastIsRefused(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
d := startDevice(t, s)
|
||||||
|
if status, _, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusAccepted {
|
||||||
|
t.Fatalf("first poll: %d", status)
|
||||||
|
}
|
||||||
|
if status, _, msg := pollDevice(t, s, d.DeviceCode); status != http.StatusTooManyRequests || msg != "slow_down" {
|
||||||
|
t.Errorf("immediate second poll: %d %q, want 429 slow_down", status, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_DisabledUserGetsNoSession(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
person := ssoBrowser(t, s)
|
||||||
|
signInSSO(t, idp, person, alice)
|
||||||
|
|
||||||
|
d := startDevice(t, s)
|
||||||
|
decide(t, person, "approve", d.UserCode)
|
||||||
|
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE username = 'alice'")
|
||||||
|
s.readyToPoll(t)
|
||||||
|
var before int
|
||||||
|
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&before)
|
||||||
|
if status, cookie, _ := pollDevice(t, s, d.DeviceCode); status != http.StatusGone || cookie != nil {
|
||||||
|
t.Errorf("poll: %d %v, want 410 and no cookie", status, cookie)
|
||||||
|
}
|
||||||
|
var after int
|
||||||
|
s.db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&after)
|
||||||
|
if after != before {
|
||||||
|
t.Error("a session was created for a disabled user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_OnlyExistsWithSSOConfigured(t *testing.T) {
|
||||||
|
s := newTS(t) // no SSO
|
||||||
|
resp := newBrowser(t, s.URL).do(t, http.MethodPost, "/api/oidc/device", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("device start with SSO off: %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
s *ts
|
||||||
|
want bool
|
||||||
|
}{{"off", s, false}, {"on", newSSOTS(t, idp), true}} {
|
||||||
|
var cfg struct {
|
||||||
|
DeviceLogin bool `json:"device_login"`
|
||||||
|
}
|
||||||
|
decode(t, newBrowser(t, c.s.URL).do(t, http.MethodGet, "/api/auth/config", nil), &cfg)
|
||||||
|
if cfg.DeviceLogin != c.want {
|
||||||
|
t.Errorf("auth config device_login with SSO %s: %v, want %v", c.name, cfg.DeviceLogin, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevice_StartIsRateLimited(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
b := newBrowser(t, s.URL)
|
||||||
|
var last int
|
||||||
|
for range 32 {
|
||||||
|
resp := b.do(t, http.MethodPost, "/api/oidc/device", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
last = resp.StatusCode
|
||||||
|
}
|
||||||
|
if last != http.StatusTooManyRequests {
|
||||||
|
t.Errorf("32nd start: %d, want 429", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After signing in the browser is sent on to where the person was going, which
|
||||||
|
// is how somebody without a session gets from /device?code=... through the
|
||||||
|
// provider and back to it. Only paths on this server are honoured.
|
||||||
|
func TestSSO_NextIsHonouredOnlyForPathsOnThisServer(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t)
|
||||||
|
s := newSSOTS(t, idp)
|
||||||
|
|
||||||
|
for _, c := range []struct{ next, want string }{
|
||||||
|
{"/device?code=BCDF-GHJK", "/device?code=BCDF-GHJK"},
|
||||||
|
{"/team/members", "/team/members"},
|
||||||
|
{"", "/"},
|
||||||
|
{"//evil.example/x", "/"},
|
||||||
|
{"/\\evil.example", "/"},
|
||||||
|
{"https://evil.example/", "/"},
|
||||||
|
{"evil.example", "/"},
|
||||||
|
{"/api/users", "/"},
|
||||||
|
{"/ok\r\nSet-Cookie: x=y", "/"},
|
||||||
|
{"/" + strings.Repeat("a", 600), "/"},
|
||||||
|
} {
|
||||||
|
b := ssoBrowser(t, s)
|
||||||
|
resp := b.do(t, http.MethodGet, "/api/oidc/login?next="+url.QueryEscape(c.next), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
loc, _ := url.Parse(resp.Header.Get("Location"))
|
||||||
|
q := loc.Query()
|
||||||
|
got := callback(t, b, idp.issueCode(alice, q.Get("nonce"), q.Get("code_challenge")), q.Get("state"))
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("next %q: redirected to %q, want %q", c.next, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+185
-1
@@ -346,10 +346,194 @@ func handleGetEscalation(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respond(w, http.StatusOK, escalationResponse(policy, teamID))
|
view, err := escalationStatus(r.Context(), db, teamID, policy)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Level statuses, as the Escalation page colours them.
|
||||||
|
const (
|
||||||
|
levelReady = "ready"
|
||||||
|
levelEscalating = "escalating"
|
||||||
|
levelUnreachable = "unreachable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// escalationTargetView is a target with who it means today and whether that
|
||||||
|
// person can actually be woken. The extra fields are output only: the PUT body
|
||||||
|
// is the plain escalationTargetJSON, and anything else in it is ignored.
|
||||||
|
type escalationTargetView struct {
|
||||||
|
escalationTargetJSON
|
||||||
|
|
||||||
|
// Username is who the target resolves to right now: the named person, or
|
||||||
|
// whoever the rota says is on call today. Empty when nobody is.
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
|
||||||
|
// Reachable is whether a page to this target would go anywhere, and Problem
|
||||||
|
// says why not when it would not — the same conditions pageLevel skips on.
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
Problem string `json:"problem,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationLevelView struct {
|
||||||
|
Position int64 `json:"position"`
|
||||||
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
Targets []escalationTargetView `json:"targets"`
|
||||||
|
|
||||||
|
// Status is unreachable when no target of the level could be woken — a rung
|
||||||
|
// that looks configured and pages nobody, which is worth seeing before an
|
||||||
|
// incident finds it — escalating when an unanswered incident has climbed to
|
||||||
|
// it, and ready otherwise.
|
||||||
|
Status string `json:"status"`
|
||||||
|
|
||||||
|
// Waiting lists the open, unacknowledged incidents currently on this level.
|
||||||
|
Waiting []int64 `json:"waiting"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type escalationView struct {
|
||||||
|
TeamID int64 `json:"team_id"`
|
||||||
|
RepeatCount int64 `json:"repeat_count"`
|
||||||
|
FallbackTopic string `json:"fallback_topic"`
|
||||||
|
Levels []escalationLevelView `json:"levels"`
|
||||||
|
|
||||||
|
// LastEscalatedAt is when an incident of this team last moved up the ladder,
|
||||||
|
// or ran off the end of it, and LastEscalatedIncidentID which one. Absent
|
||||||
|
// when nothing ever has: a ladder nobody has needed yet.
|
||||||
|
LastEscalatedAt *time.Time `json:"last_escalated_at,omitempty"`
|
||||||
|
LastEscalatedIncidentID *int64 `json:"last_escalated_incident_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// escalationStatus is a team's ladder together with what it would do right now
|
||||||
|
// and what it has been doing. The resolution follows pageLevel's rules, so the
|
||||||
|
// page cannot promise a page that the notifier would skip.
|
||||||
|
func escalationStatus(ctx context.Context, db *sql.DB, teamID int64, policy *escalationPolicy) (escalationView, error) {
|
||||||
|
base := escalationResponse(policy, teamID)
|
||||||
|
out := escalationView{
|
||||||
|
TeamID: teamID, RepeatCount: base.RepeatCount, FallbackTopic: base.FallbackTopic,
|
||||||
|
Levels: []escalationLevelView{},
|
||||||
|
}
|
||||||
|
if !policy.configured() {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
onCall, err := currentOnCall(ctx, db, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type account struct {
|
||||||
|
username string
|
||||||
|
topic bool
|
||||||
|
disabled bool
|
||||||
|
}
|
||||||
|
accounts := map[int64]account{}
|
||||||
|
lookup := func(id int64) (account, error) {
|
||||||
|
if a, ok := accounts[id]; ok {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
var a account
|
||||||
|
var topic *string
|
||||||
|
var disabledAt *int64
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT username, ntfy_topic, disabled_at FROM users WHERE id = $1", id).
|
||||||
|
Scan(&a.username, &topic, &disabledAt); err != nil {
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
a.topic = topic != nil && *topic != ""
|
||||||
|
a.disabled = disabledAt != nil
|
||||||
|
accounts[id] = a
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
waiting := map[int64][]int64{}
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, escalation_level FROM incidents
|
||||||
|
WHERE team_id = $1 AND resolved_at IS NULL AND archived_at IS NULL
|
||||||
|
AND status = 'triggered' AND escalation_level > 0
|
||||||
|
ORDER BY id`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, level int64
|
||||||
|
if err := rows.Scan(&id, &level); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
waiting[level] = append(waiting[level], id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range base.Levels {
|
||||||
|
level := escalationLevelView{
|
||||||
|
Position: l.Position, TimeoutSeconds: l.TimeoutSeconds,
|
||||||
|
Targets: []escalationTargetView{}, Waiting: []int64{},
|
||||||
|
}
|
||||||
|
if w := waiting[l.Position]; w != nil {
|
||||||
|
level.Waiting = w
|
||||||
|
}
|
||||||
|
|
||||||
|
anyReachable := false
|
||||||
|
for _, t := range l.Targets {
|
||||||
|
view := escalationTargetView{escalationTargetJSON: t}
|
||||||
|
userID := t.UserID
|
||||||
|
if t.Kind == "oncall" {
|
||||||
|
userID = onCall
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case userID == nil:
|
||||||
|
view.Problem = "nobody is on call today"
|
||||||
|
default:
|
||||||
|
a, err := lookup(*userID)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
view.Problem = "account not found"
|
||||||
|
case a.disabled:
|
||||||
|
view.Username, view.Problem = a.username, "account is disabled"
|
||||||
|
case !a.topic:
|
||||||
|
view.Username, view.Problem = a.username, "has no ntfy topic"
|
||||||
|
default:
|
||||||
|
view.Username, view.Reachable = a.username, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyReachable = anyReachable || view.Reachable
|
||||||
|
level.Targets = append(level.Targets, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !anyReachable:
|
||||||
|
level.Status = levelUnreachable
|
||||||
|
case l.Position >= 2 && len(level.Waiting) > 0:
|
||||||
|
level.Status = levelEscalating
|
||||||
|
default:
|
||||||
|
level.Status = levelReady
|
||||||
|
}
|
||||||
|
out.Levels = append(out.Levels, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
var incidentID, at int64
|
||||||
|
switch err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT e.incident_id, e.created_at
|
||||||
|
FROM incident_events e JOIN incidents i ON i.id = e.incident_id
|
||||||
|
WHERE i.team_id = $1 AND e.type = $2
|
||||||
|
ORDER BY e.created_at DESC, e.id DESC LIMIT 1`, teamID, evEscalated).
|
||||||
|
Scan(&incidentID, &at); {
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
case err != nil:
|
||||||
|
return out, err
|
||||||
|
default:
|
||||||
|
t := time.Unix(at, 0).UTC()
|
||||||
|
out.LastEscalatedAt, out.LastEscalatedIncidentID = &t, &incidentID
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
type escalationLevelJSON struct {
|
type escalationLevelJSON struct {
|
||||||
Position int64 `json:"position"`
|
Position int64 `json:"position"`
|
||||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
|||||||
@@ -383,3 +383,122 @@ func TestEscalation_SkipsUnreachableTargets(t *testing.T) {
|
|||||||
t.Errorf("a target with no topic should page nothing, paged %v", got)
|
t.Errorf("a target with no topic should page nothing, paged %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The ladder as the Escalation page reads it
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ladderLevel struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Waiting []int64 `json:"waiting"`
|
||||||
|
Targets []struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
Problem string `json:"problem"`
|
||||||
|
} `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ladderView struct {
|
||||||
|
Levels []ladderLevel `json:"levels"`
|
||||||
|
LastEscalatedAt *string `json:"last_escalated_at"`
|
||||||
|
LastEscalatedIncidentID *int64 `json:"last_escalated_incident_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLadder(t *testing.T, s *ts) ladderView {
|
||||||
|
t.Helper()
|
||||||
|
var v ladderView
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/escalation", nil), &v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Targets say who they mean today, so "whoever is on call" is a name and not a
|
||||||
|
// promise.
|
||||||
|
func TestEscalation_StatusResolvesTargets(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
v := readLadder(t, s)
|
||||||
|
if len(v.Levels) != 2 {
|
||||||
|
t.Fatalf("expected 2 levels, got %d", len(v.Levels))
|
||||||
|
}
|
||||||
|
if got := v.Levels[0].Targets[0]; got.Kind != "oncall" || got.Username != "admin" || !got.Reachable {
|
||||||
|
t.Errorf("the rota target should resolve to the person on call, got %+v", got)
|
||||||
|
}
|
||||||
|
if got := v.Levels[1].Targets[0]; got.Username != "second" || !got.Reachable {
|
||||||
|
t.Errorf("the named target should be reachable, got %+v", got)
|
||||||
|
}
|
||||||
|
if v.Levels[0].Status != "ready" || v.Levels[1].Status != "ready" || v.LastEscalatedAt != nil {
|
||||||
|
t.Errorf("an idle, healthy ladder is ready and has never escalated, got %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rung that would page nobody is called out before an incident finds it.
|
||||||
|
func TestEscalation_StatusFlagsUnreachableLevels(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
silent := teamUser(t, s, "silent", "terdut-silent")
|
||||||
|
ladder(t, s, silent, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
// Nobody on call today, and the named person loses their topic.
|
||||||
|
s.exec(t, "DELETE FROM schedule_entries")
|
||||||
|
s.exec(t, "UPDATE users SET ntfy_topic = NULL WHERE id = $1", silent)
|
||||||
|
|
||||||
|
v := readLadder(t, s)
|
||||||
|
if v.Levels[0].Status != "unreachable" || v.Levels[0].Targets[0].Problem != "nobody is on call today" {
|
||||||
|
t.Errorf("an empty rota should make level 1 unreachable, got %+v", v.Levels[0])
|
||||||
|
}
|
||||||
|
if v.Levels[1].Status != "unreachable" || v.Levels[1].Targets[0].Problem != "has no ntfy topic" {
|
||||||
|
t.Errorf("a person with no topic should make level 2 unreachable, got %+v", v.Levels[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
s.exec(t, "UPDATE users SET disabled_at = 1 WHERE id = $1", silent)
|
||||||
|
if p := readLadder(t, s).Levels[1].Targets[0].Problem; p != "account is disabled" {
|
||||||
|
t.Errorf("a disabled account should say so, got %q", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where unanswered incidents are right now, and when the ladder last did its
|
||||||
|
// job.
|
||||||
|
func TestEscalation_StatusShowsWhoIsWaitingAndLastEscalation(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
second := teamUser(t, s, "second", "terdut-second")
|
||||||
|
ladder(t, s, second, 0, "terdut-fallback")
|
||||||
|
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-wait", "DiskFull", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
// On level 1 it is waiting, which is normal and not yet an escalation.
|
||||||
|
v := readLadder(t, s)
|
||||||
|
if len(v.Levels[0].Waiting) != 1 || v.Levels[0].Status != "ready" || v.LastEscalatedAt != nil {
|
||||||
|
t.Fatalf("a fresh incident waits on level 1 quietly, got %+v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
overdue(t, s, 1)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
v = readLadder(t, s)
|
||||||
|
if v.Levels[1].Status != "escalating" || len(v.Levels[1].Waiting) != 1 || v.Levels[1].Waiting[0] != 1 {
|
||||||
|
t.Errorf("level 2 should be escalating with the incident on it, got %+v", v.Levels[1])
|
||||||
|
}
|
||||||
|
if v.LastEscalatedAt == nil || v.LastEscalatedIncidentID == nil || *v.LastEscalatedIncidentID != 1 {
|
||||||
|
t.Errorf("the escalation should be recorded, got %+v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody answers: nothing is waiting, but the history stays.
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil).Body.Close()
|
||||||
|
v = readLadder(t, s)
|
||||||
|
if v.Levels[1].Status != "ready" || len(v.Levels[1].Waiting) != 0 || v.LastEscalatedAt == nil {
|
||||||
|
t.Errorf("an acknowledged incident stops waiting but stays in the history, got %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No ladder is a real answer, not an error.
|
||||||
|
func TestEscalation_StatusWithoutALadder(t *testing.T) {
|
||||||
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com", RepeatEvery: 15 * time.Minute})
|
||||||
|
v := readLadder(t, s)
|
||||||
|
if len(v.Levels) != 0 || v.LastEscalatedAt != nil {
|
||||||
|
t.Errorf("a team with no ladder should read as empty, got %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const (
|
|||||||
evUnsnoozed = "unsnoozed"
|
evUnsnoozed = "unsnoozed"
|
||||||
evResolved = "resolved"
|
evResolved = "resolved"
|
||||||
evNote = "note"
|
evNote = "note"
|
||||||
|
// evResolutionNote is the note worth finding again: what fixed it. The
|
||||||
|
// similar-incidents lookup and the page lead with these; plain notes are
|
||||||
|
// the working chatter and stay one click away.
|
||||||
|
evResolutionNote = "resolution_note"
|
||||||
evDeadmanSilent = "deadman_silent"
|
evDeadmanSilent = "deadman_silent"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -295,6 +299,34 @@ func openIncidentForAlert(ctx context.Context, q querier, alertID int64) (int64,
|
|||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// volatileLabels say where a problem ran this time, not what the problem is, so
|
||||||
|
// they stay out of the signature. Migration 008's backfill lists the same set.
|
||||||
|
var volatileLabels = map[string]bool{
|
||||||
|
"instance": true, "pod": true, "pod_name": true, "pod_ip": true,
|
||||||
|
"container": true, "container_name": true, "endpoint": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// incidentSignature identifies "the same problem" across incidents: the alert
|
||||||
|
// name plus the stable group labels, sorted. Incidents in one team with equal
|
||||||
|
// signatures are what the similar-incidents lookup returns. title stands in for
|
||||||
|
// the name when the payload carried no alertname (groupless and dead man's
|
||||||
|
// switch incidents).
|
||||||
|
func incidentSignature(groupLabels map[string]string, title string) string {
|
||||||
|
name := groupLabels["alertname"]
|
||||||
|
if name == "" {
|
||||||
|
name = title
|
||||||
|
}
|
||||||
|
rest := make([]string, 0, len(groupLabels))
|
||||||
|
for k, v := range groupLabels {
|
||||||
|
if k == "alertname" || volatileLabels[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest = append(rest, k+"="+v)
|
||||||
|
}
|
||||||
|
sort.Strings(rest)
|
||||||
|
return name + "|" + strings.Join(rest, ",")
|
||||||
|
}
|
||||||
|
|
||||||
// incidentTitle renders a human-readable title from Alertmanager's groupLabels,
|
// incidentTitle renders a human-readable title from Alertmanager's groupLabels,
|
||||||
// leading with the alert name and appending whatever else the operator grouped
|
// leading with the alert name and appending whatever else the operator grouped
|
||||||
// by. Falls back to the alert's own name when the payload carried no groupLabels.
|
// by. Falls back to the alert's own name when the payload carried no groupLabels.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -237,6 +238,15 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, _ := userFromContext(r.Context())
|
user, _ := userFromContext(r.Context())
|
||||||
|
// The body is optional: clients that predate resolution notes send none.
|
||||||
|
var req struct {
|
||||||
|
Resolution string `json:"resolution"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil && err != io.EOF {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Resolution = strings.TrimSpace(req.Resolution)
|
||||||
if !updateOpenIncident(w, r, db, id,
|
if !updateOpenIncident(w, r, db, id,
|
||||||
`UPDATE incidents SET status = 'resolved', resolved_at = $1, resolution_source = $2
|
`UPDATE incidents SET status = 'resolved', resolved_at = $1, resolution_source = $2
|
||||||
WHERE id = $3 AND resolved_at IS NULL`,
|
WHERE id = $3 AND resolved_at IS NULL`,
|
||||||
@@ -252,6 +262,12 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.Resolution != "" {
|
||||||
|
if err := logEvent(r.Context(), db, id, evResolutionNote, &user.ID, nil, &req.Resolution); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
respondIncident(w, r, db, id)
|
respondIncident(w, r, db, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,11 +437,17 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
// Pinned files the note as the resolution note: what fixed it.
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
}
|
}
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
noteType := evNote
|
||||||
|
if req.Pinned {
|
||||||
|
noteType = evResolutionNote
|
||||||
|
}
|
||||||
if req.Content == "" {
|
if req.Content == "" {
|
||||||
respond(w, http.StatusBadRequest, errResp("content is required"))
|
respond(w, http.StatusBadRequest, errResp("content is required"))
|
||||||
return
|
return
|
||||||
@@ -440,7 +462,7 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
|||||||
err := db.QueryRowContext(r.Context(), `
|
err := db.QueryRowContext(r.Context(), `
|
||||||
INSERT INTO incident_events (incident_id, type, user_id, detail, created_at)
|
INSERT INTO incident_events (incident_id, type, user_id, detail, created_at)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id`, id, evNote, user.ID, req.Content, now.Unix()).Scan(&eventID)
|
RETURNING id`, id, noteType, user.ID, req.Content, now.Unix()).Scan(&eventID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -449,7 +471,7 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusCreated, models.IncidentEvent{
|
respond(w, http.StatusCreated, models.IncidentEvent{
|
||||||
ID: eventID,
|
ID: eventID,
|
||||||
IncidentID: id,
|
IncidentID: id,
|
||||||
Type: evNote,
|
Type: noteType,
|
||||||
UserID: &user.ID,
|
UserID: &user.ID,
|
||||||
Username: &user.Username,
|
Username: &user.Username,
|
||||||
Detail: &req.Content,
|
Detail: &req.Content,
|
||||||
@@ -475,8 +497,8 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc {
|
|||||||
user, _ := userFromContext(r.Context())
|
user, _ := userFromContext(r.Context())
|
||||||
res, err := db.ExecContext(r.Context(), `
|
res, err := db.ExecContext(r.Context(), `
|
||||||
DELETE FROM incident_events
|
DELETE FROM incident_events
|
||||||
WHERE id = $1 AND incident_id = $2 AND type = $3 AND user_id = $4`,
|
WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4) AND user_id = $5`,
|
||||||
eventID, id, evNote, user.ID)
|
eventID, id, evNote, evResolutionNote, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,8 +132,13 @@ func sessionUser(ctx context.Context, db *sql.DB, token string) (sessionID, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
|
if now.Sub(time.Unix(lastSeen, 0)) > sessionTouchEvery {
|
||||||
db.ExecContext(ctx,
|
// LEAST keeps a capped session (a single sign-on login) from sliding
|
||||||
"UPDATE sessions SET last_seen_at = $1, expires_at = $2 WHERE id = $3",
|
// past its ceiling; with no ceiling COALESCE makes it the plain slide.
|
||||||
|
db.ExecContext(ctx, `
|
||||||
|
UPDATE sessions
|
||||||
|
SET last_seen_at = $1,
|
||||||
|
expires_at = LEAST($2::bigint, COALESCE(max_expires_at, $2::bigint))
|
||||||
|
WHERE id = $3`,
|
||||||
now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
|
now.Unix(), now.Add(sessionTTL).Unix(), sessionID)
|
||||||
}
|
}
|
||||||
return sessionID, userID, true
|
return sessionID, userID, true
|
||||||
|
|||||||
@@ -330,6 +330,16 @@ func deliver(ctx context.Context, db *sql.DB, cfg NotifyConfig, n outboxRow) err
|
|||||||
|
|
||||||
msg := renderNotification(inc, n, firing, cfg)
|
msg := renderNotification(inc, n, firing, cfg)
|
||||||
|
|
||||||
|
// The page that opens an incident carries what fixed it last time, so the
|
||||||
|
// person woken up starts from that. Best effort: a failed lookup must not
|
||||||
|
// hold back the page itself.
|
||||||
|
if n.kind == notifyTriggered {
|
||||||
|
if sim, err := similarIncidents(ctx, db, n.incidentID, 1); err == nil && len(sim) > 0 && len(sim[0].ResolutionNotes) > 0 {
|
||||||
|
notes := sim[0].ResolutionNotes
|
||||||
|
msg.Message += "\nLast time: " + shorten(derefString(notes[len(notes)-1].Detail), 160)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// An Acknowledge button needs both a user to attribute the acknowledgement
|
// An Acknowledge button needs both a user to attribute the acknowledgement
|
||||||
// to and a URL the phone can reach. Minted per delivery, so every push
|
// to and a URL the phone can reach. Minted per delivery, so every push
|
||||||
// carries its own short-lived token rather than reusing one.
|
// carries its own short-lived token rather than reusing one.
|
||||||
@@ -573,6 +583,17 @@ func plural(n int) string {
|
|||||||
return "s"
|
return "s"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shorten cuts s to at most n runes, marking the cut, and flattens newlines so
|
||||||
|
// a multi-line note stays one line in a push.
|
||||||
|
func shorten(s string, n int) string {
|
||||||
|
s = strings.Join(strings.Fields(s), " ")
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:n-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
// derefString reads a nullable text column as a plain string.
|
// derefString reads a nullable text column as a plain string.
|
||||||
func derefString(s *string) string {
|
func derefString(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// teamOIDCGroups is one team's own OIDC binding: which group, if any, grants
|
||||||
|
// member access and which grants owner access. The same shape answers GET and
|
||||||
|
// is accepted by PUT. An empty string means no group grants that role here.
|
||||||
|
type teamOIDCGroups struct {
|
||||||
|
MemberGroup string `json:"member_group"`
|
||||||
|
OwnerGroup string `json:"owner_group"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetTeamOIDCGroups answers which groups control a team's membership.
|
||||||
|
// Member-gated like the member list itself: this is part of "who is in the
|
||||||
|
// team and why", not a setting only an owner should be able to see.
|
||||||
|
func handleGetTeamOIDCGroups(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamMember(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var g teamOIDCGroups
|
||||||
|
err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT COALESCE(oidc_member_group, ''), COALESCE(oidc_owner_group, '') FROM teams WHERE id = $1",
|
||||||
|
teamID).Scan(&g.MemberGroup, &g.OwnerGroup)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetTeamOIDCGroups sets which groups control a team's membership.
|
||||||
|
//
|
||||||
|
// Owner-gated, the same as the schedule, the integrations and the escalation
|
||||||
|
// ladder: this decides who can end up in the team, which is exactly the kind
|
||||||
|
// of thing only the team's own owner (or an administrator repairing it) should
|
||||||
|
// be able to change. An empty string clears a binding.
|
||||||
|
func handleSetTeamOIDCGroups(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req teamOIDCGroups
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(r.Context(), `
|
||||||
|
UPDATE teams
|
||||||
|
SET oidc_member_group = NULLIF($1, ''),
|
||||||
|
oidc_owner_group = NULLIF($2, '')
|
||||||
|
WHERE id = $3`,
|
||||||
|
req.MemberGroup, req.OwnerGroup, teamID); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-4
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/oidc"
|
||||||
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@@ -20,6 +21,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
|||||||
// two would let a burst of sign-ups lock somebody out of logging in.
|
// two would let a burst of sign-ups lock somebody out of logging in.
|
||||||
loginLimit := newLoginLimiter()
|
loginLimit := newLoginLimiter()
|
||||||
signupLimiter := newLoginLimiter()
|
signupLimiter := newLoginLimiter()
|
||||||
|
oidcLimit := newLoginLimiter()
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
@@ -50,18 +52,43 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
|||||||
// an invite link is good, so the form can say so before somebody picks a
|
// an invite link is good, so the form can say so before somebody picks a
|
||||||
// password.
|
// password.
|
||||||
r.Get("/api/signup", handleSignupInfo(db))
|
r.Get("/api/signup", handleSignupInfo(db))
|
||||||
r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
|
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
|
||||||
|
Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
|
||||||
|
|
||||||
|
// How to sign in: what the login form and the TUI offer before anybody types.
|
||||||
|
r.Get("/api/auth/config", handleAuthConfig(cfg))
|
||||||
|
|
||||||
// Signing in to the web UI. Login trades a password for a session cookie,
|
// Signing in to the web UI. Login trades a password for a session cookie,
|
||||||
// which AuthMiddleware accepts in place of an API key.
|
// which AuthMiddleware accepts in place of an API key.
|
||||||
r.Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
|
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
|
||||||
|
Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
|
||||||
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
|
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
|
||||||
|
|
||||||
|
// Single sign-on. Both routes are navigations the browser makes, to and from
|
||||||
|
// the provider, so they answer with redirects rather than JSON.
|
||||||
|
if cfg.OIDC.Enabled() {
|
||||||
|
prov := oidc.New(cfg.OIDC, notify.PublicURL)
|
||||||
|
r.Get("/api/oidc/login", handleOIDCLogin(db, prov, oidcLimit, notify.PublicURL))
|
||||||
|
r.Get("/api/oidc/callback", handleOIDCCallback(db, prov, notify.PublicURL))
|
||||||
|
|
||||||
|
// Device login, for a client with no browser of its own. Both are
|
||||||
|
// unauthenticated: the device code in the body is the credential.
|
||||||
|
r.Post("/api/oidc/device", handleDeviceStart(db, oidcLimit, notify.PublicURL))
|
||||||
|
r.Post("/api/oidc/device/token", handleDeviceToken(db, cfg.OIDC.SessionMaxAge, notify.PublicURL))
|
||||||
|
}
|
||||||
|
|
||||||
// All other /api routes require a valid API key.
|
// All other /api routes require a valid API key.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(AuthMiddleware(db))
|
r.Use(AuthMiddleware(db))
|
||||||
|
|
||||||
r.Get("/api/me", handleMe(db))
|
r.Get("/api/me", handleMe(db))
|
||||||
|
|
||||||
|
// Approving or refusing a device login is done by somebody signed in
|
||||||
|
// to a browser, and needs the same SSO configuration the flow does.
|
||||||
|
if cfg.OIDC.Enabled() {
|
||||||
|
r.Post("/api/oidc/device/approve", handleDeviceDecision(db, true))
|
||||||
|
r.Post("/api/oidc/device/deny", handleDeviceDecision(db, false))
|
||||||
|
}
|
||||||
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
|
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
|
||||||
// Proves the topic works, which is the only part of "notifications are
|
// Proves the topic works, which is the only part of "notifications are
|
||||||
// set up" that the person holding the phone can confirm.
|
// set up" that the person holding the phone can confirm.
|
||||||
@@ -112,6 +139,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
|||||||
r.Get("/api/incidents/{id}", handleGetIncident(db))
|
r.Get("/api/incidents/{id}", handleGetIncident(db))
|
||||||
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
|
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
|
||||||
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
|
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
|
||||||
|
r.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db))
|
||||||
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
|
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
|
||||||
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
|
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
|
||||||
r.Post("/api/incidents/{id}/resolve", handleIncidentResolve(db))
|
r.Post("/api/incidents/{id}/resolve", handleIncidentResolve(db))
|
||||||
@@ -132,6 +160,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
|||||||
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
||||||
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
||||||
|
|
||||||
|
// A team's own OIDC group binding: which provider groups grant member
|
||||||
|
// and owner access to it.
|
||||||
|
r.Get("/api/teams/{teamID}/oidc-groups", handleGetTeamOIDCGroups(db))
|
||||||
|
r.Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(db))
|
||||||
|
|
||||||
// Invite links into this team.
|
// Invite links into this team.
|
||||||
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
||||||
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
||||||
@@ -143,12 +176,14 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
|||||||
|
|
||||||
// A team's own dead man's switches: which of its alerts are heartbeats,
|
// A team's own dead man's switches: which of its alerts are heartbeats,
|
||||||
// and how long a silence has to last before somebody is paged.
|
// and how long a silence has to last before somebody is paged.
|
||||||
r.Get("/api/teams/{teamID}/deadman", handleGetTeamDeadman(db))
|
r.Get("/api/teams/{teamID}/deadman/switches", handleListTeamDeadman(db))
|
||||||
r.Put("/api/teams/{teamID}/deadman", handleSetTeamDeadman(db))
|
r.Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
|
||||||
|
r.Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
|
||||||
|
|
||||||
// Integrations: where a team's alerts come in, and the key that says so.
|
// Integrations: where a team's alerts come in, and the key that says so.
|
||||||
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
|
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
|
||||||
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
|
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
|
||||||
|
r.Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
|
||||||
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
|
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
|
||||||
|
|
||||||
// The rota is per team. /api/schedule/current is the exception: it
|
// The rota is per team. /api/schedule/current is the exception: it
|
||||||
|
|||||||
@@ -237,6 +237,14 @@ type adminTeam struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Members int64 `json:"members"`
|
Members int64 `json:"members"`
|
||||||
OpenIncidents int64 `json:"open_incidents"`
|
OpenIncidents int64 `json:"open_incidents"`
|
||||||
|
|
||||||
|
// OIDCMemberGroup and OIDCOwnerGroup are the team's own group binding,
|
||||||
|
// read-only here: an administrator can see why a team's OIDC-sourced
|
||||||
|
// membership looks the way it does without being able to change it out
|
||||||
|
// from under the team's owner. Setting it is PUT
|
||||||
|
// /api/teams/{teamID}/oidc-groups, owner-only.
|
||||||
|
OIDCMemberGroup string `json:"oidc_member_group,omitempty"`
|
||||||
|
OIDCOwnerGroup string `json:"oidc_owner_group,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAdminListTeams lists every team on the server, with its size. The
|
// handleAdminListTeams lists every team on the server, with its size. The
|
||||||
@@ -248,7 +256,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
|||||||
SELECT t.id, t.name, t.created_at,
|
SELECT t.id, t.name, t.created_at,
|
||||||
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||||
(SELECT COUNT(*) FROM incidents i
|
(SELECT COUNT(*) FROM incidents i
|
||||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
|
||||||
|
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
|
||||||
FROM teams t
|
FROM teams t
|
||||||
ORDER BY t.name`)
|
ORDER BY t.name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,7 +270,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t adminTeam
|
var t adminTeam
|
||||||
var created int64
|
var created int64
|
||||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); err != nil {
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
|
||||||
|
&t.OIDCMemberGroup, &t.OIDCOwnerGroup); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -298,10 +308,12 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
|
|||||||
SELECT t.id, t.name, t.created_at,
|
SELECT t.id, t.name, t.created_at,
|
||||||
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||||
(SELECT COUNT(*) FROM incidents i
|
(SELECT COUNT(*) FROM incidents i
|
||||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
|
||||||
|
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
|
||||||
FROM teams t
|
FROM teams t
|
||||||
WHERE t.id = $1`, teamID).
|
WHERE t.id = $1`, teamID).
|
||||||
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents)
|
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
|
||||||
|
&t.OIDCMemberGroup, &t.OIDCOwnerGroup)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
respond(w, http.StatusNotFound, errResp("not found"))
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
return
|
return
|
||||||
@@ -315,7 +327,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
|
|||||||
// Same query and same ordering as handleListTeamMembers, so the two
|
// Same query and same ordering as handleListTeamMembers, so the two
|
||||||
// answers to "who is in this team" cannot disagree about the answer.
|
// answers to "who is in this team" cannot disagree about the answer.
|
||||||
rows, err := db.QueryContext(r.Context(), `
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at
|
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source
|
||||||
FROM team_members m
|
FROM team_members m
|
||||||
JOIN users u ON u.id = m.user_id
|
JOIN users u ON u.id = m.user_id
|
||||||
WHERE m.team_id = $1
|
WHERE m.team_id = $1
|
||||||
@@ -330,7 +342,7 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m models.TeamMember
|
var m models.TeamMember
|
||||||
var joined int64
|
var joined int64
|
||||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil {
|
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
similarDefaultLimit = 5
|
||||||
|
similarMaxLimit = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleIncidentSimilar lists earlier, resolved incidents in the same team with
|
||||||
|
// the same signature that someone left notes on, incidents with a resolution
|
||||||
|
// note first. This is the "have we seen this before" answer for a responder
|
||||||
|
// looking at a fresh incident; the plain notes are one timeline fetch away.
|
||||||
|
func handleIncidentSimilar(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := incidentIDParam(w, r, db)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := similarDefaultLimit
|
||||||
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid limit"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit = min(n, similarMaxLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := similarIncidents(r.Context(), db, id, limit)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func similarIncidents(ctx context.Context, q querier, id int64, limit int) ([]models.SimilarIncident, error) {
|
||||||
|
rows, err := q.QueryContext(ctx, `
|
||||||
|
SELECT o.id, o.title, o.triggered_at, o.resolved_at,
|
||||||
|
(SELECT COUNT(*) FROM incident_events e
|
||||||
|
WHERE e.incident_id = o.id AND e.type = $3)
|
||||||
|
FROM incidents i
|
||||||
|
JOIN incidents o ON o.team_id = i.team_id AND o.signature = i.signature
|
||||||
|
WHERE i.id = $1 AND o.id <> i.id AND o.resolved_at IS NOT NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM incident_events e
|
||||||
|
WHERE e.incident_id = o.id AND e.type IN ($3, $4))
|
||||||
|
ORDER BY EXISTS (SELECT 1 FROM incident_events e
|
||||||
|
WHERE e.incident_id = o.id AND e.type = $4) DESC,
|
||||||
|
o.triggered_at DESC
|
||||||
|
LIMIT $2`, id, limit, evNote, evResolutionNote)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []models.SimilarIncident{}
|
||||||
|
ids := []int64{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s models.SimilarIncident
|
||||||
|
var triggered, resolved int64
|
||||||
|
if err := rows.Scan(&s.ID, &s.Title, &triggered, &resolved, &s.NoteCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.TriggeredAt = time.Unix(triggered, 0).UTC()
|
||||||
|
s.ResolvedAt = time.Unix(resolved, 0).UTC()
|
||||||
|
s.ResolutionNotes = []models.IncidentEvent{}
|
||||||
|
out = append(out, s)
|
||||||
|
ids = append(ids, s.ID)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nrows, err := q.QueryContext(ctx, `
|
||||||
|
SELECT e.id, e.incident_id, e.type, e.user_id, u.username, e.detail, e.created_at
|
||||||
|
FROM incident_events e
|
||||||
|
LEFT JOIN users u ON u.id = e.user_id
|
||||||
|
WHERE e.incident_id = ANY($1) AND e.type = $2
|
||||||
|
ORDER BY e.created_at, e.id`, ids, evResolutionNote)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer nrows.Close()
|
||||||
|
|
||||||
|
byID := make(map[int64]*models.SimilarIncident, len(out))
|
||||||
|
for i := range out {
|
||||||
|
byID[out[i].ID] = &out[i]
|
||||||
|
}
|
||||||
|
for nrows.Next() {
|
||||||
|
var e models.IncidentEvent
|
||||||
|
var ts int64
|
||||||
|
if err := nrows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username, &e.Detail, &ts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
s := byID[e.IncidentID]
|
||||||
|
s.ResolutionNotes = append(s.ResolutionNotes, e)
|
||||||
|
}
|
||||||
|
return out, nrows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// postGrouped posts a firing webhook whose group labels are exactly the given
|
||||||
|
// map, unlike postWebhook, which only ever groups by alertname.
|
||||||
|
func postGrouped(t *testing.T, s *ts, fingerprint, startsAt string, groupLabels map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
labels := map[string]string{}
|
||||||
|
for k, v := range groupLabels {
|
||||||
|
labels[k] = v
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"version": "4", "status": "firing",
|
||||||
|
"groupKey": fingerprint,
|
||||||
|
"groupLabels": groupLabels,
|
||||||
|
"alerts": []map[string]any{
|
||||||
|
amAlert(fingerprint, groupLabels["alertname"], "firing", startsAt, zeroTime, labels),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
|
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||||
|
"application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post webhook: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func similar(t *testing.T, s *ts, id int) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var out []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/incidents/"+strconv.Itoa(id)+"/similar", nil), &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same alert on another instance is the same problem; a resolution note left on
|
||||||
|
// the first one is what the second one should be shown.
|
||||||
|
func TestSimilar_IgnoresVolatileLabelsAndLeadsWithResolutionNote(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postGrouped(t, s, "fp-a", "2026-05-20T10:00:00Z",
|
||||||
|
map[string]string{"alertname": "DiskFull", "instance": "web-1", "job": "node"})
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve",
|
||||||
|
map[string]string{"resolution": "rotated the logs"}).Body.Close()
|
||||||
|
|
||||||
|
postGrouped(t, s, "fp-b", "2026-05-21T10:00:00Z",
|
||||||
|
map[string]string{"alertname": "DiskFull", "instance": "web-2", "job": "node"})
|
||||||
|
|
||||||
|
got := similar(t, s, 2)
|
||||||
|
if len(got) != 1 || int(got[0]["id"].(float64)) != 1 {
|
||||||
|
t.Fatalf("expected incident 1 as the only similar one, got %v", got)
|
||||||
|
}
|
||||||
|
notes := got[0]["resolution_notes"].([]any)
|
||||||
|
if len(notes) != 1 || notes[0].(map[string]any)["detail"] != "rotated the logs" {
|
||||||
|
t.Fatalf("expected the resolution note, got %v", notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different stable label (job) is a different problem, and an incident nobody
|
||||||
|
// wrote a note on has nothing to show.
|
||||||
|
func TestSimilar_DifferentSignatureOrNoNotesIsExcluded(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postGrouped(t, s, "fp-1", "2026-05-20T10:00:00Z",
|
||||||
|
map[string]string{"alertname": "DiskFull", "job": "node"})
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/notes", map[string]string{"content": "checked"}).Body.Close()
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
postGrouped(t, s, "fp-2", "2026-05-20T11:00:00Z",
|
||||||
|
map[string]string{"alertname": "DiskFull", "job": "db"})
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/2/resolve", nil).Body.Close()
|
||||||
|
|
||||||
|
postGrouped(t, s, "fp-3", "2026-05-21T10:00:00Z",
|
||||||
|
map[string]string{"alertname": "DiskFull", "job": "db"})
|
||||||
|
|
||||||
|
// Incident 3 matches 2 by signature, but 2 has no notes.
|
||||||
|
if got := similar(t, s, 3); len(got) != 0 {
|
||||||
|
t.Fatalf("expected nothing similar to incident 3, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An open incident is not "earlier experience" yet, and the incident itself is
|
||||||
|
// never its own match.
|
||||||
|
func TestSimilar_OpenIncidentsAreNotListed(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postGrouped(t, s, "fp-o1", "2026-05-20T10:00:00Z", map[string]string{"alertname": "Flap"})
|
||||||
|
s.req(t, http.MethodPost, "/api/incidents/1/notes",
|
||||||
|
map[string]any{"content": "still open", "pinned": true}).Body.Close()
|
||||||
|
postGrouped(t, s, "fp-o2", "2026-05-21T10:00:00Z", map[string]string{"alertname": "Flap"})
|
||||||
|
|
||||||
|
if got := similar(t, s, 2); len(got) != 0 {
|
||||||
|
t.Fatalf("expected an open incident not to be listed, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listSources reads a team's alert sources as the Sources page does.
|
||||||
|
func listSources(t *testing.T, tm teamFixture) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
return list(t, tm.call(http.MethodGet, "/api/teams/"+id64(tm.id)+"/integrations", nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSource mints a second source in a team and returns its key.
|
||||||
|
func addSource(t *testing.T, tm teamFixture, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
var out struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, tm.call(http.MethodPost, "/api/teams/"+id64(tm.id)+"/integrations",
|
||||||
|
map[string]string{"name": name}), &out)
|
||||||
|
return out.Key
|
||||||
|
}
|
||||||
|
|
||||||
|
// A source that has never posted is "never", with nothing to say about alerts.
|
||||||
|
func TestSources_NeverUsedIsBlank(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
|
||||||
|
got := listSources(t, tm)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 source, got %d", len(got))
|
||||||
|
}
|
||||||
|
src := got[0]
|
||||||
|
if src["status"] != "never" || src["last_used_at"] != nil || src["last_alert_at"] != nil {
|
||||||
|
t.Errorf("a source nobody has posted on should be blank, got %v", src)
|
||||||
|
}
|
||||||
|
if src["alerts_24h"].(float64) != 0 {
|
||||||
|
t.Errorf("alerts_24h = %v, want 0", src["alerts_24h"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each source is credited with what arrived on its own key, and only that.
|
||||||
|
func TestSources_AlertsAreAttributedToTheirSource(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
second := addSource(t, tm, "staging")
|
||||||
|
|
||||||
|
postToIntegration(t, s, tm.key, "fp-1", "DiskFull")
|
||||||
|
postToIntegration(t, s, tm.key, "fp-2", "CPUHot")
|
||||||
|
|
||||||
|
got := listSources(t, tm)
|
||||||
|
first, other := got[0], got[1]
|
||||||
|
if first["status"] != "active" || first["last_used_at"] == nil || first["last_alert_at"] == nil {
|
||||||
|
t.Errorf("the source that posted should be active with timestamps, got %v", first)
|
||||||
|
}
|
||||||
|
if first["alerts_24h"].(float64) != 2 {
|
||||||
|
t.Errorf("alerts_24h = %v, want 2", first["alerts_24h"])
|
||||||
|
}
|
||||||
|
if other["status"] != "never" || other["alerts_24h"].(float64) != 0 {
|
||||||
|
t.Errorf("the other source should be untouched, got %v", other)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-sending the same alert on the other key moves it: last sender wins.
|
||||||
|
postToIntegration(t, s, second, "fp-1", "DiskFull")
|
||||||
|
got = listSources(t, tm)
|
||||||
|
if got[0]["alerts_24h"].(float64) != 1 || got[1]["alerts_24h"].(float64) != 1 {
|
||||||
|
t.Errorf("fp-1 should have moved to the second source, got %v and %v",
|
||||||
|
got[0]["alerts_24h"], got[1]["alerts_24h"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A payload with no alerts in it is a webhook, not an alert: the source was
|
||||||
|
// heard from, and nothing arrived.
|
||||||
|
func TestSources_EmptyPayloadStampsUseButNotAlert(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
|
||||||
|
resp, err := http.Post(s.URL+"/api/integrations/"+tm.key+"/alertmanager",
|
||||||
|
"application/json", bytes.NewReader([]byte(`{"version":"4","status":"firing","alerts":[]}`)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
src := listSources(t, tm)[0]
|
||||||
|
if src["status"] != "active" || src["last_alert_at"] != nil {
|
||||||
|
t.Errorf("want active with no alert yet, got %v", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiet is "has posted, not lately"; the alert counter forgets after a day but
|
||||||
|
// the last alert's timestamp is kept.
|
||||||
|
func TestSources_QuietAfterADay(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
postToIntegration(t, s, tm.key, "fp-1", "DiskFull")
|
||||||
|
|
||||||
|
old := time.Now().Add(-48 * time.Hour).Unix()
|
||||||
|
s.exec(t, "UPDATE integrations SET last_used_at = $1", old)
|
||||||
|
s.exec(t, "UPDATE alerts SET received_at = $1 WHERE fingerprint = 'fp-1'", old)
|
||||||
|
|
||||||
|
src := listSources(t, tm)[0]
|
||||||
|
if src["status"] != "quiet" {
|
||||||
|
t.Errorf("status = %v, want quiet", src["status"])
|
||||||
|
}
|
||||||
|
if src["alerts_24h"].(float64) != 0 {
|
||||||
|
t.Errorf("alerts_24h = %v, want 0", src["alerts_24h"])
|
||||||
|
}
|
||||||
|
if src["last_alert_at"] == nil {
|
||||||
|
t.Error("last_alert_at should survive the day")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoking a source does not take its alerts with it.
|
||||||
|
func TestSources_RevokeKeepsTheAlerts(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
postToIntegration(t, s, tm.key, "fp-1", "DiskFull")
|
||||||
|
|
||||||
|
id := int64(listSources(t, tm)[0]["id"].(float64))
|
||||||
|
resp := tm.call(http.MethodDelete, "/api/teams/"+id64(tm.id)+"/integrations/"+id64(id), nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("revoke: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := len(list(t, tm.call(http.MethodGet, "/api/alerts", nil))); got != 1 {
|
||||||
|
t.Errorf("the alert should outlive its source, got %d alerts", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renaming is an owner's, scoped to the team, and does not touch the key.
|
||||||
|
func TestSources_Rename(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
tm := newTeam(t, s, "red")
|
||||||
|
other := newTeam(t, s, "blue")
|
||||||
|
id := int64(listSources(t, tm)[0]["id"].(float64))
|
||||||
|
path := "/api/teams/" + id64(tm.id) + "/integrations/" + id64(id)
|
||||||
|
|
||||||
|
resp := tm.call(http.MethodPatch, path, map[string]string{"name": " prod "})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("rename: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if name := listSources(t, tm)[0]["name"]; name != "prod" {
|
||||||
|
t.Errorf("name = %q, want it trimmed to prod", name)
|
||||||
|
}
|
||||||
|
postToIntegration(t, s, tm.key, "fp-1", "DiskFull") // the old key still works
|
||||||
|
|
||||||
|
for name, body := range map[string]map[string]string{
|
||||||
|
"empty": {"name": " "},
|
||||||
|
"too long": {"name": strings.Repeat("x", 101)},
|
||||||
|
} {
|
||||||
|
resp := tm.call(http.MethodPatch, path, body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s name: expected 400, got %d", name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another team's owner cannot reach it.
|
||||||
|
resp = other.call(http.MethodPatch, "/api/teams/"+id64(other.id)+"/integrations/"+id64(id),
|
||||||
|
map[string]string{"name": "mine now"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("renaming another team's source: expected 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
+336
-80
@@ -21,7 +21,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
caller, _ := userFromContext(r.Context())
|
caller, _ := userFromContext(r.Context())
|
||||||
rows, err := db.QueryContext(r.Context(), `
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
SELECT t.id, t.name, t.created_at, m.role
|
SELECT t.id, t.name, t.created_at, m.role, m.source
|
||||||
FROM teams t
|
FROM teams t
|
||||||
JOIN team_members m ON m.team_id = t.id
|
JOIN team_members m ON m.team_id = t.id
|
||||||
WHERE m.user_id = $1
|
WHERE m.user_id = $1
|
||||||
@@ -36,7 +36,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t models.Team
|
var t models.Team
|
||||||
var created int64
|
var created int64
|
||||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.QueryContext(r.Context(), `
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
SELECT t.id, t.name, t.created_at, m.role
|
SELECT t.id, t.name, t.created_at, m.role, m.source
|
||||||
FROM teams t
|
FROM teams t
|
||||||
JOIN team_members m ON m.team_id = t.id
|
JOIN team_members m ON m.team_id = t.id
|
||||||
WHERE m.user_id = $1
|
WHERE m.user_id = $1
|
||||||
@@ -98,7 +98,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t models.Team
|
var t models.Team
|
||||||
var created int64
|
var created int64
|
||||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -210,8 +210,40 @@ func handleDeleteTeam(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleListTeamMembers names everybody in a team. Visible to any member: you
|
// Member statuses, as the Members page colours them.
|
||||||
// can see who else is on the rota you are on.
|
const (
|
||||||
|
memberOnCall = "oncall"
|
||||||
|
memberReachable = "reachable"
|
||||||
|
memberUnpageable = "unpageable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memberStatus is a team member with what matters about them at 03:00: whether
|
||||||
|
// they are on call, whether a page to them would go anywhere, and whether they
|
||||||
|
// have been around. The extra fields are output only.
|
||||||
|
type memberStatus struct {
|
||||||
|
models.TeamMember
|
||||||
|
|
||||||
|
// Status is unpageable when a page to them would go nowhere — even when
|
||||||
|
// they are on call, since that is the case that matters most — on_call when
|
||||||
|
// the rota has them today, reachable otherwise.
|
||||||
|
Status string `json:"status"`
|
||||||
|
|
||||||
|
OnCall bool `json:"on_call"`
|
||||||
|
|
||||||
|
// NextShift is the first day after today the rota has them (YYYY-MM-DD).
|
||||||
|
NextShift *string `json:"next_shift,omitempty"`
|
||||||
|
|
||||||
|
// Pageable is whether they have an ntfy topic and an enabled account — the
|
||||||
|
// conditions pageLevel and the notifier skip on. Never the topic itself.
|
||||||
|
Pageable bool `json:"pageable"`
|
||||||
|
Problem string `json:"problem,omitempty"`
|
||||||
|
|
||||||
|
// LastActiveAt is the last time they used a session or an API key.
|
||||||
|
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListTeamMembers names everybody in a team, with their status. Visible to
|
||||||
|
// any member: you can see who else is on the rota you are on.
|
||||||
func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
|
func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
teamID, ok := teamParam(w, r)
|
teamID, ok := teamParam(w, r)
|
||||||
@@ -223,26 +255,56 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.QueryContext(r.Context(), `
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at
|
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source,
|
||||||
|
u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '',
|
||||||
|
u.disabled_at IS NOT NULL,
|
||||||
|
GREATEST(
|
||||||
|
COALESCE((SELECT MAX(last_seen_at) FROM sessions WHERE user_id = u.id), 0),
|
||||||
|
COALESCE((SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id), 0)),
|
||||||
|
EXISTS (SELECT 1 FROM schedule_entries s
|
||||||
|
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date = $2),
|
||||||
|
(SELECT MIN(date) FROM schedule_entries s
|
||||||
|
WHERE s.team_id = m.team_id AND s.user_id = u.id AND s.date > $2)
|
||||||
FROM team_members m
|
FROM team_members m
|
||||||
JOIN users u ON u.id = m.user_id
|
JOIN users u ON u.id = m.user_id
|
||||||
WHERE m.team_id = $1
|
WHERE m.team_id = $1
|
||||||
ORDER BY u.username`, teamID)
|
ORDER BY u.username`, teamID, todayUTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
members := []models.TeamMember{}
|
members := []memberStatus{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m models.TeamMember
|
var m memberStatus
|
||||||
var joined int64
|
var joined, lastActive int64
|
||||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined); err != nil {
|
var hasTopic, disabled bool
|
||||||
|
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source,
|
||||||
|
&hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.JoinedAt = time.Unix(joined, 0).UTC()
|
m.JoinedAt = time.Unix(joined, 0).UTC()
|
||||||
|
if lastActive > 0 {
|
||||||
|
t := time.Unix(lastActive, 0).UTC()
|
||||||
|
m.LastActiveAt = &t
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case disabled:
|
||||||
|
m.Problem = "account is disabled"
|
||||||
|
case !hasTopic:
|
||||||
|
m.Problem = "has no ntfy topic"
|
||||||
|
}
|
||||||
|
m.Pageable = m.Problem == ""
|
||||||
|
switch {
|
||||||
|
case !m.Pageable:
|
||||||
|
m.Status = memberUnpageable
|
||||||
|
case m.OnCall:
|
||||||
|
m.Status = memberOnCall
|
||||||
|
default:
|
||||||
|
m.Status = memberReachable
|
||||||
|
}
|
||||||
members = append(members, m)
|
members = append(members, m)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -281,6 +343,28 @@ func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if managed, err := isSSOManagedMember(r.Context(), db, teamID, req.UserID); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
} else if managed {
|
||||||
|
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demoting the last owner is removing them by another route: the team
|
||||||
|
// would have nobody who can edit it.
|
||||||
|
if req.Role == models.RoleMember {
|
||||||
|
last, err := isLastTeamOwner(r.Context(), db, teamID, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if last {
|
||||||
|
respond(w, http.StatusConflict, errResp("cannot demote the last owner of a team"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err := db.ExecContext(r.Context(), `
|
_, err := db.ExecContext(r.Context(), `
|
||||||
INSERT INTO team_members (team_id, user_id, role)
|
INSERT INTO team_members (team_id, user_id, role)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -316,6 +400,14 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if managed, err := isSSOManagedMember(r.Context(), db, teamID, userID); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
} else if managed {
|
||||||
|
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
|
last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
@@ -340,6 +432,20 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ssoManagedMsg is the refusal for editing access that single sign-on owns.
|
||||||
|
const ssoManagedMsg = "this membership is managed by single sign-on; change the user's groups in the identity provider"
|
||||||
|
|
||||||
|
// isSSOManagedMember reports whether the membership comes from the group sync.
|
||||||
|
// Editing it here would be undone at the person's next sign-in, so it is refused
|
||||||
|
// instead of appearing to work.
|
||||||
|
func isSSOManagedMember(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
||||||
|
var managed bool
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND source = 'oidc')",
|
||||||
|
teamID, userID).Scan(&managed)
|
||||||
|
return managed, err
|
||||||
|
}
|
||||||
|
|
||||||
func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
||||||
var last bool
|
var last bool
|
||||||
err := db.QueryRowContext(ctx, `
|
err := db.QueryRowContext(ctx, `
|
||||||
@@ -355,8 +461,42 @@ func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (boo
|
|||||||
// Integrations
|
// Integrations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// handleListIntegrations lists a team's integrations. Never the keys: those
|
// sourceQuietAfter is how long a source may go without posting before the
|
||||||
// exist in plaintext only in the response that created them.
|
// Sources page calls it quiet rather than active. A day is longer than any
|
||||||
|
// repeat_interval worth having, so an Alertmanager that is up and has anything
|
||||||
|
// firing never crosses it; a source with nothing firing may, and that is a
|
||||||
|
// reason to look, not proof of a fault — which is why this is a colour and not
|
||||||
|
// an alarm. Dead man's switches are where silence pages.
|
||||||
|
const sourceQuietAfter = 24 * time.Hour
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceActive = "active"
|
||||||
|
sourceQuiet = "quiet"
|
||||||
|
sourceNever = "never"
|
||||||
|
)
|
||||||
|
|
||||||
|
// integrationStatus is an integration as the Sources page shows it.
|
||||||
|
type integrationStatus struct {
|
||||||
|
models.Integration
|
||||||
|
|
||||||
|
// Status is active when the key posted within sourceQuietAfter, quiet when
|
||||||
|
// it has posted but not lately, never when it has not posted at all.
|
||||||
|
Status string `json:"status"`
|
||||||
|
|
||||||
|
// LastAlertAt is when an alert last arrived on this source, which is not the
|
||||||
|
// same as when it last posted: a payload with nothing usable in it stamps
|
||||||
|
// last_used_at and not this. Absent until an alert has arrived since
|
||||||
|
// migration 010 started recording it.
|
||||||
|
LastAlertAt *time.Time `json:"last_alert_at,omitempty"`
|
||||||
|
|
||||||
|
// Alerts24h counts the distinct alerts this source refreshed in the last
|
||||||
|
// day. An alert re-sent every few hours counts once, not once per re-send.
|
||||||
|
Alerts24h int64 `json:"alerts_24h"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListIntegrations lists a team's integrations with what each has been
|
||||||
|
// delivering. Never the keys: those exist in plaintext only in the response that
|
||||||
|
// created them.
|
||||||
func handleListIntegrations(db *sql.DB) http.HandlerFunc {
|
func handleListIntegrations(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
teamID, ok := teamParam(w, r)
|
teamID, ok := teamParam(w, r)
|
||||||
@@ -367,28 +507,45 @@ func handleListIntegrations(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
rows, err := db.QueryContext(r.Context(), `
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
SELECT id, team_id, kind, name, created_at, last_used_at
|
SELECT i.id, i.team_id, i.kind, i.name, i.created_at, i.last_used_at,
|
||||||
FROM integrations
|
-- Scalar subqueries, not a join and GROUP BY: each is a
|
||||||
WHERE team_id = $1
|
-- single range over alerts_integration_idx, where the join
|
||||||
ORDER BY id`, teamID)
|
-- would read every alert a source ever delivered.
|
||||||
|
(SELECT MAX(received_at) FROM alerts WHERE integration_id = i.id),
|
||||||
|
(SELECT COUNT(*) FROM alerts
|
||||||
|
WHERE integration_id = i.id AND received_at >= $2)
|
||||||
|
FROM integrations i
|
||||||
|
WHERE i.team_id = $1
|
||||||
|
ORDER BY i.id`, teamID, now.Add(-sourceQuietAfter).Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
integrations := []models.Integration{}
|
integrations := []integrationStatus{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i models.Integration
|
var i integrationStatus
|
||||||
var created int64
|
var created int64
|
||||||
var lastUsed *int64
|
var lastUsed, lastAlert *int64
|
||||||
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed); err != nil {
|
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed,
|
||||||
|
&lastAlert, &i.Alerts24h); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
i.CreatedAt = time.Unix(created, 0).UTC()
|
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
i.LastUsedAt = unixPtr(lastUsed)
|
i.LastUsedAt = unixPtr(lastUsed)
|
||||||
|
i.LastAlertAt = unixPtr(lastAlert)
|
||||||
|
switch {
|
||||||
|
case i.LastUsedAt == nil:
|
||||||
|
i.Status = sourceNever
|
||||||
|
case now.Sub(*i.LastUsedAt) > sourceQuietAfter:
|
||||||
|
i.Status = sourceQuiet
|
||||||
|
default:
|
||||||
|
i.Status = sourceActive
|
||||||
|
}
|
||||||
integrations = append(integrations, i)
|
integrations = append(integrations, i)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -457,6 +614,54 @@ func handleCreateIntegration(db *sql.DB, publicURL string) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRenameIntegration renames a source. The key is untouched, so nothing
|
||||||
|
// posting with it notices.
|
||||||
|
func handleRenameIntegration(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "integrationID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid integration id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Name) > 100 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("name is too long"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"UPDATE integrations SET name = $1 WHERE id = $2 AND team_id = $3", req.Name, id, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleDeleteIntegration(db *sql.DB) http.HandlerFunc {
|
func handleDeleteIntegration(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
teamID, ok := teamParam(w, r)
|
teamID, ok := teamParam(w, r)
|
||||||
@@ -493,24 +698,32 @@ func integrationPath(key, kind string) string {
|
|||||||
return "/api/integrations/" + key + "/" + kind
|
return "/api/integrations/" + key + "/" + kind
|
||||||
}
|
}
|
||||||
|
|
||||||
// teamIDForKey resolves an integration key to its team, and stamps the key's
|
// alertSource is who an arriving webhook is from: the integration whose key it
|
||||||
|
// used, and the team that integration puts its alerts in.
|
||||||
|
type alertSource struct {
|
||||||
|
integrationID int64
|
||||||
|
teamID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceForKey resolves an integration key to its source, and stamps the key's
|
||||||
// last use. An unknown key is not an error worth distinguishing: the caller is
|
// last use. An unknown key is not an error worth distinguishing: the caller is
|
||||||
// told nothing beyond "no".
|
// told nothing beyond "no".
|
||||||
func teamIDForKey(ctx context.Context, db *sql.DB, key string) (int64, error) {
|
func sourceForKey(ctx context.Context, db *sql.DB, key string) (alertSource, error) {
|
||||||
var teamID int64
|
var src alertSource
|
||||||
err := db.QueryRowContext(ctx,
|
err := db.QueryRowContext(ctx,
|
||||||
"SELECT team_id FROM integrations WHERE key_hash = $1", hashToken(key)).Scan(&teamID)
|
"SELECT id, team_id FROM integrations WHERE key_hash = $1", hashToken(key)).
|
||||||
|
Scan(&src.integrationID, &src.teamID)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return 0, errUnknownIntegration
|
return alertSource{}, errUnknownIntegration
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return alertSource{}, err
|
||||||
}
|
}
|
||||||
// Best effort, like an API key's: a failed stamp must not reject an alert.
|
// Best effort, like an API key's: a failed stamp must not reject an alert.
|
||||||
db.ExecContext(ctx, //nolint:errcheck
|
db.ExecContext(ctx, //nolint:errcheck
|
||||||
"UPDATE integrations SET last_used_at = $1 WHERE key_hash = $2",
|
"UPDATE integrations SET last_used_at = $1 WHERE id = $2",
|
||||||
time.Now().Unix(), hashToken(key))
|
time.Now().Unix(), src.integrationID)
|
||||||
return teamID, nil
|
return src, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var errUnknownIntegration = errors.New("unknown integration key")
|
var errUnknownIntegration = errors.New("unknown integration key")
|
||||||
@@ -539,17 +752,24 @@ func defaultTeamID(ctx context.Context, db *sql.DB) (int64, error) {
|
|||||||
// A team's dead man's switches
|
// A team's dead man's switches
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// deadmanResponse is the wire shape of a team's switch configuration. The
|
// deadmanSwitchRequest is what creating a switch takes. The timeout is seconds,
|
||||||
// timeout is seconds rather than a duration string, because that is what the
|
// because that is what the column holds and what arithmetic is done on; a client
|
||||||
// column holds and what arithmetic is done on; a client renders it.
|
// renders it.
|
||||||
type deadmanResponse struct {
|
type deadmanSwitchRequest struct {
|
||||||
TeamID int64 `json:"team_id"`
|
Name string `json:"name"`
|
||||||
Matchers string `json:"matchers"`
|
Matcher string `json:"matcher"`
|
||||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
Severity string `json:"severity"`
|
Severity string `json:"severity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleGetTeamDeadman(db *sql.DB) http.HandlerFunc {
|
// deadmanSeverities are the severities an incident can open at.
|
||||||
|
var deadmanSeverities = map[string]bool{"critical": true, "error": true, "warning": true, "info": true}
|
||||||
|
|
||||||
|
// handleListTeamDeadman lists a team's switches with what each one's heartbeats
|
||||||
|
// are doing. A team with none gets an empty list, which is a configuration and
|
||||||
|
// not an absence: answering 404 would make "off" indistinguishable from "this
|
||||||
|
// server does not do this".
|
||||||
|
func handleListTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
teamID, ok := teamParam(w, r)
|
teamID, ok := teamParam(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -559,27 +779,26 @@ func handleGetTeamDeadman(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out := deadmanResponse{TeamID: teamID, Severity: "critical"}
|
set, err := deadmanSetForTeam(r.Context(), db, teamID)
|
||||||
err := db.QueryRowContext(r.Context(),
|
if err != nil {
|
||||||
"SELECT matchers, timeout_seconds, severity FROM deadman_configs WHERE team_id = $1",
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
teamID).Scan(&out.Matchers, &out.TimeoutSeconds, &out.Severity)
|
return
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
}
|
||||||
|
out, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
|
||||||
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A team with no row watches nothing, which is a configuration and not
|
|
||||||
// an absence: answering 404 would make "off" indistinguishable from
|
|
||||||
// "this server does not do this".
|
|
||||||
respond(w, http.StatusOK, out)
|
respond(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSetTeamDeadman replaces a team's switch configuration.
|
// handleCreateTeamDeadman adds one switch.
|
||||||
//
|
//
|
||||||
// Validated by parsing: a matcher string that survives ParseDeadmanConfig with
|
// Validated by parsing: a matcher with no alertname is rejected rather than
|
||||||
// nothing usable in it is rejected rather than stored, because a switch that
|
// stored, because a switch that silently watches nothing is the failure this
|
||||||
// silently watches nothing is the failure this feature exists to prevent.
|
// feature exists to prevent.
|
||||||
func handleSetTeamDeadman(db *sql.DB) http.HandlerFunc {
|
func handleCreateTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
teamID, ok := teamParam(w, r)
|
teamID, ok := teamParam(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -589,50 +808,87 @@ func handleSetTeamDeadman(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req deadmanSwitchRequest
|
||||||
Matchers string `json:"matchers"`
|
|
||||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
}
|
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Matchers = strings.TrimSpace(req.Matchers)
|
req.Matcher = strings.TrimSpace(req.Matcher)
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
if req.Severity == "" {
|
if req.Severity == "" {
|
||||||
req.Severity = "critical"
|
req.Severity = "critical"
|
||||||
}
|
}
|
||||||
if req.TimeoutSeconds < 0 {
|
if !deadmanSeverities[req.Severity] {
|
||||||
respond(w, http.StatusBadRequest, errResp("timeout_seconds must not be negative"))
|
respond(w, http.StatusBadRequest, errResp("severity must be critical, error, warning or info"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Matchers != "" {
|
if req.TimeoutSeconds <= 0 {
|
||||||
parsed := parseDeadmanQuietly(req.Matchers, time.Duration(req.TimeoutSeconds)*time.Second, req.Severity)
|
respond(w, http.StatusBadRequest, errResp("timeout_seconds must be positive"))
|
||||||
if len(parsed.Matchers) == 0 {
|
return
|
||||||
respond(w, http.StatusBadRequest, errResp(
|
}
|
||||||
"no usable matchers: each must name an alertname, as in alertname=Watchdog,cluster=prod"))
|
if strings.Contains(req.Matcher, ";") {
|
||||||
return
|
respond(w, http.StatusBadRequest, errResp("one matcher per switch: add another switch instead of separating with ;"))
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
m, err := parseDeadmanMatcher(req.Matcher)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp(
|
||||||
|
"unusable matcher ("+err.Error()+"): each must name an alertname, as in alertname=Watchdog,cluster=prod"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
req.Name = m.config()
|
||||||
|
}
|
||||||
|
if len(req.Name) > 100 {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("name is too long"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.ExecContext(r.Context(), `
|
var id int64
|
||||||
INSERT INTO deadman_configs (team_id, matchers, timeout_seconds, severity, updated_at)
|
if err := db.QueryRowContext(r.Context(), `
|
||||||
VALUES ($1, $2, $3, $4, `+nowEpoch+`)
|
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
|
||||||
ON CONFLICT (team_id) DO UPDATE SET
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
matchers = excluded.matchers,
|
teamID, req.Name, m.config(), req.TimeoutSeconds, req.Severity).Scan(&id); err != nil {
|
||||||
timeout_seconds = excluded.timeout_seconds,
|
|
||||||
severity = excluded.severity,
|
|
||||||
updated_at = excluded.updated_at`,
|
|
||||||
teamID, req.Matchers, req.TimeoutSeconds, req.Severity); err != nil {
|
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(w, http.StatusOK, deadmanResponse{
|
respond(w, http.StatusCreated, deadmanSwitchStatus{
|
||||||
TeamID: teamID,
|
ID: id, Name: req.Name, Matcher: m.config(),
|
||||||
Matchers: req.Matchers,
|
TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
|
||||||
TimeoutSeconds: req.TimeoutSeconds,
|
Status: switchDormant, Sources: []deadmanSource{},
|
||||||
Severity: req.Severity,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteTeamDeadman removes a switch. An incident it already opened stays
|
||||||
|
// open until somebody resolves it: deleting the switch says "stop watching", not
|
||||||
|
// "the problem is gone".
|
||||||
|
func handleDeleteTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
teamID, ok := teamParam(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireTeamOwner(w, r, teamID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switchID, err := strconv.ParseInt(chi.URLParam(r, "switchID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid switch id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"DELETE FROM deadman_switches WHERE id = $1 AND team_id = $2", switchID, teamID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("switch not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -327,6 +327,112 @@ func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A team's own OIDC group binding follows the same rule as its schedule and
|
||||||
|
// its integrations: an owner sets it, a member may only read it, an outsider
|
||||||
|
// learns nothing, and an administrator can still reach it to repair a team
|
||||||
|
// whose owner has left.
|
||||||
|
func TestTeamOIDCGroups_OwnerOnlyToEdit(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
team := newTeam(t, s, "sre") // team.call authenticates as its owner
|
||||||
|
|
||||||
|
// A plain member of the same team.
|
||||||
|
var plain struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": "plain", "email": "plain@test.com"}), &plain)
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
|
||||||
|
map[string]any{"user_id": plain.ID, "role": "member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
var key struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(plain.ID)+"/api-keys",
|
||||||
|
map[string]string{"name": "test"}), &key)
|
||||||
|
memberCall := func(method, path string, body any) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
r = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// A member of a different team altogether.
|
||||||
|
_, outsiderCall := member(t, s, "outsider")
|
||||||
|
|
||||||
|
path := "/api/teams/" + id64(team.id) + "/oidc-groups"
|
||||||
|
|
||||||
|
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "sre", "owner_group": "sre-leads"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("owner PUT: %d, want 204", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
MemberGroup string `json:"member_group"`
|
||||||
|
OwnerGroup string `json:"owner_group"`
|
||||||
|
}
|
||||||
|
decode(t, team.call(http.MethodGet, path, nil), &got)
|
||||||
|
if got.MemberGroup != "sre" || got.OwnerGroup != "sre-leads" {
|
||||||
|
t.Errorf("owner GET after PUT: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = memberCall(http.MethodGet, path, nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("member GET: %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp = memberCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("member PUT: %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404, not 403: whether the team exists is itself something only its
|
||||||
|
// members should learn.
|
||||||
|
resp = outsiderCall(http.MethodGet, path, nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("outsider GET: %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp = outsiderCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("outsider PUT: %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An administrator who is not a member may still set it, the same bypass
|
||||||
|
// that lets one repair a team whose owner has left.
|
||||||
|
resp = s.req(t, http.MethodPut, path, map[string]string{"member_group": "sre2"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("admin PUT: %d, want 204", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty string clears a binding, stored as NULL rather than the literal
|
||||||
|
// empty string, so an empty group claim can never accidentally match it.
|
||||||
|
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "", "owner_group": ""})
|
||||||
|
resp.Body.Close()
|
||||||
|
var cleared struct {
|
||||||
|
MemberGroup string `json:"member_group"`
|
||||||
|
OwnerGroup string `json:"owner_group"`
|
||||||
|
}
|
||||||
|
decode(t, team.call(http.MethodGet, path, nil), &cleared)
|
||||||
|
if cleared.MemberGroup != "" || cleared.OwnerGroup != "" {
|
||||||
|
t.Errorf("cleared: %+v", cleared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A team is not somewhere an outsider can look, whatever they know about it.
|
// A team is not somewhere an outsider can look, whatever they know about it.
|
||||||
func TestTeams_OutsiderSeesNothing(t *testing.T) {
|
func TestTeams_OutsiderSeesNothing(t *testing.T) {
|
||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
|
|||||||
+16
-4
@@ -96,7 +96,7 @@ func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
|||||||
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
rows, err := db.QueryContext(r.Context(),
|
rows, err := db.QueryContext(r.Context(),
|
||||||
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users ORDER BY id")
|
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users ORDER BY id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -108,7 +108,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
|||||||
var u models.User
|
var u models.User
|
||||||
var ts int64
|
var ts int64
|
||||||
var disabled *int64
|
var disabled *int64
|
||||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled); err != nil {
|
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -329,8 +329,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
|||||||
var ts int64
|
var ts int64
|
||||||
var disabled *int64
|
var disabled *int64
|
||||||
err := db.QueryRowContext(ctx,
|
err := db.QueryRowContext(ctx,
|
||||||
"SELECT id, username, email, created_at, ntfy_topic, is_admin, disabled_at FROM users WHERE id = $1", id).
|
"SELECT id, username, email, created_at, ntfy_topic, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
|
||||||
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &disabled)
|
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.NtfyTopic, &u.IsAdmin, &u.AdminSource, &disabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
@@ -361,6 +361,18 @@ func handleSetAdmin(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !*req.IsAdmin {
|
if !*req.IsAdmin {
|
||||||
|
var managed bool
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND is_admin AND admin_source = 'oidc')",
|
||||||
|
id).Scan(&managed); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if managed {
|
||||||
|
respond(w, http.StatusConflict, errResp("administrator access is managed by single sign-on; change the user's groups in the identity provider"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
caller, _ := userFromContext(r.Context())
|
caller, _ := userFromContext(r.Context())
|
||||||
if caller.ID == id {
|
if caller.ID == id {
|
||||||
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))
|
respond(w, http.StatusConflict, errResp("cannot revoke your own administrator access"))
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,8 +63,63 @@ type Config struct {
|
|||||||
// NotifyRepeat is how long an incident may sit unacknowledged before it is
|
// NotifyRepeat is how long an incident may sit unacknowledged before it is
|
||||||
// notified again. Zero disables reminders.
|
// notified again. Zero disables reminders.
|
||||||
NotifyRepeat time.Duration
|
NotifyRepeat time.Duration
|
||||||
|
|
||||||
|
// DisablePasswordLogin refuses signing in, or signing up, with a password.
|
||||||
|
// It is how an install moves to SSO only, and turning it back off is the way
|
||||||
|
// in when the identity provider is down. Stated negatively so that the zero
|
||||||
|
// Config, which is what a test or a new caller builds, keeps passwords working.
|
||||||
|
DisablePasswordLogin bool
|
||||||
|
|
||||||
|
// OIDC configures single sign-on. The zero value, with no Issuer, is off.
|
||||||
|
OIDC OIDC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDC is the single sign-on configuration. Groups from the provider decide
|
||||||
|
// who may sign in, which teams they belong to, and whether they administer the
|
||||||
|
// install, in the manner of Grafana's org and role mapping.
|
||||||
|
type OIDC struct {
|
||||||
|
// Issuer is the provider's issuer URL. Discovery is fetched from
|
||||||
|
// <Issuer>/.well-known/openid-configuration. For Authentik this is the
|
||||||
|
// application's issuer, e.g. https://auth.example.com/application/o/terdut/.
|
||||||
|
// Empty turns single sign-on off.
|
||||||
|
Issuer string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
|
||||||
|
// Name is what the sign-in button calls the provider.
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Scopes to request. The groups claim normally needs "profile" on Authentik.
|
||||||
|
Scopes []string
|
||||||
|
|
||||||
|
// UsernameClaim, EmailClaim and GroupsClaim name the ID token claims read.
|
||||||
|
UsernameClaim string
|
||||||
|
EmailClaim string
|
||||||
|
GroupsClaim string
|
||||||
|
|
||||||
|
// TrustEmail links a sign-in to an existing local user by email even when the
|
||||||
|
// provider does not vouch that the address is verified. Authentik reports
|
||||||
|
// email_verified false unless told otherwise, and an install that runs its
|
||||||
|
// own provider has already decided that its addresses can be trusted.
|
||||||
|
TrustEmail bool
|
||||||
|
|
||||||
|
// AllowedGroups gates sign-in: somebody in none of them is refused, however
|
||||||
|
// well the provider authenticated them. Empty admits everybody the provider
|
||||||
|
// authenticates, and access control is left to the provider.
|
||||||
|
AllowedGroups []string
|
||||||
|
|
||||||
|
// AdminGroup grants the system administrator flag while the user is in it.
|
||||||
|
AdminGroup string
|
||||||
|
|
||||||
|
// SessionMaxAge is the hard ceiling on a session made by an SSO login. The
|
||||||
|
// login is the only moment groups are re-read, so this is how long a change
|
||||||
|
// in the provider may take to reach terdut.
|
||||||
|
SessionMaxAge time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether single sign-on is configured.
|
||||||
|
func (o OIDC) Enabled() bool { return o.Issuer != "" }
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
addr := os.Getenv("TERDUT_ADDR")
|
addr := os.Getenv("TERDUT_ADDR")
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
@@ -89,9 +148,96 @@ func Load() Config {
|
|||||||
NtfyFallbackTopic: os.Getenv("TERDUT_NTFY_FALLBACK_TOPIC"),
|
NtfyFallbackTopic: os.Getenv("TERDUT_NTFY_FALLBACK_TOPIC"),
|
||||||
PublicURL: os.Getenv("TERDUT_PUBLIC_URL"),
|
PublicURL: os.Getenv("TERDUT_PUBLIC_URL"),
|
||||||
NotifyRepeat: duration("TERDUT_NOTIFY_REPEAT", 15*time.Minute),
|
NotifyRepeat: duration("TERDUT_NOTIFY_REPEAT", 15*time.Minute),
|
||||||
|
|
||||||
|
DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true),
|
||||||
|
OIDC: loadOIDC(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadOIDC() OIDC {
|
||||||
|
o := OIDC{
|
||||||
|
Issuer: strings.TrimSpace(os.Getenv("TERDUT_OIDC_ISSUER")),
|
||||||
|
ClientID: os.Getenv("TERDUT_OIDC_CLIENT_ID"),
|
||||||
|
ClientSecret: os.Getenv("TERDUT_OIDC_CLIENT_SECRET"),
|
||||||
|
Name: str("TERDUT_OIDC_NAME", "SSO"),
|
||||||
|
Scopes: list("TERDUT_OIDC_SCOPES", "openid profile email"),
|
||||||
|
UsernameClaim: str("TERDUT_OIDC_USERNAME_CLAIM", "preferred_username"),
|
||||||
|
EmailClaim: str("TERDUT_OIDC_EMAIL_CLAIM", "email"),
|
||||||
|
GroupsClaim: str("TERDUT_OIDC_GROUPS_CLAIM", "groups"),
|
||||||
|
TrustEmail: boolean("TERDUT_OIDC_TRUST_EMAIL", false),
|
||||||
|
AllowedGroups: list("TERDUT_OIDC_ALLOWED_GROUPS", ""),
|
||||||
|
AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"),
|
||||||
|
SessionMaxAge: duration("TERDUT_OIDC_SESSION_MAX_AGE", 12*time.Hour),
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reports a configuration the server should refuse to start with.
|
||||||
|
// Single sign-on is the only part that can be inconsistent: a half-configured
|
||||||
|
// provider would come up and then fail every login, which is harder to notice
|
||||||
|
// than not starting.
|
||||||
|
func (c Config) Validate() error {
|
||||||
|
o := c.OIDC
|
||||||
|
if !o.Enabled() {
|
||||||
|
if c.DisablePasswordLogin {
|
||||||
|
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
|
||||||
|
}
|
||||||
|
if o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
|
||||||
|
return errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(o.Issuer); err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return fmt.Errorf("TERDUT_OIDC_ISSUER %q is not a URL", o.Issuer)
|
||||||
|
}
|
||||||
|
if o.ClientID == "" || o.ClientSecret == "" {
|
||||||
|
return errors.New("TERDUT_OIDC_CLIENT_ID and TERDUT_OIDC_CLIENT_SECRET are required with TERDUT_OIDC_ISSUER")
|
||||||
|
}
|
||||||
|
if c.PublicURL == "" {
|
||||||
|
return errors.New("TERDUT_PUBLIC_URL is required with TERDUT_OIDC_ISSUER: it is the base of the redirect URI")
|
||||||
|
}
|
||||||
|
if o.SessionMaxAge <= 0 {
|
||||||
|
return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive")
|
||||||
|
}
|
||||||
|
// Team grants are no longer visible here: they live on each team's own
|
||||||
|
// oidc_member_group/oidc_owner_group columns, set by that team's owner, not
|
||||||
|
// in config Validate can see at startup. The one thing left to guard against
|
||||||
|
// is an install nobody can administer at all.
|
||||||
|
if c.DisablePasswordLogin && o.AdminGroup == "" {
|
||||||
|
return errors.New("TERDUT_PASSWORD_LOGIN=false with no TERDUT_OIDC_ADMIN_GROUP leaves nobody able to administer the install")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(env, def string) string {
|
||||||
|
if s := strings.TrimSpace(os.Getenv(env)); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// list reads a comma- or space-separated env var.
|
||||||
|
func list(env, def string) []string {
|
||||||
|
s := os.Getenv(env)
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
s = def
|
||||||
|
}
|
||||||
|
return strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolean reads a true/false env var. An unrecognised value takes the default,
|
||||||
|
// so the two flags read this way (password login on, trusting email off) both
|
||||||
|
// fail towards the cautious setting.
|
||||||
|
func boolean(env string, def bool) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(env))) {
|
||||||
|
case "true", "1", "yes":
|
||||||
|
return true
|
||||||
|
case "false", "0", "no":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
// duration reads a time.ParseDuration-formatted env var. An unset or
|
// duration reads a time.ParseDuration-formatted env var. An unset or
|
||||||
// unparseable value falls back to def rather than failing startup: a typo in one
|
// unparseable value falls back to def rather than failing startup: a typo in one
|
||||||
// tuning knob should not take the server down.
|
// tuning knob should not take the server down.
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
base := func() map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"TERDUT_PUBLIC_URL": "https://terdut.example.com",
|
||||||
|
"TERDUT_OIDC_ISSUER": "https://auth.example.com/application/o/terdut/",
|
||||||
|
"TERDUT_OIDC_CLIENT_ID": "id",
|
||||||
|
"TERDUT_OIDC_CLIENT_SECRET": "secret",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
env func(map[string]string)
|
||||||
|
wantErr string // substring; empty means valid
|
||||||
|
}{
|
||||||
|
{"off by default", func(m map[string]string) { clear(m) }, ""},
|
||||||
|
{"minimal sso", func(m map[string]string) {}, ""},
|
||||||
|
{"groups without issuer", func(m map[string]string) {
|
||||||
|
clear(m)
|
||||||
|
m["TERDUT_OIDC_ADMIN_GROUP"] = "admins"
|
||||||
|
}, "ISSUER is not"},
|
||||||
|
{"missing secret", func(m map[string]string) { delete(m, "TERDUT_OIDC_CLIENT_SECRET") }, "CLIENT_SECRET"},
|
||||||
|
{"missing public url", func(m map[string]string) { delete(m, "TERDUT_PUBLIC_URL") }, "PUBLIC_URL"},
|
||||||
|
{"bad issuer", func(m map[string]string) { m["TERDUT_OIDC_ISSUER"] = "not a url" }, "not a URL"},
|
||||||
|
{"password off without sso", func(m map[string]string) {
|
||||||
|
clear(m)
|
||||||
|
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
||||||
|
}, "no way to sign in"},
|
||||||
|
{"password off with sso but no grants", func(m map[string]string) {
|
||||||
|
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
||||||
|
}, "nobody able"},
|
||||||
|
{"password off with admin group", func(m map[string]string) {
|
||||||
|
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
||||||
|
m["TERDUT_OIDC_ADMIN_GROUP"] = "admins"
|
||||||
|
}, ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
env := base()
|
||||||
|
tt.env(env)
|
||||||
|
for _, k := range []string{
|
||||||
|
"TERDUT_PUBLIC_URL", "TERDUT_PASSWORD_LOGIN", "TERDUT_OIDC_ISSUER", "TERDUT_OIDC_CLIENT_ID",
|
||||||
|
"TERDUT_OIDC_CLIENT_SECRET", "TERDUT_OIDC_ADMIN_GROUP",
|
||||||
|
} {
|
||||||
|
t.Setenv(k, env[k])
|
||||||
|
}
|
||||||
|
err := Load().Validate()
|
||||||
|
switch {
|
||||||
|
case tt.wantErr == "" && err != nil:
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
case tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)):
|
||||||
|
t.Errorf("error %v, want one containing %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_OIDCDefaults(t *testing.T) {
|
||||||
|
t.Setenv("TERDUT_OIDC_ISSUER", "https://auth.example.com/")
|
||||||
|
o := Load().OIDC
|
||||||
|
if o.UsernameClaim != "preferred_username" || o.EmailClaim != "email" || o.GroupsClaim != "groups" {
|
||||||
|
t.Errorf("claim defaults: %+v", o)
|
||||||
|
}
|
||||||
|
if strings.Join(o.Scopes, " ") != "openid profile email" {
|
||||||
|
t.Errorf("scopes: %v", o.Scopes)
|
||||||
|
}
|
||||||
|
if o.SessionMaxAge.Hours() != 12 {
|
||||||
|
t.Errorf("max age: %v", o.SessionMaxAge)
|
||||||
|
}
|
||||||
|
if Load().DisablePasswordLogin {
|
||||||
|
t.Error("password login should be on by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Similar incidents: a signature per incident, so "has this happened before"
|
||||||
|
-- is an indexed equality instead of a search.
|
||||||
|
--
|
||||||
|
-- The signature is the alert name plus the group labels that identify WHAT is
|
||||||
|
-- broken, minus the ones that only say WHERE it happened to run this time
|
||||||
|
-- (instance, pod, ...). Two incidents with the same signature in the same team
|
||||||
|
-- are the same problem for a responder's purposes.
|
||||||
|
--
|
||||||
|
-- Computed in Go for new incidents (incidentSignature in incident_store.go).
|
||||||
|
-- The backfill below MUST produce the same string; keep the volatile list in
|
||||||
|
-- both places in step.
|
||||||
|
ALTER TABLE incidents ADD COLUMN signature TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
UPDATE incidents SET signature =
|
||||||
|
COALESCE(NULLIF(group_labels->>'alertname', ''), title) || '|' ||
|
||||||
|
COALESCE((
|
||||||
|
SELECT string_agg(e.k || '=' || e.v, ',' ORDER BY e.k)
|
||||||
|
FROM jsonb_each_text(incidents.group_labels) AS e(k, v)
|
||||||
|
WHERE e.k <> 'alertname'
|
||||||
|
AND e.k NOT IN ('instance', 'pod', 'pod_name', 'pod_ip', 'container', 'container_name', 'endpoint')
|
||||||
|
), '');
|
||||||
|
|
||||||
|
CREATE INDEX incidents_signature_idx ON incidents(team_id, signature, triggered_at DESC);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Dead man's switches become rows of their own.
|
||||||
|
--
|
||||||
|
-- 004 kept a team's switches in one string with one timeout and one severity,
|
||||||
|
-- which was enough to configure them and not enough to show them: there was no
|
||||||
|
-- thing to list, nothing to hang a status on, and every switch in a team had to
|
||||||
|
-- share a deadline. A row per switch gives each its own name, matcher, timeout
|
||||||
|
-- and severity, and gives the Team → Switches page something to be a list of.
|
||||||
|
--
|
||||||
|
-- The matcher keeps the syntax the string used, one matcher per row:
|
||||||
|
-- `alertname=Watchdog,cluster=prod`. The unit of monitoring is still the
|
||||||
|
-- fingerprint, so a matcher that many clusters satisfy is still one switch row
|
||||||
|
-- watching several independent heartbeats.
|
||||||
|
CREATE TABLE deadman_switches (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- What the owner calls it. Defaults to the matcher when they do not say.
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- "," separates the label conditions, "=" is exact equality, and alertname is
|
||||||
|
-- mandatory: it is what keeps the sweeper's candidate query on an index.
|
||||||
|
matcher TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Seconds of silence before the switch is declared dead. Never zero: a switch
|
||||||
|
-- that cannot fire is deleted, not disabled.
|
||||||
|
timeout_seconds BIGINT NOT NULL CHECK (timeout_seconds > 0),
|
||||||
|
|
||||||
|
-- The severity its incidents open at. See 004 for why they carry their own.
|
||||||
|
severity TEXT NOT NULL DEFAULT 'critical',
|
||||||
|
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX deadman_switches_team_idx ON deadman_switches (team_id);
|
||||||
|
|
||||||
|
-- Carry every team's configuration over, one row per matcher. A team whose
|
||||||
|
-- timeout was zero had switches turned off, which is now "no rows".
|
||||||
|
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
|
||||||
|
SELECT c.team_id, btrim(m), btrim(m), c.timeout_seconds, c.severity
|
||||||
|
FROM deadman_configs c,
|
||||||
|
LATERAL regexp_split_to_table(c.matchers, ';') AS m
|
||||||
|
WHERE c.timeout_seconds > 0
|
||||||
|
AND btrim(m) <> ''
|
||||||
|
ORDER BY c.team_id;
|
||||||
|
|
||||||
|
-- The server seeds environment defaults into teams once, and remembers that it
|
||||||
|
-- did. An install that had a row per team was already seeded; without this
|
||||||
|
-- marker the first start after upgrading would seed teams that had switched
|
||||||
|
-- theirs off.
|
||||||
|
INSERT INTO settings (key, value)
|
||||||
|
SELECT 'deadman_seeded', '1'
|
||||||
|
WHERE EXISTS (SELECT 1 FROM deadman_configs);
|
||||||
|
|
||||||
|
DROP TABLE deadman_configs;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Which alert source an alert last arrived on.
|
||||||
|
--
|
||||||
|
-- Team -> Sources shows when each source last posted, which integrations
|
||||||
|
-- already knew (last_used_at, stamped on every webhook). What it could not say
|
||||||
|
-- was what a source delivered: an alert never recorded the key it came in on, so
|
||||||
|
-- "prod alertmanager" and "staging alertmanager" were indistinguishable once
|
||||||
|
-- inside. This column is that link, and lets the page show each source's last
|
||||||
|
-- alert and how many alerts it has kept fresh over the past day.
|
||||||
|
--
|
||||||
|
-- Last sender wins: every accepted payload restamps it, the way it advances
|
||||||
|
-- received_at. Two sources posting the same fingerprint into one team is
|
||||||
|
-- already one alert, and it is attributed to whichever spoke last.
|
||||||
|
--
|
||||||
|
-- Nullable, and not backfilled. Alerts that arrived before this migration have
|
||||||
|
-- no source, and NULL says so honestly rather than guessing. It heals by itself:
|
||||||
|
-- Alertmanager re-sends every alert each repeat_interval, and each re-send is an
|
||||||
|
-- accepted payload. Deleting a source keeps its alerts, unattributed.
|
||||||
|
ALTER TABLE alerts ADD COLUMN integration_id BIGINT REFERENCES integrations(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX alerts_integration_idx ON alerts (integration_id, received_at)
|
||||||
|
WHERE integration_id IS NOT NULL;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- Single sign-on through an OpenID Connect provider (Authentik, and anything
|
||||||
|
-- else that speaks OIDC).
|
||||||
|
--
|
||||||
|
-- Four things change, and none of them touches a password user: every new column
|
||||||
|
-- has a default that says "this is how it has always worked".
|
||||||
|
--
|
||||||
|
-- 1. user_identities says which provider account a user is. It is keyed on
|
||||||
|
-- (issuer, subject), never on email or username: those are mutable at the
|
||||||
|
-- provider, and a recycled address must not inherit somebody's account. A
|
||||||
|
-- user can have several identities (a second provider later), and none at all
|
||||||
|
-- (a local, password-only user), which is why this is a table and not two
|
||||||
|
-- columns on users.
|
||||||
|
--
|
||||||
|
-- 2. team_members.source and users.admin_source record who granted a role. 'oidc'
|
||||||
|
-- rows are owned by the group sync: it adds them when a group grants access
|
||||||
|
-- and removes them when it stops, and nothing else may edit them. 'manual' rows
|
||||||
|
-- are everything that existed before this migration, and are never touched by
|
||||||
|
-- the sync. Without the marker the sync could not tell a membership it created
|
||||||
|
-- from one an owner added by hand, and would have to either leave stale access
|
||||||
|
-- behind or delete people it had no business deleting.
|
||||||
|
--
|
||||||
|
-- 3. sessions.max_expires_at is a hard ceiling on a session's life. Ordinary
|
||||||
|
-- sessions slide for as long as they are used; a session made by an SSO login
|
||||||
|
-- must not, because the login is the only moment the groups are re-read.
|
||||||
|
-- Capping the session is what makes "removed from the group in the provider"
|
||||||
|
-- take effect within a bounded time. NULL means no ceiling.
|
||||||
|
--
|
||||||
|
-- 4. oidc_logins holds a login that has been started and not yet finished: the
|
||||||
|
-- state, nonce and PKCE verifier the callback must see again. A row rather
|
||||||
|
-- than a signed cookie, so it survives a restart and needs no signing key.
|
||||||
|
-- Only the hash of the state is stored, like every other token here; the
|
||||||
|
-- nonce and verifier are useless without the state that names the row.
|
||||||
|
CREATE TABLE user_identities (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
issuer TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
last_login_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||||
|
UNIQUE (issuer, subject)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX user_identities_user_idx ON user_identities (user_id);
|
||||||
|
|
||||||
|
ALTER TABLE team_members
|
||||||
|
ADD COLUMN source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'oidc'));
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN admin_source TEXT NOT NULL DEFAULT 'manual' CHECK (admin_source IN ('manual', 'oidc'));
|
||||||
|
|
||||||
|
ALTER TABLE sessions ADD COLUMN max_expires_at BIGINT;
|
||||||
|
|
||||||
|
CREATE TABLE oidc_logins (
|
||||||
|
state_hash TEXT PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
pkce_verifier TEXT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX oidc_logins_expires_idx ON oidc_logins (expires_at);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Signing in from a terminal, for clients that cannot open a browser on the
|
||||||
|
-- machine they run on (the TUI over SSH is the reason).
|
||||||
|
--
|
||||||
|
-- The flow is the OAuth device authorization grant, run by terdut itself rather
|
||||||
|
-- than the identity provider, so the terminal never talks to the provider and
|
||||||
|
-- the server issues its ordinary session at the end:
|
||||||
|
--
|
||||||
|
-- 1. The terminal asks for a login and gets two secrets: a device code it
|
||||||
|
-- keeps and polls with, and a short user code it shows the person.
|
||||||
|
-- 2. The person opens the verification URL on any device, signs in by whatever
|
||||||
|
-- means the server offers, sees the user code, and approves it.
|
||||||
|
-- 3. The terminal's next poll finds the row approved and is given a session.
|
||||||
|
--
|
||||||
|
-- Only the hash of the device code is stored, like every other token here: the
|
||||||
|
-- device code is what earns a session, so a database read must not yield one.
|
||||||
|
-- The user code is shown on screens and typed by people, so it is stored as is;
|
||||||
|
-- on its own it can only be approved, never redeemed.
|
||||||
|
--
|
||||||
|
-- user_id is the person who approved. It is empty until then, and the session
|
||||||
|
-- is minted at redemption, not at approval: an approval nobody collects must not
|
||||||
|
-- leave a live session lying about.
|
||||||
|
--
|
||||||
|
-- last_polled_at lets the server refuse a client that polls faster than the
|
||||||
|
-- interval it was told.
|
||||||
|
CREATE TABLE device_logins (
|
||||||
|
device_hash TEXT PRIMARY KEY,
|
||||||
|
user_code TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied')),
|
||||||
|
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at BIGINT NOT NULL,
|
||||||
|
last_polled_at BIGINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX device_logins_expires_idx ON device_logins (expires_at);
|
||||||
|
|
||||||
|
-- Where to send the browser once a single sign-on login completes. A person who
|
||||||
|
-- opens /device?code=... without a session has to sign in first and then come
|
||||||
|
-- back to it, and the same is true of any other deep link. Validated when it is
|
||||||
|
-- stored: only a path on this server is ever kept.
|
||||||
|
ALTER TABLE oidc_logins ADD COLUMN next TEXT NOT NULL DEFAULT '/';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Per-team OIDC group configuration, replacing the global
|
||||||
|
-- TERDUT_OIDC_GROUP_MAPPINGS env var.
|
||||||
|
--
|
||||||
|
-- Group -> team -> role used to be one global list an operator set for the
|
||||||
|
-- whole install, matched against a team by name, and the sync would create
|
||||||
|
-- the team if no team by that name existed yet. That put the decision of
|
||||||
|
-- which group controls a team in the server's environment rather than the
|
||||||
|
-- team's own hands, meant changing it needed an env var edit and a restart,
|
||||||
|
-- and let a typo in a team name silently create a stray team.
|
||||||
|
--
|
||||||
|
-- Each team now names, itself, which group grants membership and which
|
||||||
|
-- grants ownership. Nullable: most teams need neither. No uniqueness
|
||||||
|
-- constraint on either column — two teams may legitimately watch the same
|
||||||
|
-- provider group (a broad team and a narrower one both keyed off overlapping
|
||||||
|
-- groups is a choice for their owners to make, not one the schema should
|
||||||
|
-- refuse).
|
||||||
|
--
|
||||||
|
-- BREAKING CHANGE, deliberately not auto-migrated: TERDUT_OIDC_GROUP_MAPPINGS
|
||||||
|
-- stops being read as of this version, and the sync no longer creates a team
|
||||||
|
-- by name. Every team's group binding must be set again through
|
||||||
|
-- PUT /api/teams/{teamID}/oidc-groups. Until an owner does that, an
|
||||||
|
-- OIDC-sourced membership in that team is dropped at that user's next SSO
|
||||||
|
-- sign-in, the same way any other loss of group access is handled. See the
|
||||||
|
-- README's OIDC section.
|
||||||
|
ALTER TABLE teams ADD COLUMN oidc_member_group TEXT;
|
||||||
|
ALTER TABLE teams ADD COLUMN oidc_owner_group TEXT;
|
||||||
@@ -79,3 +79,15 @@ type IncidentEvent struct {
|
|||||||
Detail *string `json:"detail,omitempty"`
|
Detail *string `json:"detail,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SimilarIncident is an earlier, resolved incident with the same signature as
|
||||||
|
// the one being looked at. ResolutionNotes are the "what fixed it" notes;
|
||||||
|
// NoteCount counts the plain working notes, which live on the timeline.
|
||||||
|
type SimilarIncident struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
TriggeredAt time.Time `json:"triggered_at"`
|
||||||
|
ResolvedAt time.Time `json:"resolved_at"`
|
||||||
|
NoteCount int `json:"note_count"`
|
||||||
|
ResolutionNotes []IncidentEvent `json:"resolution_notes"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ type Team struct {
|
|||||||
// listed for a particular person. Empty when nobody in particular is
|
// listed for a particular person. Empty when nobody in particular is
|
||||||
// asking, as in the admin listing.
|
// asking, as in the admin listing.
|
||||||
Role string `json:"role,omitempty"`
|
Role string `json:"role,omitempty"`
|
||||||
|
|
||||||
|
// Source says who granted Role, on the endpoint that lists one user's teams:
|
||||||
|
// "manual", or "oidc" when the identity provider's groups did.
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Team roles. An owner configures the team — its schedule, its integrations and
|
// Team roles. An owner configures the team — its schedule, its integrations and
|
||||||
@@ -29,6 +33,10 @@ type TeamMember struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
JoinedAt time.Time `json:"joined_at"`
|
JoinedAt time.Time `json:"joined_at"`
|
||||||
|
|
||||||
|
// Source is who granted the membership: "manual", or "oidc" when the
|
||||||
|
// identity provider's groups did and only they can change it.
|
||||||
|
Source string `json:"source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Integration is how alerts get in, and the only thing that says which team an
|
// Integration is how alerts get in, and the only thing that says which team an
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ type User struct {
|
|||||||
// is too old to have the field", and the web UI decides what to show from
|
// is too old to have the field", and the web UI decides what to show from
|
||||||
// it.
|
// it.
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
|
||||||
|
// AdminSource is who granted the flag: "manual" or "oidc". An "oidc"
|
||||||
|
// administrator follows the identity provider's groups, so the UI shows it as
|
||||||
|
// managed there and the API refuses to revoke it by hand. Only set on the
|
||||||
|
// user endpoints that show it.
|
||||||
|
AdminSource string `json:"admin_source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Package oidc signs users in through an OpenID Connect provider and turns the
|
||||||
|
// groups it reports into the access terdut grants.
|
||||||
|
//
|
||||||
|
// The package knows nothing about the database or HTTP handlers: Grants is a
|
||||||
|
// pure function of configuration and groups, and Provider is the protocol. The
|
||||||
|
// api package joins them to users, teams and sessions.
|
||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role names match models.RoleOwner and RoleMember. They are restated here so
|
||||||
|
// the package stays free of the models import; config.Validate has already
|
||||||
|
// refused anything else.
|
||||||
|
const (
|
||||||
|
roleOwner = "owner"
|
||||||
|
roleMember = "member"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grants is the account-wide access a set of groups confers. Team access is a
|
||||||
|
// separate question — see TeamGroup and ComputeTeamGrants — because it is
|
||||||
|
// configured per team in the database, not in this package's cfg.
|
||||||
|
type Grants struct {
|
||||||
|
// Admitted is false when AllowedGroups is set and the user is in none of
|
||||||
|
// them. Nothing else in the struct means anything then.
|
||||||
|
Admitted bool
|
||||||
|
|
||||||
|
// Admin is whether the user is in the admin group.
|
||||||
|
Admin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeGrants evaluates the account-wide configuration against groups.
|
||||||
|
func ComputeGrants(cfg config.OIDC, groups []string) Grants {
|
||||||
|
in := make(map[string]bool, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
in[g] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var g Grants
|
||||||
|
|
||||||
|
g.Admitted = len(cfg.AllowedGroups) == 0
|
||||||
|
for _, allowed := range cfg.AllowedGroups {
|
||||||
|
if in[allowed] {
|
||||||
|
g.Admitted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !g.Admitted {
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup]
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamGroup is one team's own OIDC binding: which group, if any, grants
|
||||||
|
// member access to it and which grants owner access, as read from
|
||||||
|
// teams.oidc_member_group / teams.oidc_owner_group.
|
||||||
|
type TeamGroup struct {
|
||||||
|
TeamID int64
|
||||||
|
MemberGroup string // "" means no group grants member access here.
|
||||||
|
OwnerGroup string // "" means no group grants owner access here.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeTeamGrants evaluates every team's own group binding against groups,
|
||||||
|
// and returns the role each team grants, keyed by team ID. A team absent from
|
||||||
|
// the result is not granted at all. Where a team's member and owner groups
|
||||||
|
// both match, the owner group wins — the same "highest role wins" rule that
|
||||||
|
// applied across the old global mapping list applies here across one team's
|
||||||
|
// two fields, so belonging to both groups makes somebody an owner rather than
|
||||||
|
// whichever field happened to be checked last.
|
||||||
|
func ComputeTeamGrants(teamGroups []TeamGroup, groups []string) map[int64]string {
|
||||||
|
in := make(map[string]bool, len(groups))
|
||||||
|
for _, g := range groups {
|
||||||
|
in[g] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[int64]string{}
|
||||||
|
for _, tg := range teamGroups {
|
||||||
|
role := ""
|
||||||
|
if tg.MemberGroup != "" && in[tg.MemberGroup] {
|
||||||
|
role = roleMember
|
||||||
|
}
|
||||||
|
if tg.OwnerGroup != "" && in[tg.OwnerGroup] && rank(roleOwner) > rank(role) {
|
||||||
|
role = roleOwner
|
||||||
|
}
|
||||||
|
if role != "" {
|
||||||
|
out[tg.TeamID] = role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rank orders roles; an unknown or absent role ranks lowest.
|
||||||
|
func rank(role string) int {
|
||||||
|
switch role {
|
||||||
|
case roleOwner:
|
||||||
|
return 2
|
||||||
|
case roleMember:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// HigherRole reports whether role a outranks role b.
|
||||||
|
func HigherRole(a, b string) bool { return rank(a) > rank(b) }
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testCfg() config.OIDC {
|
||||||
|
return config.OIDC{
|
||||||
|
AllowedGroups: []string{"terdut-users"},
|
||||||
|
AdminGroup: "terdut-admins",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGrants(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
groups []string
|
||||||
|
want Grants
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "not in an allowed group is refused",
|
||||||
|
groups: []string{"sre", "terdut-admins"},
|
||||||
|
want: Grants{Admitted: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowed but no grants",
|
||||||
|
groups: []string{"terdut-users"},
|
||||||
|
want: Grants{Admitted: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "admin group grants admin",
|
||||||
|
groups: []string{"terdut-users", "terdut-admins"},
|
||||||
|
want: Grants{Admitted: true, Admin: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ComputeGrants(testCfg(), tt.groups)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("got %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeGrants_NoAllowedGroupsAdmitsEveryone(t *testing.T) {
|
||||||
|
cfg := testCfg()
|
||||||
|
cfg.AllowedGroups = nil
|
||||||
|
if g := ComputeGrants(cfg, nil); !g.Admitted {
|
||||||
|
t.Error("with no allowed groups configured, everybody the provider authenticates is admitted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testTeamGroups is one SRE team keyed off two groups (a member group and a
|
||||||
|
// higher owner group) and one Platform team keyed off a member group only —
|
||||||
|
// the same shape the old global TERDUT_OIDC_GROUP_MAPPINGS example used.
|
||||||
|
func testTeamGroups() []TeamGroup {
|
||||||
|
return []TeamGroup{
|
||||||
|
{TeamID: 1, MemberGroup: "sre", OwnerGroup: "sre-leads"},
|
||||||
|
{TeamID: 2, MemberGroup: "platform"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeTeamGrants(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
teamGroups []TeamGroup
|
||||||
|
groups []string
|
||||||
|
want map[int64]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no matching group grants nothing",
|
||||||
|
teamGroups: testTeamGroups(),
|
||||||
|
groups: []string{"terdut-users"},
|
||||||
|
want: map[int64]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "member group grants member",
|
||||||
|
teamGroups: testTeamGroups(),
|
||||||
|
groups: []string{"sre"},
|
||||||
|
want: map[int64]string{1: roleMember},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "owner group grants owner",
|
||||||
|
teamGroups: testTeamGroups(),
|
||||||
|
groups: []string{"sre-leads"},
|
||||||
|
want: map[int64]string{1: roleOwner},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "in both of a team's groups, owner wins",
|
||||||
|
teamGroups: testTeamGroups(),
|
||||||
|
groups: []string{"sre", "sre-leads"},
|
||||||
|
want: map[int64]string{1: roleOwner},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "several teams from several groups",
|
||||||
|
teamGroups: testTeamGroups(),
|
||||||
|
groups: []string{"sre", "platform"},
|
||||||
|
want: map[int64]string{1: roleMember, 2: roleMember},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two teams may share a group",
|
||||||
|
teamGroups: []TeamGroup{
|
||||||
|
{TeamID: 1, MemberGroup: "sre"},
|
||||||
|
{TeamID: 2, MemberGroup: "sre"},
|
||||||
|
},
|
||||||
|
groups: []string{"sre"},
|
||||||
|
want: map[int64]string{1: roleMember, 2: roleMember},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a team with neither field set is never granted",
|
||||||
|
teamGroups: []TeamGroup{{TeamID: 1}},
|
||||||
|
groups: []string{"sre", "sre-leads", "platform"},
|
||||||
|
want: map[int64]string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ComputeTeamGrants(tt.teamGroups, tt.groups)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("got %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStringList(t *testing.T) {
|
||||||
|
if got := stringList([]any{"a", "", 3, "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
|
||||||
|
t.Errorf("list: %v", got)
|
||||||
|
}
|
||||||
|
if got := stringList("solo"); !reflect.DeepEqual(got, []string{"solo"}) {
|
||||||
|
t.Errorf("single string: %v", got)
|
||||||
|
}
|
||||||
|
if got := stringList(nil); got != nil {
|
||||||
|
t.Errorf("nil: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CallbackPath is where the provider sends the browser back to. Register
|
||||||
|
// <TERDUT_PUBLIC_URL>/api/oidc/callback as the redirect URI at the provider.
|
||||||
|
const CallbackPath = "/api/oidc/callback"
|
||||||
|
|
||||||
|
// Identity is what the provider says about somebody who has just signed in.
|
||||||
|
type Identity struct {
|
||||||
|
Issuer string
|
||||||
|
Subject string
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
|
||||||
|
// EmailVerified is the provider's own claim. Whether to believe it is
|
||||||
|
// config.OIDC.TrustEmail's business, not this package's.
|
||||||
|
EmailVerified bool
|
||||||
|
|
||||||
|
Groups []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider runs the authorization-code flow with PKCE against one issuer.
|
||||||
|
type Provider struct {
|
||||||
|
cfg config.OIDC
|
||||||
|
redirectURL string
|
||||||
|
http *http.Client
|
||||||
|
|
||||||
|
// Discovery is fetched on first use, not at startup. A provider that is
|
||||||
|
// down when terdut starts must not stop terdut starting: password login is
|
||||||
|
// the way in while it is down, and it can only be that if the server is up.
|
||||||
|
mu sync.Mutex
|
||||||
|
provider *gooidc.Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Provider for cfg. publicURL is the base of the redirect URI.
|
||||||
|
func New(cfg config.OIDC, publicURL string) *Provider {
|
||||||
|
return &Provider{
|
||||||
|
cfg: cfg,
|
||||||
|
redirectURL: trimSlash(publicURL) + CallbackPath,
|
||||||
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSlash(s string) string {
|
||||||
|
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name is what the sign-in button calls the provider.
|
||||||
|
func (p *Provider) Name() string { return p.cfg.Name }
|
||||||
|
|
||||||
|
// Config is the configuration this provider was built from.
|
||||||
|
func (p *Provider) Config() config.OIDC { return p.cfg }
|
||||||
|
|
||||||
|
// discover returns the provider's metadata, fetching it if need be. A failure is
|
||||||
|
// not cached, so the next login tries again.
|
||||||
|
func (p *Provider) discover(ctx context.Context) (*gooidc.Provider, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.provider != nil {
|
||||||
|
return p.provider, nil
|
||||||
|
}
|
||||||
|
ctx = gooidc.ClientContext(ctx, p.http)
|
||||||
|
prov, err := gooidc.NewProvider(ctx, p.cfg.Issuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc discovery: %w", err)
|
||||||
|
}
|
||||||
|
p.provider = prov
|
||||||
|
return prov, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) oauth(prov *gooidc.Provider) *oauth2.Config {
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: p.cfg.ClientID,
|
||||||
|
ClientSecret: p.cfg.ClientSecret,
|
||||||
|
Endpoint: prov.Endpoint(),
|
||||||
|
RedirectURL: p.redirectURL,
|
||||||
|
Scopes: p.cfg.Scopes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVerifier returns a fresh PKCE code verifier.
|
||||||
|
func NewVerifier() string { return oauth2.GenerateVerifier() }
|
||||||
|
|
||||||
|
// AuthURL is where to send the browser to sign in.
|
||||||
|
func (p *Provider) AuthURL(ctx context.Context, state, nonce, verifier string) (string, error) {
|
||||||
|
prov, err := p.discover(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return p.oauth(prov).AuthCodeURL(state,
|
||||||
|
oauth2.S256ChallengeOption(verifier),
|
||||||
|
gooidc.Nonce(nonce),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange trades the authorization code for tokens, verifies the ID token
|
||||||
|
// (signature, issuer, audience, expiry and nonce) and returns who it names.
|
||||||
|
func (p *Provider) Exchange(ctx context.Context, code, verifier, nonce string) (*Identity, error) {
|
||||||
|
prov, err := p.discover(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx = gooidc.ClientContext(ctx, p.http)
|
||||||
|
|
||||||
|
tok, err := p.oauth(prov).Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc token exchange: %w", err)
|
||||||
|
}
|
||||||
|
raw, _ := tok.Extra("id_token").(string)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, errors.New("oidc: token response has no id_token")
|
||||||
|
}
|
||||||
|
idToken, err := prov.Verifier(&gooidc.Config{ClientID: p.cfg.ClientID}).Verify(ctx, raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc: verify id_token: %w", err)
|
||||||
|
}
|
||||||
|
if idToken.Nonce != nonce {
|
||||||
|
return nil, errors.New("oidc: id_token nonce mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims map[string]any
|
||||||
|
if err := idToken.Claims(&claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc: read claims: %w", err)
|
||||||
|
}
|
||||||
|
return p.identity(idToken.Issuer, idToken.Subject, claims), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// identity maps raw claims onto an Identity using the configured claim names.
|
||||||
|
func (p *Provider) identity(issuer, subject string, claims map[string]any) *Identity {
|
||||||
|
id := &Identity{Issuer: issuer, Subject: subject}
|
||||||
|
id.Username, _ = claims[p.cfg.UsernameClaim].(string)
|
||||||
|
id.Email, _ = claims[p.cfg.EmailClaim].(string)
|
||||||
|
id.EmailVerified, _ = claims["email_verified"].(bool)
|
||||||
|
id.Groups = stringList(claims[p.cfg.GroupsClaim])
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringList reads a claim that is a list of strings, or a single string, which
|
||||||
|
// some providers send for a one-element list.
|
||||||
|
func stringList(v any) []string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
if t == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{t}
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(t))
|
||||||
|
for _, e := range t {
|
||||||
|
if s, ok := e.(string); ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyIncidentIsEmbedded(t *testing.T) {
|
||||||
|
sub, err := fs.Sub(files, "static")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for file, want := range map[string]string{
|
||||||
|
"js/incident.js": "copyIncident",
|
||||||
|
"js/ui.js": "copy:",
|
||||||
|
} {
|
||||||
|
b, err := fs.ReadFile(sub, file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), want) {
|
||||||
|
t.Errorf("%s lacks %s", file, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func read(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
sub, err := fs.Sub(files, "static")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := fs.ReadFile(sub, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sign-in button has to be a link the browser navigates, not script: the
|
||||||
|
// CSP's connect-src is 'self', so a fetch to the identity provider is blocked,
|
||||||
|
// and it is a redirect to the provider that the server answers.
|
||||||
|
func TestLoginPageOffersSSOAsAPlainLink(t *testing.T) {
|
||||||
|
html := read(t, "index.html")
|
||||||
|
if !regexp.MustCompile(`<a[^>]*id="sso-link"[^>]*href="/api/oidc/login"|<a[^>]*href="/api/oidc/login"[^>]*id="sso-link"`).MatchString(html) {
|
||||||
|
t.Error("index.html has no <a id=sso-link href=/api/oidc/login>")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `id="password-login"`) {
|
||||||
|
t.Error("the password fields must sit in #password-login so a server can hide them")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every code the server can put in ?sso_error= must have a message, or a
|
||||||
|
// refused person sees a generic failure and cannot tell what to ask for.
|
||||||
|
func TestLoginExplainsEverySSOError(t *testing.T) {
|
||||||
|
js := read(t, "js/app.js")
|
||||||
|
for _, code := range []string{
|
||||||
|
"denied", "expired", "failed", "unavailable",
|
||||||
|
"not_allowed", "no_email", "email_conflict", "disabled",
|
||||||
|
} {
|
||||||
|
if !regexp.MustCompile(`\b` + code + `:`).MatchString(js) {
|
||||||
|
t.Errorf("app.js has no message for sso_error=%s", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page a terminal's prompt links to has to be reachable as a route, or the
|
||||||
|
// link 404s into the queue and the code is never seen.
|
||||||
|
func TestDevicePageIsRoutedAndCallsTheApprovalAPI(t *testing.T) {
|
||||||
|
if !strings.Contains(read(t, "index.html"), `id="view-device"`) {
|
||||||
|
t.Error("index.html has no #view-device section")
|
||||||
|
}
|
||||||
|
app := read(t, "js/app.js")
|
||||||
|
if !strings.Contains(app, "name === 'device'") || !strings.Contains(app, "device: {") {
|
||||||
|
t.Error("app.js does not route /device")
|
||||||
|
}
|
||||||
|
// The SSO button must carry the page asked for through the provider.
|
||||||
|
if !strings.Contains(app, "/api/oidc/login?next=") {
|
||||||
|
t.Error("the SSO link does not carry next=")
|
||||||
|
}
|
||||||
|
dev := read(t, "js/device.js")
|
||||||
|
for _, want := range []string{"approveDevice", "denyDevice"} {
|
||||||
|
if !strings.Contains(dev, want) {
|
||||||
|
t.Errorf("device.js never calls %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api := read(t, "js/api.js")
|
||||||
|
for _, want := range []string{"/oidc/device/approve", "/oidc/device/deny"} {
|
||||||
|
if !strings.Contains(api, want) {
|
||||||
|
t.Errorf("api.js has no call to %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSO-managed access must be marked in every view that edits it.
|
||||||
|
func TestManagedAccessIsMarkedWhereItIsEdited(t *testing.T) {
|
||||||
|
for _, file := range []string{"js/team.js", "js/adminteam.js", "js/adminuser.js", "js/admin.js"} {
|
||||||
|
js := read(t, file)
|
||||||
|
if !strings.Contains(js, "ssoBadge") || !strings.Contains(js, "SSO_MANAGED") {
|
||||||
|
t.Errorf("%s does not mark or explain SSO-managed access", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-5
@@ -137,6 +137,13 @@ h1, h2, h3 { margin: 0; line-height: 1.25; }
|
|||||||
.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||||
.login-brand h1 { font-size: 24px; letter-spacing: -0.01em; }
|
.login-brand h1 { font-size: 24px; letter-spacing: -0.01em; }
|
||||||
.login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; }
|
.login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; }
|
||||||
|
.login-password { display: grid; gap: 14px; }
|
||||||
|
/* "or" between the single sign-on button and the password form. */
|
||||||
|
.login-divider {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin: 0;
|
||||||
|
color: var(--faint); font-size: 13px;
|
||||||
|
}
|
||||||
|
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
|
||||||
label { display: grid; gap: 6px; }
|
label { display: grid; gap: 6px; }
|
||||||
label > span { font-size: 13px; font-weight: 600; color: var(--muted); }
|
label > span { font-size: 13px; font-weight: 600; color: var(--muted); }
|
||||||
@@ -173,7 +180,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
|
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
|
||||||
}
|
}
|
||||||
.btn:hover { background: var(--surface-hover); }
|
.btn:hover { background: var(--surface-hover); }
|
||||||
.btn:disabled { opacity: 0.55; cursor: default; }
|
.btn:disabled, .btn-sm:disabled { opacity: 0.55; cursor: default; }
|
||||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||||
.btn-primary:hover { background: var(--accent); filter: brightness(1.06); }
|
.btn-primary:hover { background: var(--accent); filter: brightness(1.06); }
|
||||||
.btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; }
|
.btn-danger { background: var(--crit); border-color: var(--crit); color: #fff; }
|
||||||
@@ -254,6 +261,33 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center;
|
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- team selector ---------- */
|
||||||
|
/* The global control for which team the app is scoped to. Hidden (via the
|
||||||
|
`hidden` attribute, set from teamselector.js) for anybody in fewer than two
|
||||||
|
teams, the same rule every other team-aware control in this file follows. */
|
||||||
|
|
||||||
|
.nav-team-selector,
|
||||||
|
.team-selector-mobile {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||||
|
background: var(--surface); color: var(--text);
|
||||||
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
padding: 4px 12px; max-width: 100%;
|
||||||
|
}
|
||||||
|
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||||
|
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* A team's identity colour — not a status, so never the severity palette. Six
|
||||||
|
colours, then they repeat; teamColorClass() in format.js picks one by the
|
||||||
|
team's id, the same rcN convention the rota's per-person chips use. */
|
||||||
|
.team-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); }
|
||||||
|
.team-dot.rc1 { background: var(--accent); }
|
||||||
|
.team-dot.rc2 { background: var(--ok); }
|
||||||
|
.team-dot.rc3 { background: var(--snooze); }
|
||||||
|
.team-dot.rc4 { background: var(--warn); }
|
||||||
|
.team-dot.rc5 { background: var(--teal); }
|
||||||
|
.team-dot.rc6 { background: var(--pink); }
|
||||||
|
|
||||||
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
|
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
|
||||||
.view-page { padding-left: 16px; padding-right: 16px; }
|
.view-page { padding-left: 16px; padding-right: 16px; }
|
||||||
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
|
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
|
||||||
@@ -275,6 +309,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
}
|
}
|
||||||
.chips::-webkit-scrollbar { display: none; }
|
.chips::-webkit-scrollbar { display: none; }
|
||||||
.chip {
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
flex: none;
|
flex: none;
|
||||||
min-height: 34px; padding: 0 12px;
|
min-height: 34px; padding: 0 12px;
|
||||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||||
@@ -327,6 +362,16 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
background: var(--surface-2); border: 1px solid var(--border);
|
background: var(--surface-2); border: 1px solid var(--border);
|
||||||
color: var(--muted); font-size: 12px; white-space: nowrap;
|
color: var(--muted); font-size: 12px; white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
/* The page a terminal's sign-in prompt links to. */
|
||||||
|
.device-card { max-width: 420px; margin: 24px auto; padding: 20px; display: grid; gap: 14px; }
|
||||||
|
.device-code {
|
||||||
|
margin: 0; padding: 14px; text-align: center;
|
||||||
|
font: 700 30px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
/* Access the identity provider's groups grant. The tint says "not yours to edit here". */
|
||||||
|
.row-team.sso { margin-left: 6px; background: var(--accent-soft); border-color: transparent; color: var(--accent); }
|
||||||
.row.resolved .row-title { color: var(--muted); }
|
.row.resolved .row-title { color: var(--muted); }
|
||||||
|
|
||||||
.sev-critical { --sev: var(--crit); }
|
.sev-critical { --sev: var(--crit); }
|
||||||
@@ -359,7 +404,16 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
|
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
|
||||||
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
|
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
|
||||||
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
|
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
|
||||||
.badge.st-resolved { background: var(--ok-soft); color: var(--ok); }
|
.badge.st-resolved, .badge.st-healthy { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.badge.st-dead { background: var(--crit-soft); color: var(--crit); }
|
||||||
|
/* Dormant is the plain badge on purpose: nothing has gone wrong and nothing has
|
||||||
|
gone right, which is what the muted default already says. */
|
||||||
|
.badge.st-dormant, .badge.st-never { background: var(--surface-2); color: var(--muted); }
|
||||||
|
.badge.st-active { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.badge.st-quiet, .badge.st-escalating { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.badge.st-ready, .badge.st-oncall { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.badge.st-reachable { background: var(--surface-2); color: var(--muted); }
|
||||||
|
.badge.st-unreachable, .badge.st-unpageable { background: var(--crit-soft); color: var(--crit); }
|
||||||
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
|
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
|
||||||
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
|
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
|
||||||
.badge.sev-info { background: var(--info-soft); color: var(--info); }
|
.badge.sev-info { background: var(--info-soft); color: var(--info); }
|
||||||
@@ -377,6 +431,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
.detail-head .copy { margin-left: auto; }
|
||||||
|
.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||||
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
||||||
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
||||||
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
||||||
@@ -450,11 +506,16 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
.tl-text { overflow-wrap: anywhere; }
|
.tl-text { overflow-wrap: anywhere; }
|
||||||
.tl-text .who { font-weight: 650; }
|
.tl-text .who { font-weight: 650; }
|
||||||
.tl-time { color: var(--faint); font-size: 12px; }
|
.tl-time { color: var(--faint); font-size: 12px; }
|
||||||
.tl-note .note {
|
.note {
|
||||||
margin-top: 6px; padding: 10px 12px;
|
margin-top: 6px; padding: 10px 12px;
|
||||||
background: var(--surface-2); border-radius: var(--radius-sm);
|
background: var(--surface-2); border-radius: var(--radius-sm);
|
||||||
white-space: pre-wrap; overflow-wrap: anywhere;
|
white-space: pre-wrap; overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
.note-fix { background: var(--ok-soft); border-left: 3px solid var(--ok); }
|
||||||
|
.similar { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.similar-item { padding: 10px 0; }
|
||||||
|
.similar-item + .similar-item { border-top: 1px solid var(--border, var(--surface-2)); }
|
||||||
|
.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); }
|
||||||
.note-actions { display: flex; justify-content: flex-end; }
|
.note-actions { display: flex; justify-content: flex-end; }
|
||||||
.note-actions .btn { color: var(--muted); }
|
.note-actions .btn { color: var(--muted); }
|
||||||
|
|
||||||
@@ -612,6 +673,7 @@ kbd {
|
|||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
|
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
.nav-team-selector { margin: -8px 10px 14px; width: calc(100% - 20px); }
|
||||||
.nav-link {
|
.nav-link {
|
||||||
flex-direction: row; justify-content: flex-start; gap: 12px;
|
flex-direction: row; justify-content: flex-start; gap: 12px;
|
||||||
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
||||||
@@ -633,6 +695,10 @@ kbd {
|
|||||||
.view-queue .pane { overflow: auto; height: 100dvh; }
|
.view-queue .pane { overflow: auto; height: 100dvh; }
|
||||||
.pane-list { border-right: 1px solid var(--border); }
|
.pane-list { border-right: 1px solid var(--border); }
|
||||||
.pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; }
|
.pane-list .chips { position: sticky; top: 0; z-index: 2; background: var(--bg); padding-top: 16px; }
|
||||||
|
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
|
||||||
|
is hidden, so the chips wrap here instead: Archived stays reachable. */
|
||||||
|
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
|
||||||
|
.pane-list .chip-sep { display: none; }
|
||||||
.view-queue:not(.has-detail) .pane-detail { display: block; }
|
.view-queue:not(.has-detail) .pane-detail { display: block; }
|
||||||
|
|
||||||
/* On desktop the list stays visible next to the detail. */
|
/* On desktop the list stays visible next to the detail. */
|
||||||
@@ -680,6 +746,18 @@ kbd {
|
|||||||
.disabled-row td { opacity: 0.55; }
|
.disabled-row td { opacity: 0.55; }
|
||||||
.btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); }
|
.btn-sm.danger { color: var(--crit); border-color: var(--crit-soft); }
|
||||||
|
|
||||||
|
/* --- status lists: alert sources and dead man's switches -----------------
|
||||||
|
Six columns do not fit a phone, so the table scrolls inside its card rather
|
||||||
|
than the page. A heartbeat under a switch with several is indented, the way
|
||||||
|
the escalation ladder indents its levels. */
|
||||||
|
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.table-scroll { overflow-x: auto; margin-top: 12px; }
|
||||||
|
.status-table th, .status-table td { white-space: nowrap; }
|
||||||
|
.status-table td.wrap { white-space: normal; min-width: 12em; }
|
||||||
|
.status-table .source-row td { border-bottom-style: dashed; }
|
||||||
|
.status-table .source-row td:first-child { padding-left: 16px; }
|
||||||
|
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||||
|
|
||||||
.inline-form { display: flex; gap: 8px; margin-top: 12px; }
|
.inline-form { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
.inline-form input { flex: 1; min-width: 0; }
|
.inline-form input { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
@@ -735,13 +813,12 @@ kbd {
|
|||||||
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
|
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
|
||||||
.stacked-form label.checkbox { gap: 8px; }
|
.stacked-form label.checkbox { gap: 8px; }
|
||||||
.stacked-form input.wide { min-width: min(420px, 100%); }
|
.stacked-form input.wide { min-width: min(420px, 100%); }
|
||||||
.team-picker { margin-top: 8px; max-width: 100%; }
|
|
||||||
|
|
||||||
/* The rota, a month at a time. A name is too wide to print thirty times and
|
/* The rota, a month at a time. A name is too wide to print thirty times and
|
||||||
too alike down a column to read, so a day carries an initial in that
|
too alike down a column to read, so a day carries an initial in that
|
||||||
person's colour and the legend underneath says whose. A shift is then a run
|
person's colour and the legend underneath says whose. A shift is then a run
|
||||||
of one colour, which is the shape the question actually has. */
|
of one colour, which is the shape the question actually has. */
|
||||||
.rota-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; padding: 10px; }
|
.rota-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; }
|
||||||
.rota-wd {
|
.rota-wd {
|
||||||
padding-bottom: 4px; text-align: center;
|
padding-bottom: 4px; text-align: center;
|
||||||
color: var(--muted); font-size: 11px; font-weight: 700;
|
color: var(--muted); font-size: 11px; font-weight: 700;
|
||||||
@@ -755,6 +832,21 @@ kbd {
|
|||||||
}
|
}
|
||||||
button.rota-day { cursor: pointer; }
|
button.rota-day { cursor: pointer; }
|
||||||
button.rota-day:hover { background: var(--surface-2); }
|
button.rota-day:hover { background: var(--surface-2); }
|
||||||
|
/* The week number starts each row. Quiet by default, because it is a label
|
||||||
|
first; an owner's tap on it is the second thing it does. */
|
||||||
|
.rota-week {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
border: 0; border-radius: var(--radius-sm); background: none;
|
||||||
|
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
button.rota-week { cursor: pointer; }
|
||||||
|
button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||||
|
.rota-week.current { color: var(--accent); font-weight: 700; }
|
||||||
|
/* The sheet's row of who holds each day of the week. */
|
||||||
|
.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; }
|
||||||
|
.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
|
||||||
|
.week-holder.past { opacity: 0.55; }
|
||||||
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||||
.rota-day.today { background: var(--accent-soft); }
|
.rota-day.today { background: var(--accent-soft); }
|
||||||
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
|
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
|
||||||
@@ -798,6 +890,11 @@ button.rota-day:hover { background: var(--surface-2); }
|
|||||||
.ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
|
.ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
|
||||||
.ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
.ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||||
.target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
.target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.ladder-editor { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin-top: 12px; }
|
||||||
|
/* A target that would not wake anybody says why, in place: it is the reason a
|
||||||
|
level is red, and the thing to go and fix. */
|
||||||
|
.target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
||||||
|
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
|
||||||
|
|
||||||
/* An integration key is shown exactly once, so it should look like something
|
/* An integration key is shown exactly once, so it should look like something
|
||||||
to act on rather than another row of text. */
|
to act on rather than another row of text. */
|
||||||
|
|||||||
@@ -25,20 +25,29 @@
|
|||||||
<img src="/icon.svg" alt="" width="40" height="40">
|
<img src="/icon.svg" alt="" width="40" height="40">
|
||||||
<h1>terdut</h1>
|
<h1>terdut</h1>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<!-- Why a sign-in failed, when the identity provider sent the browser back
|
||||||
<span>Username</span>
|
here with ?sso_error=. Kept apart from the password form's own error. -->
|
||||||
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
<p class="form-error" id="sso-error" role="alert" hidden></p>
|
||||||
</label>
|
<!-- A plain link, not a fetch: the browser has to navigate to the provider,
|
||||||
<label>
|
and the page's CSP allows no connection to anywhere else. -->
|
||||||
<span>Password</span>
|
<a class="btn btn-primary btn-block" id="sso-link" href="/api/oidc/login" hidden>Sign in with SSO</a>
|
||||||
<input name="password" type="password" autocomplete="current-password" required>
|
<p class="login-divider" id="login-or" hidden><span>or</span></p>
|
||||||
</label>
|
<div class="login-password" id="password-login">
|
||||||
<p class="form-error" role="alert" hidden></p>
|
<label>
|
||||||
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
<span>Username</span>
|
||||||
<p class="login-hint">No password yet? Ask an admin to set one, or run
|
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
||||||
<code>PUT /api/users/{id}/password</code> with your API key.</p>
|
</label>
|
||||||
<p class="login-hint" id="signup-link" hidden>
|
<label>
|
||||||
No account? <a href="/signup">Create one</a>.</p>
|
<span>Password</span>
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<p class="form-error" role="alert" hidden></p>
|
||||||
|
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
||||||
|
<p class="login-hint">No password yet? Ask an admin to set one, or run
|
||||||
|
<code>PUT /api/users/{id}/password</code> with your API key.</p>
|
||||||
|
<p class="login-hint" id="signup-link" hidden>
|
||||||
|
No account? <a href="/signup">Create one</a>.</p>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Sign-up. Shown instead of the login card at /signup, and only offers
|
<!-- Sign-up. Shown instead of the login card at /signup, and only offers
|
||||||
@@ -77,6 +86,9 @@
|
|||||||
<img src="/icon.svg" alt="" width="28" height="28">
|
<img src="/icon.svg" alt="" width="28" height="28">
|
||||||
<span>terdut</span>
|
<span>terdut</span>
|
||||||
</a>
|
</a>
|
||||||
|
<!-- Which team the app is scoped to. Hidden unless the signed-in user is
|
||||||
|
in more than one; teamselector.js fills it in and wires the click. -->
|
||||||
|
<button class="nav-team-selector" id="team-selector" type="button" hidden></button>
|
||||||
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
|
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||||
<span class="nav-label">Queue</span>
|
<span class="nav-label">Queue</span>
|
||||||
@@ -117,6 +129,7 @@
|
|||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
|
||||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||||
</div>
|
</div>
|
||||||
<span class="open-pill" id="open-pill" hidden></span>
|
<span class="open-pill" id="open-pill" hidden></span>
|
||||||
@@ -142,6 +155,8 @@
|
|||||||
which the Team tab cannot show for a team you are not a member of. -->
|
which the Team tab cannot show for a team you are not a member of. -->
|
||||||
<section id="view-adminteam" class="view view-page" data-view="adminteam" hidden></section>
|
<section id="view-adminteam" class="view view-page" data-view="adminteam" hidden></section>
|
||||||
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
||||||
|
<!-- Approve a terminal's sign-in, at /device?code=...: the page its prompt links to. -->
|
||||||
|
<section id="view-device" class="view view-page" data-view="device" hidden></section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog id="sheet" class="sheet"></dialog>
|
<dialog id="sheet" class="sheet"></dialog>
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ function render() {
|
|||||||
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
||||||
notifyForm(user),
|
notifyForm(user),
|
||||||
|
|
||||||
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
...passwordSection(user, hasPassword),
|
||||||
passwordForm(user, hasPassword),
|
|
||||||
|
|
||||||
h('div', { class: 'only-desktop' },
|
h('div', { class: 'only-desktop' },
|
||||||
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
||||||
@@ -119,6 +118,24 @@ function notifyForm(user) {
|
|||||||
return form;
|
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) {
|
function passwordForm(user, hasPassword) {
|
||||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// gate.
|
// gate.
|
||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, spinner, confirm, menuCard } from './ui.js';
|
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||||
import { state, myID } from './state.js';
|
import { state, myID } from './state.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-admin');
|
const view = () => document.getElementById('view-admin');
|
||||||
@@ -197,7 +197,9 @@ function usersCard() {
|
|||||||
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||||
self && h('span', { class: 'you', text: 'you' })),
|
self && h('span', { class: 'you', text: 'you' })),
|
||||||
h('td', { class: 'muted', text: u.email }),
|
h('td', { class: 'muted', text: u.email }),
|
||||||
h('td', {}, u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null),
|
h('td', {},
|
||||||
|
u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null,
|
||||||
|
u.is_admin && u.admin_source === 'oidc' ? ssoBadge() : null),
|
||||||
h('td', {},
|
h('td', {},
|
||||||
// Neither action is offered for your own account: the server refuses
|
// Neither action is offered for your own account: the server refuses
|
||||||
// both, and an enabled-looking button that always fails is worse than
|
// both, and an enabled-looking button that always fails is worse than
|
||||||
@@ -206,6 +208,9 @@ function usersCard() {
|
|||||||
class: 'btn-sm',
|
class: 'btn-sm',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||||
|
// The server refuses to revoke what the groups grant.
|
||||||
|
disabled: ssoAdmin(u),
|
||||||
|
title: ssoAdmin(u) ? SSO_MANAGED : null,
|
||||||
onclick: () => setAdmin(u, !u.is_admin),
|
onclick: () => setAdmin(u, !u.is_admin),
|
||||||
}),
|
}),
|
||||||
!self && h('button', {
|
!self && h('button', {
|
||||||
@@ -255,6 +260,8 @@ function invitePointer() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ssoAdmin = (u) => u.is_admin && u.admin_source === 'oidc';
|
||||||
|
|
||||||
async function setAdmin(user, next) {
|
async function setAdmin(user, next) {
|
||||||
if (next && !(await confirm({
|
if (next && !(await confirm({
|
||||||
title: `Make ${user.username} an administrator?`,
|
title: `Make ${user.username} an administrator?`,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
// gate.
|
// gate.
|
||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
|
import { h, clear, spinner, confirm, toast, icon, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { navigate } from './app.js';
|
import { navigate } from './app.js';
|
||||||
import { when } from './format.js';
|
import { when } from './format.js';
|
||||||
@@ -146,6 +146,13 @@ function identityCard() {
|
|||||||
fact('Created', when(t.created_at)),
|
fact('Created', when(t.created_at)),
|
||||||
fact('Members', String(t.members)),
|
fact('Members', String(t.members)),
|
||||||
fact('Open incidents', String(t.open_incidents)),
|
fact('Open incidents', String(t.open_incidents)),
|
||||||
|
// Read-only here: an administrator can see why a team's OIDC-sourced
|
||||||
|
// membership looks the way it does, but setting it is the team's own
|
||||||
|
// owner's call, from the Team tab.
|
||||||
|
...(state.auth?.oidc?.enabled ? [
|
||||||
|
fact('OIDC member group', t.oidc_member_group || '—'),
|
||||||
|
fact('OIDC owner group', t.oidc_owner_group || '—'),
|
||||||
|
] : []),
|
||||||
),
|
),
|
||||||
form, err, ok,
|
form, err, ok,
|
||||||
);
|
);
|
||||||
@@ -166,17 +173,22 @@ function membersCard() {
|
|||||||
// Unlike the Team tab's own member list, the name is a link: that
|
// Unlike the Team tab's own member list, the name is a link: that
|
||||||
// person's page is where the rest of them lives.
|
// person's page is where the rest of them lives.
|
||||||
h('td', {}, h('a', { class: 'row-link', href: `/admin/users/${m.user_id}`, text: m.username })),
|
h('td', {}, h('a', { class: 'row-link', href: `/admin/users/${m.user_id}`, text: m.username })),
|
||||||
h('td', { class: 'muted small', text: m.role }),
|
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
|
||||||
h('td', { class: 'row-actions' },
|
h('td', { class: 'row-actions' },
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'btn-sm', type: 'button',
|
class: 'btn-sm', type: 'button',
|
||||||
text: m.role === 'owner' ? 'Make member' : 'Make owner',
|
text: m.role === 'owner' ? 'Make member' : 'Make owner',
|
||||||
|
// The server refuses to edit a membership the groups grant.
|
||||||
|
disabled: m.source === 'oidc',
|
||||||
|
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||||
// The same endpoint both ways: adding is an upsert on the role.
|
// The same endpoint both ways: adding is an upsert on the role.
|
||||||
onclick: () => act(() =>
|
onclick: () => act(() =>
|
||||||
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
|
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
|
||||||
}),
|
}),
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||||
|
disabled: m.source === 'oidc',
|
||||||
|
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||||
// The server refuses the last owner with a 409, which act() shows.
|
// The server refuses the last owner with a 409, which act() shows.
|
||||||
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
|
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
// enforcement of them.
|
// enforcement of them.
|
||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
|
import { h, clear, spinner, confirm, toast, icon, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||||
import { state, myID } from './state.js';
|
import { state, myID } from './state.js';
|
||||||
import { navigate } from './app.js';
|
import { navigate } from './app.js';
|
||||||
import { when } from './format.js';
|
import { when } from './format.js';
|
||||||
@@ -96,6 +96,7 @@ function identityCard() {
|
|||||||
h('div', { class: 'user-head' },
|
h('div', { class: 'user-head' },
|
||||||
h('h2', { text: u.username }),
|
h('h2', { text: u.username }),
|
||||||
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
|
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
|
||||||
|
u.is_admin && u.admin_source === 'oidc' && ssoBadge(),
|
||||||
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||||
self && h('span', { class: 'you', text: 'you' })),
|
self && h('span', { class: 'you', text: 'you' })),
|
||||||
h('dl', { class: 'user-facts' },
|
h('dl', { class: 'user-facts' },
|
||||||
@@ -110,6 +111,9 @@ function identityCard() {
|
|||||||
!self && h('button', {
|
!self && h('button', {
|
||||||
class: 'btn', type: 'button',
|
class: 'btn', type: 'button',
|
||||||
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||||
|
// The server refuses to revoke what the groups grant.
|
||||||
|
disabled: u.is_admin && u.admin_source === 'oidc',
|
||||||
|
title: u.is_admin && u.admin_source === 'oidc' ? SSO_MANAGED : null,
|
||||||
onclick: () => setAdmin(!u.is_admin),
|
onclick: () => setAdmin(!u.is_admin),
|
||||||
}),
|
}),
|
||||||
!self && h('button', {
|
!self && h('button', {
|
||||||
@@ -162,16 +166,21 @@ function teamsCard() {
|
|||||||
// Not a link: the Team tab always shows the viewer's own team, so
|
// Not a link: the Team tab always shows the viewer's own team, so
|
||||||
// sending them there from somebody else's membership would be a lie.
|
// sending them there from somebody else's membership would be a lie.
|
||||||
h('td', {}, h('strong', { text: t.name })),
|
h('td', {}, h('strong', { text: t.name })),
|
||||||
h('td', { class: 'muted small', text: t.role }),
|
h('td', { class: 'muted small' }, t.role, t.source === 'oidc' && ssoBadge()),
|
||||||
h('td', { class: 'row-actions' },
|
h('td', { class: 'row-actions' },
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'btn-sm', type: 'button',
|
class: 'btn-sm', type: 'button',
|
||||||
text: t.role === 'owner' ? 'Make member' : 'Make owner',
|
text: t.role === 'owner' ? 'Make member' : 'Make owner',
|
||||||
|
// The server refuses to edit a membership the groups grant.
|
||||||
|
disabled: t.source === 'oidc',
|
||||||
|
title: t.source === 'oidc' ? SSO_MANAGED : null,
|
||||||
onclick: () => act(() =>
|
onclick: () => act(() =>
|
||||||
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
|
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
|
||||||
}),
|
}),
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||||
|
disabled: t.source === 'oidc',
|
||||||
|
title: t.source === 'oidc' ? SSO_MANAGED : null,
|
||||||
// The server refuses the last owner with a 409, which act() shows.
|
// The server refuses the last owner with a 409, which act() shows.
|
||||||
onclick: () => act(() => api.removeTeamMember(t.id, userID)),
|
onclick: () => act(() => api.removeTeamMember(t.id, userID)),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -59,8 +59,13 @@ async function call(method, path, { query, body, signal } = {}) {
|
|||||||
|
|
||||||
// session
|
// session
|
||||||
export const me = () => call('GET', '/me');
|
export const me = () => call('GET', '/me');
|
||||||
|
// How this server can be signed in to: { password_login, oidc: { enabled, name } }.
|
||||||
|
export const authConfig = () => call('GET', '/auth/config');
|
||||||
export const login = (username, password) => call('POST', '/login', { body: { username, password } });
|
export const login = (username, password) => call('POST', '/login', { body: { username, password } });
|
||||||
export const logout = () => call('POST', '/logout');
|
export const logout = () => call('POST', '/logout');
|
||||||
|
// Approve or refuse a sign-in a terminal started; code is what it is showing.
|
||||||
|
export const approveDevice = (code) => call('POST', '/oidc/device/approve', { body: { user_code: code } });
|
||||||
|
export const denyDevice = (code) => call('POST', '/oidc/device/deny', { body: { user_code: code } });
|
||||||
export const setPassword = (userID, password, currentPassword) =>
|
export const setPassword = (userID, password, currentPassword) =>
|
||||||
call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } });
|
call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } });
|
||||||
|
|
||||||
@@ -83,13 +88,14 @@ export const timeline = (id) => call('GET', `/incidents/${id}/timeline`);
|
|||||||
|
|
||||||
export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`);
|
export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`);
|
||||||
export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`);
|
export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`);
|
||||||
export const resolve = (id) => call('POST', `/incidents/${id}/resolve`);
|
export const resolve = (id, resolution) => call('POST', `/incidents/${id}/resolve`, resolution ? { body: { resolution } } : {});
|
||||||
export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } });
|
export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } });
|
||||||
export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec });
|
export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec });
|
||||||
export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`);
|
export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`);
|
||||||
export const archive = (id) => call('POST', `/incidents/${id}/archive`);
|
export const archive = (id) => call('POST', `/incidents/${id}/archive`);
|
||||||
export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
|
export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
|
||||||
export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } });
|
export const addNote = (id, content, pinned = false) => call('POST', `/incidents/${id}/notes`, { body: { content, pinned } });
|
||||||
|
export const similar = (id) => call('GET', `/incidents/${id}/similar`);
|
||||||
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
||||||
|
|
||||||
// stats
|
// stats
|
||||||
@@ -129,14 +135,23 @@ export const addTeamMember = (id, userID, role) =>
|
|||||||
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
|
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
|
||||||
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
|
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
|
||||||
|
|
||||||
|
// Which OIDC groups grant member and owner access to this team.
|
||||||
|
export const oidcGroups = (id) => call('GET', `/teams/${id}/oidc-groups`);
|
||||||
|
export const setOidcGroups = (id, body) => call('PUT', `/teams/${id}/oidc-groups`, { body });
|
||||||
|
|
||||||
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
|
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
|
||||||
export const createIntegration = (id, name) =>
|
export const createIntegration = (id, name) =>
|
||||||
call('POST', `/teams/${id}/integrations`, { body: { name } });
|
call('POST', `/teams/${id}/integrations`, { body: { name } });
|
||||||
|
export const renameIntegration = (id, integrationID, name) =>
|
||||||
|
call('PATCH', `/teams/${id}/integrations/${integrationID}`, { body: { name } });
|
||||||
export const deleteIntegration = (id, integrationID) =>
|
export const deleteIntegration = (id, integrationID) =>
|
||||||
call('DELETE', `/teams/${id}/integrations/${integrationID}`);
|
call('DELETE', `/teams/${id}/integrations/${integrationID}`);
|
||||||
|
|
||||||
export const deadman = (id) => call('GET', `/teams/${id}/deadman`);
|
export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
|
||||||
export const setDeadman = (id, body) => call('PUT', `/teams/${id}/deadman`, { body });
|
export const createDeadmanSwitch = (id, body) =>
|
||||||
|
call('POST', `/teams/${id}/deadman/switches`, { body });
|
||||||
|
export const deleteDeadmanSwitch = (id, switchID) =>
|
||||||
|
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
|
||||||
|
|
||||||
export const escalation = (id) => call('GET', `/teams/${id}/escalation`);
|
export const escalation = (id) => call('GET', `/teams/${id}/escalation`);
|
||||||
export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body });
|
export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body });
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import * as alerts from './alerts.js';
|
|||||||
import * as stats from './stats.js';
|
import * as stats from './stats.js';
|
||||||
import * as account from './account.js';
|
import * as account from './account.js';
|
||||||
import * as team from './team.js';
|
import * as team from './team.js';
|
||||||
|
import * as teamselector from './teamselector.js';
|
||||||
import * as admin from './admin.js';
|
import * as admin from './admin.js';
|
||||||
import * as adminuser from './adminuser.js';
|
import * as adminuser from './adminuser.js';
|
||||||
import * as adminteam from './adminteam.js';
|
import * as adminteam from './adminteam.js';
|
||||||
|
import * as device from './device.js';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@ const SECTIONS = {
|
|||||||
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
||||||
adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
|
adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
|
||||||
more: { title: 'Account', view: account },
|
more: { title: 'Account', view: account },
|
||||||
|
// Reached by link from a terminal's sign-in prompt, not from the nav.
|
||||||
|
device: { title: 'Sign in a terminal', view: device },
|
||||||
};
|
};
|
||||||
|
|
||||||
// The mobile hamburger menu's contents — the same sections the desktop
|
// The mobile hamburger menu's contents — the same sections the desktop
|
||||||
@@ -61,7 +65,7 @@ function parseRoute(pathname) {
|
|||||||
if (t) return { section: 'admin', tab: t.tab };
|
if (t) return { section: 'admin', tab: t.tab };
|
||||||
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
||||||
if (tt) return { section: 'team', tab: tt.tab };
|
if (tt) return { section: 'team', tab: tt.tab };
|
||||||
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more') return { section: name };
|
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more' || name === 'device') return { section: name };
|
||||||
return { section: 'queue', incident: null };
|
return { section: 'queue', incident: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +231,8 @@ async function boot() {
|
|||||||
$('login-form').addEventListener('submit', onLogin);
|
$('login-form').addEventListener('submit', onLogin);
|
||||||
$('signup-form').addEventListener('submit', onSignup);
|
$('signup-form').addEventListener('submit', onSignup);
|
||||||
$('menu-btn').addEventListener('click', openNavMenu);
|
$('menu-btn').addEventListener('click', openNavMenu);
|
||||||
|
teamselector.init();
|
||||||
|
ssoErrorCode = takeSSOError();
|
||||||
|
|
||||||
// /signup is the one route that works without a session.
|
// /signup is the one route that works without a session.
|
||||||
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
||||||
@@ -236,8 +242,12 @@ async function boot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Before anything renders: the account view decides from it whether a
|
||||||
|
// password is worth offering to set.
|
||||||
|
await loadAuthConfig();
|
||||||
state.me = await api.me();
|
state.me = await api.me();
|
||||||
await loadTeams();
|
await loadTeams();
|
||||||
|
teamselector.render();
|
||||||
// The Admin tab exists only for an administrator. Somebody who types /admin
|
// The Admin tab exists only for an administrator. Somebody who types /admin
|
||||||
// anyway gets the view's own "ask an administrator" card, not a blank page.
|
// anyway gets the view's own "ask an administrator" card, not a blank page.
|
||||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||||
@@ -321,6 +331,7 @@ async function onSignup(e) {
|
|||||||
history.replaceState({ depth: 0 }, '', '/');
|
history.replaceState({ depth: 0 }, '', '/');
|
||||||
route = parseRoute('/');
|
route = parseRoute('/');
|
||||||
await loadTeams();
|
await loadTeams();
|
||||||
|
teamselector.render();
|
||||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||||
showApp();
|
showApp();
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
@@ -331,17 +342,87 @@ async function onSignup(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLogin() {
|
// Why single sign-on sent the browser back, by the code the server puts in
|
||||||
|
// ?sso_error=. The provider's name is the one the administrator configured.
|
||||||
|
function ssoErrorText(code, name) {
|
||||||
|
const sso = name || 'single sign-on';
|
||||||
|
return {
|
||||||
|
denied: `Signing in with ${sso} was cancelled or refused.`,
|
||||||
|
expired: 'That sign-in expired or was already used. Try again.',
|
||||||
|
failed: `Signing in with ${sso} failed. Try again, and tell an administrator if it keeps happening.`,
|
||||||
|
unavailable: `${sso} could not be reached. Try again in a moment.`,
|
||||||
|
not_allowed: 'Your account is not allowed to use terdut. Ask an administrator to add you to the right group.',
|
||||||
|
no_email: `${sso} did not send an email address for you, which terdut needs.`,
|
||||||
|
email_conflict: 'An account with your email address already exists and could not be linked to this sign-in. Ask an administrator.',
|
||||||
|
disabled: 'Your account is disabled. Ask an administrator.',
|
||||||
|
}[code] || `Signing in with ${sso} failed.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads how the server can be signed in to. An older server has no such
|
||||||
|
// endpoint, and one that cannot be asked is treated as offering passwords only:
|
||||||
|
// the form that always existed is better than a blank page.
|
||||||
|
async function loadAuthConfig() {
|
||||||
|
try {
|
||||||
|
state.auth = await api.authConfig();
|
||||||
|
} catch {
|
||||||
|
/* keep the last answer, or the defaults */
|
||||||
|
}
|
||||||
|
return state.auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reason the last single sign-on attempt failed, read once at boot. It is
|
||||||
|
// held here rather than re-read from the address because showLogin runs more
|
||||||
|
// than once on the way to the form (the 401 from /api/me reaches it through the
|
||||||
|
// API layer and again through boot's own catch), and only the first would see it.
|
||||||
|
let ssoErrorCode = null;
|
||||||
|
|
||||||
|
// Takes ?sso_error= off the address, so a reload does not repeat the message.
|
||||||
|
function takeSSOError() {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const code = params.get('sso_error');
|
||||||
|
if (code === null) return null;
|
||||||
|
params.delete('sso_error');
|
||||||
|
const query = params.toString();
|
||||||
|
history.replaceState(null, '', location.pathname + (query ? `?${query}` : '') + location.hash);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showLogin() {
|
||||||
poll.stop();
|
poll.stop();
|
||||||
ui.closeSheet(null);
|
ui.closeSheet(null);
|
||||||
reset();
|
reset();
|
||||||
$('boot').hidden = true;
|
|
||||||
$('app').hidden = true;
|
$('app').hidden = true;
|
||||||
$('login').hidden = false;
|
|
||||||
$('signup-form').hidden = true;
|
$('signup-form').hidden = true;
|
||||||
|
|
||||||
|
// Ask before showing anything, so the form does not flash the password
|
||||||
|
// fields at somebody whose server has turned them off.
|
||||||
|
const auth = await loadAuthConfig();
|
||||||
|
const sso = auth.oidc?.enabled ? auth.oidc : null;
|
||||||
|
const passwords = auth.password_login !== false;
|
||||||
|
|
||||||
|
const link = $('sso-link');
|
||||||
|
link.hidden = !sso;
|
||||||
|
if (sso) {
|
||||||
|
link.textContent = `Sign in with ${sso.name || 'SSO'}`;
|
||||||
|
// Come back to the page that was asked for: a link to /device?code=... has
|
||||||
|
// to survive the trip through the provider. The server only honours paths
|
||||||
|
// on this server, and ignores the front page.
|
||||||
|
const here = location.pathname + location.search;
|
||||||
|
link.href = here === '/' ? '/api/oidc/login' : `/api/oidc/login?next=${encodeURIComponent(here)}`;
|
||||||
|
}
|
||||||
|
$('login-or').hidden = !(sso && passwords);
|
||||||
|
$('password-login').hidden = !passwords;
|
||||||
|
|
||||||
|
const ssoErr = $('sso-error');
|
||||||
|
ssoErr.hidden = ssoErrorCode === null;
|
||||||
|
if (ssoErrorCode !== null) ssoErr.textContent = ssoErrorText(ssoErrorCode, sso?.name);
|
||||||
|
|
||||||
|
$('boot').hidden = true;
|
||||||
|
$('login').hidden = false;
|
||||||
$('login-form').hidden = false;
|
$('login-form').hidden = false;
|
||||||
const form = $('login-form');
|
const form = $('login-form');
|
||||||
form.querySelector('.form-error').hidden = true;
|
form.querySelector('#password-login .form-error').hidden = true;
|
||||||
|
if (!passwords) return;
|
||||||
// Only offer the door that is open. Somebody without an invite on an
|
// Only offer the door that is open. Somebody without an invite on an
|
||||||
// invite-only server should be told, not sent to a form that refuses them.
|
// invite-only server should be told, not sent to a form that refuses them.
|
||||||
api.signupInfo().then((info) => {
|
api.signupInfo().then((info) => {
|
||||||
@@ -354,9 +435,13 @@ function showLogin() {
|
|||||||
async function onLogin(e) {
|
async function onLogin(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.currentTarget;
|
const form = e.currentTarget;
|
||||||
const err = form.querySelector('.form-error');
|
// Not the first .form-error: that one is the single sign-on message above.
|
||||||
|
const err = form.querySelector('#password-login .form-error');
|
||||||
const btn = form.querySelector('button[type=submit]');
|
const btn = form.querySelector('button[type=submit]');
|
||||||
err.hidden = true;
|
err.hidden = true;
|
||||||
|
// Whatever single sign-on said is about the last attempt, not this one.
|
||||||
|
ssoErrorCode = null;
|
||||||
|
$('sso-error').hidden = true;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
state.me = await api.login(form.username.value.trim(), form.password.value);
|
state.me = await api.login(form.username.value.trim(), form.password.value);
|
||||||
@@ -380,6 +465,7 @@ export async function signOut() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showApp() {
|
function showApp() {
|
||||||
|
ssoErrorCode = null;
|
||||||
$('boot').hidden = true;
|
$('boot').hidden = true;
|
||||||
$('login').hidden = true;
|
$('login').hidden = true;
|
||||||
$('app').hidden = false;
|
$('app').hidden = false;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Approving a sign-in that a terminal started, at /device?code=XXXX-XXXX.
|
||||||
|
//
|
||||||
|
// The terminal (the TUI) shows a code and a link to this page. Whoever opens it
|
||||||
|
// is already signed in — by the provider or by password, whichever the login
|
||||||
|
// page offered — and is asked to approve. Approving hands that terminal a
|
||||||
|
// session for *this* account, so the page names the account and the code, and
|
||||||
|
// tells anybody who did not start this to refuse.
|
||||||
|
|
||||||
|
import * as api from './api.js';
|
||||||
|
import { h, clear } from './ui.js';
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { navigate } from './app.js';
|
||||||
|
|
||||||
|
const view = () => document.getElementById('view-device');
|
||||||
|
|
||||||
|
// What has been decided for the code on screen, so a re-render does not offer
|
||||||
|
// to approve it a second time.
|
||||||
|
let outcome = null; // { code, approved }
|
||||||
|
|
||||||
|
export function show() {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const code = new URLSearchParams(location.search).get('code') || '';
|
||||||
|
if (!code) return clear(view(), enterCode());
|
||||||
|
if (outcome && outcome.code === code) return clear(view(), decided(outcome.approved));
|
||||||
|
return clear(view(), confirmCard(code));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reached without a code, for somebody who typed the address by hand.
|
||||||
|
function enterCode() {
|
||||||
|
const input = h('input', {
|
||||||
|
name: 'code', autocomplete: 'off', autocapitalize: 'characters', spellcheck: 'false',
|
||||||
|
placeholder: 'XXXX-XXXX', required: true,
|
||||||
|
});
|
||||||
|
const form = h('form', { class: 'card device-card' },
|
||||||
|
h('h2', { text: 'Sign in a terminal' }),
|
||||||
|
h('p', { class: 'muted', text: 'Enter the code the terminal is showing.' }),
|
||||||
|
h('label', {}, h('span', { text: 'Code' }), input),
|
||||||
|
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Continue' }));
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/device?code=${encodeURIComponent(input.value.trim())}`);
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCard(code) {
|
||||||
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
|
const approve = h('button', { class: 'btn btn-primary', type: 'button', text: 'Approve' });
|
||||||
|
const refuse = h('button', { class: 'btn', type: 'button', text: 'Refuse' });
|
||||||
|
|
||||||
|
const decide = (approved) => async () => {
|
||||||
|
err.hidden = true;
|
||||||
|
approve.disabled = refuse.disabled = true;
|
||||||
|
try {
|
||||||
|
await (approved ? api.approveDevice(code) : api.denyDevice(code));
|
||||||
|
outcome = { code, approved };
|
||||||
|
render();
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
err.hidden = false;
|
||||||
|
approve.disabled = refuse.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
approve.addEventListener('click', decide(true));
|
||||||
|
refuse.addEventListener('click', decide(false));
|
||||||
|
|
||||||
|
return h('div', { class: 'card device-card' },
|
||||||
|
h('h2', { text: 'Sign in a terminal?' }),
|
||||||
|
h('p', {}, 'A terminal is asking to sign in as ', h('strong', { text: state.me.user.username }),
|
||||||
|
'. Check that this code matches the one it is showing:'),
|
||||||
|
h('p', { class: 'device-code', text: code }),
|
||||||
|
h('p', { class: 'muted small',
|
||||||
|
text: 'Only approve a sign-in you started yourself. Whoever is approved here acts as you.' }),
|
||||||
|
err,
|
||||||
|
h('div', { class: 'row-actions' }, approve, refuse));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decided(approved) {
|
||||||
|
return h('div', { class: 'card device-card' },
|
||||||
|
h('h2', { text: approved ? 'Approved' : 'Refused' }),
|
||||||
|
h('p', { class: 'muted', text: approved
|
||||||
|
? 'You can go back to your terminal. It signs in within a few seconds.'
|
||||||
|
: 'That terminal will not be signed in.' }),
|
||||||
|
h('a', { class: 'btn', href: '/', text: 'Go to the queue' }));
|
||||||
|
}
|
||||||
@@ -66,21 +66,22 @@ export function mondayOf(d) {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ISO 8601 week number: weeks start on Monday and week 1 is the one holding the
|
||||||
|
// year's first Thursday, which is what a rota that runs Monday to Sunday means
|
||||||
|
// by "week 40". Taken from the Thursday of d's week, whose year is the week's.
|
||||||
|
export function isoWeek(d) {
|
||||||
|
const thu = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
thu.setDate(thu.getDate() + 3 - ((thu.getDay() + 6) % 7));
|
||||||
|
const jan4 = new Date(thu.getFullYear(), 0, 4);
|
||||||
|
return 1 + Math.round(((thu - jan4) / 86400000 - 3 + ((jan4.getDay() + 6) % 7)) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
export function addDays(d, n) {
|
export function addDays(d, n) {
|
||||||
const r = new Date(d);
|
const r = new Date(d);
|
||||||
r.setDate(r.getDate() + n);
|
r.setDate(r.getDate() + n);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISO 8601 week number.
|
|
||||||
export function isoWeek(d) {
|
|
||||||
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
||||||
const day = t.getUTCDay() || 7;
|
|
||||||
t.setUTCDate(t.getUTCDate() + 4 - day);
|
|
||||||
const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1));
|
|
||||||
return Math.ceil(((t - yearStart) / DAY + 1) / 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const STATUS_LABEL = {
|
export const STATUS_LABEL = {
|
||||||
triggered: 'Triggered',
|
triggered: 'Triggered',
|
||||||
acknowledged: 'Acknowledged',
|
acknowledged: 'Acknowledged',
|
||||||
@@ -97,6 +98,14 @@ export function severityClass(sev) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A stable identity colour for a team, so the same team always reads the same
|
||||||
|
// colour without the server needing to store one. Teams have no colour field;
|
||||||
|
// this hashes the id into the six-colour rcN palette app.css already has for
|
||||||
|
// the rota's per-person chips (a team is not a status, so never severity).
|
||||||
|
export function teamColorClass(teamID) {
|
||||||
|
return `rc${(((teamID % 6) + 6) % 6) + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
// A one-line summary of the group labels, without the one the title already shows.
|
// A one-line summary of the group labels, without the one the title already shows.
|
||||||
export function labelSummary(labels, skip = 'alertname') {
|
export function labelSummary(labels, skip = 'alertname') {
|
||||||
return Object.entries(labels || {})
|
return Object.entries(labels || {})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const pane = () => document.getElementById('detail');
|
|||||||
let currentID = null;
|
let currentID = null;
|
||||||
let inc = null;
|
let inc = null;
|
||||||
let events = [];
|
let events = [];
|
||||||
|
let similarList = [];
|
||||||
let error = null;
|
let error = null;
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
|
||||||
@@ -38,10 +39,15 @@ export async function refresh() {
|
|||||||
const id = currentID;
|
const id = currentID;
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
try {
|
try {
|
||||||
const [i, t] = await Promise.all([api.incident(id), api.timeline(id)]);
|
// Similar incidents are a courtesy: an older server answers 404 and a
|
||||||
|
// failure here must not hide the incident itself.
|
||||||
|
const [i, t, sim] = await Promise.all([
|
||||||
|
api.incident(id), api.timeline(id), api.similar(id).catch(() => []),
|
||||||
|
]);
|
||||||
if (id !== currentID) return;
|
if (id !== currentID) return;
|
||||||
inc = i;
|
inc = i;
|
||||||
events = t;
|
events = t;
|
||||||
|
similarList = sim;
|
||||||
error = null;
|
error = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (id !== currentID) return;
|
if (id !== currentID) return;
|
||||||
@@ -60,6 +66,8 @@ function render() {
|
|||||||
h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back },
|
h('button', { class: 'btn btn-ghost btn-icon back', type: 'button', 'aria-label': 'Back to queue', onclick: back },
|
||||||
icon('back')),
|
icon('back')),
|
||||||
h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }),
|
h('span', { class: 'crumb', text: currentID != null ? `Incident #${currentID}` : '' }),
|
||||||
|
inc && h('button', { class: 'btn btn-ghost btn-icon copy', type: 'button', 'aria-label': 'Copy incident', title: 'Copy incident (y)', onclick: copyIncident },
|
||||||
|
icon('copy')),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!inc) {
|
if (!inc) {
|
||||||
@@ -81,6 +89,7 @@ function render() {
|
|||||||
facts(),
|
facts(),
|
||||||
groupLabels(),
|
groupLabels(),
|
||||||
alertsSection(),
|
alertsSection(),
|
||||||
|
similarSection(),
|
||||||
timelineSection(),
|
timelineSection(),
|
||||||
),
|
),
|
||||||
actionBar(),
|
actionBar(),
|
||||||
@@ -179,8 +188,9 @@ function alertItem(a) {
|
|||||||
|
|
||||||
// ---------- timeline ----------
|
// ---------- timeline ----------
|
||||||
|
|
||||||
function eventText(ev) {
|
// named spells users out instead of "you", for text that leaves this page.
|
||||||
const person = ev.user_id != null ? who(ev.user_id, ev.username) : null;
|
function eventText(ev, named = false) {
|
||||||
|
const person = ev.user_id != null ? (named ? ev.username || 'someone' : who(ev.user_id, ev.username)) : null;
|
||||||
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
|
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
|
||||||
const alertName = () => {
|
const alertName = () => {
|
||||||
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
|
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);
|
||||||
@@ -197,6 +207,7 @@ function eventText(ev) {
|
|||||||
case 'unsnoozed': return [strong(person), ' ended the snooze'];
|
case 'unsnoozed': return [strong(person), ' ended the snooze'];
|
||||||
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
|
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
|
||||||
case 'note': return [strong(person), ' added a note'];
|
case 'note': return [strong(person), ' added a note'];
|
||||||
|
case 'resolution_note': return [strong(person), ' noted what fixed it'];
|
||||||
case 'notified': {
|
case 'notified': {
|
||||||
const to = person ? strong(person) : 'the fallback topic';
|
const to = person ? strong(person) : 'the fallback topic';
|
||||||
if (ev.detail === 'reminder') return ['Reminder sent to ', to];
|
if (ev.detail === 'reminder') return ['Reminder sent to ', to];
|
||||||
@@ -209,6 +220,22 @@ function eventText(ev) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Earlier incidents with the same signature that someone left notes on, the
|
||||||
|
// ones that recorded what fixed it first. Plain notes are on that incident's
|
||||||
|
// own page.
|
||||||
|
function similarSection() {
|
||||||
|
if (!similarList.length) return null;
|
||||||
|
return h('section', { class: 'section' },
|
||||||
|
h('h2', { class: 'section-title' }, h('span', { text: 'Seen before' })),
|
||||||
|
h('div', { class: 'card' },
|
||||||
|
h('ul', { class: 'similar' }, similarList.map((s) => h('li', { class: 'similar-item' },
|
||||||
|
h('a', { href: `/incidents/${s.id}`, text: `#${s.id} ${s.title}` }),
|
||||||
|
h('div', { class: 'sub', text: `${when(s.resolved_at)} · ${ago(s.resolved_at)}${s.note_count ? ` · ${s.note_count} note${s.note_count === 1 ? '' : 's'}` : ''}` }),
|
||||||
|
...s.resolution_notes.map((n) => h('div', { class: 'note note-fix', text: n.detail || '' })),
|
||||||
|
)))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function timelineSection() {
|
function timelineSection() {
|
||||||
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
||||||
return h('section', { class: 'section' },
|
return h('section', { class: 'section' },
|
||||||
@@ -223,20 +250,116 @@ function timelineSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
|
||||||
|
|
||||||
function timelineItem(ev) {
|
function timelineItem(ev) {
|
||||||
const mine = ev.type === 'note' && ev.user_id === myID();
|
const mine = isNote(ev) && ev.user_id === myID();
|
||||||
return h('li', { class: `tl-item tl-${ev.type}` },
|
return h('li', { class: `tl-item tl-${ev.type}` },
|
||||||
h('span', { class: 'tl-dot' }),
|
h('span', { class: 'tl-dot' }),
|
||||||
h('div', { class: 'tl-body' },
|
h('div', { class: 'tl-body' },
|
||||||
h('div', { class: 'tl-text' }, eventText(ev)),
|
h('div', { class: 'tl-text' }, eventText(ev)),
|
||||||
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
|
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
|
||||||
ev.type === 'note' && h('div', { class: 'note', text: ev.detail || '' }),
|
isNote(ev) && h('div', { class: ev.type === 'resolution_note' ? 'note note-fix' : 'note', text: ev.detail || '' }),
|
||||||
mine && h('div', { class: 'note-actions' },
|
mine && h('div', { class: 'note-actions' },
|
||||||
h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => deleteNote(ev) }, icon('trash'), 'Delete')),
|
h('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => deleteNote(ev) }, icon('trash'), 'Delete')),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- copy ----------
|
||||||
|
|
||||||
|
const fence = (rows) => ['```', ...rows, '```'];
|
||||||
|
const pairs = (obj) => Object.entries(obj || {}).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`);
|
||||||
|
|
||||||
|
// incidentMarkdown is everything on this page as text that reads well in a chat
|
||||||
|
// or an agent prompt. Times are ISO 8601, since "3 min ago" means nothing once
|
||||||
|
// it has been pasted somewhere else.
|
||||||
|
function incidentMarkdown() {
|
||||||
|
const out = [`# Incident #${inc.id}: ${inc.title}`, ''];
|
||||||
|
const add = (k, v) => { if (v != null && v !== '') out.push(`- ${k}: ${v}`); };
|
||||||
|
add('Status', inc.status);
|
||||||
|
add('Severity', inc.severity);
|
||||||
|
add('Team', inc.team_name);
|
||||||
|
add('Assigned to', inc.assigned_to_id != null ? inc.assigned_to || 'someone' : 'unassigned');
|
||||||
|
add('Triggered', inc.triggered_at);
|
||||||
|
if (inc.acknowledged_at) add('Acknowledged', `${inc.acknowledged_at} by ${inc.acknowledged_by || 'someone'}`);
|
||||||
|
if (isOpen() && isFuture(inc.snoozed_until)) add('Snoozed until', inc.snoozed_until);
|
||||||
|
if (inc.escalation_level > 0) add('Escalation level', inc.escalation_level);
|
||||||
|
if (inc.resolved_at) add('Resolved', `${inc.resolved_at} (${inc.resolution_source === 'manual' ? 'manually' : 'all alerts stopped firing'})`);
|
||||||
|
if (inc.archived_at) add('Archived', inc.archived_at);
|
||||||
|
const group = pairs(inc.group_labels);
|
||||||
|
if (group.length) out.push('- Grouped by:', ...group.map((g) => ` - ${g}`));
|
||||||
|
|
||||||
|
const alerts = inc.alerts || [];
|
||||||
|
out.push('', `## Alerts (${alerts.length})`);
|
||||||
|
for (const a of alerts) {
|
||||||
|
out.push('', `### ${a.name} (${a.status})`);
|
||||||
|
out.push(`- Started: ${a.starts_at}`);
|
||||||
|
if (a.status === 'resolved' && a.ends_at) out.push(`- Ended: ${a.ends_at}`);
|
||||||
|
if (a.generator_url) out.push(`- Source: ${a.generator_url}`);
|
||||||
|
const labels = pairs(a.labels);
|
||||||
|
if (labels.length) out.push('', 'Labels:', ...fence(labels));
|
||||||
|
const annotations = Object.entries(a.annotations || {}).sort(([x], [y]) => x.localeCompare(y));
|
||||||
|
if (annotations.length) out.push('', 'Annotations:', ...fence(annotations.map(([k, v]) => `${k}: ${v}`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
||||||
|
if (sorted.length) {
|
||||||
|
out.push('', '## Timeline', '');
|
||||||
|
for (const ev of sorted) {
|
||||||
|
const text = eventText(ev, true).map((f) => (f instanceof Node ? f.textContent : f)).join('');
|
||||||
|
out.push(`- ${ev.created_at} ${text}`);
|
||||||
|
if (isNote(ev) && ev.detail) {
|
||||||
|
const label = ev.type === 'resolution_note' ? ' (what fixed it)' : '';
|
||||||
|
out.push(...(label ? [label] : []), ...ev.detail.split('\n').map((l) => ` > ${l}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (similarList.length) {
|
||||||
|
out.push('', '## Seen before', '', 'Earlier incidents with the same signature:');
|
||||||
|
for (const s of similarList) {
|
||||||
|
out.push(`- #${s.id} ${s.title} (resolved ${s.resolved_at})`);
|
||||||
|
for (const n of s.resolution_notes || []) {
|
||||||
|
out.push(' - What fixed it:', ...(n.detail || '').split('\n').map((l) => ` > ${l}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push('', `_Copied from Terminal Duty at ${new Date().toISOString()}_`, '');
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeClipboard falls back to execCommand: the async API needs a secure
|
||||||
|
// context, and this server is often reached over plain HTTP.
|
||||||
|
async function writeClipboard(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
const ta = h('textarea', { readonly: true, 'aria-hidden': 'true', class: 'clip-buffer' });
|
||||||
|
ta.value = text;
|
||||||
|
document.body.append(ta);
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
if (!document.execCommand('copy')) throw new Error('copy refused');
|
||||||
|
} finally {
|
||||||
|
ta.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyIncident() {
|
||||||
|
if (!inc) return;
|
||||||
|
try {
|
||||||
|
await writeClipboard(incidentMarkdown());
|
||||||
|
toast('Copied incident');
|
||||||
|
} catch {
|
||||||
|
toast('Could not copy', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- actions ----------
|
// ---------- actions ----------
|
||||||
|
|
||||||
const isOpen = () => inc.status !== 'resolved';
|
const isOpen = () => inc.status !== 'resolved';
|
||||||
@@ -296,15 +419,35 @@ function unacknowledge() {
|
|||||||
|
|
||||||
async function resolve() {
|
async function resolve() {
|
||||||
const id = inc.id;
|
const id = inc.id;
|
||||||
const ok = await confirm({
|
const res = await openSheet(() => {
|
||||||
title: 'Resolve this incident?',
|
const textarea = h('textarea', {
|
||||||
text: 'Resolving is final. If these alerts fire again they open a new incident, '
|
name: 'resolution', autofocus: true, maxlength: '10000',
|
||||||
+ 'and if any are still firing this one stays closed regardless. '
|
placeholder: 'What fixed it? Optional, shown on the next similar incident.',
|
||||||
+ 'Use snooze if you only need it out of the way.',
|
});
|
||||||
confirmLabel: 'Resolve',
|
const form = h('form', {
|
||||||
danger: true,
|
class: 'sheet-form',
|
||||||
|
onsubmit: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
closeSheet({ resolution: textarea.value.trim() });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h('h2', { class: 'sheet-title', text: 'Resolve this incident?' }),
|
||||||
|
h('p', {
|
||||||
|
text: 'Resolving is final. If these alerts fire again they open a new incident, '
|
||||||
|
+ 'and if any are still firing this one stays closed regardless. '
|
||||||
|
+ 'Use snooze if you only need it out of the way.',
|
||||||
|
}),
|
||||||
|
textarea,
|
||||||
|
h('div', { class: 'sheet-actions' },
|
||||||
|
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
|
||||||
|
h('button', { class: 'btn btn-danger', type: 'submit', text: 'Resolve' })),
|
||||||
|
);
|
||||||
|
textarea.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) form.requestSubmit();
|
||||||
|
});
|
||||||
|
return form;
|
||||||
});
|
});
|
||||||
if (ok) await run(() => api.resolve(id), 'Resolved');
|
if (res) await run(() => api.resolve(id, res.resolution), 'Resolved');
|
||||||
}
|
}
|
||||||
|
|
||||||
function archive() {
|
function archive() {
|
||||||
@@ -382,16 +525,18 @@ async function addNote() {
|
|||||||
const textarea = h('textarea', {
|
const textarea = h('textarea', {
|
||||||
name: 'content', required: true, autofocus: true, placeholder: 'What did you find? What did you do?', maxlength: '10000',
|
name: 'content', required: true, autofocus: true, placeholder: 'What did you find? What did you do?', maxlength: '10000',
|
||||||
});
|
});
|
||||||
|
const fix = h('input', { type: 'checkbox', name: 'fix' });
|
||||||
const form = h('form', {
|
const form = h('form', {
|
||||||
class: 'sheet-form',
|
class: 'sheet-form',
|
||||||
onsubmit: (e) => {
|
onsubmit: (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const v = textarea.value.trim();
|
const v = textarea.value.trim();
|
||||||
if (v) closeSheet(v);
|
if (v) closeSheet({ content: v, pinned: fix.checked });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
h('h2', { class: 'sheet-title', text: 'Add note' }),
|
h('h2', { class: 'sheet-title', text: 'Add note' }),
|
||||||
textarea,
|
textarea,
|
||||||
|
h('label', { class: 'check' }, fix, ' This is what fixed it (shown on similar incidents)'),
|
||||||
h('div', { class: 'sheet-actions' },
|
h('div', { class: 'sheet-actions' },
|
||||||
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
|
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }),
|
||||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save note' })),
|
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save note' })),
|
||||||
@@ -402,7 +547,7 @@ async function addNote() {
|
|||||||
});
|
});
|
||||||
return form;
|
return form;
|
||||||
});
|
});
|
||||||
if (content) await run(() => api.addNote(id, content), 'Note added');
|
if (content) await run(() => api.addNote(id, content.content, content.pinned), 'Note added');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteNote(ev) {
|
async function deleteNote(ev) {
|
||||||
@@ -422,10 +567,12 @@ async function moreMenu() {
|
|||||||
items.push(item('user', 'Assign…', assign));
|
items.push(item('user', 'Assign…', assign));
|
||||||
items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze));
|
items.push(isSnoozed() ? item('bell', 'End snooze', unsnooze) : item('clock', 'Snooze…', snooze));
|
||||||
items.push(item('note', 'Add note…', addNote));
|
items.push(item('note', 'Add note…', addNote));
|
||||||
|
items.push(item('copy', 'Copy incident', copyIncident));
|
||||||
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
|
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
|
||||||
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
|
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
|
||||||
} else {
|
} else {
|
||||||
items.push(item('note', 'Add note…', addNote));
|
items.push(item('note', 'Add note…', addNote));
|
||||||
|
items.push(item('copy', 'Copy incident', copyIncident));
|
||||||
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
|
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,6 +601,7 @@ export function key(e) {
|
|||||||
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
|
case 'z': if (isOpen() && !isSnoozed()) snooze(); return true;
|
||||||
case 'Z': if (isSnoozed()) unsnooze(); return true;
|
case 'Z': if (isSnoozed()) unsnooze(); return true;
|
||||||
case 'c': addNote(); return true;
|
case 'c': addNote(); return true;
|
||||||
|
case 'y': copyIncident(); return true;
|
||||||
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
|
case 'x': if (!isOpen()) (inc.archived_at ? unarchive() : archive()); return true;
|
||||||
default: return false;
|
default: return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||||
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
|
import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
|
||||||
import { state, myID } from './state.js';
|
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
|
||||||
import * as onboarding from './onboarding.js';
|
import * as onboarding from './onboarding.js';
|
||||||
import { navigate } from './app.js';
|
import { navigate } from './app.js';
|
||||||
|
|
||||||
@@ -27,34 +27,21 @@ const EMPTY = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onboarding.onRerender(() => renderList());
|
onboarding.onRerender(() => renderList());
|
||||||
|
// The queue used to keep its own team filter (a per-tab sessionStorage value,
|
||||||
|
// out of step with team.js's own picker); both now defer to the global
|
||||||
|
// selector's shared state, so re-render whenever it changes.
|
||||||
|
onTeamChange(() => {
|
||||||
|
renderChips();
|
||||||
|
refresh({ fresh: true });
|
||||||
|
});
|
||||||
|
|
||||||
let filter = loadFilter();
|
let filter = loadFilter();
|
||||||
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
|
|
||||||
let items = null; // null while loading
|
let items = null; // null while loading
|
||||||
let error = null;
|
let error = null;
|
||||||
let selected = null;
|
let selected = null;
|
||||||
let cursor = -1; // keyboard position in the list
|
let cursor = -1; // keyboard position in the list
|
||||||
let built = false;
|
let built = false;
|
||||||
|
|
||||||
function loadTeamFilter() {
|
|
||||||
try {
|
|
||||||
return sessionStorage.getItem('terdut.queue.team') || '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTeamFilter(id) {
|
|
||||||
teamFilter = id;
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem('terdut.queue.team', id);
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable */
|
|
||||||
}
|
|
||||||
renderChips();
|
|
||||||
refresh({ fresh: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFilter() {
|
function loadFilter() {
|
||||||
try {
|
try {
|
||||||
const f = sessionStorage.getItem('terdut.queue.filter');
|
const f = sessionStorage.getItem('terdut.queue.filter');
|
||||||
@@ -89,8 +76,8 @@ export async function refresh({ fresh = false } = {}) {
|
|||||||
// The open list is already fetched for the badges; no need to ask twice.
|
// The open list is already fetched for the badges; no need to ask twice.
|
||||||
// The cached open queue covers every team, so it can only be reused when
|
// The cached open queue covers every team, so it can only be reused when
|
||||||
// no team filter is applied.
|
// no team filter is applied.
|
||||||
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
|
const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query;
|
||||||
const cached = filter === 'open' && !fresh && !teamFilter;
|
const cached = filter === 'open' && !fresh && state.selectedTeamID == null;
|
||||||
const result = cached ? state.open : await api.incidents(query);
|
const result = cached ? state.open : await api.incidents(query);
|
||||||
await onboarding.load();
|
await onboarding.load();
|
||||||
if (requested !== filter) return;
|
if (requested !== filter) return;
|
||||||
@@ -136,19 +123,20 @@ function renderChips() {
|
|||||||
class: 'chip',
|
class: 'chip',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
role: 'tab',
|
role: 'tab',
|
||||||
'aria-selected': String(teamFilter === ''),
|
'aria-selected': String(state.selectedTeamID == null),
|
||||||
onclick: () => setTeamFilter(''),
|
onclick: () => setSelectedTeam(null),
|
||||||
text: 'All teams',
|
}, h('span', { class: 'team-dot' }), ' All teams'));
|
||||||
}));
|
|
||||||
for (const team of state.teams) {
|
for (const team of state.teams) {
|
||||||
chips.push(h('button', {
|
chips.push(h('button', {
|
||||||
class: 'chip',
|
class: 'chip',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
role: 'tab',
|
role: 'tab',
|
||||||
'aria-selected': String(teamFilter === String(team.id)),
|
'aria-selected': String(team.id === state.selectedTeamID),
|
||||||
onclick: () => setTeamFilter(String(team.id)),
|
onclick: () => setSelectedTeam(team.id),
|
||||||
text: team.name,
|
},
|
||||||
}));
|
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
|
||||||
|
' ' + team.name,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,58 @@ import * as api from './api.js';
|
|||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
me: null, // { user, has_password }
|
me: null, // { user, has_password }
|
||||||
|
// How the server can be signed in to. The defaults are an older server's
|
||||||
|
// answer: passwords, no single sign-on.
|
||||||
|
auth: { password_login: true, oidc: { enabled: false, name: '' } },
|
||||||
open: [], // the default queue: open, not snoozed
|
open: [], // the default queue: open, not snoozed
|
||||||
teams: [], // the teams the viewer belongs to, each with their role
|
teams: [], // the teams the viewer belongs to, each with their role
|
||||||
|
// Which team the whole app is scoped to right now; null means "All teams".
|
||||||
|
// Set only through setSelectedTeam below, never assigned directly, so every
|
||||||
|
// view stays in sync and the choice is remembered across reloads.
|
||||||
|
selectedTeamID: loadSelectedTeam(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// The team whose schedule and settings the views act on. A viewer in one team —
|
const SELECTED_TEAM_KEY = 'terdut.selectedTeam';
|
||||||
// which is everybody until somebody makes a second — never has to choose.
|
|
||||||
|
function loadSelectedTeam() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SELECTED_TEAM_KEY);
|
||||||
|
return raw ? Number(raw) : null;
|
||||||
|
} catch {
|
||||||
|
return null; // storage unavailable, or nothing saved yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callbacks to run whenever the selected team changes, so every view that
|
||||||
|
// cares — the queue's filter, the Team settings page, the selector's own
|
||||||
|
// trigger — stays in sync without a general event bus, following the one
|
||||||
|
// precedent for this in the codebase: onboarding.js's onRerender.
|
||||||
|
const teamListeners = [];
|
||||||
|
export function onTeamChange(cb) {
|
||||||
|
teamListeners.push(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSelectedTeam changes which team the app is scoped to (id, or null for
|
||||||
|
// "All teams"), persists it — a durable preference, unlike the per-tab
|
||||||
|
// sessionStorage filter this replaces — and tells every registered listener.
|
||||||
|
export function setSelectedTeam(id) {
|
||||||
|
state.selectedTeamID = id;
|
||||||
|
try {
|
||||||
|
if (id == null) localStorage.removeItem(SELECTED_TEAM_KEY);
|
||||||
|
else localStorage.setItem(SELECTED_TEAM_KEY, String(id));
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
for (const cb of teamListeners) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The team whose schedule and settings the views act on: the selected team,
|
||||||
|
// falling back to the first one the viewer belongs to — which is everybody's
|
||||||
|
// only team until somebody makes a second, or the stored selection naming a
|
||||||
|
// team the account has since left.
|
||||||
export function currentTeam() {
|
export function currentTeam() {
|
||||||
return state.teams[0] || null;
|
const teams = state.teams || [];
|
||||||
|
return teams.find((t) => t.id === state.selectedTeamID) || teams[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function myID() {
|
export function myID() {
|
||||||
@@ -33,6 +77,12 @@ export async function users() {
|
|||||||
|
|
||||||
export async function loadTeams() {
|
export async function loadTeams() {
|
||||||
state.teams = await api.teams();
|
state.teams = await api.teams();
|
||||||
|
// A stored id that no longer names one of the account's teams — left it, or
|
||||||
|
// this is simply a different account signed in on the same browser — is as
|
||||||
|
// good as unset.
|
||||||
|
if (state.selectedTeamID != null && !state.teams.some((t) => t.id === state.selectedTeamID)) {
|
||||||
|
state.selectedTeamID = null;
|
||||||
|
}
|
||||||
return state.teams;
|
return state.teams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+692
-191
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ const ICONS = {
|
|||||||
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
||||||
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
||||||
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
|
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
|
||||||
|
copy: ['rect:9,9,11,11,2', 'M5 15V6a2 2 0 0 1 2-2h9'],
|
||||||
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
||||||
chevronLeft: ['M15 18l-6-6 6-6'],
|
chevronLeft: ['M15 18l-6-6 6-6'],
|
||||||
chevronRight: ['M9 6l6 6-6 6'],
|
chevronRight: ['M9 6l6 6-6 6'],
|
||||||
@@ -169,6 +170,15 @@ export function badge(text, cls = '') {
|
|||||||
return h('span', { class: `badge ${cls}`, text });
|
return h('span', { class: `badge ${cls}`, text });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Access granted by the identity provider's groups. The server refuses to edit
|
||||||
|
// it by hand (it would be undone at the next sign-in), so the controls that
|
||||||
|
// would try are disabled with this as their reason.
|
||||||
|
export const SSO_MANAGED = 'Managed by single sign-on. Change the person’s groups in the identity provider.';
|
||||||
|
|
||||||
|
export function ssoBadge(text = 'SSO') {
|
||||||
|
return h('span', { class: 'row-team sso', title: SSO_MANAGED, text });
|
||||||
|
}
|
||||||
|
|
||||||
export function labelChip(k, v) {
|
export function labelChip(k, v) {
|
||||||
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
|
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user