Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee576f793 | |||
| b610b1817a | |||
| 949d6595ba | |||
| 33356ca978 | |||
| e5b4df7c03 | |||
| 5b4683febf |
@@ -152,9 +152,14 @@ TERDUT_OIDC_CLIENT_ID=terdut
|
|||||||
TERDUT_OIDC_CLIENT_SECRET=...
|
TERDUT_OIDC_CLIENT_SECRET=...
|
||||||
TERDUT_OIDC_ALLOWED_GROUPS=terdut-users,terdut-admins
|
TERDUT_OIDC_ALLOWED_GROUPS=terdut-users,terdut-admins
|
||||||
TERDUT_OIDC_ADMIN_GROUP=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
|
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
|
`/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
|
`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
|
2. *Whether.* With `TERDUT_OIDC_ALLOWED_GROUPS` set, somebody in none of them is
|
||||||
refused and nothing is created.
|
refused and nothing is created.
|
||||||
3. *What.* The administrator flag follows `TERDUT_OIDC_ADMIN_GROUP`. Team roles
|
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
|
follow each team's own `oidc_member_group`/`oidc_owner_group`; where both of a
|
||||||
wins.
|
team's groups match, the owner group wins.
|
||||||
|
|
||||||
**Managed access.** What the sync grants is marked as managed by single sign-on,
|
**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
|
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
|
(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.
|
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
|
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
|
hand-added member whose team's own group grants a *higher* role, who is raised and
|
||||||
on managed. Editing managed access by hand (`POST` or `DELETE` on a team's
|
from then on managed. Editing managed access by hand (`POST` or `DELETE` on a
|
||||||
members, revoking an SSO-granted administrator) is refused with `409`, since the
|
team's members, revoking an SSO-granted administrator) is refused with `409`, since
|
||||||
next sign-in would undo it.
|
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
|
**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
|
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_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_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_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 |
|
| `TERDUT_OIDC_SESSION_MAX_AGE` | `12h` | Hard ceiling on a session made by an SSO sign-in |
|
||||||
|
|
||||||
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
|
Durations use Go syntax (`30m`, `12h`, `168h`). An unparseable value falls back to the default.
|
||||||
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `PATCH` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Rename `{"name"}`. The key does not change |
|
||||||
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
|
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.29.1
|
version: 0.32.0
|
||||||
appVersion: "v0.29.1"
|
appVersion: "v0.32.0"
|
||||||
|
|||||||
@@ -107,10 +107,6 @@ spec:
|
|||||||
- name: TERDUT_OIDC_ADMIN_GROUP
|
- name: TERDUT_OIDC_ADMIN_GROUP
|
||||||
value: {{ .Values.oidc.adminGroup | quote }}
|
value: {{ .Values.oidc.adminGroup | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.oidc.groupMappings }}
|
|
||||||
- name: TERDUT_OIDC_GROUP_MAPPINGS
|
|
||||||
value: {{ .Values.oidc.groupMappings | toJson | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
|
|||||||
@@ -148,12 +148,10 @@ oidc:
|
|||||||
allowedGroups: []
|
allowedGroups: []
|
||||||
# Members of this group are system administrators.
|
# Members of this group are system administrators.
|
||||||
adminGroup: ""
|
adminGroup: ""
|
||||||
# Team roles from groups. A team that does not exist is created. Where several
|
# Which group grants a team's membership and ownership is each team's own
|
||||||
# groups grant the same team the highest role wins.
|
# setting now, not chart config: an owner sets it from the Members tab, or
|
||||||
# - group: sre
|
# PUT /api/teams/{teamID}/oidc-groups. A team must already exist for a group
|
||||||
# team: SRE
|
# to grant access to it.
|
||||||
# role: member # member or owner
|
|
||||||
groupMappings: []
|
|
||||||
# Hard ceiling on a session made by a single sign-on login.
|
# Hard ceiling on a session made by a single sign-on login.
|
||||||
sessionMaxAge: 12h
|
sessionMaxAge: 12h
|
||||||
|
|
||||||
|
|||||||
+41
-15
@@ -211,7 +211,15 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
var se ssoError
|
var se ssoError
|
||||||
if errors.As(err, &se) {
|
if errors.As(err, &se) {
|
||||||
@@ -253,7 +261,7 @@ func safeNext(next string) string {
|
|||||||
|
|
||||||
// signInSSO resolves the identity to a user and applies its grants, in one
|
// 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.
|
// 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)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -272,12 +280,34 @@ func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identi
|
|||||||
if disabled {
|
if disabled {
|
||||||
return 0, ssoDisabled
|
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 0, err
|
||||||
}
|
}
|
||||||
return userID, tx.Commit()
|
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
|
// resolveSSOUser finds the user an identity belongs to, linking or creating one
|
||||||
// when this is its first sign-in.
|
// when this is its first sign-in.
|
||||||
//
|
//
|
||||||
@@ -407,7 +437,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
|
// 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
|
// only ever raises them (turning them into 'oidc' rows), never lowers or removes
|
||||||
// them.
|
// 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.
|
// Administrator. A manual administrator stays one whatever the groups say.
|
||||||
if g.Admin {
|
if g.Admin {
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
@@ -422,21 +456,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.
|
// Teams. The result of the loop is the set of teams the groups grant.
|
||||||
granted := make([]int64, 0, len(g.Teams))
|
granted := make([]int64, 0, len(teamRoles))
|
||||||
for name, role := range g.Teams {
|
for teamID, role := range teamRoles {
|
||||||
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 = append(granted, teamID)
|
granted = append(granted, teamID)
|
||||||
|
|
||||||
// A row the sync owns follows the groups in both directions. One added by
|
// 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
|
// 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, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO team_members (team_id, user_id, role, source)
|
INSERT INTO team_members (team_id, user_id, role, source)
|
||||||
VALUES ($1, $2, $3, 'oidc')
|
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
|
badNonce bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ssoConfig is a terdut configuration wired to idp, with the mapping the tests
|
// ssoConfig is a terdut configuration wired to idp: terdut-users may sign in,
|
||||||
// share: terdut-users may sign in, terdut-admins administer, and the sre groups
|
// terdut-admins administer. Which groups grant which team is not config
|
||||||
// grant roles in the SRE team.
|
// 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 {
|
func ssoConfig(idp *fakeIdP) config.Config {
|
||||||
c := testConfig()
|
c := testConfig()
|
||||||
c.OIDC = config.OIDC{
|
c.OIDC = config.OIDC{
|
||||||
@@ -143,16 +144,28 @@ func ssoConfig(idp *fakeIdP) config.Config {
|
|||||||
GroupsClaim: "groups",
|
GroupsClaim: "groups",
|
||||||
AllowedGroups: []string{"terdut-users"},
|
AllowedGroups: []string{"terdut-users"},
|
||||||
AdminGroup: "terdut-admins",
|
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,
|
SessionMaxAge: 12 * time.Hour,
|
||||||
}
|
}
|
||||||
return c
|
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 {
|
func newSSOTS(t *testing.T, idp *fakeIdP, tweak ...func(*config.Config)) *ts {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c := ssoConfig(idp)
|
c := ssoConfig(idp)
|
||||||
@@ -290,6 +303,8 @@ func sameMap(a, b map[string]string) bool {
|
|||||||
func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) {
|
func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) {
|
||||||
idp := newFakeIdP(t)
|
idp := newFakeIdP(t)
|
||||||
s := newSSOTS(t, idp)
|
s := newSSOTS(t, idp)
|
||||||
|
s.seedTeam(t, "SRE", "sre", "sre-leads")
|
||||||
|
s.seedTeam(t, "Platform", "platform", "")
|
||||||
b := ssoBrowser(t, s)
|
b := ssoBrowser(t, s)
|
||||||
|
|
||||||
if loc := signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "platform")); loc != "/" {
|
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) {
|
func TestSSO_RefusedOutsideAllowedGroups(t *testing.T) {
|
||||||
idp := newFakeIdP(t)
|
idp := newFakeIdP(t)
|
||||||
s := newSSOTS(t, idp)
|
s := newSSOTS(t, idp)
|
||||||
@@ -362,6 +398,7 @@ func TestSSO_ManualAdminIsNeverRevoked(t *testing.T) {
|
|||||||
func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) {
|
func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) {
|
||||||
idp := newFakeIdP(t)
|
idp := newFakeIdP(t)
|
||||||
s := newSSOTS(t, idp)
|
s := newSSOTS(t, idp)
|
||||||
|
s.seedTeam(t, "SRE", "sre", "sre-leads")
|
||||||
|
|
||||||
signInSSO(t, idp, ssoBrowser(t, s), alice)
|
signInSSO(t, idp, ssoBrowser(t, s), alice)
|
||||||
// Somebody adds alice to another team by hand.
|
// Somebody adds alice to another team by hand.
|
||||||
@@ -379,6 +416,7 @@ func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) {
|
|||||||
func TestSSO_HighestRoleWinsAndRoleChangesFollow(t *testing.T) {
|
func TestSSO_HighestRoleWinsAndRoleChangesFollow(t *testing.T) {
|
||||||
idp := newFakeIdP(t)
|
idp := newFakeIdP(t)
|
||||||
s := newSSOTS(t, idp)
|
s := newSSOTS(t, idp)
|
||||||
|
s.seedTeam(t, "SRE", "sre", "sre-leads")
|
||||||
|
|
||||||
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "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"}) {
|
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.
|
// 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 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)
|
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')`)
|
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"}) {
|
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)
|
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) {
|
func TestSSO_ManagedAccessCannotBeEditedByHand(t *testing.T) {
|
||||||
idp := newFakeIdP(t)
|
idp := newFakeIdP(t)
|
||||||
s := newSSOTS(t, idp)
|
s := newSSOTS(t, idp)
|
||||||
|
s.seedTeam(t, "SRE", "sre", "")
|
||||||
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "terdut-admins"))
|
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "terdut-admins"))
|
||||||
|
|
||||||
var aliceID, sreID int64
|
var aliceID, sreID int64
|
||||||
@@ -712,6 +751,7 @@ func TestSSO_UnreachableProviderRedirectsWithAnError(t *testing.T) {
|
|||||||
func TestSSO_APIShowsWhereAccessCameFrom(t *testing.T) {
|
func TestSSO_APIShowsWhereAccessCameFrom(t *testing.T) {
|
||||||
idp := newFakeIdP(t)
|
idp := newFakeIdP(t)
|
||||||
s := newSSOTS(t, idp)
|
s := newSSOTS(t, idp)
|
||||||
|
s.seedTeam(t, "SRE", "sre", "")
|
||||||
b := ssoBrowser(t, s)
|
b := ssoBrowser(t, s)
|
||||||
signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "terdut-admins"))
|
signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "terdut-admins"))
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
|||||||
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
||||||
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
||||||
|
|
||||||
|
// A team's own OIDC group binding: which provider groups grant member
|
||||||
|
// and owner access to it.
|
||||||
|
r.Get("/api/teams/{teamID}/oidc-groups", handleGetTeamOIDCGroups(db))
|
||||||
|
r.Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(db))
|
||||||
|
|
||||||
// Invite links into this team.
|
// Invite links into this team.
|
||||||
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
||||||
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
||||||
|
|||||||
@@ -237,6 +237,14 @@ type adminTeam struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Members int64 `json:"members"`
|
Members int64 `json:"members"`
|
||||||
OpenIncidents int64 `json:"open_incidents"`
|
OpenIncidents int64 `json:"open_incidents"`
|
||||||
|
|
||||||
|
// OIDCMemberGroup and OIDCOwnerGroup are the team's own group binding,
|
||||||
|
// read-only here: an administrator can see why a team's OIDC-sourced
|
||||||
|
// membership looks the way it does without being able to change it out
|
||||||
|
// from under the team's owner. Setting it is PUT
|
||||||
|
// /api/teams/{teamID}/oidc-groups, owner-only.
|
||||||
|
OIDCMemberGroup string `json:"oidc_member_group,omitempty"`
|
||||||
|
OIDCOwnerGroup string `json:"oidc_owner_group,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAdminListTeams lists every team on the server, with its size. The
|
// handleAdminListTeams lists every team on the server, with its size. The
|
||||||
@@ -248,7 +256,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
|||||||
SELECT t.id, t.name, t.created_at,
|
SELECT t.id, t.name, t.created_at,
|
||||||
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||||
(SELECT COUNT(*) FROM incidents i
|
(SELECT COUNT(*) FROM incidents i
|
||||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
|
||||||
|
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
|
||||||
FROM teams t
|
FROM teams t
|
||||||
ORDER BY t.name`)
|
ORDER BY t.name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,7 +270,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t adminTeam
|
var t adminTeam
|
||||||
var created int64
|
var created int64
|
||||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); err != nil {
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
|
||||||
|
&t.OIDCMemberGroup, &t.OIDCOwnerGroup); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -298,10 +308,12 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
|
|||||||
SELECT t.id, t.name, t.created_at,
|
SELECT t.id, t.name, t.created_at,
|
||||||
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||||
(SELECT COUNT(*) FROM incidents i
|
(SELECT COUNT(*) FROM incidents i
|
||||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
|
||||||
|
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
|
||||||
FROM teams t
|
FROM teams t
|
||||||
WHERE t.id = $1`, teamID).
|
WHERE t.id = $1`, teamID).
|
||||||
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents)
|
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
|
||||||
|
&t.OIDCMemberGroup, &t.OIDCOwnerGroup)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
respond(w, http.StatusNotFound, errResp("not found"))
|
respond(w, http.StatusNotFound, errResp("not found"))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -327,6 +327,112 @@ func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A team's own OIDC group binding follows the same rule as its schedule and
|
||||||
|
// its integrations: an owner sets it, a member may only read it, an outsider
|
||||||
|
// learns nothing, and an administrator can still reach it to repair a team
|
||||||
|
// whose owner has left.
|
||||||
|
func TestTeamOIDCGroups_OwnerOnlyToEdit(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
team := newTeam(t, s, "sre") // team.call authenticates as its owner
|
||||||
|
|
||||||
|
// A plain member of the same team.
|
||||||
|
var plain struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": "plain", "email": "plain@test.com"}), &plain)
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
|
||||||
|
map[string]any{"user_id": plain.ID, "role": "member"})
|
||||||
|
resp.Body.Close()
|
||||||
|
var key struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(plain.ID)+"/api-keys",
|
||||||
|
map[string]string{"name": "test"}), &key)
|
||||||
|
memberCall := func(method, path string, body any) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
r = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// A member of a different team altogether.
|
||||||
|
_, outsiderCall := member(t, s, "outsider")
|
||||||
|
|
||||||
|
path := "/api/teams/" + id64(team.id) + "/oidc-groups"
|
||||||
|
|
||||||
|
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "sre", "owner_group": "sre-leads"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("owner PUT: %d, want 204", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
MemberGroup string `json:"member_group"`
|
||||||
|
OwnerGroup string `json:"owner_group"`
|
||||||
|
}
|
||||||
|
decode(t, team.call(http.MethodGet, path, nil), &got)
|
||||||
|
if got.MemberGroup != "sre" || got.OwnerGroup != "sre-leads" {
|
||||||
|
t.Errorf("owner GET after PUT: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = memberCall(http.MethodGet, path, nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("member GET: %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp = memberCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("member PUT: %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404, not 403: whether the team exists is itself something only its
|
||||||
|
// members should learn.
|
||||||
|
resp = outsiderCall(http.MethodGet, path, nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("outsider GET: %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp = outsiderCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("outsider PUT: %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An administrator who is not a member may still set it, the same bypass
|
||||||
|
// that lets one repair a team whose owner has left.
|
||||||
|
resp = s.req(t, http.MethodPut, path, map[string]string{"member_group": "sre2"})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("admin PUT: %d, want 204", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty string clears a binding, stored as NULL rather than the literal
|
||||||
|
// empty string, so an empty group claim can never accidentally match it.
|
||||||
|
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "", "owner_group": ""})
|
||||||
|
resp.Body.Close()
|
||||||
|
var cleared struct {
|
||||||
|
MemberGroup string `json:"member_group"`
|
||||||
|
OwnerGroup string `json:"owner_group"`
|
||||||
|
}
|
||||||
|
decode(t, team.call(http.MethodGet, path, nil), &cleared)
|
||||||
|
if cleared.MemberGroup != "" || cleared.OwnerGroup != "" {
|
||||||
|
t.Errorf("cleared: %+v", cleared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A team is not somewhere an outsider can look, whatever they know about it.
|
// A team is not somewhere an outsider can look, whatever they know about it.
|
||||||
func TestTeams_OutsiderSeesNothing(t *testing.T) {
|
func TestTeams_OutsiderSeesNothing(t *testing.T) {
|
||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -112,25 +111,10 @@ type OIDC struct {
|
|||||||
// AdminGroup grants the system administrator flag while the user is in it.
|
// AdminGroup grants the system administrator flag while the user is in it.
|
||||||
AdminGroup string
|
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
|
// 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
|
// login is the only moment groups are re-read, so this is how long a change
|
||||||
// in the provider may take to reach terdut.
|
// in the provider may take to reach terdut.
|
||||||
SessionMaxAge time.Duration
|
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.
|
// Enabled reports whether single sign-on is configured.
|
||||||
@@ -185,11 +169,6 @@ func loadOIDC() OIDC {
|
|||||||
AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"),
|
AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"),
|
||||||
SessionMaxAge: duration("TERDUT_OIDC_SESSION_MAX_AGE", 12*time.Hour),
|
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
|
return o
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,14 +178,11 @@ func loadOIDC() OIDC {
|
|||||||
// than not starting.
|
// than not starting.
|
||||||
func (c Config) Validate() error {
|
func (c Config) Validate() error {
|
||||||
o := c.OIDC
|
o := c.OIDC
|
||||||
if o.parseErr != nil {
|
|
||||||
return o.parseErr
|
|
||||||
}
|
|
||||||
if !o.Enabled() {
|
if !o.Enabled() {
|
||||||
if c.DisablePasswordLogin {
|
if c.DisablePasswordLogin {
|
||||||
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
|
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 errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -223,16 +199,12 @@ func (c Config) Validate() error {
|
|||||||
if o.SessionMaxAge <= 0 {
|
if o.SessionMaxAge <= 0 {
|
||||||
return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive")
|
return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive")
|
||||||
}
|
}
|
||||||
for i, m := range o.GroupMappings {
|
// Team grants are no longer visible here: they live on each team's own
|
||||||
if m.Group == "" || m.Team == "" {
|
// oidc_member_group/oidc_owner_group columns, set by that team's owner, not
|
||||||
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: group and team are required", i)
|
// in config Validate can see at startup. The one thing left to guard against
|
||||||
}
|
// is an install nobody can administer at all.
|
||||||
if m.Role != "owner" && m.Role != "member" {
|
if c.DisablePasswordLogin && o.AdminGroup == "" {
|
||||||
return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: role must be owner or member, got %q", i, m.Role)
|
return errors.New("TERDUT_PASSWORD_LOGIN=false with no TERDUT_OIDC_ADMIN_GROUP leaves nobody able to administer the install")
|
||||||
}
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
return nil
|
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 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"},
|
{"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 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) {
|
{"password off without sso", func(m map[string]string) {
|
||||||
clear(m)
|
clear(m)
|
||||||
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
m["TERDUT_PASSWORD_LOGIN"] = "false"
|
||||||
@@ -56,7 +46,7 @@ func TestValidate(t *testing.T) {
|
|||||||
tt.env(env)
|
tt.env(env)
|
||||||
for _, k := range []string{
|
for _, k := range []string{
|
||||||
"TERDUT_PUBLIC_URL", "TERDUT_PASSWORD_LOGIN", "TERDUT_OIDC_ISSUER", "TERDUT_OIDC_CLIENT_ID",
|
"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])
|
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;
|
||||||
+40
-14
@@ -18,7 +18,9 @@ const (
|
|||||||
roleMember = "member"
|
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 {
|
type Grants struct {
|
||||||
// Admitted is false when AllowedGroups is set and the user is in none of
|
// Admitted is false when AllowedGroups is set and the user is in none of
|
||||||
// them. Nothing else in the struct means anything then.
|
// 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 is whether the user is in the admin group.
|
||||||
Admin bool
|
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 {
|
func ComputeGrants(cfg config.OIDC, groups []string) Grants {
|
||||||
in := make(map[string]bool, len(groups))
|
in := make(map[string]bool, len(groups))
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
in[g] = true
|
in[g] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
g := Grants{Teams: map[string]string{}}
|
var g Grants
|
||||||
|
|
||||||
g.Admitted = len(cfg.AllowedGroups) == 0
|
g.Admitted = len(cfg.AllowedGroups) == 0
|
||||||
for _, allowed := range cfg.AllowedGroups {
|
for _, allowed := range cfg.AllowedGroups {
|
||||||
@@ -54,16 +51,45 @@ func ComputeGrants(cfg config.OIDC, groups []string) Grants {
|
|||||||
}
|
}
|
||||||
|
|
||||||
g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup]
|
g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup]
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
for _, m := range cfg.GroupMappings {
|
// TeamGroup is one team's own OIDC binding: which group, if any, grants
|
||||||
if !in[m.Group] {
|
// member access to it and which grants owner access, as read from
|
||||||
continue
|
// 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]) {
|
if tg.OwnerGroup != "" && in[tg.OwnerGroup] && rank(roleOwner) > rank(role) {
|
||||||
g.Teams[m.Team] = m.Role
|
role = roleOwner
|
||||||
|
}
|
||||||
|
if role != "" {
|
||||||
|
out[tg.TeamID] = role
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return g
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// rank orders roles; an unknown or absent role ranks lowest.
|
// rank orders roles; an unknown or absent role ranks lowest.
|
||||||
|
|||||||
@@ -11,11 +11,6 @@ func testCfg() config.OIDC {
|
|||||||
return config.OIDC{
|
return config.OIDC{
|
||||||
AllowedGroups: []string{"terdut-users"},
|
AllowedGroups: []string{"terdut-users"},
|
||||||
AdminGroup: "terdut-admins",
|
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",
|
name: "not in an allowed group is refused",
|
||||||
groups: []string{"sre", "terdut-admins"},
|
groups: []string{"sre", "terdut-admins"},
|
||||||
want: Grants{Admitted: false, Teams: map[string]string{}},
|
want: Grants{Admitted: false},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "allowed but no grants",
|
name: "allowed but no grants",
|
||||||
groups: []string{"terdut-users"},
|
groups: []string{"terdut-users"},
|
||||||
want: Grants{Admitted: true, Teams: map[string]string{}},
|
want: Grants{Admitted: true},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "admin group grants admin",
|
name: "admin group grants admin",
|
||||||
groups: []string{"terdut-users", "terdut-admins"},
|
groups: []string{"terdut-users", "terdut-admins"},
|
||||||
want: Grants{Admitted: true, Admin: true, Teams: map[string]string{}},
|
want: Grants{Admitted: true, Admin: true},
|
||||||
},
|
|
||||||
{
|
|
||||||
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"}},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
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) {
|
func TestStringList(t *testing.T) {
|
||||||
if got := stringList([]any{"a", "", 3, "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
|
if got := stringList([]any{"a", "", 3, "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
|
||||||
t.Errorf("list: %v", got)
|
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;
|
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- team selector ---------- */
|
||||||
|
/* The global control for which team the app is scoped to. Hidden (via the
|
||||||
|
`hidden` attribute, set from teamselector.js) for anybody in fewer than two
|
||||||
|
teams, the same rule every other team-aware control in this file follows. */
|
||||||
|
|
||||||
|
.nav-team-selector,
|
||||||
|
.team-selector-mobile {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||||
|
background: var(--surface); color: var(--text);
|
||||||
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
padding: 4px 12px; max-width: 100%;
|
||||||
|
}
|
||||||
|
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||||
|
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* A team's identity colour — not a status, so never the severity palette. Six
|
||||||
|
colours, then they repeat; teamColorClass() in format.js picks one by the
|
||||||
|
team's id, the same rcN convention the rota's per-person chips use. */
|
||||||
|
.team-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); }
|
||||||
|
.team-dot.rc1 { background: var(--accent); }
|
||||||
|
.team-dot.rc2 { background: var(--ok); }
|
||||||
|
.team-dot.rc3 { background: var(--snooze); }
|
||||||
|
.team-dot.rc4 { background: var(--warn); }
|
||||||
|
.team-dot.rc5 { background: var(--teal); }
|
||||||
|
.team-dot.rc6 { background: var(--pink); }
|
||||||
|
|
||||||
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
|
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
|
||||||
.view-page { padding-left: 16px; padding-right: 16px; }
|
.view-page { padding-left: 16px; padding-right: 16px; }
|
||||||
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
|
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
|
||||||
@@ -282,6 +309,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
}
|
}
|
||||||
.chips::-webkit-scrollbar { display: none; }
|
.chips::-webkit-scrollbar { display: none; }
|
||||||
.chip {
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
flex: none;
|
flex: none;
|
||||||
min-height: 34px; padding: 0 12px;
|
min-height: 34px; padding: 0 12px;
|
||||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||||
@@ -611,6 +639,8 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
.account-name { font-size: 18px; font-weight: 750; }
|
.account-name { font-size: 18px; font-weight: 750; }
|
||||||
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
|
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
|
||||||
.pw-form { display: grid; gap: 12px; padding: 16px; }
|
.pw-form { display: grid; gap: 12px; padding: 16px; }
|
||||||
|
.account-fold { border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
|
.account-fold .stacked-form { margin-top: 8px; }
|
||||||
.form-ok {
|
.form-ok {
|
||||||
margin: 0; padding: 10px 12px;
|
margin: 0; padding: 10px 12px;
|
||||||
background: var(--ok-soft); color: var(--ok);
|
background: var(--ok-soft); color: var(--ok);
|
||||||
@@ -645,6 +675,7 @@ kbd {
|
|||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
|
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
.nav-team-selector { margin: -8px 10px 14px; width: calc(100% - 20px); }
|
||||||
.nav-link {
|
.nav-link {
|
||||||
flex-direction: row; justify-content: flex-start; gap: 12px;
|
flex-direction: row; justify-content: flex-start; gap: 12px;
|
||||||
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
||||||
@@ -784,7 +815,6 @@ kbd {
|
|||||||
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
|
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
|
||||||
.stacked-form label.checkbox { gap: 8px; }
|
.stacked-form label.checkbox { gap: 8px; }
|
||||||
.stacked-form input.wide { min-width: min(420px, 100%); }
|
.stacked-form input.wide { min-width: min(420px, 100%); }
|
||||||
.team-picker { margin-top: 8px; max-width: 100%; }
|
|
||||||
|
|
||||||
/* The rota, a month at a time. A name is too wide to print thirty times and
|
/* The rota, a month at a time. A name is too wide to print thirty times and
|
||||||
too alike down a column to read, so a day carries an initial in that
|
too alike down a column to read, so a day carries an initial in that
|
||||||
|
|||||||
@@ -86,6 +86,9 @@
|
|||||||
<img src="/icon.svg" alt="" width="28" height="28">
|
<img src="/icon.svg" alt="" width="28" height="28">
|
||||||
<span>terdut</span>
|
<span>terdut</span>
|
||||||
</a>
|
</a>
|
||||||
|
<!-- Which team the app is scoped to. Hidden unless the signed-in user is
|
||||||
|
in more than one; teamselector.js fills it in and wires the click. -->
|
||||||
|
<button class="nav-team-selector" id="team-selector" type="button" hidden></button>
|
||||||
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
|
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||||
<span class="nav-label">Queue</span>
|
<span class="nav-label">Queue</span>
|
||||||
@@ -126,6 +129,7 @@
|
|||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
|
||||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||||
</div>
|
</div>
|
||||||
<span class="open-pill" id="open-pill" hidden></span>
|
<span class="open-pill" id="open-pill" hidden></span>
|
||||||
|
|||||||
@@ -43,9 +43,12 @@ function render() {
|
|||||||
//
|
//
|
||||||
// The topic is the whole address — the server it is published to is the
|
// The topic is the whole address — the server it is published to is the
|
||||||
// install's one ntfy, set in the deployment and not something a user picks.
|
// install's one ntfy, set in the deployment and not something a user picks.
|
||||||
|
function notifyStatus(topic) {
|
||||||
|
return topic ? `Topic: ${topic}` : 'No topic set — pages go to the team’s fallback topic.';
|
||||||
|
}
|
||||||
|
|
||||||
function notifyForm(user) {
|
function notifyForm(user) {
|
||||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
|
||||||
const topic = h('input', {
|
const topic = h('input', {
|
||||||
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
|
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
|
||||||
autocapitalize: 'none', spellcheck: false,
|
autocapitalize: 'none', spellcheck: false,
|
||||||
@@ -54,29 +57,7 @@ function notifyForm(user) {
|
|||||||
});
|
});
|
||||||
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
|
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
|
||||||
|
|
||||||
// Only offered once a topic is saved: the test publishes to whatever the
|
const form = h('form', { class: 'stacked-form' },
|
||||||
// server has stored, not to whatever is half-typed in the field.
|
|
||||||
const test = h('button', {
|
|
||||||
class: 'btn', type: 'button', text: 'Send a test push',
|
|
||||||
hidden: !user.ntfy_topic,
|
|
||||||
onclick: async () => {
|
|
||||||
err.hidden = true;
|
|
||||||
ok.hidden = true;
|
|
||||||
test.disabled = true;
|
|
||||||
try {
|
|
||||||
await api.testNotification();
|
|
||||||
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
|
|
||||||
ok.hidden = false;
|
|
||||||
} catch (ex) {
|
|
||||||
err.textContent = ex.message;
|
|
||||||
err.hidden = false;
|
|
||||||
} finally {
|
|
||||||
test.disabled = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = h('form', { class: 'card pw-form' },
|
|
||||||
h('label', {},
|
h('label', {},
|
||||||
h('span', { text: 'ntfy topic' }),
|
h('span', { text: 'ntfy topic' }),
|
||||||
topic),
|
topic),
|
||||||
@@ -89,25 +70,46 @@ function notifyForm(user) {
|
|||||||
h('p', { class: 'muted small' },
|
h('p', { class: 'muted small' },
|
||||||
'Anyone who knows the topic can read your pages and publish to it, so ',
|
'Anyone who knows the topic can read your pages and publish to it, so ',
|
||||||
'pick something unguessable rather than your name.'),
|
'pick something unguessable rather than your name.'),
|
||||||
err, ok,
|
err,
|
||||||
h('div', { class: 'row-actions' }, submit, test),
|
submit,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const status = h('p', { class: 'muted', text: notifyStatus(user.ntfy_topic) });
|
||||||
|
const summary = h('summary', { text: user.ntfy_topic ? 'Change topic' : 'Set a topic' });
|
||||||
|
const details = h('details', { class: 'account-fold' }, summary, form);
|
||||||
|
|
||||||
|
// Only offered once a topic is saved: the test publishes to whatever the
|
||||||
|
// server has stored, not to whatever is half-typed in the field.
|
||||||
|
const test = h('button', {
|
||||||
|
class: 'btn', type: 'button', text: 'Send a test push',
|
||||||
|
hidden: !user.ntfy_topic,
|
||||||
|
onclick: async () => {
|
||||||
|
test.disabled = true;
|
||||||
|
try {
|
||||||
|
await api.testNotification();
|
||||||
|
toast('Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.');
|
||||||
|
} catch (ex) {
|
||||||
|
toast(ex.message, 'error');
|
||||||
|
} finally {
|
||||||
|
test.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
err.hidden = true;
|
err.hidden = true;
|
||||||
ok.hidden = true;
|
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
try {
|
try {
|
||||||
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
|
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
|
||||||
// Keep the cached user in step, so the onboarding checklist stops
|
// Keep the cached user in step, so the onboarding checklist stops
|
||||||
// asking for this and the test button appears without a reload.
|
// asking for this and the test button appears without a reload.
|
||||||
state.me.user = updated;
|
state.me.user = updated;
|
||||||
ok.textContent = updated.ntfy_topic
|
status.textContent = notifyStatus(updated.ntfy_topic);
|
||||||
? 'Topic saved.'
|
summary.textContent = updated.ntfy_topic ? 'Change topic' : 'Set a topic';
|
||||||
: 'Topic cleared. Your pages go to the team’s fallback topic.';
|
|
||||||
ok.hidden = false;
|
|
||||||
test.hidden = !updated.ntfy_topic;
|
test.hidden = !updated.ntfy_topic;
|
||||||
|
details.open = false;
|
||||||
|
toast(updated.ntfy_topic ? 'Topic saved' : 'Topic cleared. Your pages go to the team’s fallback topic.');
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
err.textContent = ex.message;
|
err.textContent = ex.message;
|
||||||
err.hidden = false;
|
err.hidden = false;
|
||||||
@@ -115,7 +117,11 @@ function notifyForm(user) {
|
|||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return form;
|
|
||||||
|
return h('div', { class: 'card pw-form' },
|
||||||
|
status,
|
||||||
|
h('div', { class: 'row-actions' }, test),
|
||||||
|
details);
|
||||||
}
|
}
|
||||||
|
|
||||||
// With password login switched off a password opens nothing, so somebody who
|
// With password login switched off a password opens nothing, so somebody who
|
||||||
@@ -131,14 +137,13 @@ function passwordSection(user, hasPassword) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
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),
|
passwordForm(user, hasPassword),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function passwordForm(user, hasPassword) {
|
function passwordForm(user, hasPassword) {
|
||||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
|
||||||
const current = hasPassword
|
const current = hasPassword
|
||||||
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
|
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
|
||||||
: null;
|
: null;
|
||||||
@@ -148,18 +153,24 @@ function passwordForm(user, hasPassword) {
|
|||||||
|
|
||||||
// A hidden username field lets password managers file the new password
|
// A hidden username field lets password managers file the new password
|
||||||
// under the right account.
|
// under the right account.
|
||||||
const form = h('form', { class: 'card pw-form', autocomplete: 'on' },
|
const form = h('form', { class: 'stacked-form', autocomplete: 'on' },
|
||||||
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
|
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
|
||||||
current && h('label', {}, h('span', { text: 'Current password' }), current),
|
current && h('label', {}, h('span', { text: 'Current password' }), current),
|
||||||
h('label', {}, h('span', { text: 'New password' }), next),
|
h('label', {}, h('span', { text: 'New password' }), next),
|
||||||
h('label', {}, h('span', { text: 'Repeat new password' }), again),
|
h('label', {}, h('span', { text: 'Repeat new password' }), again),
|
||||||
err, ok, submit,
|
err, submit,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const status = h('p', {
|
||||||
|
class: 'muted',
|
||||||
|
text: hasPassword ? 'Password set.' : 'No password set — sign-in needs one of the other methods.',
|
||||||
|
});
|
||||||
|
const summary = h('summary', { text: hasPassword ? 'Change password' : 'Set a password' });
|
||||||
|
const details = h('details', { class: 'account-fold' }, summary, form);
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
err.hidden = true;
|
err.hidden = true;
|
||||||
ok.hidden = true;
|
|
||||||
if (next.value !== again.value) {
|
if (next.value !== again.value) {
|
||||||
err.textContent = 'The new passwords do not match.';
|
err.textContent = 'The new passwords do not match.';
|
||||||
err.hidden = false;
|
err.hidden = false;
|
||||||
@@ -171,13 +182,14 @@ function passwordForm(user, hasPassword) {
|
|||||||
state.me.has_password = true;
|
state.me.has_password = true;
|
||||||
form.reset();
|
form.reset();
|
||||||
if (!current) {
|
if (!current) {
|
||||||
// From now on the form needs the current-password field.
|
// From now on the form needs the current-password field, and a fresh
|
||||||
|
// render already comes up with the fold closed.
|
||||||
render();
|
render();
|
||||||
toast('Password saved');
|
toast('Password saved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ok.textContent = 'Password saved. Other devices have been signed out.';
|
details.open = false;
|
||||||
ok.hidden = false;
|
toast('Password saved. Other devices have been signed out.');
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
err.textContent = ex.message;
|
err.textContent = ex.message;
|
||||||
err.hidden = false;
|
err.hidden = false;
|
||||||
@@ -185,7 +197,7 @@ function passwordForm(user, hasPassword) {
|
|||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return form;
|
return h('div', { class: 'card pw-form' }, status, details);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortcuts() {
|
function shortcuts() {
|
||||||
|
|||||||
@@ -146,6 +146,13 @@ function identityCard() {
|
|||||||
fact('Created', when(t.created_at)),
|
fact('Created', when(t.created_at)),
|
||||||
fact('Members', String(t.members)),
|
fact('Members', String(t.members)),
|
||||||
fact('Open incidents', String(t.open_incidents)),
|
fact('Open incidents', String(t.open_incidents)),
|
||||||
|
// Read-only here: an administrator can see why a team's OIDC-sourced
|
||||||
|
// membership looks the way it does, but setting it is the team's own
|
||||||
|
// owner's call, from the Team tab.
|
||||||
|
...(state.auth?.oidc?.enabled ? [
|
||||||
|
fact('OIDC member group', t.oidc_member_group || '—'),
|
||||||
|
fact('OIDC owner group', t.oidc_owner_group || '—'),
|
||||||
|
] : []),
|
||||||
),
|
),
|
||||||
form, err, ok,
|
form, err, ok,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ export const addTeamMember = (id, userID, role) =>
|
|||||||
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
|
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
|
||||||
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
|
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
|
||||||
|
|
||||||
|
// Which OIDC groups grant member and owner access to this team.
|
||||||
|
export const oidcGroups = (id) => call('GET', `/teams/${id}/oidc-groups`);
|
||||||
|
export const setOidcGroups = (id, body) => call('PUT', `/teams/${id}/oidc-groups`, { body });
|
||||||
|
|
||||||
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
|
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
|
||||||
export const createIntegration = (id, name) =>
|
export const createIntegration = (id, name) =>
|
||||||
call('POST', `/teams/${id}/integrations`, { body: { name } });
|
call('POST', `/teams/${id}/integrations`, { body: { name } });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import * as alerts from './alerts.js';
|
|||||||
import * as stats from './stats.js';
|
import * as stats from './stats.js';
|
||||||
import * as account from './account.js';
|
import * as account from './account.js';
|
||||||
import * as team from './team.js';
|
import * as team from './team.js';
|
||||||
|
import * as teamselector from './teamselector.js';
|
||||||
import * as admin from './admin.js';
|
import * as admin from './admin.js';
|
||||||
import * as adminuser from './adminuser.js';
|
import * as adminuser from './adminuser.js';
|
||||||
import * as adminteam from './adminteam.js';
|
import * as adminteam from './adminteam.js';
|
||||||
@@ -230,6 +231,7 @@ async function boot() {
|
|||||||
$('login-form').addEventListener('submit', onLogin);
|
$('login-form').addEventListener('submit', onLogin);
|
||||||
$('signup-form').addEventListener('submit', onSignup);
|
$('signup-form').addEventListener('submit', onSignup);
|
||||||
$('menu-btn').addEventListener('click', openNavMenu);
|
$('menu-btn').addEventListener('click', openNavMenu);
|
||||||
|
teamselector.init();
|
||||||
ssoErrorCode = takeSSOError();
|
ssoErrorCode = takeSSOError();
|
||||||
|
|
||||||
// /signup is the one route that works without a session.
|
// /signup is the one route that works without a session.
|
||||||
@@ -245,6 +247,7 @@ async function boot() {
|
|||||||
await loadAuthConfig();
|
await loadAuthConfig();
|
||||||
state.me = await api.me();
|
state.me = await api.me();
|
||||||
await loadTeams();
|
await loadTeams();
|
||||||
|
teamselector.render();
|
||||||
// The Admin tab exists only for an administrator. Somebody who types /admin
|
// The Admin tab exists only for an administrator. Somebody who types /admin
|
||||||
// anyway gets the view's own "ask an administrator" card, not a blank page.
|
// anyway gets the view's own "ask an administrator" card, not a blank page.
|
||||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||||
@@ -328,6 +331,7 @@ async function onSignup(e) {
|
|||||||
history.replaceState({ depth: 0 }, '', '/');
|
history.replaceState({ depth: 0 }, '', '/');
|
||||||
route = parseRoute('/');
|
route = parseRoute('/');
|
||||||
await loadTeams();
|
await loadTeams();
|
||||||
|
teamselector.render();
|
||||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||||
showApp();
|
showApp();
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ export function severityClass(sev) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A stable identity colour for a team, so the same team always reads the same
|
||||||
|
// colour without the server needing to store one. Teams have no colour field;
|
||||||
|
// this hashes the id into the six-colour rcN palette app.css already has for
|
||||||
|
// the rota's per-person chips (a team is not a status, so never severity).
|
||||||
|
export function teamColorClass(teamID) {
|
||||||
|
return `rc${(((teamID % 6) + 6) % 6) + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
// A one-line summary of the group labels, without the one the title already shows.
|
// A one-line summary of the group labels, without the one the title already shows.
|
||||||
export function labelSummary(labels, skip = 'alertname') {
|
export function labelSummary(labels, skip = 'alertname') {
|
||||||
return Object.entries(labels || {})
|
return Object.entries(labels || {})
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||||
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
|
import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
|
||||||
import { state, myID } from './state.js';
|
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
|
||||||
import * as onboarding from './onboarding.js';
|
import * as onboarding from './onboarding.js';
|
||||||
import { navigate } from './app.js';
|
import { navigate } from './app.js';
|
||||||
|
|
||||||
@@ -27,34 +27,21 @@ const EMPTY = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onboarding.onRerender(() => renderList());
|
onboarding.onRerender(() => renderList());
|
||||||
|
// The queue used to keep its own team filter (a per-tab sessionStorage value,
|
||||||
|
// out of step with team.js's own picker); both now defer to the global
|
||||||
|
// selector's shared state, so re-render whenever it changes.
|
||||||
|
onTeamChange(() => {
|
||||||
|
renderChips();
|
||||||
|
refresh({ fresh: true });
|
||||||
|
});
|
||||||
|
|
||||||
let filter = loadFilter();
|
let filter = loadFilter();
|
||||||
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
|
|
||||||
let items = null; // null while loading
|
let items = null; // null while loading
|
||||||
let error = null;
|
let error = null;
|
||||||
let selected = null;
|
let selected = null;
|
||||||
let cursor = -1; // keyboard position in the list
|
let cursor = -1; // keyboard position in the list
|
||||||
let built = false;
|
let built = false;
|
||||||
|
|
||||||
function loadTeamFilter() {
|
|
||||||
try {
|
|
||||||
return sessionStorage.getItem('terdut.queue.team') || '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTeamFilter(id) {
|
|
||||||
teamFilter = id;
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem('terdut.queue.team', id);
|
|
||||||
} catch {
|
|
||||||
/* storage unavailable */
|
|
||||||
}
|
|
||||||
renderChips();
|
|
||||||
refresh({ fresh: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFilter() {
|
function loadFilter() {
|
||||||
try {
|
try {
|
||||||
const f = sessionStorage.getItem('terdut.queue.filter');
|
const f = sessionStorage.getItem('terdut.queue.filter');
|
||||||
@@ -89,8 +76,8 @@ export async function refresh({ fresh = false } = {}) {
|
|||||||
// The open list is already fetched for the badges; no need to ask twice.
|
// The open list is already fetched for the badges; no need to ask twice.
|
||||||
// The cached open queue covers every team, so it can only be reused when
|
// The cached open queue covers every team, so it can only be reused when
|
||||||
// no team filter is applied.
|
// no team filter is applied.
|
||||||
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
|
const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query;
|
||||||
const cached = filter === 'open' && !fresh && !teamFilter;
|
const cached = filter === 'open' && !fresh && state.selectedTeamID == null;
|
||||||
const result = cached ? state.open : await api.incidents(query);
|
const result = cached ? state.open : await api.incidents(query);
|
||||||
await onboarding.load();
|
await onboarding.load();
|
||||||
if (requested !== filter) return;
|
if (requested !== filter) return;
|
||||||
@@ -136,19 +123,20 @@ function renderChips() {
|
|||||||
class: 'chip',
|
class: 'chip',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
role: 'tab',
|
role: 'tab',
|
||||||
'aria-selected': String(teamFilter === ''),
|
'aria-selected': String(state.selectedTeamID == null),
|
||||||
onclick: () => setTeamFilter(''),
|
onclick: () => setSelectedTeam(null),
|
||||||
text: 'All teams',
|
}, h('span', { class: 'team-dot' }), ' All teams'));
|
||||||
}));
|
|
||||||
for (const team of state.teams) {
|
for (const team of state.teams) {
|
||||||
chips.push(h('button', {
|
chips.push(h('button', {
|
||||||
class: 'chip',
|
class: 'chip',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
role: 'tab',
|
role: 'tab',
|
||||||
'aria-selected': String(teamFilter === String(team.id)),
|
'aria-selected': String(team.id === state.selectedTeamID),
|
||||||
onclick: () => setTeamFilter(String(team.id)),
|
onclick: () => setSelectedTeam(team.id),
|
||||||
text: team.name,
|
},
|
||||||
}));
|
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
|
||||||
|
' ' + team.name,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,53 @@ export const state = {
|
|||||||
auth: { password_login: true, oidc: { enabled: false, name: '' } },
|
auth: { password_login: true, oidc: { enabled: false, name: '' } },
|
||||||
open: [], // the default queue: open, not snoozed
|
open: [], // the default queue: open, not snoozed
|
||||||
teams: [], // the teams the viewer belongs to, each with their role
|
teams: [], // the teams the viewer belongs to, each with their role
|
||||||
|
// Which team the whole app is scoped to right now; null means "All teams".
|
||||||
|
// Set only through setSelectedTeam below, never assigned directly, so every
|
||||||
|
// view stays in sync and the choice is remembered across reloads.
|
||||||
|
selectedTeamID: loadSelectedTeam(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// The team whose schedule and settings the views act on. A viewer in one team —
|
const SELECTED_TEAM_KEY = 'terdut.selectedTeam';
|
||||||
// which is everybody until somebody makes a second — never has to choose.
|
|
||||||
|
function loadSelectedTeam() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SELECTED_TEAM_KEY);
|
||||||
|
return raw ? Number(raw) : null;
|
||||||
|
} catch {
|
||||||
|
return null; // storage unavailable, or nothing saved yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callbacks to run whenever the selected team changes, so every view that
|
||||||
|
// cares — the queue's filter, the Team settings page, the selector's own
|
||||||
|
// trigger — stays in sync without a general event bus, following the one
|
||||||
|
// precedent for this in the codebase: onboarding.js's onRerender.
|
||||||
|
const teamListeners = [];
|
||||||
|
export function onTeamChange(cb) {
|
||||||
|
teamListeners.push(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSelectedTeam changes which team the app is scoped to (id, or null for
|
||||||
|
// "All teams"), persists it — a durable preference, unlike the per-tab
|
||||||
|
// sessionStorage filter this replaces — and tells every registered listener.
|
||||||
|
export function setSelectedTeam(id) {
|
||||||
|
state.selectedTeamID = id;
|
||||||
|
try {
|
||||||
|
if (id == null) localStorage.removeItem(SELECTED_TEAM_KEY);
|
||||||
|
else localStorage.setItem(SELECTED_TEAM_KEY, String(id));
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
for (const cb of teamListeners) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The team whose schedule and settings the views act on: the selected team,
|
||||||
|
// falling back to the first one the viewer belongs to — which is everybody's
|
||||||
|
// only team until somebody makes a second, or the stored selection naming a
|
||||||
|
// team the account has since left.
|
||||||
export function currentTeam() {
|
export function currentTeam() {
|
||||||
return state.teams[0] || null;
|
const teams = state.teams || [];
|
||||||
|
return teams.find((t) => t.id === state.selectedTeamID) || teams[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function myID() {
|
export function myID() {
|
||||||
@@ -36,6 +77,12 @@ export async function users() {
|
|||||||
|
|
||||||
export async function loadTeams() {
|
export async function loadTeams() {
|
||||||
state.teams = await api.teams();
|
state.teams = await api.teams();
|
||||||
|
// A stored id that no longer names one of the account's teams — left it, or
|
||||||
|
// this is simply a different account signed in on the same browser — is as
|
||||||
|
// good as unset.
|
||||||
|
if (state.selectedTeamID != null && !state.teams.some((t) => t.id === state.selectedTeamID)) {
|
||||||
|
state.selectedTeamID = null;
|
||||||
|
}
|
||||||
return state.teams;
|
return state.teams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.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';
|
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-team');
|
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' },
|
{ 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;
|
let teamID = null;
|
||||||
// Which sub-section is open. Remembered rather than passed, because the poll
|
// Which sub-section is open. Remembered rather than passed, because the poll
|
||||||
// loop calls refresh() with no route.
|
// 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 error = null;
|
||||||
let freshKey = null; // an integration key, shown once, until the view is left
|
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) {
|
export function show(route) {
|
||||||
const next = route?.tab ?? null;
|
const next = route?.tab ?? null;
|
||||||
// A different sub-section wants different data, so the old answer goes
|
// A different sub-section wants different data, so the old answer goes
|
||||||
@@ -61,13 +71,8 @@ export function show(route) {
|
|||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedTeam() {
|
|
||||||
const teams = state.teams || [];
|
|
||||||
return teams.find((t) => t.id === teamID) || currentTeam();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refresh() {
|
export async function refresh() {
|
||||||
const team = selectedTeam();
|
const team = currentTeam();
|
||||||
if (!team) {
|
if (!team) {
|
||||||
data = null;
|
data = null;
|
||||||
render();
|
render();
|
||||||
@@ -101,8 +106,12 @@ async function load(id) {
|
|||||||
return { members, schedule };
|
return { members, schedule };
|
||||||
}
|
}
|
||||||
if (tab === 'members') {
|
if (tab === 'members') {
|
||||||
const [members, users] = await Promise.all([api.teamMembers(id), allUsers()]);
|
const [members, users, oidcGroups] = await Promise.all([
|
||||||
return { members, users };
|
api.teamMembers(id),
|
||||||
|
allUsers(),
|
||||||
|
state.auth?.oidc?.enabled ? api.oidcGroups(id) : null,
|
||||||
|
]);
|
||||||
|
return { members, users, oidcGroups };
|
||||||
}
|
}
|
||||||
if (tab === 'escalation') {
|
if (tab === 'escalation') {
|
||||||
const [members, escalation] = await Promise.all([api.teamMembers(id), api.escalation(id)]);
|
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.
|
// Names which team's settings the six sections below belong to. It used to be
|
||||||
// It is above the sections rather than inside one because it changes the
|
// a picker of its own for somebody in more than one team; that job now belongs
|
||||||
// subject of all six.
|
// to the global team selector in the nav, which is what onTeamChange above
|
||||||
|
// reacts to.
|
||||||
function teamPicker() {
|
function teamPicker() {
|
||||||
if ((state.teams || []).length < 2) {
|
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
||||||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
|
||||||
}
|
|
||||||
const select = h('select', { class: 'team-picker' },
|
|
||||||
...state.teams.map((t) => h('option', {
|
|
||||||
value: String(t.id), text: t.name, selected: t.id === teamID,
|
|
||||||
})));
|
|
||||||
select.addEventListener('change', () => {
|
|
||||||
teamID = Number(select.value);
|
|
||||||
data = null;
|
|
||||||
freshKey = null;
|
|
||||||
refresh();
|
|
||||||
});
|
|
||||||
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- overview --------------------------------------------------------------
|
// --- overview --------------------------------------------------------------
|
||||||
@@ -1046,6 +1043,73 @@ function shiftCell(m) {
|
|||||||
: h('span', { text: 'not scheduled' });
|
: 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() {
|
function membersCard() {
|
||||||
const members = data.members || [];
|
const members = data.members || [];
|
||||||
const owners = members.filter((m) => m.role === 'owner').length;
|
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('div', { class: 'card-head' },
|
||||||
h('h2', { text: 'Members' }),
|
h('h2', { text: 'Members' }),
|
||||||
isOwner() && h('button', {
|
isOwner() && h('button', {
|
||||||
@@ -1108,7 +1172,7 @@ function membersCard() {
|
|||||||
h('th'))),
|
h('th'))),
|
||||||
h('tbody', {}, rows)))
|
h('tbody', {}, rows)))
|
||||||
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
|
: 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
|
// 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