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.