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:
+50
-10
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user