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:
+41
-15
@@ -211,7 +211,15 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := signInSSO(r.Context(), db, cfg, identity, grants)
|
||||
teamGroups, err := loadTeamGroups(r.Context(), db)
|
||||
if err != nil {
|
||||
log.Printf("oidc: load team groups: %v", err)
|
||||
ssoRedirect(w, r, ssoFailed)
|
||||
return
|
||||
}
|
||||
teamGrants := oidc.ComputeTeamGrants(teamGroups, identity.Groups)
|
||||
|
||||
userID, err := signInSSO(r.Context(), db, cfg, identity, grants, teamGrants)
|
||||
if err != nil {
|
||||
var se ssoError
|
||||
if errors.As(err, &se) {
|
||||
@@ -253,7 +261,7 @@ func safeNext(next string) string {
|
||||
|
||||
// signInSSO resolves the identity to a user and applies its grants, in one
|
||||
// transaction: a login that fails half way must not leave memberships changed.
|
||||
func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identity, g oidc.Grants) (int64, error) {
|
||||
func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identity, g oidc.Grants, teamRoles map[int64]string) (int64, error) {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -272,12 +280,34 @@ func signInSSO(ctx context.Context, db *sql.DB, cfg config.OIDC, id *oidc.Identi
|
||||
if disabled {
|
||||
return 0, ssoDisabled
|
||||
}
|
||||
if err := syncGrants(ctx, tx, userID, g); err != nil {
|
||||
if err := syncGrants(ctx, tx, userID, g, teamRoles); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return userID, tx.Commit()
|
||||
}
|
||||
|
||||
// loadTeamGroups reads every team's own OIDC group binding, for the sync to
|
||||
// evaluate against one user's groups at a time. Teams are few, so this reads
|
||||
// the whole table rather than filtering it.
|
||||
func loadTeamGroups(ctx context.Context, db *sql.DB) ([]oidc.TeamGroup, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT id, COALESCE(oidc_member_group, ''), COALESCE(oidc_owner_group, '') FROM teams")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []oidc.TeamGroup
|
||||
for rows.Next() {
|
||||
var tg oidc.TeamGroup
|
||||
if err := rows.Scan(&tg.TeamID, &tg.MemberGroup, &tg.OwnerGroup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, tg)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// resolveSSOUser finds the user an identity belongs to, linking or creating one
|
||||
// when this is its first sign-in.
|
||||
//
|
||||
@@ -407,7 +437,11 @@ func refreshProfile(ctx context.Context, tx *sql.Tx, userID int64, id *oidc.Iden
|
||||
// administrator is a manual one. Rows added by hand are 'manual', and the sync
|
||||
// only ever raises them (turning them into 'oidc' rows), never lowers or removes
|
||||
// them.
|
||||
func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants) error {
|
||||
//
|
||||
// teamRoles is keyed by team ID, not name: a team must already exist, with its
|
||||
// own oidc_member_group/oidc_owner_group set by its owner, before a group can
|
||||
// grant access to it. The sync never creates a team.
|
||||
func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants, teamRoles map[int64]string) error {
|
||||
// Administrator. A manual administrator stays one whatever the groups say.
|
||||
if g.Admin {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
@@ -422,21 +456,13 @@ func syncGrants(ctx context.Context, tx *sql.Tx, userID int64, g oidc.Grants) er
|
||||
}
|
||||
|
||||
// Teams. The result of the loop is the set of teams the groups grant.
|
||||
granted := make([]int64, 0, len(g.Teams))
|
||||
for name, role := range g.Teams {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"INSERT INTO teams (name) VALUES ($1) ON CONFLICT (name) DO NOTHING", name); err != nil {
|
||||
return err
|
||||
}
|
||||
var teamID int64
|
||||
if err := tx.QueryRowContext(ctx, "SELECT id FROM teams WHERE name = $1", name).Scan(&teamID); err != nil {
|
||||
return err
|
||||
}
|
||||
granted := make([]int64, 0, len(teamRoles))
|
||||
for teamID, role := range teamRoles {
|
||||
granted = append(granted, teamID)
|
||||
|
||||
// A row the sync owns follows the groups in both directions. One added by
|
||||
// hand is only raised: a member the owner made an owner by hand is not
|
||||
// demoted because the mapping says member.
|
||||
// demoted because the group says member.
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO team_members (team_id, user_id, role, source)
|
||||
VALUES ($1, $2, $3, 'oidc')
|
||||
|
||||
Reference in New Issue
Block a user