Let each team name its own OIDC group, not a global mapping

Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.

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

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

TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
This commit is contained in:
Niklas Ye
2026-09-27 11:43:57 +02:00
parent 97a4814c04
commit 5b4683febf
17 changed files with 554 additions and 129 deletions
+22 -8
View File
@@ -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
@@ -329,7 +342,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.
@@ -829,6 +841,8 @@ administrator who is not in the team gets the same `404` as anybody else.
| `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 |