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
+40 -14
View File
@@ -18,7 +18,9 @@ const (
roleMember = "member"
)
// Grants is the access a set of groups confers.
// Grants is the account-wide access a set of groups confers. Team access is a
// separate question — see TeamGroup and ComputeTeamGrants — because it is
// configured per team in the database, not in this package's cfg.
type Grants struct {
// Admitted is false when AllowedGroups is set and the user is in none of
// them. Nothing else in the struct means anything then.
@@ -26,21 +28,16 @@ type Grants struct {
// Admin is whether the user is in the admin group.
Admin bool
// Teams maps team name to role. Where several groups grant the same team the
// highest role wins, so belonging to both a members group and an owners
// group makes somebody an owner rather than whichever mapping came last.
Teams map[string]string
}
// ComputeGrants evaluates the configured mappings against groups.
// ComputeGrants evaluates the account-wide configuration against groups.
func ComputeGrants(cfg config.OIDC, groups []string) Grants {
in := make(map[string]bool, len(groups))
for _, g := range groups {
in[g] = true
}
g := Grants{Teams: map[string]string{}}
var g Grants
g.Admitted = len(cfg.AllowedGroups) == 0
for _, allowed := range cfg.AllowedGroups {
@@ -54,16 +51,45 @@ func ComputeGrants(cfg config.OIDC, groups []string) Grants {
}
g.Admin = cfg.AdminGroup != "" && in[cfg.AdminGroup]
return g
}
for _, m := range cfg.GroupMappings {
if !in[m.Group] {
continue
// TeamGroup is one team's own OIDC binding: which group, if any, grants
// member access to it and which grants owner access, as read from
// teams.oidc_member_group / teams.oidc_owner_group.
type TeamGroup struct {
TeamID int64
MemberGroup string // "" means no group grants member access here.
OwnerGroup string // "" means no group grants owner access here.
}
// ComputeTeamGrants evaluates every team's own group binding against groups,
// and returns the role each team grants, keyed by team ID. A team absent from
// the result is not granted at all. Where a team's member and owner groups
// both match, the owner group wins — the same "highest role wins" rule that
// applied across the old global mapping list applies here across one team's
// two fields, so belonging to both groups makes somebody an owner rather than
// whichever field happened to be checked last.
func ComputeTeamGrants(teamGroups []TeamGroup, groups []string) map[int64]string {
in := make(map[string]bool, len(groups))
for _, g := range groups {
in[g] = true
}
out := map[int64]string{}
for _, tg := range teamGroups {
role := ""
if tg.MemberGroup != "" && in[tg.MemberGroup] {
role = roleMember
}
if rank(m.Role) > rank(g.Teams[m.Team]) {
g.Teams[m.Team] = m.Role
if tg.OwnerGroup != "" && in[tg.OwnerGroup] && rank(roleOwner) > rank(role) {
role = roleOwner
}
if role != "" {
out[tg.TeamID] = role
}
}
return g
return out
}
// rank orders roles; an unknown or absent role ranks lowest.
+76 -18
View File
@@ -11,11 +11,6 @@ func testCfg() config.OIDC {
return config.OIDC{
AllowedGroups: []string{"terdut-users"},
AdminGroup: "terdut-admins",
GroupMappings: []config.GroupMapping{
{Group: "sre", Team: "SRE", Role: "member"},
{Group: "sre-leads", Team: "SRE", Role: "owner"},
{Group: "platform", Team: "Platform", Role: "member"},
},
}
}
@@ -28,27 +23,17 @@ func TestComputeGrants(t *testing.T) {
{
name: "not in an allowed group is refused",
groups: []string{"sre", "terdut-admins"},
want: Grants{Admitted: false, Teams: map[string]string{}},
want: Grants{Admitted: false},
},
{
name: "allowed but no grants",
groups: []string{"terdut-users"},
want: Grants{Admitted: true, Teams: map[string]string{}},
want: Grants{Admitted: true},
},
{
name: "admin group grants admin",
groups: []string{"terdut-users", "terdut-admins"},
want: Grants{Admitted: true, Admin: true, Teams: map[string]string{}},
},
{
name: "team roles from several groups",
groups: []string{"terdut-users", "sre", "platform"},
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "member", "Platform": "member"}},
},
{
name: "highest role wins whatever the order",
groups: []string{"sre-leads", "terdut-users", "sre"},
want: Grants{Admitted: true, Teams: map[string]string{"SRE": "owner"}},
want: Grants{Admitted: true, Admin: true},
},
}
for _, tt := range tests {
@@ -69,6 +54,79 @@ func TestComputeGrants_NoAllowedGroupsAdmitsEveryone(t *testing.T) {
}
}
// testTeamGroups is one SRE team keyed off two groups (a member group and a
// higher owner group) and one Platform team keyed off a member group only —
// the same shape the old global TERDUT_OIDC_GROUP_MAPPINGS example used.
func testTeamGroups() []TeamGroup {
return []TeamGroup{
{TeamID: 1, MemberGroup: "sre", OwnerGroup: "sre-leads"},
{TeamID: 2, MemberGroup: "platform"},
}
}
func TestComputeTeamGrants(t *testing.T) {
tests := []struct {
name string
teamGroups []TeamGroup
groups []string
want map[int64]string
}{
{
name: "no matching group grants nothing",
teamGroups: testTeamGroups(),
groups: []string{"terdut-users"},
want: map[int64]string{},
},
{
name: "member group grants member",
teamGroups: testTeamGroups(),
groups: []string{"sre"},
want: map[int64]string{1: roleMember},
},
{
name: "owner group grants owner",
teamGroups: testTeamGroups(),
groups: []string{"sre-leads"},
want: map[int64]string{1: roleOwner},
},
{
name: "in both of a team's groups, owner wins",
teamGroups: testTeamGroups(),
groups: []string{"sre", "sre-leads"},
want: map[int64]string{1: roleOwner},
},
{
name: "several teams from several groups",
teamGroups: testTeamGroups(),
groups: []string{"sre", "platform"},
want: map[int64]string{1: roleMember, 2: roleMember},
},
{
name: "two teams may share a group",
teamGroups: []TeamGroup{
{TeamID: 1, MemberGroup: "sre"},
{TeamID: 2, MemberGroup: "sre"},
},
groups: []string{"sre"},
want: map[int64]string{1: roleMember, 2: roleMember},
},
{
name: "a team with neither field set is never granted",
teamGroups: []TeamGroup{{TeamID: 1}},
groups: []string{"sre", "sre-leads", "platform"},
want: map[int64]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ComputeTeamGrants(tt.teamGroups, tt.groups)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %+v, want %+v", got, tt.want)
}
})
}
}
func TestStringList(t *testing.T) {
if got := stringList([]any{"a", "", 3, "b"}); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Errorf("list: %v", got)