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

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

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

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

TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
This commit is contained in:
Niklas Ye
2026-09-27 11:43:57 +02:00
parent 97a4814c04
commit 5b4683febf
17 changed files with 554 additions and 129 deletions
+106
View File
@@ -327,6 +327,112 @@ func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
}
}
// A team's own OIDC group binding follows the same rule as its schedule and
// its integrations: an owner sets it, a member may only read it, an outsider
// learns nothing, and an administrator can still reach it to repair a team
// whose owner has left.
func TestTeamOIDCGroups_OwnerOnlyToEdit(t *testing.T) {
s := newTS(t)
team := newTeam(t, s, "sre") // team.call authenticates as its owner
// A plain member of the same team.
var plain struct {
ID int64 `json:"id"`
}
decode(t, s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": "plain", "email": "plain@test.com"}), &plain)
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
map[string]any{"user_id": plain.ID, "role": "member"})
resp.Body.Close()
var key struct {
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(plain.ID)+"/api-keys",
map[string]string{"name": "test"}), &key)
memberCall := func(method, path string, body any) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
data, _ := json.Marshal(body)
r = bytes.NewReader(data)
}
req, _ := http.NewRequest(method, s.URL+path, r)
req.Header.Set("Authorization", "Bearer "+key.Key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
}
// A member of a different team altogether.
_, outsiderCall := member(t, s, "outsider")
path := "/api/teams/" + id64(team.id) + "/oidc-groups"
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "sre", "owner_group": "sre-leads"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("owner PUT: %d, want 204", resp.StatusCode)
}
var got struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &got)
if got.MemberGroup != "sre" || got.OwnerGroup != "sre-leads" {
t.Errorf("owner GET after PUT: %+v", got)
}
resp = memberCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("member GET: %d, want 200", resp.StatusCode)
}
resp = memberCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("member PUT: %d, want 403", resp.StatusCode)
}
// 404, not 403: whether the team exists is itself something only its
// members should learn.
resp = outsiderCall(http.MethodGet, path, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider GET: %d, want 404", resp.StatusCode)
}
resp = outsiderCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("outsider PUT: %d, want 404", resp.StatusCode)
}
// An administrator who is not a member may still set it, the same bypass
// that lets one repair a team whose owner has left.
resp = s.req(t, http.MethodPut, path, map[string]string{"member_group": "sre2"})
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("admin PUT: %d, want 204", resp.StatusCode)
}
// An empty string clears a binding, stored as NULL rather than the literal
// empty string, so an empty group claim can never accidentally match it.
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "", "owner_group": ""})
resp.Body.Close()
var cleared struct {
MemberGroup string `json:"member_group"`
OwnerGroup string `json:"owner_group"`
}
decode(t, team.call(http.MethodGet, path, nil), &cleared)
if cleared.MemberGroup != "" || cleared.OwnerGroup != "" {
t.Errorf("cleared: %+v", cleared)
}
}
// A team is not somewhere an outsider can look, whatever they know about it.
func TestTeams_OutsiderSeesNothing(t *testing.T) {
s := newTS(t)