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
+50 -10
View File
@@ -127,9 +127,10 @@ type idpUser struct {
badNonce bool
}
// ssoConfig is a terdut configuration wired to idp, with the mapping the tests
// share: terdut-users may sign in, terdut-admins administer, and the sre groups
// grant roles in the SRE team.
// ssoConfig is a terdut configuration wired to idp: terdut-users may sign in,
// terdut-admins administer. Which groups grant which team is not config
// anymore — it is each team's own oidc_member_group/oidc_owner_group, so a
// test that needs one seeds it with seedTeam.
func ssoConfig(idp *fakeIdP) config.Config {
c := testConfig()
c.OIDC = config.OIDC{
@@ -143,16 +144,28 @@ func ssoConfig(idp *fakeIdP) config.Config {
GroupsClaim: "groups",
AllowedGroups: []string{"terdut-users"},
AdminGroup: "terdut-admins",
GroupMappings: []config.GroupMapping{
{Group: "sre", Team: "SRE", Role: "member"},
{Group: "sre-leads", Team: "SRE", Role: "owner"},
{Group: "platform", Team: "Platform", Role: "member"},
},
SessionMaxAge: 12 * time.Hour,
}
return c
}
// seedTeam creates a team with an OIDC group binding, the way an owner would
// set one from the Members tab. Teams are no longer created by the sync
// itself, so a test whose groups should grant something needs the team to
// already exist. An empty group means that role is not granted by one.
func (s *ts) seedTeam(t *testing.T, name, memberGroup, ownerGroup string) int64 {
t.Helper()
var id int64
err := s.db.QueryRow(`
INSERT INTO teams (name, oidc_member_group, oidc_owner_group)
VALUES ($1, NULLIF($2, ''), NULLIF($3, '')) RETURNING id`,
name, memberGroup, ownerGroup).Scan(&id)
if err != nil {
t.Fatalf("seed team %q: %v", name, err)
}
return id
}
func newSSOTS(t *testing.T, idp *fakeIdP, tweak ...func(*config.Config)) *ts {
t.Helper()
c := ssoConfig(idp)
@@ -290,6 +303,8 @@ func sameMap(a, b map[string]string) bool {
func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "sre-leads")
s.seedTeam(t, "Platform", "platform", "")
b := ssoBrowser(t, s)
if loc := signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "platform")); loc != "/" {
@@ -305,6 +320,27 @@ func TestSSO_FirstSignInCreatesUserAndGrantsTeams(t *testing.T) {
}
}
// A group matching no team's own binding grants nothing and creates nothing:
// unlike the old global mapping, the sync never creates a team by name.
func TestSSO_NoAutoCreateTeam(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
var before int
s.db.QueryRow("SELECT COUNT(*) FROM teams").Scan(&before)
signInSSO(t, idp, ssoBrowser(t, s), alice) // groups include "sre"; no team names it
if got := s.memberships(t, "alice"); len(got) != 0 {
t.Errorf("memberships %v, want none: no team's oidc_member_group/oidc_owner_group is set", got)
}
var after int
s.db.QueryRow("SELECT COUNT(*) FROM teams").Scan(&after)
if after != before {
t.Errorf("team count %d -> %d, want no team created", before, after)
}
}
func TestSSO_RefusedOutsideAllowedGroups(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
@@ -362,6 +398,7 @@ func TestSSO_ManualAdminIsNeverRevoked(t *testing.T) {
func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "sre-leads")
signInSSO(t, idp, ssoBrowser(t, s), alice)
// Somebody adds alice to another team by hand.
@@ -379,6 +416,7 @@ func TestSSO_LosingAGroupRemovesOnlyManagedAccess(t *testing.T) {
func TestSSO_HighestRoleWinsAndRoleChangesFollow(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "sre-leads")
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "sre-leads"))
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/oidc"}) {
@@ -396,11 +434,11 @@ func TestSSO_ManualMemberIsRaisedNeverLowered(t *testing.T) {
// alice exists locally, is a manual owner of SRE, and is linked by email.
s.exec(t, "INSERT INTO users (username, email) VALUES ('alice', 'alice@example.com')")
s.exec(t, "INSERT INTO teams (name) VALUES ('SRE')")
s.seedTeam(t, "SRE", "sre", "")
s.exec(t, `INSERT INTO team_members (team_id, user_id, role)
VALUES ((SELECT id FROM teams WHERE name = 'SRE'), (SELECT id FROM users WHERE username = 'alice'), 'owner')`)
signInSSO(t, idp, ssoBrowser(t, s), alice) // the mapping only says member
signInSSO(t, idp, ssoBrowser(t, s), alice) // the group only grants member
if got := s.memberships(t, "alice"); !sameMap(got, map[string]string{"SRE": "owner/manual"}) {
t.Errorf("%v: a hand-made owner must not be lowered by a member mapping", got)
}
@@ -614,6 +652,7 @@ func TestSSO_ProviderErrorGoesBackToTheUI(t *testing.T) {
func TestSSO_ManagedAccessCannotBeEditedByHand(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "")
signInSSO(t, idp, ssoBrowser(t, s), withGroups(alice, "terdut-users", "sre", "terdut-admins"))
var aliceID, sreID int64
@@ -712,6 +751,7 @@ func TestSSO_UnreachableProviderRedirectsWithAnError(t *testing.T) {
func TestSSO_APIShowsWhereAccessCameFrom(t *testing.T) {
idp := newFakeIdP(t)
s := newSSOTS(t, idp)
s.seedTeam(t, "SRE", "sre", "")
b := ssoBrowser(t, s)
signInSSO(t, idp, b, withGroups(alice, "terdut-users", "sre", "terdut-admins"))