5b4683febf
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.
75 lines
2.3 KiB
Go
75 lines
2.3 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
)
|
|
|
|
// teamOIDCGroups is one team's own OIDC binding: which group, if any, grants
|
|
// member access and which grants owner access. The same shape answers GET and
|
|
// is accepted by PUT. An empty string means no group grants that role here.
|
|
type teamOIDCGroups struct {
|
|
MemberGroup string `json:"member_group"`
|
|
OwnerGroup string `json:"owner_group"`
|
|
}
|
|
|
|
// handleGetTeamOIDCGroups answers which groups control a team's membership.
|
|
// Member-gated like the member list itself: this is part of "who is in the
|
|
// team and why", not a setting only an owner should be able to see.
|
|
func handleGetTeamOIDCGroups(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamMember(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
var g teamOIDCGroups
|
|
err := db.QueryRowContext(r.Context(),
|
|
"SELECT COALESCE(oidc_member_group, ''), COALESCE(oidc_owner_group, '') FROM teams WHERE id = $1",
|
|
teamID).Scan(&g.MemberGroup, &g.OwnerGroup)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, g)
|
|
}
|
|
}
|
|
|
|
// handleSetTeamOIDCGroups sets which groups control a team's membership.
|
|
//
|
|
// Owner-gated, the same as the schedule, the integrations and the escalation
|
|
// ladder: this decides who can end up in the team, which is exactly the kind
|
|
// of thing only the team's own owner (or an administrator repairing it) should
|
|
// be able to change. An empty string clears a binding.
|
|
func handleSetTeamOIDCGroups(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
var req teamOIDCGroups
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
|
|
if _, err := db.ExecContext(r.Context(), `
|
|
UPDATE teams
|
|
SET oidc_member_group = NULLIF($1, ''),
|
|
oidc_owner_group = NULLIF($2, '')
|
|
WHERE id = $3`,
|
|
req.MemberGroup, req.OwnerGroup, teamID); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|