Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f8499fa42 | |||
| ef731e85c5 | |||
| a4dd60f6b8 | |||
| b5573fbca2 | |||
| 0ee576f793 | |||
| b610b1817a | |||
| 949d6595ba | |||
| 33356ca978 | |||
| e5b4df7c03 | |||
| 5b4683febf | |||
| 97a4814c04 | |||
| a2dc9e3b03 |
@@ -16,6 +16,12 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -ldflags="-w -s -X main.version=${VERSION}" -o /terdut ./cmd/terdut
|
||||
|
||||
FROM scratch
|
||||
# scratch has no trust store, and a Go binary on it fails every HTTPS call with
|
||||
# "x509: certificate signed by unknown authority". Nothing needed one until single
|
||||
# sign-on: discovery and the token exchange are HTTPS calls to the identity provider.
|
||||
# The bundle is the builder's, copied by name so a missing file fails the build
|
||||
# rather than shipping an image that cannot sign anybody in.
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=builder /terdut /terdut
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/terdut"]
|
||||
|
||||
@@ -152,9 +152,14 @@ TERDUT_OIDC_CLIENT_ID=terdut
|
||||
TERDUT_OIDC_CLIENT_SECRET=...
|
||||
TERDUT_OIDC_ALLOWED_GROUPS=terdut-users,terdut-admins
|
||||
TERDUT_OIDC_ADMIN_GROUP=terdut-admins
|
||||
TERDUT_OIDC_GROUP_MAPPINGS='[{"group":"sre","team":"SRE","role":"member"},{"group":"sre-leads","team":"SRE","role":"owner"}]'
|
||||
```
|
||||
|
||||
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
|
||||
@@ -175,8 +180,8 @@ Account page does not offer to set a password nobody could use.
|
||||
2. *Whether.* With `TERDUT_OIDC_ALLOWED_GROUPS` set, somebody in none of them is
|
||||
refused and nothing is created.
|
||||
3. *What.* The administrator flag follows `TERDUT_OIDC_ADMIN_GROUP`. Team roles
|
||||
follow the mappings; where several groups grant the same team the highest role
|
||||
wins.
|
||||
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
|
||||
@@ -184,10 +189,18 @@ next sign-in after the group is gone, even if that leaves a team without an owne
|
||||
(an administrator can always repair a team) — the provider is the source of truth
|
||||
for what it grants, so the last-owner and last-administrator guards do not apply.
|
||||
Memberships and administrators added by hand are left alone; the exception is a
|
||||
hand-added member whose mapping grants a *higher* role, who is raised and from then
|
||||
on managed. Editing managed access by hand (`POST` or `DELETE` on a team's
|
||||
members, revoking an SSO-granted administrator) is refused with `409`, since the
|
||||
next sign-in would undo it.
|
||||
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
|
||||
@@ -321,6 +334,7 @@ over an administrator's edit.
|
||||
| `TERDUT_PUBLIC_URL` | — | Base URL a phone uses to reach this server: the notification's link into the web UI, its Acknowledge button, and whether the session cookie is `Secure` |
|
||||
| `TERDUT_NOTIFY_REPEAT` | `15m` | **seed.** How long an incident may sit unacknowledged before it is paged again. `0` notifies once and never repeats |
|
||||
| `TERDUT_PASSWORD_LOGIN` | `true` | `false` refuses password login and password sign-up (`403`), leaving single sign-on the only way in. Refused at startup unless SSO is configured |
|
||||
| `TERDUT_OPERATOR_MODE` | `false` | Declares this install gitops-managed: a session's or a user's own API key's writes to teams, escalation policies, dead man's switches and integrations are refused (`403 reason:"operator_managed"`); a [service account](#service-accounts)'s are not. Team membership and the schedule stay editable regardless |
|
||||
| `TERDUT_OIDC_ISSUER` | — | Turns single sign-on on. The provider's issuer URL; discovery is read from `<issuer>/.well-known/openid-configuration`. See [Single sign-on](#single-sign-on-oidc) |
|
||||
| `TERDUT_OIDC_CLIENT_ID` / `TERDUT_OIDC_CLIENT_SECRET` | — | **Required with an issuer.** The confidential client registered at the provider. Keep the secret in a Secret, not in values |
|
||||
| `TERDUT_OIDC_NAME` | `SSO` | What the sign-in button calls the provider |
|
||||
@@ -329,7 +343,6 @@ over an administrator's edit.
|
||||
| `TERDUT_OIDC_TRUST_EMAIL` | `false` | Link a first sign-in to an existing local user by email even if the provider does not mark the address verified |
|
||||
| `TERDUT_OIDC_ALLOWED_GROUPS` | — | Comma-separated. Only people in one of these may sign in. Empty admits everybody the provider authenticates |
|
||||
| `TERDUT_OIDC_ADMIN_GROUP` | — | Members are system administrators |
|
||||
| `TERDUT_OIDC_GROUP_MAPPINGS` | — | JSON list of `{"group","team","role"}` (`role` is `owner` or `member`). Teams that do not exist are created |
|
||||
| `TERDUT_OIDC_SESSION_MAX_AGE` | `12h` | Hard ceiling on a session made by an SSO sign-in |
|
||||
|
||||
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
|
||||
@@ -338,7 +351,7 @@ Note that `TERDUT_STALE_AFTER` and `TERDUT_DEADMAN_TIMEOUT` point in opposite di
|
||||
is a generous grace period around a `repeat_interval` you do not control; a dead man's switch is a
|
||||
deadline you set deliberately, and the heartbeat's route is configured to beat faster than it.
|
||||
|
||||
In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and `sweeper.archiveAfter`, dead man's switches via the `deadman.*` values, notifications via the `notify.*` values, and single sign-on via `oidc.*` and `passwordLogin`.
|
||||
In the Helm chart the two sweeper durations are set via `sweeper.staleAfter` and `sweeper.archiveAfter`, dead man's switches via the `deadman.*` values, notifications via the `notify.*` values, single sign-on via `oidc.*` and `passwordLogin`, and operator mode via `operatorMode`.
|
||||
|
||||
---
|
||||
|
||||
@@ -693,8 +706,8 @@ of the last heartbeat, and the heartbeat's labels are on the incident's
|
||||
|
||||
All endpoints except `/api/bootstrap`, `/api/integrations/{key}/alertmanager`,
|
||||
`/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:
|
||||
`/api/version`, `/api/oidc/login`, `/api/oidc/callback`, `/api/oidc/device` and
|
||||
`/api/oidc/device/token` require either an API key:
|
||||
|
||||
```
|
||||
Authorization: Bearer <api-key>
|
||||
@@ -709,6 +722,13 @@ granting the flag itself. Everybody else works incidents — acknowledging,
|
||||
assigning, snoozing, resolving, noting — and manages their own account and
|
||||
nobody else's. An API key carries exactly the rights of the user it belongs to.
|
||||
|
||||
A third principal, the **service account**, exists for automation (a
|
||||
Kubernetes operator, most likely) that needs to manage teams, escalation
|
||||
policies, dead man's switches and integrations without impersonating a human.
|
||||
It is not a user — it never signs in, never appears in a team's member list,
|
||||
and never holds the administrator flag — and its key is prefixed `tdsa_` so it
|
||||
reads as one at a glance in a log line. See [Service accounts](#service-accounts).
|
||||
|
||||
**Getting an account.** The first one comes from `/api/bootstrap`. After that
|
||||
it depends on `signup_mode`, an administrator setting:
|
||||
|
||||
@@ -752,9 +772,21 @@ the shape of a team, not about reading other people's incidents.
|
||||
Anything belonging to a team you are not in answers `404`, not `403`: whether an
|
||||
incident exists is itself something only its team should learn.
|
||||
|
||||
**Operator mode** (`TERDUT_OPERATOR_MODE`, see [Configuration](#configuration))
|
||||
declares this install gitops-managed. When it is on, a session or a user's own
|
||||
API key gets `403 {"error": "...", "reason": "operator_managed"}` on every
|
||||
write this README marks **owner**-gated under Teams below (creating, renaming
|
||||
or deleting a team; its OIDC group binding; its escalation ladder; its dead
|
||||
man's switches; its integrations) — a service account's writes are unaffected.
|
||||
Team membership and invites are deliberately excluded: they are never
|
||||
gitops-managed, in operator mode or out of it. `GET /api/auth/config` reports
|
||||
`operator_mode` so a client can grey those sections out before a write is ever
|
||||
attempted.
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/auth/config` | How to sign in: `{"password_login", "oidc": {"enabled","name"}, "device_login"}`. No session needed |
|
||||
| `GET` | `/api/auth/config` | How to sign in: `{"password_login", "oidc": {"enabled","name"}, "device_login", "operator_mode"}`. No session needed |
|
||||
| `GET` | `/api/version` | `{"version"}` — this build's version string. No session needed, the same as `/healthz` |
|
||||
| `POST` | `/api/login` | `{"username","password"}` → sets the session cookie, returns `{user, has_password}`. `429` after too many failures; `403` when `TERDUT_PASSWORD_LOGIN=false` |
|
||||
| `GET` | `/api/oidc/login` | Starts a single sign-on sign-in: redirects the browser to the provider. `?next=/path` is where to land afterwards; only a path on this server is honoured. Only exists when SSO is configured |
|
||||
| `POST` | `/api/oidc/device` | Starts a device login: returns `{device_code, user_code, verification_url, interval, expires_in}`. Only exists when SSO is configured |
|
||||
@@ -798,6 +830,41 @@ on anybody's.
|
||||
| `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials |
|
||||
| `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`, or `{"signup_mode": "open"\|"invite_only"}`. `400` for an unknown key or a value outside its bounds |
|
||||
|
||||
### Service accounts
|
||||
|
||||
A service account is a scoped, non-human credential for automation — not a
|
||||
`users` row, so it never signs in, is never a team member, and never carries
|
||||
the administrator flag. Two scopes:
|
||||
|
||||
- **instance** — the same reach system administration has over teams: create
|
||||
one, and mint a **team**-scoped account against any of them. There is no
|
||||
cap on how many instance-scoped accounts exist, but ordinarily there is one,
|
||||
belonging to whatever is provisioning this install end to end.
|
||||
- **team** — owner-equivalent for that one team, and nothing else: every
|
||||
**owner**-gated endpoint under [Teams](#teams), membership and invites
|
||||
included. Nothing narrower is enforced server-side; what actually keeps
|
||||
membership out of automation's hands is that no operator built against this
|
||||
scope should ever call those two endpoints — see
|
||||
[operator mode](#authentication) and `SERVICE-ACCOUNTS.md`'s note on this.
|
||||
|
||||
A key is shown once, at creation or rotation, and only its hash is stored —
|
||||
the same handling as a user's API key. Losing it means minting a new one;
|
||||
there is no way to recover a raw key from the server.
|
||||
|
||||
| Method | Path | Who | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/service-accounts` | **admin** | Every service account. Pass `?name=` instead to look one up by its exact name — open to **any** authenticated caller (human or service account), since it returns no key material and is how an account finds its own id |
|
||||
| `POST` | `/api/service-accounts` | owner\* | Create one and mint its first key `{"name","scope","team_id"?}` (`team_id` required for `scope:"team"`, absent for `scope:"instance"`). Returns `{"service_account", "key"}` — `key.key` shown once |
|
||||
| `POST` | `/api/service-accounts/{id}/keys` | owner\* | Mint an additional key `{"name"}` — rotation without recreating the account. Shown once |
|
||||
| `DELETE` | `/api/service-accounts/{id}/keys/{keyID}` | owner\* | Revoke one key |
|
||||
|
||||
\* For an **instance**-scoped account: a system administrator only. For a
|
||||
**team**-scoped account: a system administrator, that team's own human owner,
|
||||
an instance-scoped service account (minting a narrower credential for a team
|
||||
it just created), or — for the two key endpoints only — the account rotating
|
||||
or revoking its own key, which is not a privilege escalation, the same
|
||||
reasoning a user's own API keys rest on.
|
||||
|
||||
### Alert ingestion
|
||||
|
||||
Alerts arrive on a team's integration key. The key is both the credential and the
|
||||
@@ -815,20 +882,26 @@ and was removed in v0.13.0 once senders had moved onto keys.
|
||||
|
||||
### Teams
|
||||
|
||||
**owner** below means an owner of that team *or* a system administrator, who
|
||||
passes every one of these without being a member — see
|
||||
[Authentication](#authentication). **member** means membership and nothing else: an
|
||||
administrator who is not in the team gets the same `404` as anybody else.
|
||||
**owner** below means an owner of that team, a system administrator (who
|
||||
passes every one of these without being a member), or that team's own
|
||||
team-scoped [service account](#service-accounts) — including membership and
|
||||
invites, technically, though no automation this scope was designed for
|
||||
(a Kubernetes operator's CRDs, see `SERVICE-ACCOUNTS.md`) ever models team
|
||||
membership or would call those two. See [Authentication](#authentication).
|
||||
**member** means membership and nothing else: an administrator who is not in
|
||||
the team gets the same `404` as anybody else.
|
||||
|
||||
| Method | Path | Who | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/teams` | any | The caller's own teams, each with their role |
|
||||
| `POST` | `/api/teams` | any | Create a team `{"name"}`; the creator becomes its first owner |
|
||||
| `POST` | `/api/teams` | any | Create a team `{"name"}`; a human creator becomes its first owner. An instance-scoped [service account](#service-accounts) may also create one, and it gets no owner at all — expected for a team an operator is about to hand a team-scoped credential to, not an orphaned team a human made |
|
||||
| `PUT` | `/api/teams/{teamID}` | **owner** | Rename it `{"name"}`. `409` if the name is taken |
|
||||
| `DELETE` | `/api/teams/{teamID}` | **owner** | Delete a team and everything under it. `409` while it has open incidents |
|
||||
| `GET` | `/api/teams/{teamID}/members` | member | Who is in the team, with `status` (`oncall` if the rota has them today, `unpageable` when a page to them would go nowhere — even if they are on call — else `reachable`), `on_call`, `next_shift` (first rota day after today), `pageable` and `problem` (`has no ntfy topic` / `account is disabled`; never the topic itself) and `last_active_at` (their newest session or API-key use). Every member sees the same list |
|
||||
| `POST` | `/api/teams/{teamID}/members` | **owner** | Add a member, or change their role `{"user_id","role"}`. `409` when it would demote the last owner, or the membership is managed by single sign-on |
|
||||
| `DELETE` | `/api/teams/{teamID}/members/{userID}` | **owner** | Remove a member. `409` for the last owner, or a membership managed by single sign-on |
|
||||
| `GET` | `/api/teams/{teamID}/oidc-groups` | member | Which groups control this team's membership: `{"member_group","owner_group"}`. An empty string means no group grants that role here |
|
||||
| `PUT` | `/api/teams/{teamID}/oidc-groups` | **owner** | Set them. An empty string clears a binding |
|
||||
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys. Each carries `status` (`active` if its key posted within 24h, `quiet` if it has but not lately, `never`), `last_used_at` (last webhook, usable or not), `last_alert_at` (when an alert last arrived on it) and `alerts_24h` (distinct alerts it refreshed in the last day). Alerts delivered before the source was recorded (migration 010) have none, so the last two fill in as Alertmanager re-sends them |
|
||||
| `PATCH` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Rename `{"name"}`. The key does not change |
|
||||
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
|
||||
@@ -840,6 +913,7 @@ administrator who is not in the team gets the same `404` as anybody else.
|
||||
| `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
|
||||
| `GET` | `/api/teams/{teamID}/deadman/switches` | member | The team's [dead man's switches](#dead-mans-switch), each `{id, name, matcher, timeout_seconds, severity, status, last_heartbeat_at, last_triggered_at, open_incident_id, sources[]}`. `status` is `healthy`, `dead` or `dormant`; `sources` has one entry per heartbeat fingerprint. Empty when the team watches nothing |
|
||||
| `POST` | `/api/teams/{teamID}/deadman/switches` | **owner** | Add one: `{name?, matcher, timeout_seconds, severity?}`. `400` when the matcher names no `alertname` or holds several, or the timeout is not positive — a switch that silently watches nothing is the failure this feature exists to prevent |
|
||||
| `PUT` | `/api/teams/{teamID}/deadman/switches/{switchID}` | **owner** | Replace one in place, same body and validation as create. Its id is unchanged — for an automated caller reconciling a spec change, unlike delete-and-recreate |
|
||||
| `DELETE` | `/api/teams/{teamID}/deadman/switches/{switchID}` | **owner** | Stop watching. An incident it opened stays open. `404` for a switch of another team |
|
||||
|
||||
### Notifications
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# Service accounts: a scoped, non-human credential type
|
||||
|
||||
This is a design note for a feature, not an implementation plan — it exists to
|
||||
propose the shape before writing code. It's raised directly by `terdut-operator`
|
||||
(a separate repo, no shared code — see its `DESIGN.md` §6, §9, §13), which needs
|
||||
a credential for unattended, repeatable API access and currently has no good one
|
||||
available. Anything automating terdut-server long-term (this operator, CI, future
|
||||
integrations) hits the same gap, so this is written as a general primitive, not
|
||||
operator-specific.
|
||||
|
||||
## The problem
|
||||
|
||||
terdut-server has two credential types today, and neither fits "an unattended
|
||||
process that manages teams/schedules/policies on someone's behalf":
|
||||
|
||||
- **User API keys** (`api_keys`, `internal/api/users.go`) are always tied to a
|
||||
real `users` row and carry that user's full rights — every team they're a
|
||||
member of, their admin flag if set. There's no `kind`/`service` marker
|
||||
distinguishing "a human's personal automation key" from "a login session," and
|
||||
no way to mint one scoped to less than the full user.
|
||||
- **Integration keys** (`integrations`, `internal/api/*teams*.go`) are team-scoped,
|
||||
but narrowly: they authenticate exactly one inbound Alertmanager webhook call
|
||||
(`POST /api/integrations/{key}/alertmanager`) and nothing else. They're not a
|
||||
general management-API credential and shouldn't become one — overloading a
|
||||
narrow, one-way ingestion credential with broad read/write access would weaken
|
||||
the one property that makes it safe to embed in an Alertmanager config today.
|
||||
|
||||
The result: any automation that needs to create teams, set escalation policies,
|
||||
manage dead-man switches, or rotate integration keys has to hold a real human
|
||||
admin's or team owner's API key. That key is exactly as powerful as that person
|
||||
logging in — full team access, and full instance access if they're an admin.
|
||||
`terdut-operator`'s design ran directly into this (its DESIGN.md §6): its
|
||||
described bootstrap/rotation flow assumed a repeatable, identity-scoped way to
|
||||
get a credential, and `/api/bootstrap`'s actual behavior (single-shot per
|
||||
install, gated on `COUNT(*) FROM users`, confirmed via `internal/api/users.go`
|
||||
and `charts/terdut-server/templates/bootstrap-job.yaml`) doesn't provide one —
|
||||
it mints exactly one founding admin, once, ever.
|
||||
|
||||
## Goals
|
||||
|
||||
- A credential type that isn't a human: doesn't touch OIDC group sync, login,
|
||||
session, or the `is_admin`/account-management semantics that come with a real
|
||||
`users` row.
|
||||
- Two scopes matching the two shapes automation actually needs: instance-wide
|
||||
(create/list teams — what a server-owning controller needs) and team-scoped
|
||||
(manage one team's escalation policy, dead-man switches, integrations,
|
||||
schedule, OIDC group bindings — what a per-team controller or integration
|
||||
needs).
|
||||
- Repeatable issuance and rotation — unlike `/api/bootstrap`, callable more than
|
||||
once, by anything that already holds admin rights, without destroying and
|
||||
recreating state to get a fresh credential.
|
||||
- Visibly distinct from a human in every place identity shows up (audit trails,
|
||||
timeline entries, UI attribution) — a service account acting on a team should
|
||||
never be indistinguishable from a person.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Not a general OAuth2/OIDC client-credentials flow — this is a bearer-token
|
||||
primitive matching the shape `api_keys` already uses (SHA-256 hash stored,
|
||||
raw key shown once at creation), not a new auth protocol.
|
||||
- Not replacing integration keys — those stay as the narrow, one-way webhook
|
||||
credential they are today.
|
||||
- Not modeling per-endpoint or per-verb permissions within a scope — `instance`
|
||||
and `team` are the only two scopes for now; finer-grained scoping is future
|
||||
work if a real need shows up.
|
||||
|
||||
## Proposed shape
|
||||
|
||||
### Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE service_accounts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE, -- e.g. "terdut-operator"
|
||||
scope TEXT NOT NULL CHECK (scope IN ('instance', 'team')),
|
||||
team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
-- team_id required iff scope = 'team'; NULL iff scope = 'instance'
|
||||
created_by BIGINT REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE service_account_keys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
service_account_id BIGINT NOT NULL REFERENCES service_accounts(id) ON DELETE CASCADE,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL, -- e.g. "initial", "2026-Q4-rotation"
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
```
|
||||
|
||||
Deliberately not a `users` row: no `password_hash`, no `is_admin`, no
|
||||
`user_identities` linkage, so it's structurally impossible for a service account
|
||||
to be pulled into OIDC group sync or password login. Multiple keys per account
|
||||
(mirroring `api_keys`' existing one-user-many-keys shape) so rotation is "mint a
|
||||
new key, revoke the old one," not "recreate the account."
|
||||
|
||||
### Endpoints
|
||||
|
||||
- `POST /api/service-accounts` — instance-scope/admin-only. Body:
|
||||
`{"name": ..., "scope": "instance"|"team", "teamID": ... }` (teamID required
|
||||
iff scope=team, and caller must be that team's owner or a system admin).
|
||||
Returns the account plus its first raw key (shown once, same pattern as
|
||||
`POST /api/users/{id}/api-keys`). Safe to call again with the same `name` —
|
||||
see "idempotent lookup" below — unlike `/api/bootstrap`, which is inherently
|
||||
one-shot by design (it's answering "does any user exist yet," a question with
|
||||
no analogue once one already does).
|
||||
- `POST /api/service-accounts/{id}/keys` — mint an additional key on an existing
|
||||
account (self-service-equivalent: instance admin for `instance` scope, team
|
||||
owner or system admin for `team` scope). Enables rotation without recreating
|
||||
the account or losing its identity/audit history.
|
||||
- `DELETE /api/service-accounts/{id}/keys/{keyID}` — revoke one key, mirroring
|
||||
`DELETE /api/users/{id}/api-keys/{keyID}`.
|
||||
- `GET /api/service-accounts?name=` — look up an existing account by name.
|
||||
This is what turns "I tried to create my account and got a conflict" into a
|
||||
normal flow instead of an error: a controller that expects to have already
|
||||
registered itself calls this first, and only falls through to `POST` if
|
||||
nothing comes back.
|
||||
|
||||
### Auth middleware
|
||||
|
||||
`internal/api/middleware.go`'s existing dual resolution (`Authorization: Bearer`
|
||||
→ `apiKeyUser()`, or session cookie → `sessionUser()`, both landing on the same
|
||||
`models.User` + team-membership context) gains a third path: a bearer token that
|
||||
hashes to a `service_account_keys.key_hash` resolves to a distinct principal
|
||||
type, not a synthesized `models.User`. `requireTeamMember`/`requireTeamOwner`
|
||||
treat a matching team-scoped service account as owner-equivalent for that one
|
||||
team (satisfies the same checks a real team owner would), and an instance-scoped
|
||||
one as satisfying `AdminOnly` for team-creation/listing purposes **and** for
|
||||
minting a `team`-scoped service account against any team (`POST
|
||||
/api/service-accounts {"scope":"team","teamID":...}`) — this second permission
|
||||
is what lets an operator-style caller create a team, then immediately mint that
|
||||
team its own narrower credential, without a human in the loop for every team.
|
||||
Neither permission extends to user-management endpoints (`POST /api/users`,
|
||||
`PUT /api/users/{id}/admin`, etc.), which stay human-admin-only. Anywhere
|
||||
identity is recorded for a human (incident timeline
|
||||
`acknowledged_by`/`assigned_to`, audit-relevant fields), a service-account
|
||||
principal is stored and displayed distinctly, e.g. `service-account:terdut-operator`,
|
||||
never coerced into a `user_id` FK.
|
||||
|
||||
**Team scope, as implemented, is owner-equivalent for every `requireTeamOwner`
|
||||
endpoint, membership and invites included — nothing server-side carves those
|
||||
two out.** That's broader than what `terdut-operator`'s CRDs actually need
|
||||
(escalation/deadman/integrations/OIDC-bindings only; membership is explicitly
|
||||
never gitops-managed, see its DESIGN.md §4.2), a gap acknowledged rather than
|
||||
closed here: narrowing this to exclude
|
||||
`POST/DELETE /api/teams/{teamID}/members*` and
|
||||
`.../invites*` specifically for a service-account caller is a small, isolated
|
||||
follow-up (special-case those handlers rather than `requireTeamOwner` itself,
|
||||
which every other owner-gated endpoint still wants shared). Until then, what
|
||||
actually keeps membership out of automation's hands is that no operator built
|
||||
against this scope should ever call those two endpoints — not a server-side
|
||||
refusal.
|
||||
|
||||
## What this unblocks
|
||||
|
||||
Directly resolves `terdut-operator` DESIGN.md §6's two broken assumptions:
|
||||
1. **Bootstrap becomes single-purpose again.** `/api/bootstrap` mints exactly
|
||||
the founding human admin, once. The operator's actual first-reconcile flow:
|
||||
call `/api/bootstrap` only on a genuinely empty install; otherwise (or
|
||||
immediately after, if it won the bootstrap race) call
|
||||
`GET /api/service-accounts?name=terdut-operator`, and `POST` one if it
|
||||
doesn't exist yet. From then on the operator never touches `/api/bootstrap`
|
||||
again.
|
||||
2. **Rotation becomes real.** `POST /api/service-accounts/{id}/keys` + revoke the
|
||||
old one — no destructive DB-level workaround, no re-triggering a single-shot
|
||||
endpoint that can't fire twice.
|
||||
3. **Cross-namespace credential mirroring is no longer needed at all.**
|
||||
`terdut-operator`'s current design holds every credential — instance- and
|
||||
team-scoped alike — privately in the operator's own namespace, never in
|
||||
the namespace of the CR each one authenticates for; reconciliation happens
|
||||
entirely inside the operator's controller loop, so no CR owner ever needs
|
||||
read access to a terdut-server credential regardless of same- or
|
||||
cross-namespace `serverRef`. Team scoping is still what bounds the blast
|
||||
radius of any individual credential: a leaked team-scoped key exposes
|
||||
exactly one team's resources, never the whole server, which is what makes
|
||||
holding many credentials in one place (the operator's namespace) an
|
||||
acceptable trade rather than reintroducing the mirrored design's
|
||||
server-admin-equivalent-everywhere problem.
|
||||
|
||||
## Suggested sequencing
|
||||
|
||||
Land this before `terdut-operator` implements any bootstrap/credential-handling
|
||||
code — that code would otherwise be written against the current one-shot,
|
||||
user-only credential model as a known-temporary workaround, which is wasted
|
||||
effort on a repo that currently has zero implementation to begin with.
|
||||
@@ -15,5 +15,5 @@ type: application
|
||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||
# metadata and drives nothing.
|
||||
version: 0.29.0
|
||||
appVersion: "v0.29.0"
|
||||
version: 0.33.0
|
||||
appVersion: "v0.33.0"
|
||||
|
||||
@@ -75,6 +75,8 @@ spec:
|
||||
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
|
||||
- name: TERDUT_PASSWORD_LOGIN
|
||||
value: {{ .Values.passwordLogin | quote }}
|
||||
- name: TERDUT_OPERATOR_MODE
|
||||
value: {{ .Values.operatorMode | quote }}
|
||||
{{- if .Values.oidc.enabled }}
|
||||
- name: TERDUT_OIDC_ISSUER
|
||||
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
|
||||
@@ -107,10 +109,6 @@ spec:
|
||||
- name: TERDUT_OIDC_ADMIN_GROUP
|
||||
value: {{ .Values.oidc.adminGroup | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.oidc.groupMappings }}
|
||||
- name: TERDUT_OIDC_GROUP_MAPPINGS
|
||||
value: {{ .Values.oidc.groupMappings | toJson | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -112,6 +112,14 @@ notify:
|
||||
# redeploy) if the identity provider is down and somebody has to get in.
|
||||
passwordLogin: true
|
||||
|
||||
# Declares this install gitops-managed: writes to teams, escalation policies,
|
||||
# dead man's switches and integrations from a session or a user's own API key
|
||||
# are refused, while a service account's (see SERVICE-ACCOUNTS.md) are not.
|
||||
# Off by default — turning it on is a statement that something like
|
||||
# terdut-operator, not a person in the web UI, owns this install's
|
||||
# configuration from here on.
|
||||
operatorMode: false
|
||||
|
||||
# Single sign-on through an OpenID Connect provider such as Authentik.
|
||||
#
|
||||
# At the provider, create an OAuth2/OpenID application whose redirect URI is
|
||||
@@ -148,12 +156,10 @@ oidc:
|
||||
allowedGroups: []
|
||||
# Members of this group are system administrators.
|
||||
adminGroup: ""
|
||||
# Team roles from groups. A team that does not exist is created. Where several
|
||||
# groups grant the same team the highest role wins.
|
||||
# - group: sre
|
||||
# team: SRE
|
||||
# role: member # member or owner
|
||||
groupMappings: []
|
||||
# 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
|
||||
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ func main() {
|
||||
log.Fatalf("seed settings: %v", err)
|
||||
}
|
||||
|
||||
router := api.NewRouter(database, notify, cfg)
|
||||
router := api.NewRouter(database, notify, cfg, version)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
|
||||
@@ -60,7 +60,7 @@ func newTSWith(t *testing.T, deadman api.DeadmanConfig, cfg api.NotifyConfig, co
|
||||
t.Helper()
|
||||
|
||||
database := newTestDB(t)
|
||||
srv := httptest.NewServer(api.NewRouter(database, cfg, conf))
|
||||
srv := httptest.NewServer(api.NewRouter(database, cfg, conf, "test"))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
|
||||
|
||||
@@ -301,7 +301,7 @@ func TestSetPassword_EndsOtherSessionsButNotThisOne(t *testing.T) {
|
||||
|
||||
func TestBootstrap_WithPassword(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig()))
|
||||
srv := httptest.NewServer(api.NewRouter(database, api.NotifyConfig{}, testConfig(), "test"))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
body := `{"username":"admin","email":"a@test.com","password":"` + adminPassword + `"}`
|
||||
|
||||
+122
-7
@@ -9,15 +9,17 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
ctxUser contextKey = "user"
|
||||
ctxSession contextKey = "session"
|
||||
ctxTeams contextKey = "teams"
|
||||
ctxUser contextKey = "user"
|
||||
ctxSession contextKey = "session"
|
||||
ctxTeams contextKey = "teams"
|
||||
ctxServiceAccount contextKey = "service_account"
|
||||
)
|
||||
|
||||
// AuthMiddleware accepts either of the two credentials the server issues: an
|
||||
@@ -39,12 +41,19 @@ func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
|
||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||
return
|
||||
}
|
||||
userID, ok := apiKeyUser(r.Context(), db, token)
|
||||
if !ok {
|
||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||
if userID, ok := apiKeyUser(r.Context(), db, token); ok {
|
||||
serveAs(w, r, next, db, userID, 0)
|
||||
return
|
||||
}
|
||||
serveAs(w, r, next, db, userID, 0)
|
||||
// Tried second, not first: a user API key is the common case,
|
||||
// and a service-account key is visibly prefixed (tdsa_) so this
|
||||
// second lookup is rarely reached on a request that was going
|
||||
// to fail anyway.
|
||||
if sa, ok := serviceAccountFor(r.Context(), db, token); ok {
|
||||
serveAsServiceAccount(w, r, next, sa)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -188,6 +197,112 @@ func userFromContext(ctx context.Context) (models.User, bool) {
|
||||
return u, ok
|
||||
}
|
||||
|
||||
// serviceAccountPrincipal is a service account as resolved from its key:
|
||||
// enough to authorize requests, never the key itself.
|
||||
type serviceAccountPrincipal struct {
|
||||
id int64
|
||||
name string
|
||||
scope string
|
||||
teamID int64 // meaningless (zero) for instance scope
|
||||
}
|
||||
|
||||
// serviceAccountFor resolves a service-account key to its account and stamps
|
||||
// its last use, the same shape apiKeyUser has for a user's own key.
|
||||
func serviceAccountFor(ctx context.Context, db *sql.DB, token string) (serviceAccountPrincipal, bool) {
|
||||
var sa serviceAccountPrincipal
|
||||
var keyID int64
|
||||
var teamID sql.NullInt64
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT k.id, a.id, a.name, a.scope, a.team_id
|
||||
FROM service_account_keys k
|
||||
JOIN service_accounts a ON a.id = k.service_account_id
|
||||
WHERE k.key_hash = $1`, hashToken(token),
|
||||
).Scan(&keyID, &sa.id, &sa.name, &sa.scope, &teamID)
|
||||
if err != nil {
|
||||
return serviceAccountPrincipal{}, false
|
||||
}
|
||||
if teamID.Valid {
|
||||
sa.teamID = teamID.Int64
|
||||
}
|
||||
|
||||
// best-effort; don't fail the request if this update fails
|
||||
db.ExecContext(ctx,
|
||||
"UPDATE service_account_keys SET last_used_at = $1 WHERE id = $2",
|
||||
time.Now().Unix(), keyID)
|
||||
return sa, true
|
||||
}
|
||||
|
||||
// serveAsServiceAccount hands the request on with a service account's
|
||||
// identity in context. A team-scoped account gets a single synthetic
|
||||
// membership — owner of its own team, nothing else — which is what makes it
|
||||
// satisfy requireTeamMember/requireTeamOwner exactly as a real owner would,
|
||||
// without teaching either function about a second kind of caller. An
|
||||
// instance-scoped account gets no memberships at all: it acts on teams by id,
|
||||
// not by belonging to one.
|
||||
//
|
||||
// No CSRF check, for the same reason an API key needs none: a service-account
|
||||
// key is only ever set by the client that holds it, never attached by a
|
||||
// browser to a request another site makes.
|
||||
func serveAsServiceAccount(w http.ResponseWriter, r *http.Request, next http.Handler, sa serviceAccountPrincipal) {
|
||||
ctx := r.Context()
|
||||
if sa.scope == models.ServiceAccountScopeTeam {
|
||||
ctx = context.WithValue(ctx, ctxTeams, []membership{{teamID: sa.teamID, role: models.RoleOwner}})
|
||||
}
|
||||
ctx = context.WithValue(ctx, ctxServiceAccount, sa)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
func serviceAccountFromContext(ctx context.Context) (serviceAccountPrincipal, bool) {
|
||||
sa, ok := ctx.Value(ctxServiceAccount).(serviceAccountPrincipal)
|
||||
return sa, ok
|
||||
}
|
||||
|
||||
// isInstanceServiceAccount reports whether the caller is an instance-scoped
|
||||
// service account — the one identity allowed to create a team and mint a
|
||||
// team-scoped account against any of them, the two things system
|
||||
// administration can already do that this extends to automation.
|
||||
func isInstanceServiceAccount(ctx context.Context) bool {
|
||||
sa, ok := serviceAccountFromContext(ctx)
|
||||
return ok && sa.scope == models.ServiceAccountScopeInstance
|
||||
}
|
||||
|
||||
// operatorReason marks a write that operator mode refused as such, distinct
|
||||
// from every other 403 this server returns, so a client — the web UI or
|
||||
// terdut-tui — can tell "you may not" from "this is managed elsewhere" and
|
||||
// show the right message instead of a bare "forbidden".
|
||||
const operatorReason = "operator_managed"
|
||||
|
||||
// OperatorModeBlock refuses a human write (session or a user's own API key)
|
||||
// on a route it wraps, while letting a service account through. That is the
|
||||
// whole point of operator mode: automation holding a service-account key
|
||||
// (terdut-operator, most likely) keeps reconciling these resources, and a
|
||||
// person in the web UI or terdut-tui gets a clear "edit this through your
|
||||
// GitOps source instead" rather than a write that the next resync would only
|
||||
// undo.
|
||||
//
|
||||
// Checked after AuthMiddleware, the same way AdminOnly is: by the time a
|
||||
// request reaches here the caller is already known to be a service account
|
||||
// or not. A router that never enables operator mode pays nothing for this —
|
||||
// it hands back next unchanged rather than wrapping it in a check that would
|
||||
// always pass.
|
||||
func OperatorModeBlock(cfg config.Config) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
if !cfg.OperatorMode {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := serviceAccountFromContext(r.Context()); ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusForbidden, map[string]string{
|
||||
"error": "this server is in operator mode; edit this through your GitOps source instead of the web UI or API",
|
||||
"reason": operatorReason,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// membership is the caller's role in one team.
|
||||
type membership struct {
|
||||
teamID int64
|
||||
|
||||
+51
-16
@@ -66,9 +66,18 @@ func handleAuthConfig(cfg config.Config) http.HandlerFunc {
|
||||
// DeviceLogin is whether a client that cannot open a browser (the TUI)
|
||||
// can sign in by showing a code, through /api/oidc/device.
|
||||
DeviceLogin bool `json:"device_login"`
|
||||
|
||||
// OperatorMode is whether this install is gitops-managed: writes to
|
||||
// teams, escalation policies, dead man's switches and integrations
|
||||
// from a session or a user's own API key are refused (OperatorModeBlock),
|
||||
// though a service account's are not. The web UI reads this before
|
||||
// anybody signs in, the same way it reads PasswordLogin/OIDC, so it can
|
||||
// show those sections read-only from the start rather than only after
|
||||
// a write fails.
|
||||
OperatorMode bool `json:"operator_mode"`
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := response{PasswordLogin: !cfg.DisablePasswordLogin}
|
||||
resp := response{PasswordLogin: !cfg.DisablePasswordLogin, OperatorMode: cfg.OperatorMode}
|
||||
if cfg.OIDC.Enabled() {
|
||||
resp.OIDC = oidcInfo{Enabled: true, Name: cfg.OIDC.Name}
|
||||
resp.DeviceLogin = true
|
||||
@@ -211,7 +220,15 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := signInSSO(r.Context(), db, cfg, identity, grants)
|
||||
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) {
|
||||
@@ -253,7 +270,7 @@ func safeNext(next string) string {
|
||||
|
||||
// signInSSO resolves the identity to a user and applies its grants, in one
|
||||
// transaction: a login that fails half way must not leave memberships changed.
|
||||
func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identity, g oidc.Grants) (int64, error) {
|
||||
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
|
||||
@@ -272,12 +289,34 @@ func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identi
|
||||
if disabled {
|
||||
return 0, ssoDisabled
|
||||
}
|
||||
if err := syncGrants(ctx, tx, userID, g); err != nil {
|
||||
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.
|
||||
//
|
||||
@@ -407,7 +446,11 @@ func refreshProfile(ctx context.Context, tx *sql.Tx, userID int64, id *oidc.Iden
|
||||
// administrator is a manual one. Rows added by hand are 'manual', and the sync
|
||||
// only ever raises them (turning them into 'oidc' rows), never lowers or removes
|
||||
// them.
|
||||
func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants) error {
|
||||
//
|
||||
// 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,
|
||||
@@ -422,21 +465,13 @@ func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants) er
|
||||
}
|
||||
|
||||
// Teams. The result of the loop is the set of teams the groups grant.
|
||||
granted := make([]int64, 0, len(g.Teams))
|
||||
for name, role := range g.Teams {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"INSERT INTO teams (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", name); err != nil {
|
||||
return err
|
||||
}
|
||||
var teamID int64
|
||||
if err := tx.QueryRowContext(ctx, "SELECT id FROM teams WHERE name = $1", name).Scan(&teamID); err != nil {
|
||||
return err
|
||||
}
|
||||
granted := 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 mapping says member.
|
||||
// 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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+50
-10
@@ -127,9 +127,10 @@ type idpUser struct {
|
||||
badNonce bool
|
||||
}
|
||||
|
||||
// ssoConfig is a terdut configuration wired to idp, with the mapping the tests
|
||||
// share: terdut-users may sign in, terdut-admins administer, and the sre groups
|
||||
// grant roles in the SRE team.
|
||||
// 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{
|
||||
@@ -143,16 +144,28 @@ func ssoConfig(idp *fakeIdP) config.Config {
|
||||
GroupsClaim: "groups",
|
||||
AllowedGroups: []string{"terdut-users"},
|
||||
AdminGroup: "terdut-admins",
|
||||
GroupMappings: []config.GroupMapping{
|
||||
{Group: "sre", Team: "SRE", Role: "member"},
|
||||
{Group: "sre-leads", Team: "SRE", Role: "owner"},
|
||||
{Group: "platform", Team: "Platform", Role: "member"},
|
||||
},
|
||||
SessionMaxAge: 12 * time.Hour,
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -290,6 +303,8 @@ func sameMap(a, b map[string]string) bool {
|
||||
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 != "/" {
|
||||
@@ -305,6 +320,27 @@ func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -362,6 +398,7 @@ func TestSSO_ManualAdminIsNeverRevoked(t *testing.T) {
|
||||
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.
|
||||
@@ -379,6 +416,7 @@ func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) {
|
||||
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"}) {
|
||||
@@ -396,11 +434,11 @@ func TestSSO_ManualMemberIsRaisedNeverLowered(t *testing.T) {
|
||||
|
||||
// alice exists locally, is a manual owner of SRE, and is linked by email.
|
||||
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'alice@example.com')")
|
||||
s.exec(t, "INSERT INTO teams (name) VALUES ('SRE')")
|
||||
s.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 mapping only says member
|
||||
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)
|
||||
}
|
||||
@@ -614,6 +652,7 @@ func TestSSO_ProviderErrorGoesBackToTheUI(t *testing.T) {
|
||||
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
|
||||
@@ -712,6 +751,7 @@ func TestSSO_UnreachableProviderRedirectsWithAnError(t *testing.T) {
|
||||
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"))
|
||||
|
||||
|
||||
+42
-12
@@ -14,8 +14,12 @@ import (
|
||||
// NewRouter builds the HTTP surface. notify is passed through to the webhook,
|
||||
// the only handler that has to decide where a new incident's page goes; a zero
|
||||
// notify disables notifications. Dead man's switches are per team and read from
|
||||
// the database, so nothing about them is wired in here.
|
||||
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
|
||||
// the database, so nothing about them is wired in here. version is reported
|
||||
// verbatim by GET /api/version, unauthenticated like /healthz: a client
|
||||
// deciding whether it can talk to this server — terdut-tui, terdut-operator —
|
||||
// needs to ask before it holds a credential for it, and the version is not a
|
||||
// secret.
|
||||
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version string) http.Handler {
|
||||
// One limiter each, both process-wide for the life of the router: login
|
||||
// counts failed passwords, sign-up counts account creation, and mixing the
|
||||
// two would let a burst of sign-ups lock somebody out of logging in.
|
||||
@@ -30,6 +34,9 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
r.Get("/api/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, http.StatusOK, map[string]string{"version": version})
|
||||
})
|
||||
|
||||
// Unauthenticated: bootstrap, the Alertmanager webhook receiver, and the
|
||||
// Acknowledge button in a push notification. The last one is authorised by
|
||||
@@ -151,35 +158,58 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
||||
r.Post("/api/incidents/{id}/notes", handleCreateNote(db))
|
||||
r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db))
|
||||
|
||||
// Service accounts: a scoped, non-human credential for automation
|
||||
// (terdut-operator, most likely) that needs to manage the resources
|
||||
// below without impersonating a human user. See SERVICE-ACCOUNTS.md.
|
||||
r.Get("/api/service-accounts", handleListServiceAccounts(db))
|
||||
r.Post("/api/service-accounts", handleCreateServiceAccount(db))
|
||||
r.Post("/api/service-accounts/{id}/keys", handleCreateServiceAccountKey(db))
|
||||
r.Delete("/api/service-accounts/{id}/keys/{keyID}", handleDeleteServiceAccountKey(db))
|
||||
|
||||
// Operator mode (TERDUT_OPERATOR_MODE) makes every write below refuse a
|
||||
// human caller (a session or a user's own API key) while still letting
|
||||
// a service account through — see OperatorModeBlock. opMode is a no-op
|
||||
// wrapper when the flag is off, so this costs nothing on a server that
|
||||
// never sets it.
|
||||
opMode := OperatorModeBlock(cfg)
|
||||
|
||||
// Teams. A user sees the teams they belong to; an owner configures one.
|
||||
r.Get("/api/teams", handleListTeams(db))
|
||||
r.Post("/api/teams", handleCreateTeam(db))
|
||||
r.Put("/api/teams/{teamID}", handleRenameTeam(db))
|
||||
r.Delete("/api/teams/{teamID}", handleDeleteTeam(db))
|
||||
r.With(opMode).Post("/api/teams", handleCreateTeam(db))
|
||||
r.With(opMode).Put("/api/teams/{teamID}", handleRenameTeam(db))
|
||||
r.With(opMode).Delete("/api/teams/{teamID}", handleDeleteTeam(db))
|
||||
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db))
|
||||
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
||||
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
||||
|
||||
// Invite links into this team.
|
||||
// A team's own OIDC group binding: which provider groups grant member
|
||||
// and owner access to it.
|
||||
r.Get("/api/teams/{teamID}/oidc-groups", handleGetTeamOIDCGroups(db))
|
||||
r.With(opMode).Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(db))
|
||||
|
||||
// Invite links into this team. Not operator-mode-gated: membership is
|
||||
// deliberately never gitops-managed (see terdut-operator's DESIGN.md
|
||||
// §4.2), so it stays editable regardless of this flag.
|
||||
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
||||
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
||||
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
|
||||
|
||||
// A team's escalation ladder: who is paged when nobody answers.
|
||||
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
|
||||
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
|
||||
r.With(opMode).Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
|
||||
|
||||
// A team's own dead man's switches: which of its alerts are heartbeats,
|
||||
// and how long a silence has to last before somebody is paged.
|
||||
r.Get("/api/teams/{teamID}/deadman/switches", handleListTeamDeadman(db))
|
||||
r.Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
|
||||
r.Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
|
||||
r.With(opMode).Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
|
||||
r.With(opMode).Put("/api/teams/{teamID}/deadman/switches/{switchID}", handleUpdateTeamDeadman(db))
|
||||
r.With(opMode).Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
|
||||
|
||||
// Integrations: where a team's alerts come in, and the key that says so.
|
||||
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
|
||||
r.Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
|
||||
r.Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
|
||||
r.Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
|
||||
r.With(opMode).Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
|
||||
r.With(opMode).Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
|
||||
r.With(opMode).Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
|
||||
|
||||
// The rota is per team. /api/schedule/current is the exception: it
|
||||
// answers across every team the caller is in, which is what somebody on
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// serviceAccountKeyPrefix marks a service-account key visibly, in logs and at
|
||||
// a glance, distinct from a user's own personal API key. It carries no
|
||||
// meaning to the server itself — the hash is looked up the same way either
|
||||
// kind of key is — it exists entirely for whoever is reading a log line or an
|
||||
// audit trail.
|
||||
const serviceAccountKeyPrefix = "tdsa_"
|
||||
|
||||
// randomServiceAccountToken is randomToken with serviceAccountKeyPrefix on the
|
||||
// raw value, hashed as a whole: the prefix is not a fixed header stripped
|
||||
// before hashing, it is part of the secret, the same as if it had been
|
||||
// generated that long to begin with.
|
||||
func randomServiceAccountToken() (raw, hash string, err error) {
|
||||
body, _, err := randomToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
raw = serviceAccountKeyPrefix + body
|
||||
return raw, hashToken(raw), nil
|
||||
}
|
||||
|
||||
// callerIsAdmin reports whether the caller is a signed-in human system
|
||||
// administrator. A service account never is, by design (SERVICE-ACCOUNTS.md):
|
||||
// account and user management stays human-only, service accounts included.
|
||||
func callerIsAdmin(ctx context.Context) bool {
|
||||
u, ok := userFromContext(ctx)
|
||||
return ok && u.IsAdmin
|
||||
}
|
||||
|
||||
// callerOwnsTeam reports whether the caller is a human owner of teamID. Built
|
||||
// on callerRole/ctxTeams like requireTeamOwner, but without writing a
|
||||
// response: callers here need to combine it with other ways of being
|
||||
// allowed, not stop at the first no.
|
||||
func callerOwnsTeam(ctx context.Context, teamID int64) bool {
|
||||
role, ok := callerRole(ctx, teamID)
|
||||
return ok && role == models.RoleOwner
|
||||
}
|
||||
|
||||
// handleCreateServiceAccount creates a service account and mints its first
|
||||
// key. Who may do this depends on scope: an instance-scoped account (which
|
||||
// can in turn create a team and a team-scoped account for it) is system
|
||||
// administration's own reach extended to automation, so only a human admin
|
||||
// grants one. A team-scoped account is that team's owner's reach, so a human
|
||||
// admin, the target team's own human owner, or an existing instance-scoped
|
||||
// service account (minting itself a narrower credential for a team it just
|
||||
// created) may create one.
|
||||
func handleCreateServiceAccount(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
TeamID int64 `json:"team_id"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
if req.Scope != models.ServiceAccountScopeInstance && req.Scope != models.ServiceAccountScopeTeam {
|
||||
respond(w, http.StatusBadRequest, errResp("scope must be instance or team"))
|
||||
return
|
||||
}
|
||||
if req.Scope == models.ServiceAccountScopeTeam && req.TeamID == 0 {
|
||||
respond(w, http.StatusBadRequest, errResp("team_id is required for a team-scoped account"))
|
||||
return
|
||||
}
|
||||
if req.Scope == models.ServiceAccountScopeInstance && req.TeamID != 0 {
|
||||
respond(w, http.StatusBadRequest, errResp("team_id must not be set for an instance-scoped account"))
|
||||
return
|
||||
}
|
||||
|
||||
allowed := callerIsAdmin(r.Context())
|
||||
if !allowed && req.Scope == models.ServiceAccountScopeTeam {
|
||||
allowed = callerOwnsTeam(r.Context(), req.TeamID) || isInstanceServiceAccount(r.Context())
|
||||
}
|
||||
if !allowed {
|
||||
respond(w, http.StatusForbidden, errResp("team owner, system administrator, or instance-scoped service account access required"))
|
||||
return
|
||||
}
|
||||
|
||||
var callerUserID *int64
|
||||
if u, ok := userFromContext(r.Context()); ok {
|
||||
id := u.ID
|
||||
callerUserID = &id
|
||||
}
|
||||
var teamID *int64
|
||||
if req.Scope == models.ServiceAccountScopeTeam {
|
||||
teamID = &req.TeamID
|
||||
}
|
||||
|
||||
var sa models.ServiceAccount
|
||||
var created int64
|
||||
if err := db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO service_accounts (name, scope, team_id, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, scope, team_id, created_by, created_at`,
|
||||
req.Name, req.Scope, teamID, callerUserID,
|
||||
).Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
respond(w, http.StatusConflict, errResp("a service account with that name already exists"))
|
||||
return
|
||||
}
|
||||
// The only foreign key that can fail here is team_id: an
|
||||
// instance-scoped caller is not otherwise checked against it
|
||||
// (callerOwnsTeam already proved it exists for a human owner).
|
||||
respond(w, http.StatusBadRequest, errResp("unknown team_id"))
|
||||
return
|
||||
}
|
||||
sa.CreatedAt = time.Unix(created, 0).UTC()
|
||||
|
||||
key, err := mintServiceAccountKey(r.Context(), db, sa.ID, "initial")
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusCreated, map[string]any{"service_account": sa, "key": key})
|
||||
}
|
||||
}
|
||||
|
||||
// mintServiceAccountKey inserts one key for an existing account and returns
|
||||
// it with its raw value populated — the one moment that value exists outside
|
||||
// the request that generated it.
|
||||
func mintServiceAccountKey(ctx context.Context, db *sql.DB, serviceAccountID int64, name string) (models.ServiceAccountKey, error) {
|
||||
raw, hash, err := randomServiceAccountToken()
|
||||
if err != nil {
|
||||
return models.ServiceAccountKey{}, err
|
||||
}
|
||||
var key models.ServiceAccountKey
|
||||
var created int64
|
||||
if err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO service_account_keys (service_account_id, key_hash, name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, service_account_id, name, created_at`,
|
||||
serviceAccountID, hash, name,
|
||||
).Scan(&key.ID, &key.ServiceAccountID, &key.Name, &created); err != nil {
|
||||
return models.ServiceAccountKey{}, err
|
||||
}
|
||||
key.CreatedAt = time.Unix(created, 0).UTC()
|
||||
key.Key = raw
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func fetchServiceAccount(ctx context.Context, db *sql.DB, id int64) (models.ServiceAccount, error) {
|
||||
var sa models.ServiceAccount
|
||||
var created int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT id, name, scope, team_id, created_by, created_at FROM service_accounts WHERE id = $1", id,
|
||||
).Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created)
|
||||
if err != nil {
|
||||
return sa, err
|
||||
}
|
||||
sa.CreatedAt = time.Unix(created, 0).UTC()
|
||||
return sa, nil
|
||||
}
|
||||
|
||||
// callerMayManageServiceAccount reports whether the caller may mint or revoke
|
||||
// a key on sa: a system administrator, that team-scoped account's own human
|
||||
// owner, or the account rotating its own credential — which is not a
|
||||
// privilege escalation, the same reasoning requireSelfOrAdmin already rests
|
||||
// on for a user's own API keys.
|
||||
func callerMayManageServiceAccount(ctx context.Context, sa models.ServiceAccount) bool {
|
||||
if callerIsAdmin(ctx) {
|
||||
return true
|
||||
}
|
||||
if sa.TeamID != nil && callerOwnsTeam(ctx, *sa.TeamID) {
|
||||
return true
|
||||
}
|
||||
if self, ok := serviceAccountFromContext(ctx); ok && self.id == sa.ID {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func serviceAccountParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid service account id"))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func handleCreateServiceAccountKey(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := serviceAccountParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sa, err := fetchServiceAccount(r.Context(), db, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respond(w, http.StatusNotFound, errResp("service account not found"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if !callerMayManageServiceAccount(r.Context(), sa) {
|
||||
respond(w, http.StatusForbidden, errResp("team owner, system administrator, or the account itself may rotate its key"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
|
||||
key, err := mintServiceAccountKey(r.Context(), db, sa.ID, req.Name)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusCreated, key)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteServiceAccountKey(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := serviceAccountParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sa, err := fetchServiceAccount(r.Context(), db, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respond(w, http.StatusNotFound, errResp("service account not found"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if !callerMayManageServiceAccount(r.Context(), sa) {
|
||||
respond(w, http.StatusForbidden, errResp("team owner, system administrator, or the account itself may revoke its key"))
|
||||
return
|
||||
}
|
||||
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid key id"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"DELETE FROM service_account_keys WHERE id = $1 AND service_account_id = $2", keyID, sa.ID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusNotFound, errResp("key not found"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleListServiceAccounts lists every service account, or looks one up by
|
||||
// its exact name with ?name=. The name lookup is open to any authenticated
|
||||
// caller, human or service account: it returns no key material, and it is
|
||||
// what lets a service account find its own account on the 403 that follows a
|
||||
// second POST — the self-registration pattern SERVICE-ACCOUNTS.md describes.
|
||||
// Listing everything, with no filter, stays administrator-only.
|
||||
func handleListServiceAccounts(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimSpace(r.URL.Query().Get("name"))
|
||||
if name == "" && !callerIsAdmin(r.Context()) {
|
||||
respond(w, http.StatusForbidden, errResp("administrator access required to list every service account; pass ?name= to look up one by name"))
|
||||
return
|
||||
}
|
||||
|
||||
query := "SELECT id, name, scope, team_id, created_by, created_at FROM service_accounts"
|
||||
var args []any
|
||||
if name != "" {
|
||||
query += " WHERE name = $1"
|
||||
args = append(args, name)
|
||||
}
|
||||
query += " ORDER BY id"
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
accounts := []models.ServiceAccount{}
|
||||
for rows.Next() {
|
||||
var sa models.ServiceAccount
|
||||
var created int64
|
||||
if err := rows.Scan(&sa.ID, &sa.Name, &sa.Scope, &sa.TeamID, &sa.CreatedBy, &created); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
sa.CreatedAt = time.Unix(created, 0).UTC()
|
||||
accounts = append(accounts, sa)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, accounts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
// reqAs is s.req with an arbitrary bearer credential in place of the admin's
|
||||
// own key, for exercising a service account's or another user's key.
|
||||
func (s *ts) reqAs(t *testing.T, key, method, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
data, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(data)
|
||||
}
|
||||
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// createServiceAccount creates a service account as callerKey and returns its
|
||||
// freshly minted raw key.
|
||||
func createServiceAccount(t *testing.T, s *ts, callerKey, name, scope string, teamID int64) string {
|
||||
t.Helper()
|
||||
body := map[string]any{"name": name, "scope": scope}
|
||||
if teamID != 0 {
|
||||
body["team_id"] = teamID
|
||||
}
|
||||
resp := s.reqAs(t, callerKey, http.MethodPost, "/api/service-accounts", body)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("create service account %s: %d", name, resp.StatusCode)
|
||||
}
|
||||
var result struct {
|
||||
Key struct {
|
||||
Key string `json:"key"`
|
||||
} `json:"key"`
|
||||
}
|
||||
decode(t, resp, &result)
|
||||
if result.Key.Key == "" {
|
||||
t.Fatalf("create service account %s: no key returned", name)
|
||||
}
|
||||
return result.Key.Key
|
||||
}
|
||||
|
||||
// createTeamAs creates a team as callerKey and returns its id.
|
||||
func createTeamAs(t *testing.T, s *ts, callerKey, name string) int64 {
|
||||
t.Helper()
|
||||
resp := s.reqAs(t, callerKey, http.MethodPost, "/api/teams", map[string]string{"name": name})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("create team %s: %d", name, resp.StatusCode)
|
||||
}
|
||||
var team struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, resp, &team)
|
||||
return team.ID
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instance scope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestServiceAccount_InstanceScopeCreatesTeamWithNoHumanOwner(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
|
||||
if !strings.HasPrefix(instanceKey, "tdsa_") {
|
||||
t.Errorf("expected a service-account key to carry the tdsa_ prefix, got %q", instanceKey)
|
||||
}
|
||||
|
||||
resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/teams", map[string]string{"name": "provisioned"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("instance-scoped account creating a team: %d", resp.StatusCode)
|
||||
}
|
||||
var team struct {
|
||||
ID int64 `json:"id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
decode(t, resp, &team)
|
||||
if team.Role != "" {
|
||||
t.Errorf("expected no role on a team a service account created (no human owner), got %q", team.Role)
|
||||
}
|
||||
|
||||
// It still exists, visible to an administrator, even with no member.
|
||||
var admin []map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/admin/teams", nil), &admin)
|
||||
found := false
|
||||
for _, tm := range admin {
|
||||
if int64(tm["id"].(float64)) == team.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected the service-account-created team to appear in /api/admin/teams")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAccount_TeamScopeCannotCreateTeam(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
|
||||
resp := s.reqAs(t, keyA, http.MethodPost, "/api/teams", map[string]string{"name": "should-fail"})
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403, a team-scoped account creating a team, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Team scope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The whole point of team scope: bound to its own team, refused everywhere
|
||||
// else, the same as an instance-scoped account minting a key per TerdutTeam
|
||||
// rather than sharing one server-admin-equivalent credential would need.
|
||||
func TestServiceAccount_TeamScopeIsBoundToItsOwnTeam(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
teamB := createTeamAs(t, s, instanceKey, "team-b")
|
||||
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
|
||||
policy := map[string]any{"repeat_count": 0, "fallback_topic": "", "levels": []any{}}
|
||||
|
||||
resp := s.reqAs(t, keyA, http.MethodPut, "/api/teams/"+id64(teamA)+"/escalation", policy)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("team-a's own key setting its escalation: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// 404, not 403: the same "does this exist" refusal a human non-member
|
||||
// gets from requireTeamMember, not a distinguishable "you may not".
|
||||
resp2 := s.reqAs(t, keyA, http.MethodPut, "/api/teams/"+id64(teamB)+"/escalation", policy)
|
||||
if resp2.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404 reaching into another team, got %d", resp2.StatusCode)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
}
|
||||
|
||||
// Team scope is owner-equivalent broadly (SERVICE-ACCOUNTS.md), not limited to
|
||||
// one endpoint: escalation, dead man's switches and integrations all work.
|
||||
func TestServiceAccount_TeamScopeManagesItsResources(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
|
||||
resp := s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/deadman/switches",
|
||||
map[string]any{"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("team-scoped account creating a dead man's switch: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp2 := s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
|
||||
map[string]string{"name": "prod"})
|
||||
if resp2.StatusCode != http.StatusCreated {
|
||||
t.Errorf("team-scoped account creating an integration: %d", resp2.StatusCode)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key rotation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestServiceAccount_SelfRotatesItsOwnKey(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
|
||||
// Self-lookup by name, the pattern that turns /api/bootstrap's 403 into a
|
||||
// normal flow instead of an unhandled error.
|
||||
var accounts []map[string]any
|
||||
decode(t, s.reqAs(t, instanceKey, http.MethodGet, "/api/service-accounts?name=terdut-operator", nil), &accounts)
|
||||
if len(accounts) != 1 {
|
||||
t.Fatalf("expected exactly one match for ?name=terdut-operator, got %d", len(accounts))
|
||||
}
|
||||
id := int64(accounts[0]["id"].(float64))
|
||||
|
||||
resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/service-accounts/"+id64(id)+"/keys",
|
||||
map[string]string{"name": "rotated"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("self-rotation: %d", resp.StatusCode)
|
||||
}
|
||||
var newKey struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, resp, &newKey)
|
||||
|
||||
if resp := s.reqAs(t, newKey.Key, http.MethodPost, "/api/teams", map[string]string{"name": "after-rotation"}); resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("expected the newly rotated key to work, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Rotation adds a key, it does not itself revoke the old one.
|
||||
if resp := s.reqAs(t, instanceKey, http.MethodGet, "/api/service-accounts?name=terdut-operator", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected the original key to still work until explicitly revoked, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operator mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Operator mode is exercised against a second router over an
|
||||
// already-configured database, rather than turning it on for newTSWith's own
|
||||
// setup: that setup creates the default integration with the admin's (human)
|
||||
// key, which is precisely the write operator mode exists to refuse, and in
|
||||
// the real deployment this flag targets that setup was never done by a human
|
||||
// to begin with — the operator itself would have provisioned it.
|
||||
func TestOperatorMode_BlocksHumanWritesButAllowsServiceAccounts(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
conf := testConfig()
|
||||
conf.OperatorMode = true
|
||||
opSrv := httptest.NewServer(api.NewRouter(s.db, s.notify, conf, "test"))
|
||||
t.Cleanup(opSrv.Close)
|
||||
do := func(key, method, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
data, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(data)
|
||||
}
|
||||
req, _ := http.NewRequest(method, opSrv.URL+path, r)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// The bootstrap admin's own key is a human credential: refused.
|
||||
resp := do(s.key, http.MethodPost, "/api/teams", map[string]string{"name": "human-team"})
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for a human write under operator mode, got %d", resp.StatusCode)
|
||||
}
|
||||
var refusal map[string]string
|
||||
decode(t, resp, &refusal)
|
||||
if refusal["reason"] != "operator_managed" {
|
||||
t.Errorf("expected reason=operator_managed, got %q", refusal["reason"])
|
||||
}
|
||||
|
||||
// Creating the service account itself is not gated by operator mode —
|
||||
// it is how an operator identifies itself, not one of the resources it
|
||||
// manages.
|
||||
resp2 := do(s.key, http.MethodPost, "/api/service-accounts",
|
||||
map[string]any{"name": "terdut-operator", "scope": models.ServiceAccountScopeInstance})
|
||||
if resp2.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create service account under operator mode: %d", resp2.StatusCode)
|
||||
}
|
||||
var result struct {
|
||||
Key struct {
|
||||
Key string `json:"key"`
|
||||
} `json:"key"`
|
||||
}
|
||||
decode(t, resp2, &result)
|
||||
|
||||
resp3 := do(result.Key.Key, http.MethodPost, "/api/teams", map[string]string{"name": "operator-team"})
|
||||
if resp3.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("expected 201 for a service-account write under operator mode, got %d", resp3.StatusCode)
|
||||
}
|
||||
resp3.Body.Close()
|
||||
|
||||
// Reads are unaffected regardless of caller.
|
||||
if resp := do(s.key, http.MethodGet, "/api/teams", nil); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected reads to stay open under operator mode, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorMode_OffLeavesHumanWritesAlone(t *testing.T) {
|
||||
s := newTS(t) // testConfig(): OperatorMode false
|
||||
resp := s.req(t, http.MethodPost, "/api/teams", map[string]string{"name": "still-fine"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("expected a human write to succeed with operator mode off, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestVersion(t *testing.T) {
|
||||
s := newTS(t)
|
||||
resp, err := http.Get(s.URL + "/api/version")
|
||||
if err != nil {
|
||||
t.Fatalf("get version: %v", err)
|
||||
}
|
||||
var v struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
decode(t, resp, &v)
|
||||
if v.Version != "test" {
|
||||
t.Errorf("expected version %q, got %q", "test", v.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dead man's switch update-in-place
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDeadman_UpdateInPlacePreservesID(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
var created struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/deadman/switches",
|
||||
map[string]any{"matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"}), &created)
|
||||
|
||||
resp := s.req(t, http.MethodPut, "/api/teams/"+defaultTeam+"/deadman/switches/"+id64(created.ID),
|
||||
map[string]any{"name": "renamed", "matcher": "alertname=Watchdog", "timeout_seconds": 1200, "severity": "warning"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("update switch: %d", resp.StatusCode)
|
||||
}
|
||||
var updated struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
decode(t, resp, &updated)
|
||||
if updated.ID != created.ID {
|
||||
t.Errorf("expected id to stay %d, got %d", created.ID, updated.ID)
|
||||
}
|
||||
if updated.Name != "renamed" || updated.TimeoutSeconds != 1200 || updated.Severity != "warning" {
|
||||
t.Errorf("expected the update to apply, got %+v", updated)
|
||||
}
|
||||
|
||||
var list []map[string]any
|
||||
decode(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/deadman/switches", nil), &list)
|
||||
if len(list) != 1 {
|
||||
t.Errorf("expected the update to replace in place, not add a row, got %d switches", len(list))
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,14 @@ type adminTeam struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Members int64 `json:"members"`
|
||||
OpenIncidents int64 `json:"open_incidents"`
|
||||
|
||||
// OIDCMemberGroup and OIDCOwnerGroup are the team's own group binding,
|
||||
// read-only here: an administrator can see why a team's OIDC-sourced
|
||||
// membership looks the way it does without being able to change it out
|
||||
// from under the team's owner. Setting it is PUT
|
||||
// /api/teams/{teamID}/oidc-groups, owner-only.
|
||||
OIDCMemberGroup string `json:"oidc_member_group,omitempty"`
|
||||
OIDCOwnerGroup string `json:"oidc_owner_group,omitempty"`
|
||||
}
|
||||
|
||||
// handleAdminListTeams lists every team on the server, with its size. The
|
||||
@@ -248,7 +256,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
||||
SELECT t.id, t.name, t.created_at,
|
||||
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||
(SELECT COUNT(*) FROM incidents i
|
||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
|
||||
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
|
||||
FROM teams t
|
||||
ORDER BY t.name`)
|
||||
if err != nil {
|
||||
@@ -261,7 +270,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var t adminTeam
|
||||
var created int64
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
|
||||
&t.OIDCMemberGroup, &t.OIDCOwnerGroup); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -298,10 +308,12 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
|
||||
SELECT t.id, t.name, t.created_at,
|
||||
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||
(SELECT COUNT(*) FROM incidents i
|
||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
|
||||
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
|
||||
FROM teams t
|
||||
WHERE t.id = $1`, teamID).
|
||||
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents)
|
||||
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
|
||||
&t.OIDCMemberGroup, &t.OIDCOwnerGroup)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return
|
||||
|
||||
+122
-7
@@ -116,6 +116,15 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
|
||||
// handleCreateTeam creates a team and makes its creator the first owner. A team
|
||||
// with no owner would need an administrator to repair before anybody could use
|
||||
// it, so the two happen in one transaction.
|
||||
//
|
||||
// An instance-scoped service account may also create a team (SERVICE-ACCOUNTS.md:
|
||||
// it acts with the same reach system administration has over teams), but it
|
||||
// is not a users row and cannot become an owner the way a person does. The
|
||||
// team it creates starts with no human owner at all — not a bug, the expected
|
||||
// shape for one terdut-operator is about to provision: a system administrator
|
||||
// can always act as owner to repair or hand it off (requireTeamOwner), and
|
||||
// the account that created it mints itself a team-scoped credential for it
|
||||
// next, via POST /api/service-accounts.
|
||||
func handleCreateTeam(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
@@ -131,7 +140,14 @@ func handleCreateTeam(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
caller, _ := userFromContext(r.Context())
|
||||
caller, isUser := userFromContext(r.Context())
|
||||
if !isUser && !isInstanceServiceAccount(r.Context()) {
|
||||
// A team-scoped service account authenticates as owner of exactly
|
||||
// one team already (see serveAsServiceAccount); letting it create
|
||||
// another would reach outside that boundary.
|
||||
respond(w, http.StatusForbidden, errResp("instance-scoped service account or user access required"))
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
@@ -152,11 +168,13 @@ func handleCreateTeam(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(),
|
||||
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
|
||||
team.ID, caller.ID, models.RoleOwner); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
if isUser {
|
||||
if _, err := tx.ExecContext(r.Context(),
|
||||
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
|
||||
team.ID, caller.ID, models.RoleOwner); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
@@ -164,7 +182,9 @@ func handleCreateTeam(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
team.CreatedAt = time.Unix(created, 0).UTC()
|
||||
team.Role = models.RoleOwner
|
||||
if isUser {
|
||||
team.Role = models.RoleOwner
|
||||
}
|
||||
respond(w, http.StatusCreated, team)
|
||||
}
|
||||
}
|
||||
@@ -861,6 +881,101 @@ func handleCreateTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpdateTeamDeadman replaces one switch's configuration in place.
|
||||
// Added alongside create/delete so an automated caller (terdut-operator) can
|
||||
// reconcile a spec change without deleting and recreating the switch, which
|
||||
// would otherwise be the only option and would needlessly rotate its id for
|
||||
// no reason a reconciler's diff should ever manufacture.
|
||||
func handleUpdateTeamDeadman(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
switchID, err := strconv.ParseInt(chi.URLParam(r, "switchID"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid switch id"))
|
||||
return
|
||||
}
|
||||
|
||||
var req deadmanSwitchRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
req.Matcher = strings.TrimSpace(req.Matcher)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Severity == "" {
|
||||
req.Severity = "critical"
|
||||
}
|
||||
if !deadmanSeverities[req.Severity] {
|
||||
respond(w, http.StatusBadRequest, errResp("severity must be critical, error, warning or info"))
|
||||
return
|
||||
}
|
||||
if req.TimeoutSeconds <= 0 {
|
||||
respond(w, http.StatusBadRequest, errResp("timeout_seconds must be positive"))
|
||||
return
|
||||
}
|
||||
if strings.Contains(req.Matcher, ";") {
|
||||
respond(w, http.StatusBadRequest, errResp("one matcher per switch: add another switch instead of separating with ;"))
|
||||
return
|
||||
}
|
||||
m, err := parseDeadmanMatcher(req.Matcher)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp(
|
||||
"unusable matcher ("+err.Error()+"): each must name an alertname, as in alertname=Watchdog,cluster=prod"))
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
req.Name = m.config()
|
||||
}
|
||||
if len(req.Name) > 100 {
|
||||
respond(w, http.StatusBadRequest, errResp("name is too long"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(), `
|
||||
UPDATE deadman_switches
|
||||
SET name = $1, matcher = $2, timeout_seconds = $3, severity = $4
|
||||
WHERE id = $5 AND team_id = $6`,
|
||||
req.Name, m.config(), req.TimeoutSeconds, req.Severity, switchID, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusNotFound, errResp("switch not found"))
|
||||
return
|
||||
}
|
||||
|
||||
// The full status, not the bare request echoed back: an update can
|
||||
// change whether the switch is dormant, alive or dead (a longer
|
||||
// timeout can revive one that just went dead), and a caller
|
||||
// reconciling against status deserves the same view
|
||||
// handleListTeamDeadman would give it.
|
||||
set, err := deadmanSetForTeam(r.Context(), db, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
statuses, err := deadmanStatuses(r.Context(), db, teamID, set, time.Now())
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
for _, s := range statuses {
|
||||
if s.ID == switchID {
|
||||
respond(w, http.StatusOK, s)
|
||||
return
|
||||
}
|
||||
}
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteTeamDeadman removes a switch. An incident it already opened stays
|
||||
// open until somebody resolves it: deleting the switch says "stop watching", not
|
||||
// "the problem is gone".
|
||||
|
||||
@@ -327,6 +327,112 @@ func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A team's own OIDC group binding follows the same rule as its schedule and
|
||||
// its integrations: an owner sets it, a member may only read it, an outsider
|
||||
// learns nothing, and an administrator can still reach it to repair a team
|
||||
// whose owner has left.
|
||||
func TestTeamOIDCGroups_OwnerOnlyToEdit(t *testing.T) {
|
||||
s := newTS(t)
|
||||
team := newTeam(t, s, "sre") // team.call authenticates as its owner
|
||||
|
||||
// A plain member of the same team.
|
||||
var plain struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": "plain", "email": "plain@test.com"}), &plain)
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
|
||||
map[string]any{"user_id": plain.ID, "role": "member"})
|
||||
resp.Body.Close()
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(plain.ID)+"/api-keys",
|
||||
map[string]string{"name": "test"}), &key)
|
||||
memberCall := func(method, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
data, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(data)
|
||||
}
|
||||
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// A member of a different team altogether.
|
||||
_, outsiderCall := member(t, s, "outsider")
|
||||
|
||||
path := "/api/teams/" + id64(team.id) + "/oidc-groups"
|
||||
|
||||
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "sre", "owner_group": "sre-leads"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("owner PUT: %d, want 204", resp.StatusCode)
|
||||
}
|
||||
var got struct {
|
||||
MemberGroup string `json:"member_group"`
|
||||
OwnerGroup string `json:"owner_group"`
|
||||
}
|
||||
decode(t, team.call(http.MethodGet, path, nil), &got)
|
||||
if got.MemberGroup != "sre" || got.OwnerGroup != "sre-leads" {
|
||||
t.Errorf("owner GET after PUT: %+v", got)
|
||||
}
|
||||
|
||||
resp = memberCall(http.MethodGet, path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("member GET: %d, want 200", resp.StatusCode)
|
||||
}
|
||||
resp = memberCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("member PUT: %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 404, not 403: whether the team exists is itself something only its
|
||||
// members should learn.
|
||||
resp = outsiderCall(http.MethodGet, path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("outsider GET: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
resp = outsiderCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("outsider PUT: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
// An administrator who is not a member may still set it, the same bypass
|
||||
// that lets one repair a team whose owner has left.
|
||||
resp = s.req(t, http.MethodPut, path, map[string]string{"member_group": "sre2"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("admin PUT: %d, want 204", resp.StatusCode)
|
||||
}
|
||||
|
||||
// An empty string clears a binding, stored as NULL rather than the literal
|
||||
// empty string, so an empty group claim can never accidentally match it.
|
||||
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "", "owner_group": ""})
|
||||
resp.Body.Close()
|
||||
var cleared struct {
|
||||
MemberGroup string `json:"member_group"`
|
||||
OwnerGroup string `json:"owner_group"`
|
||||
}
|
||||
decode(t, team.call(http.MethodGet, path, nil), &cleared)
|
||||
if cleared.MemberGroup != "" || cleared.OwnerGroup != "" {
|
||||
t.Errorf("cleared: %+v", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
// A team is not somewhere an outsider can look, whatever they know about it.
|
||||
func TestTeams_OutsiderSeesNothing(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
+17
-35
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
@@ -73,6 +72,14 @@ type Config struct {
|
||||
|
||||
// OIDC configures single sign-on. The zero value, with no Issuer, is off.
|
||||
OIDC OIDC
|
||||
|
||||
// OperatorMode declares this install gitops-managed: writes to teams,
|
||||
// escalation policies, dead man's switches and integrations from a human
|
||||
// (a session or a user's own API key) are refused, while a service
|
||||
// account's are not. Deploy-time and restart-required, like the rest of
|
||||
// "where this server is plugged in" — it is a statement about who owns
|
||||
// this install's configuration, not a per-request toggle.
|
||||
OperatorMode bool
|
||||
}
|
||||
|
||||
// OIDC is the single sign-on configuration. Groups from the provider decide
|
||||
@@ -112,25 +119,10 @@ type OIDC struct {
|
||||
// AdminGroup grants the system administrator flag while the user is in it.
|
||||
AdminGroup string
|
||||
|
||||
// GroupMappings grants team roles. A user in Group gets Role in Team.
|
||||
GroupMappings []GroupMapping
|
||||
|
||||
// SessionMaxAge is the hard ceiling on a session made by an SSO login. The
|
||||
// login is the only moment groups are re-read, so this is how long a change
|
||||
// in the provider may take to reach terdut.
|
||||
SessionMaxAge time.Duration
|
||||
|
||||
// parseErr is a malformed TERDUT_OIDC_GROUP_MAPPINGS, reported by Validate:
|
||||
// Load cannot fail, and a mapping that was silently dropped would grant
|
||||
// less access than the operator wrote down.
|
||||
parseErr error
|
||||
}
|
||||
|
||||
// GroupMapping grants Role in Team to members of Group.
|
||||
type GroupMapping struct {
|
||||
Group string `json:"group"`
|
||||
Team string `json:"team"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// Enabled reports whether single sign-on is configured.
|
||||
@@ -167,6 +159,8 @@ func Load() Config {
|
||||
|
||||
DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true),
|
||||
OIDC: loadOIDC(),
|
||||
|
||||
OperatorMode: boolean("TERDUT_OPERATOR_MODE", false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,11 +179,6 @@ func loadOIDC() OIDC {
|
||||
AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"),
|
||||
SessionMaxAge: duration("TERDUT_OIDC_SESSION_MAX_AGE", 12*time.Hour),
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("TERDUT_OIDC_GROUP_MAPPINGS")); raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &o.GroupMappings); err != nil {
|
||||
o.parseErr = fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS: %w", err)
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -199,14 +188,11 @@ func loadOIDC() OIDC {
|
||||
// than not starting.
|
||||
func (c Config) Validate() error {
|
||||
o := c.OIDC
|
||||
if o.parseErr != nil {
|
||||
return o.parseErr
|
||||
}
|
||||
if !o.Enabled() {
|
||||
if c.DisablePasswordLogin {
|
||||
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
|
||||
}
|
||||
if len(o.GroupMappings) > 0 || o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
|
||||
if o.AdminGroup != "" || len(o.AllowedGroups) > 0 {
|
||||
return errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not")
|
||||
}
|
||||
return nil
|
||||
@@ -223,16 +209,12 @@ func (c Config) Validate() error {
|
||||
if o.SessionMaxAge <= 0 {
|
||||
return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive")
|
||||
}
|
||||
for i, m := range o.GroupMappings {
|
||||
if m.Group == "" || m.Team == "" {
|
||||
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: group and team are required", i)
|
||||
}
|
||||
if m.Role != "owner" && m.Role != "member" {
|
||||
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: role must be owner or member, got %q", i, m.Role)
|
||||
}
|
||||
}
|
||||
if c.DisablePasswordLogin && len(o.GroupMappings) == 0 && o.AdminGroup == "" {
|
||||
return errors.New("TERDUT_PASSWORD_LOGIN=false with no OIDC group grants leaves nobody able to do anything")
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -28,16 +28,6 @@ func TestValidate(t *testing.T) {
|
||||
{"missing secret", func(m map[string]string) { delete(m, "TERDUT_OIDC_CLIENT_SECRET") }, "CLIENT_SECRET"},
|
||||
{"missing public url", func(m map[string]string) { delete(m, "TERDUT_PUBLIC_URL") }, "PUBLIC_URL"},
|
||||
{"bad issuer", func(m map[string]string) { m["TERDUT_OIDC_ISSUER"] = "not a url" }, "not a URL"},
|
||||
{"bad mapping json", func(m map[string]string) { m["TERDUT_OIDC_GROUP_MAPPINGS"] = "{nope" }, "GROUP_MAPPINGS"},
|
||||
{"bad mapping role", func(m map[string]string) {
|
||||
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","team":"t","role":"admin"}]`
|
||||
}, "role must be"},
|
||||
{"mapping needs team", func(m map[string]string) {
|
||||
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","role":"member"}]`
|
||||
}, "group and team"},
|
||||
{"good mapping", func(m map[string]string) {
|
||||
m["TERDUT_OIDC_GROUP_MAPPINGS"] = `[{"group":"g","team":"t","role":"owner"}]`
|
||||
}, ""},
|
||||
{"password off without sso", func(m map[string]string) {
|
||||
clear(m)
|
||||
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
||||
@@ -56,7 +46,7 @@ func TestValidate(t *testing.T) {
|
||||
tt.env(env)
|
||||
for _, k := range []string{
|
||||
"TERDUT_PUBLIC_URL", "TERDUT_PASSWORD_LOGIN", "TERDUT_OIDC_ISSUER", "TERDUT_OIDC_CLIENT_ID",
|
||||
"TERDUT_OIDC_CLIENT_SECRET", "TERDUT_OIDC_ADMIN_GROUP", "TERDUT_OIDC_GROUP_MAPPINGS",
|
||||
"TERDUT_OIDC_CLIENT_SECRET", "TERDUT_OIDC_ADMIN_GROUP",
|
||||
} {
|
||||
t.Setenv(k, env[k])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Per-team OIDC group configuration, replacing the global
|
||||
-- TERDUT_OIDC_GROUP_MAPPINGS env var.
|
||||
--
|
||||
-- Group -> team -> role used to be one global list an operator set for the
|
||||
-- whole install, matched against a team by name, and the sync would create
|
||||
-- the team if no team by that name existed yet. That put the decision of
|
||||
-- which group controls a team in the server's environment rather than the
|
||||
-- team's own hands, meant changing it needed an env var edit and a restart,
|
||||
-- and let a typo in a team name silently create a stray team.
|
||||
--
|
||||
-- Each team now names, itself, which group grants membership and which
|
||||
-- grants ownership. Nullable: most teams need neither. No uniqueness
|
||||
-- constraint on either column — two teams may legitimately watch the same
|
||||
-- provider group (a broad team and a narrower one both keyed off overlapping
|
||||
-- groups is a choice for their owners to make, not one the schema should
|
||||
-- refuse).
|
||||
--
|
||||
-- BREAKING CHANGE, deliberately not auto-migrated: TERDUT_OIDC_GROUP_MAPPINGS
|
||||
-- stops being read as of this version, and the sync no longer creates a team
|
||||
-- by name. Every team's group binding must be set again through
|
||||
-- PUT /api/teams/{teamID}/oidc-groups. Until an owner does that, an
|
||||
-- OIDC-sourced membership in that team is dropped at that user's next SSO
|
||||
-- sign-in, the same way any other loss of group access is handled. See the
|
||||
-- README's OIDC section.
|
||||
ALTER TABLE teams ADD COLUMN oidc_member_group TEXT;
|
||||
ALTER TABLE teams ADD COLUMN oidc_owner_group TEXT;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Service accounts: a scoped, non-human credential for automation (e.g.
|
||||
-- terdut-operator) that needs to manage teams, escalation policies, dead
|
||||
-- man's switches, integrations and OIDC group bindings without impersonating
|
||||
-- a human user. See SERVICE-ACCOUNTS.md for the design this implements.
|
||||
--
|
||||
-- Deliberately not a users row: no password_hash, no is_admin, no
|
||||
-- user_identities linkage, so a service account can never be pulled into
|
||||
-- OIDC group sync or password login, and is never mistaken for a human in an
|
||||
-- audit trail.
|
||||
--
|
||||
-- scope is 'instance' (acts with the same reach system administration has
|
||||
-- over teams: create one, list them, mint a 'team'-scoped account against
|
||||
-- any of them) or 'team' (acts as that one team's owner, and nothing else).
|
||||
-- The CHECK ties team_id's presence to scope directly, rather than leaving it
|
||||
-- to application code to keep the two consistent.
|
||||
CREATE TABLE service_accounts (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('instance', 'team')),
|
||||
team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||
CONSTRAINT service_accounts_scope_team_id_chk CHECK (
|
||||
(scope = 'team' AND team_id IS NOT NULL) OR
|
||||
(scope = 'instance' AND team_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX service_accounts_team_id_idx ON service_accounts(team_id);
|
||||
|
||||
-- One account, many keys: rotation is minting a new one and revoking the
|
||||
-- old, the same shape api_keys already has, so an account's identity and
|
||||
-- audit history survive a rotation instead of being recreated by it.
|
||||
CREATE TABLE service_account_keys (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
service_account_id BIGINT NOT NULL REFERENCES service_accounts(id) ON DELETE CASCADE,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
||||
last_used_at BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX service_account_keys_service_account_id_idx ON service_account_keys(service_account_id);
|
||||
@@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Service account scopes. Instance acts with the same reach system
|
||||
// administration has over teams: create one, list them, mint a team-scoped
|
||||
// account against any of them. Team acts as that one team's owner, and
|
||||
// nothing else.
|
||||
const (
|
||||
ServiceAccountScopeInstance = "instance"
|
||||
ServiceAccountScopeTeam = "team"
|
||||
)
|
||||
|
||||
// ServiceAccount is a non-human credential: not a users row, so it never
|
||||
// touches OIDC group sync, login, or the is_admin flag, and is never mistaken
|
||||
// for a human in an audit trail (see api_keys' user_id, which every service
|
||||
// account key deliberately does not have).
|
||||
type ServiceAccount struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
TeamID *int64 `json:"team_id,omitempty"`
|
||||
CreatedBy *int64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ServiceAccountKey is one bearer credential on a ServiceAccount. Multiple
|
||||
// keys per account, the same shape as APIKey, are what let rotation mint a
|
||||
// new one and revoke the old without recreating the account.
|
||||
type ServiceAccountKey struct {
|
||||
ID int64 `json:"id"`
|
||||
ServiceAccountID int64 `json:"service_account_id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
Key string `json:"key,omitempty"` // populated only on creation, never stored
|
||||
}
|
||||
+40
-14
@@ -18,7 +18,9 @@ const (
|
||||
roleMember = "member"
|
||||
)
|
||||
|
||||
// Grants is the access a set of groups confers.
|
||||
// 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.
|
||||
@@ -26,21 +28,16 @@ type Grants struct {
|
||||
|
||||
// Admin is whether the user is in the admin group.
|
||||
Admin bool
|
||||
|
||||
// Teams maps team name to role. Where several groups grant the same team the
|
||||
// highest role wins, so belonging to both a members group and an owners
|
||||
// group makes somebody an owner rather than whichever mapping came last.
|
||||
Teams map[string]string
|
||||
}
|
||||
|
||||
// ComputeGrants evaluates the configured mappings against groups.
|
||||
// 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
|
||||
}
|
||||
|
||||
g := Grants{Teams: map[string]string{}}
|
||||
var g Grants
|
||||
|
||||
g.Admitted = len(cfg.AllowedGroups) == 0
|
||||
for _, allowed := range cfg.AllowedGroups {
|
||||
@@ -54,16 +51,45 @@ func ComputeGrants(cfg config.OIDC, groups []string) Grants {
|
||||
}
|
||||
|
||||
g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup]
|
||||
return g
|
||||
}
|
||||
|
||||
for _, m := range cfg.GroupMappings {
|
||||
if !in[m.Group] {
|
||||
continue
|
||||
// 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 rank(m.Role) > rank(g.Teams[m.Team]) {
|
||||
g.Teams[m.Team] = m.Role
|
||||
if tg.OwnerGroup != "" && in[tg.OwnerGroup] && rank(roleOwner) > rank(role) {
|
||||
role = roleOwner
|
||||
}
|
||||
if role != "" {
|
||||
out[tg.TeamID] = role
|
||||
}
|
||||
}
|
||||
return g
|
||||
return out
|
||||
}
|
||||
|
||||
// rank orders roles; an unknown or absent role ranks lowest.
|
||||
|
||||
@@ -11,11 +11,6 @@ func testCfg() config.OIDC {
|
||||
return config.OIDC{
|
||||
AllowedGroups: []string{"terdut-users"},
|
||||
AdminGroup: "terdut-admins",
|
||||
GroupMappings: []config.GroupMapping{
|
||||
{Group: "sre", Team: "SRE", Role: "member"},
|
||||
{Group: "sre-leads", Team: "SRE", Role: "owner"},
|
||||
{Group: "platform", Team: "Platform", Role: "member"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,27 +23,17 @@ func TestComputeGrants(t *testing.T) {
|
||||
{
|
||||
name: "not in an allowed group is refused",
|
||||
groups: []string{"sre", "terdut-admins"},
|
||||
want: Grants{Admitted: false, Teams: map[string]string{}},
|
||||
want: Grants{Admitted: false},
|
||||
},
|
||||
{
|
||||
name: "allowed but no grants",
|
||||
groups: []string{"terdut-users"},
|
||||
want: Grants{Admitted: true, Teams: map[string]string{}},
|
||||
want: Grants{Admitted: true},
|
||||
},
|
||||
{
|
||||
name: "admin group grants admin",
|
||||
groups: []string{"terdut-users", "terdut-admins"},
|
||||
want: Grants{Admitted: true, Admin: true, Teams: map[string]string{}},
|
||||
},
|
||||
{
|
||||
name: "team roles from several groups",
|
||||
groups: []string{"terdut-users", "sre", "platform"},
|
||||
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "member", "Platform": "member"}},
|
||||
},
|
||||
{
|
||||
name: "highest role wins whatever the order",
|
||||
groups: []string{"sre-leads", "terdut-users", "sre"},
|
||||
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "owner"}},
|
||||
want: Grants{Admitted: true, Admin: true},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -69,6 +54,79 @@ func TestComputeGrants_NoAllowedGroupsAdmitsEveryone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -261,6 +261,33 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center;
|
||||
}
|
||||
|
||||
/* ---------- team selector ---------- */
|
||||
/* The global control for which team the app is scoped to. Hidden (via the
|
||||
`hidden` attribute, set from teamselector.js) for anybody in fewer than two
|
||||
teams, the same rule every other team-aware control in this file follows. */
|
||||
|
||||
.nav-team-selector,
|
||||
.team-selector-mobile {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||
background: var(--surface); color: var(--text);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
padding: 4px 12px; max-width: 100%;
|
||||
}
|
||||
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* A team's identity colour — not a status, so never the severity palette. Six
|
||||
colours, then they repeat; teamColorClass() in format.js picks one by the
|
||||
team's id, the same rcN convention the rota's per-person chips use. */
|
||||
.team-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); }
|
||||
.team-dot.rc1 { background: var(--accent); }
|
||||
.team-dot.rc2 { background: var(--ok); }
|
||||
.team-dot.rc3 { background: var(--snooze); }
|
||||
.team-dot.rc4 { background: var(--warn); }
|
||||
.team-dot.rc5 { background: var(--teal); }
|
||||
.team-dot.rc6 { background: var(--pink); }
|
||||
|
||||
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
|
||||
.view-page { padding-left: 16px; padding-right: 16px; }
|
||||
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
|
||||
@@ -282,6 +309,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
}
|
||||
.chips::-webkit-scrollbar { display: none; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
flex: none;
|
||||
min-height: 34px; padding: 0 12px;
|
||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||
@@ -611,6 +639,8 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
.account-name { font-size: 18px; font-weight: 750; }
|
||||
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
|
||||
.pw-form { display: grid; gap: 12px; padding: 16px; }
|
||||
.account-fold { border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.account-fold .stacked-form { margin-top: 8px; }
|
||||
.form-ok {
|
||||
margin: 0; padding: 10px 12px;
|
||||
background: var(--ok-soft); color: var(--ok);
|
||||
@@ -645,6 +675,7 @@ kbd {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
|
||||
}
|
||||
.nav-team-selector { margin: -8px 10px 14px; width: calc(100% - 20px); }
|
||||
.nav-link {
|
||||
flex-direction: row; justify-content: flex-start; gap: 12px;
|
||||
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
||||
@@ -784,7 +815,6 @@ kbd {
|
||||
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
|
||||
.stacked-form label.checkbox { gap: 8px; }
|
||||
.stacked-form input.wide { min-width: min(420px, 100%); }
|
||||
.team-picker { margin-top: 8px; max-width: 100%; }
|
||||
|
||||
/* The rota, a month at a time. A name is too wide to print thirty times and
|
||||
too alike down a column to read, so a day carries an initial in that
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
<img src="/icon.svg" alt="" width="28" height="28">
|
||||
<span>terdut</span>
|
||||
</a>
|
||||
<!-- Which team the app is scoped to. Hidden unless the signed-in user is
|
||||
in more than one; teamselector.js fills it in and wires the click. -->
|
||||
<button class="nav-team-selector" id="team-selector" type="button" hidden></button>
|
||||
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||
<span class="nav-label">Queue</span>
|
||||
@@ -126,6 +129,7 @@
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
||||
</button>
|
||||
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
|
||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||
</div>
|
||||
<span class="open-pill" id="open-pill" hidden></span>
|
||||
|
||||
@@ -43,9 +43,12 @@ function render() {
|
||||
//
|
||||
// The topic is the whole address — the server it is published to is the
|
||||
// install's one ntfy, set in the deployment and not something a user picks.
|
||||
function notifyStatus(topic) {
|
||||
return topic ? `Topic: ${topic}` : 'No topic set — pages go to the team’s fallback topic.';
|
||||
}
|
||||
|
||||
function notifyForm(user) {
|
||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||
const topic = h('input', {
|
||||
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
|
||||
autocapitalize: 'none', spellcheck: false,
|
||||
@@ -54,29 +57,7 @@ function notifyForm(user) {
|
||||
});
|
||||
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
|
||||
|
||||
// Only offered once a topic is saved: the test publishes to whatever the
|
||||
// server has stored, not to whatever is half-typed in the field.
|
||||
const test = h('button', {
|
||||
class: 'btn', type: 'button', text: 'Send a test push',
|
||||
hidden: !user.ntfy_topic,
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
ok.hidden = true;
|
||||
test.disabled = true;
|
||||
try {
|
||||
await api.testNotification();
|
||||
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
|
||||
ok.hidden = false;
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
} finally {
|
||||
test.disabled = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const form = h('form', { class: 'card pw-form' },
|
||||
const form = h('form', { class: 'stacked-form' },
|
||||
h('label', {},
|
||||
h('span', { text: 'ntfy topic' }),
|
||||
topic),
|
||||
@@ -89,25 +70,46 @@ function notifyForm(user) {
|
||||
h('p', { class: 'muted small' },
|
||||
'Anyone who knows the topic can read your pages and publish to it, so ',
|
||||
'pick something unguessable rather than your name.'),
|
||||
err, ok,
|
||||
h('div', { class: 'row-actions' }, submit, test),
|
||||
err,
|
||||
submit,
|
||||
);
|
||||
|
||||
const status = h('p', { class: 'muted', text: notifyStatus(user.ntfy_topic) });
|
||||
const summary = h('summary', { text: user.ntfy_topic ? 'Change topic' : 'Set a topic' });
|
||||
const details = h('details', { class: 'account-fold' }, summary, form);
|
||||
|
||||
// Only offered once a topic is saved: the test publishes to whatever the
|
||||
// server has stored, not to whatever is half-typed in the field.
|
||||
const test = h('button', {
|
||||
class: 'btn', type: 'button', text: 'Send a test push',
|
||||
hidden: !user.ntfy_topic,
|
||||
onclick: async () => {
|
||||
test.disabled = true;
|
||||
try {
|
||||
await api.testNotification();
|
||||
toast('Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.');
|
||||
} catch (ex) {
|
||||
toast(ex.message, 'error');
|
||||
} finally {
|
||||
test.disabled = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
err.hidden = true;
|
||||
ok.hidden = true;
|
||||
submit.disabled = true;
|
||||
try {
|
||||
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
|
||||
// Keep the cached user in step, so the onboarding checklist stops
|
||||
// asking for this and the test button appears without a reload.
|
||||
state.me.user = updated;
|
||||
ok.textContent = updated.ntfy_topic
|
||||
? 'Topic saved.'
|
||||
: 'Topic cleared. Your pages go to the team’s fallback topic.';
|
||||
ok.hidden = false;
|
||||
status.textContent = notifyStatus(updated.ntfy_topic);
|
||||
summary.textContent = updated.ntfy_topic ? 'Change topic' : 'Set a topic';
|
||||
test.hidden = !updated.ntfy_topic;
|
||||
details.open = false;
|
||||
toast(updated.ntfy_topic ? 'Topic saved' : 'Topic cleared. Your pages go to the team’s fallback topic.');
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
@@ -115,7 +117,11 @@ function notifyForm(user) {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
return form;
|
||||
|
||||
return h('div', { class: 'card pw-form' },
|
||||
status,
|
||||
h('div', { class: 'row-actions' }, test),
|
||||
details);
|
||||
}
|
||||
|
||||
// With password login switched off a password opens nothing, so somebody who
|
||||
@@ -131,14 +137,13 @@ function passwordSection(user, hasPassword) {
|
||||
];
|
||||
}
|
||||
return [
|
||||
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
|
||||
passwordForm(user, hasPassword),
|
||||
];
|
||||
}
|
||||
|
||||
function passwordForm(user, hasPassword) {
|
||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||
const current = hasPassword
|
||||
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
|
||||
: null;
|
||||
@@ -148,18 +153,24 @@ function passwordForm(user, hasPassword) {
|
||||
|
||||
// A hidden username field lets password managers file the new password
|
||||
// under the right account.
|
||||
const form = h('form', { class: 'card pw-form', autocomplete: 'on' },
|
||||
const form = h('form', { class: 'stacked-form', autocomplete: 'on' },
|
||||
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
|
||||
current && h('label', {}, h('span', { text: 'Current password' }), current),
|
||||
h('label', {}, h('span', { text: 'New password' }), next),
|
||||
h('label', {}, h('span', { text: 'Repeat new password' }), again),
|
||||
err, ok, submit,
|
||||
err, submit,
|
||||
);
|
||||
|
||||
const status = h('p', {
|
||||
class: 'muted',
|
||||
text: hasPassword ? 'Password set.' : 'No password set — sign-in needs one of the other methods.',
|
||||
});
|
||||
const summary = h('summary', { text: hasPassword ? 'Change password' : 'Set a password' });
|
||||
const details = h('details', { class: 'account-fold' }, summary, form);
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
err.hidden = true;
|
||||
ok.hidden = true;
|
||||
if (next.value !== again.value) {
|
||||
err.textContent = 'The new passwords do not match.';
|
||||
err.hidden = false;
|
||||
@@ -171,13 +182,14 @@ function passwordForm(user, hasPassword) {
|
||||
state.me.has_password = true;
|
||||
form.reset();
|
||||
if (!current) {
|
||||
// From now on the form needs the current-password field.
|
||||
// From now on the form needs the current-password field, and a fresh
|
||||
// render already comes up with the fold closed.
|
||||
render();
|
||||
toast('Password saved');
|
||||
return;
|
||||
}
|
||||
ok.textContent = 'Password saved. Other devices have been signed out.';
|
||||
ok.hidden = false;
|
||||
details.open = false;
|
||||
toast('Password saved. Other devices have been signed out.');
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
@@ -185,7 +197,7 @@ function passwordForm(user, hasPassword) {
|
||||
submit.disabled = false;
|
||||
}
|
||||
});
|
||||
return form;
|
||||
return h('div', { class: 'card pw-form' }, status, details);
|
||||
}
|
||||
|
||||
function shortcuts() {
|
||||
|
||||
@@ -146,6 +146,13 @@ function identityCard() {
|
||||
fact('Created', when(t.created_at)),
|
||||
fact('Members', String(t.members)),
|
||||
fact('Open incidents', String(t.open_incidents)),
|
||||
// Read-only here: an administrator can see why a team's OIDC-sourced
|
||||
// membership looks the way it does, but setting it is the team's own
|
||||
// owner's call, from the Team tab.
|
||||
...(state.auth?.oidc?.enabled ? [
|
||||
fact('OIDC member group', t.oidc_member_group || '—'),
|
||||
fact('OIDC owner group', t.oidc_owner_group || '—'),
|
||||
] : []),
|
||||
),
|
||||
form, err, ok,
|
||||
);
|
||||
|
||||
@@ -135,6 +135,10 @@ export const addTeamMember = (id, userID, role) =>
|
||||
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
|
||||
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
|
||||
|
||||
// Which OIDC groups grant member and owner access to this team.
|
||||
export const oidcGroups = (id) => call('GET', `/teams/${id}/oidc-groups`);
|
||||
export const setOidcGroups = (id, body) => call('PUT', `/teams/${id}/oidc-groups`, { body });
|
||||
|
||||
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
|
||||
export const createIntegration = (id, name) =>
|
||||
call('POST', `/teams/${id}/integrations`, { body: { name } });
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as alerts from './alerts.js';
|
||||
import * as stats from './stats.js';
|
||||
import * as account from './account.js';
|
||||
import * as team from './team.js';
|
||||
import * as teamselector from './teamselector.js';
|
||||
import * as admin from './admin.js';
|
||||
import * as adminuser from './adminuser.js';
|
||||
import * as adminteam from './adminteam.js';
|
||||
@@ -230,6 +231,7 @@ async function boot() {
|
||||
$('login-form').addEventListener('submit', onLogin);
|
||||
$('signup-form').addEventListener('submit', onSignup);
|
||||
$('menu-btn').addEventListener('click', openNavMenu);
|
||||
teamselector.init();
|
||||
ssoErrorCode = takeSSOError();
|
||||
|
||||
// /signup is the one route that works without a session.
|
||||
@@ -245,6 +247,7 @@ async function boot() {
|
||||
await loadAuthConfig();
|
||||
state.me = await api.me();
|
||||
await loadTeams();
|
||||
teamselector.render();
|
||||
// The Admin tab exists only for an administrator. Somebody who types /admin
|
||||
// anyway gets the view's own "ask an administrator" card, not a blank page.
|
||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||
@@ -328,6 +331,7 @@ async function onSignup(e) {
|
||||
history.replaceState({ depth: 0 }, '', '/');
|
||||
route = parseRoute('/');
|
||||
await loadTeams();
|
||||
teamselector.render();
|
||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||
showApp();
|
||||
} catch (ex) {
|
||||
|
||||
@@ -98,6 +98,14 @@ export function severityClass(sev) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// A stable identity colour for a team, so the same team always reads the same
|
||||
// colour without the server needing to store one. Teams have no colour field;
|
||||
// this hashes the id into the six-colour rcN palette app.css already has for
|
||||
// the rota's per-person chips (a team is not a status, so never severity).
|
||||
export function teamColorClass(teamID) {
|
||||
return `rc${(((teamID % 6) + 6) % 6) + 1}`;
|
||||
}
|
||||
|
||||
// A one-line summary of the group labels, without the one the title already shows.
|
||||
export function labelSummary(labels, skip = 'alertname') {
|
||||
return Object.entries(labels || {})
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
|
||||
import { state, myID } from './state.js';
|
||||
import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
|
||||
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
|
||||
import * as onboarding from './onboarding.js';
|
||||
import { navigate } from './app.js';
|
||||
|
||||
@@ -27,34 +27,21 @@ const EMPTY = {
|
||||
};
|
||||
|
||||
onboarding.onRerender(() => renderList());
|
||||
// The queue used to keep its own team filter (a per-tab sessionStorage value,
|
||||
// out of step with team.js's own picker); both now defer to the global
|
||||
// selector's shared state, so re-render whenever it changes.
|
||||
onTeamChange(() => {
|
||||
renderChips();
|
||||
refresh({ fresh: true });
|
||||
});
|
||||
|
||||
let filter = loadFilter();
|
||||
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
|
||||
let items = null; // null while loading
|
||||
let error = null;
|
||||
let selected = null;
|
||||
let cursor = -1; // keyboard position in the list
|
||||
let built = false;
|
||||
|
||||
function loadTeamFilter() {
|
||||
try {
|
||||
return sessionStorage.getItem('terdut.queue.team') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function setTeamFilter(id) {
|
||||
teamFilter = id;
|
||||
try {
|
||||
sessionStorage.setItem('terdut.queue.team', id);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
renderChips();
|
||||
refresh({ fresh: true });
|
||||
}
|
||||
|
||||
function loadFilter() {
|
||||
try {
|
||||
const f = sessionStorage.getItem('terdut.queue.filter');
|
||||
@@ -89,8 +76,8 @@ export async function refresh({ fresh = false } = {}) {
|
||||
// The open list is already fetched for the badges; no need to ask twice.
|
||||
// The cached open queue covers every team, so it can only be reused when
|
||||
// no team filter is applied.
|
||||
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
|
||||
const cached = filter === 'open' && !fresh && !teamFilter;
|
||||
const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query;
|
||||
const cached = filter === 'open' && !fresh && state.selectedTeamID == null;
|
||||
const result = cached ? state.open : await api.incidents(query);
|
||||
await onboarding.load();
|
||||
if (requested !== filter) return;
|
||||
@@ -136,19 +123,20 @@ function renderChips() {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(teamFilter === ''),
|
||||
onclick: () => setTeamFilter(''),
|
||||
text: 'All teams',
|
||||
}));
|
||||
'aria-selected': String(state.selectedTeamID == null),
|
||||
onclick: () => setSelectedTeam(null),
|
||||
}, h('span', { class: 'team-dot' }), ' All teams'));
|
||||
for (const team of state.teams) {
|
||||
chips.push(h('button', {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(teamFilter === String(team.id)),
|
||||
onclick: () => setTeamFilter(String(team.id)),
|
||||
text: team.name,
|
||||
}));
|
||||
'aria-selected': String(team.id === state.selectedTeamID),
|
||||
onclick: () => setSelectedTeam(team.id),
|
||||
},
|
||||
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
|
||||
' ' + team.name,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,53 @@ export const state = {
|
||||
auth: { password_login: true, oidc: { enabled: false, name: '' } },
|
||||
open: [], // the default queue: open, not snoozed
|
||||
teams: [], // the teams the viewer belongs to, each with their role
|
||||
// Which team the whole app is scoped to right now; null means "All teams".
|
||||
// Set only through setSelectedTeam below, never assigned directly, so every
|
||||
// view stays in sync and the choice is remembered across reloads.
|
||||
selectedTeamID: loadSelectedTeam(),
|
||||
};
|
||||
|
||||
// The team whose schedule and settings the views act on. A viewer in one team —
|
||||
// which is everybody until somebody makes a second — never has to choose.
|
||||
const SELECTED_TEAM_KEY = 'terdut.selectedTeam';
|
||||
|
||||
function loadSelectedTeam() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SELECTED_TEAM_KEY);
|
||||
return raw ? Number(raw) : null;
|
||||
} catch {
|
||||
return null; // storage unavailable, or nothing saved yet
|
||||
}
|
||||
}
|
||||
|
||||
// Callbacks to run whenever the selected team changes, so every view that
|
||||
// cares — the queue's filter, the Team settings page, the selector's own
|
||||
// trigger — stays in sync without a general event bus, following the one
|
||||
// precedent for this in the codebase: onboarding.js's onRerender.
|
||||
const teamListeners = [];
|
||||
export function onTeamChange(cb) {
|
||||
teamListeners.push(cb);
|
||||
}
|
||||
|
||||
// setSelectedTeam changes which team the app is scoped to (id, or null for
|
||||
// "All teams"), persists it — a durable preference, unlike the per-tab
|
||||
// sessionStorage filter this replaces — and tells every registered listener.
|
||||
export function setSelectedTeam(id) {
|
||||
state.selectedTeamID = id;
|
||||
try {
|
||||
if (id == null) localStorage.removeItem(SELECTED_TEAM_KEY);
|
||||
else localStorage.setItem(SELECTED_TEAM_KEY, String(id));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
for (const cb of teamListeners) cb();
|
||||
}
|
||||
|
||||
// The team whose schedule and settings the views act on: the selected team,
|
||||
// falling back to the first one the viewer belongs to — which is everybody's
|
||||
// only team until somebody makes a second, or the stored selection naming a
|
||||
// team the account has since left.
|
||||
export function currentTeam() {
|
||||
return state.teams[0] || null;
|
||||
const teams = state.teams || [];
|
||||
return teams.find((t) => t.id === state.selectedTeamID) || teams[0] || null;
|
||||
}
|
||||
|
||||
export function myID() {
|
||||
@@ -36,6 +77,12 @@ export async function users() {
|
||||
|
||||
export async function loadTeams() {
|
||||
state.teams = await api.teams();
|
||||
// A stored id that no longer names one of the account's teams — left it, or
|
||||
// this is simply a different account signed in on the same browser — is as
|
||||
// good as unset.
|
||||
if (state.selectedTeamID != null && !state.teams.some((t) => t.id === state.selectedTeamID)) {
|
||||
state.selectedTeamID = null;
|
||||
}
|
||||
return state.teams;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { state, currentTeam, users as allUsers, myID } from './state.js';
|
||||
import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
|
||||
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
|
||||
|
||||
const view = () => document.getElementById('view-team');
|
||||
@@ -41,6 +41,9 @@ export const TABS = [
|
||||
{ tab: 'deadman', path: '/team/deadman', label: 'Switches', title: 'Dead man’s switches' },
|
||||
];
|
||||
|
||||
// Cached from currentTeam() on each refresh(), for the many actions below
|
||||
// (assignSchedule, addTeamMember, ...) that need a plain id rather than a
|
||||
// round trip through state.
|
||||
let teamID = null;
|
||||
// Which sub-section is open. Remembered rather than passed, because the poll
|
||||
// loop calls refresh() with no route.
|
||||
@@ -49,6 +52,13 @@ let data = null; // { team, ... }; which fields are present varies by tab
|
||||
let error = null;
|
||||
let freshKey = null; // an integration key, shown once, until the view is left
|
||||
|
||||
// The global team selector is what changes which team this page shows now;
|
||||
// re-fetch under whichever sub-section is open when it fires.
|
||||
onTeamChange(() => {
|
||||
data = null;
|
||||
refresh();
|
||||
});
|
||||
|
||||
export function show(route) {
|
||||
const next = route?.tab ?? null;
|
||||
// A different sub-section wants different data, so the old answer goes
|
||||
@@ -61,13 +71,8 @@ export function show(route) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
function selectedTeam() {
|
||||
const teams = state.teams || [];
|
||||
return teams.find((t) => t.id === teamID) || currentTeam();
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
const team = selectedTeam();
|
||||
const team = currentTeam();
|
||||
if (!team) {
|
||||
data = null;
|
||||
render();
|
||||
@@ -101,8 +106,12 @@ async function load(id) {
|
||||
return { members, schedule };
|
||||
}
|
||||
if (tab === 'members') {
|
||||
const [members, users] = await Promise.all([api.teamMembers(id), allUsers()]);
|
||||
return { members, users };
|
||||
const [members, users, oidcGroups] = await Promise.all([
|
||||
api.teamMembers(id),
|
||||
allUsers(),
|
||||
state.auth?.oidc?.enabled ? api.oidcGroups(id) : null,
|
||||
]);
|
||||
return { members, users, oidcGroups };
|
||||
}
|
||||
if (tab === 'escalation') {
|
||||
const [members, escalation] = await Promise.all([api.teamMembers(id), api.escalation(id)]);
|
||||
@@ -168,24 +177,12 @@ function subnav() {
|
||||
})));
|
||||
}
|
||||
|
||||
// Only shown to somebody in more than one team, like the queue's filter chips.
|
||||
// It is above the sections rather than inside one because it changes the
|
||||
// subject of all six.
|
||||
// Names which team's settings the six sections below belong to. It used to be
|
||||
// a picker of its own for somebody in more than one team; that job now belongs
|
||||
// to the global team selector in the nav, which is what onTeamChange above
|
||||
// reacts to.
|
||||
function teamPicker() {
|
||||
if ((state.teams || []).length < 2) {
|
||||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
||||
}
|
||||
const select = h('select', { class: 'team-picker' },
|
||||
...state.teams.map((t) => h('option', {
|
||||
value: String(t.id), text: t.name, selected: t.id === teamID,
|
||||
})));
|
||||
select.addEventListener('change', () => {
|
||||
teamID = Number(select.value);
|
||||
data = null;
|
||||
freshKey = null;
|
||||
refresh();
|
||||
});
|
||||
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
|
||||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
||||
}
|
||||
|
||||
// --- overview --------------------------------------------------------------
|
||||
@@ -1046,6 +1043,73 @@ function shiftCell(m) {
|
||||
: h('span', { text: 'not scheduled' });
|
||||
}
|
||||
|
||||
// The team's own OIDC group binding, shown only on an SSO-enabled install:
|
||||
// which group grants membership and which grants ownership. Read-only text
|
||||
// for a member, an edit sheet for an owner — the server enforces the same
|
||||
// split on the endpoint underneath.
|
||||
function oidcGroupsCard() {
|
||||
if (!state.auth?.oidc?.enabled) return null;
|
||||
const g = data.oidcGroups || { member_group: '', owner_group: '' };
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'card-head' },
|
||||
h('h2', { text: 'Single sign-on' }),
|
||||
isOwner() && h('button', {
|
||||
class: 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
|
||||
})),
|
||||
h('p', { class: 'muted small' },
|
||||
'Members of the group below are added to this team automatically at ',
|
||||
'sign-in; members of the owner group become owners. Leave a field ',
|
||||
'blank to grant nothing this way.'),
|
||||
h('dl', { class: 'user-facts' },
|
||||
fact('Member group', g.member_group || '—'),
|
||||
fact('Owner group', g.owner_group || '—'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function fact(label, value) {
|
||||
return [h('dt', { text: label }), h('dd', { text: value })];
|
||||
}
|
||||
|
||||
function openOidcGroupsEditor() {
|
||||
const g = data.oidcGroups || { member_group: '', owner_group: '' };
|
||||
const memberGroup = h('input', {
|
||||
type: 'text', value: g.member_group, placeholder: 'e.g. sre', autofocus: true,
|
||||
});
|
||||
const ownerGroup = h('input', { type: 'text', value: g.owner_group, placeholder: 'e.g. sre-leads' });
|
||||
const problem = h('p', { class: 'load-error', hidden: true });
|
||||
|
||||
const form = h('form', { class: 'stacked-form' },
|
||||
h('label', {}, 'Member group ', memberGroup),
|
||||
h('label', {}, 'Owner group ', ownerGroup),
|
||||
h('p', { class: 'muted small' },
|
||||
'A person in both becomes an owner. Whoever the group lists is kept in ',
|
||||
'sync at their next sign-in — a member added by hand can still be made ',
|
||||
'an owner, but not the other way round.'),
|
||||
problem,
|
||||
h('div', { class: 'sheet-actions' },
|
||||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save' })));
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.setOidcGroups(teamID, {
|
||||
member_group: memberGroup.value.trim(),
|
||||
owner_group: ownerGroup.value.trim(),
|
||||
});
|
||||
} catch (err) {
|
||||
problem.textContent = err.message;
|
||||
problem.hidden = false;
|
||||
return;
|
||||
}
|
||||
closeSheet(true);
|
||||
refresh();
|
||||
});
|
||||
|
||||
openSheet(() => [h('h2', { class: 'sheet-title', text: 'Single sign-on groups' }), form]);
|
||||
}
|
||||
|
||||
function membersCard() {
|
||||
const members = data.members || [];
|
||||
const owners = members.filter((m) => m.role === 'owner').length;
|
||||
@@ -1090,7 +1154,7 @@ function membersCard() {
|
||||
);
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
return [oidcGroupsCard(), h('div', { class: 'card' },
|
||||
h('div', { class: 'card-head' },
|
||||
h('h2', { text: 'Members' }),
|
||||
isOwner() && h('button', {
|
||||
@@ -1108,7 +1172,7 @@ function membersCard() {
|
||||
h('th'))),
|
||||
h('tbody', {}, rows)))
|
||||
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
|
||||
);
|
||||
)];
|
||||
}
|
||||
|
||||
// One sheet for both jobs a member's row has: who, and as what. Adding is
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user