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')
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+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"))
|
||||
|
||||
|
||||
@@ -160,6 +160,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
|
||||
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
||||
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
||||
|
||||
// A team's own OIDC group binding: which provider groups grant member
|
||||
// and owner access to it.
|
||||
r.Get("/api/teams/{teamID}/oidc-groups", handleGetTeamOIDCGroups(db))
|
||||
r.Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(db))
|
||||
|
||||
// Invite links into this team.
|
||||
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
||||
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
||||
|
||||
@@ -237,6 +237,14 @@ type adminTeam struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Members int64 `json:"members"`
|
||||
OpenIncidents int64 `json:"open_incidents"`
|
||||
|
||||
// OIDCMemberGroup and OIDCOwnerGroup are the team's own group binding,
|
||||
// read-only here: an administrator can see why a team's OIDC-sourced
|
||||
// membership looks the way it does without being able to change it out
|
||||
// from under the team's owner. Setting it is PUT
|
||||
// /api/teams/{teamID}/oidc-groups, owner-only.
|
||||
OIDCMemberGroup string `json:"oidc_member_group,omitempty"`
|
||||
OIDCOwnerGroup string `json:"oidc_owner_group,omitempty"`
|
||||
}
|
||||
|
||||
// handleAdminListTeams lists every team on the server, with its size. The
|
||||
@@ -248,7 +256,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
||||
SELECT t.id, t.name, t.created_at,
|
||||
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||
(SELECT COUNT(*) FROM incidents i
|
||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
|
||||
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
|
||||
FROM teams t
|
||||
ORDER BY t.name`)
|
||||
if err != nil {
|
||||
@@ -261,7 +270,8 @@ func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var t adminTeam
|
||||
var created int64
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
|
||||
&t.OIDCMemberGroup, &t.OIDCOwnerGroup); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -298,10 +308,12 @@ func handleAdminGetTeam(db *sql.DB) http.HandlerFunc {
|
||||
SELECT t.id, t.name, t.created_at,
|
||||
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
||||
(SELECT COUNT(*) FROM incidents i
|
||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
||||
WHERE i.team_id = t.id AND i.resolved_at IS NULL),
|
||||
COALESCE(t.oidc_member_group, ''), COALESCE(t.oidc_owner_group, '')
|
||||
FROM teams t
|
||||
WHERE t.id = $1`, teamID).
|
||||
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents)
|
||||
Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents,
|
||||
&t.OIDCMemberGroup, &t.OIDCOwnerGroup)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return
|
||||
|
||||
@@ -327,6 +327,112 @@ func TestTeams_MemberCannotConfigureTheTeam(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A team's own OIDC group binding follows the same rule as its schedule and
|
||||
// its integrations: an owner sets it, a member may only read it, an outsider
|
||||
// learns nothing, and an administrator can still reach it to repair a team
|
||||
// whose owner has left.
|
||||
func TestTeamOIDCGroups_OwnerOnlyToEdit(t *testing.T) {
|
||||
s := newTS(t)
|
||||
team := newTeam(t, s, "sre") // team.call authenticates as its owner
|
||||
|
||||
// A plain member of the same team.
|
||||
var plain struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": "plain", "email": "plain@test.com"}), &plain)
|
||||
resp := s.req(t, http.MethodPost, "/api/teams/"+id64(team.id)+"/members",
|
||||
map[string]any{"user_id": plain.ID, "role": "member"})
|
||||
resp.Body.Close()
|
||||
var key struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
decode(t, s.req(t, http.MethodPost, "/api/users/"+id64(plain.ID)+"/api-keys",
|
||||
map[string]string{"name": "test"}), &key)
|
||||
memberCall := func(method, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != nil {
|
||||
data, _ := json.Marshal(body)
|
||||
r = bytes.NewReader(data)
|
||||
}
|
||||
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// A member of a different team altogether.
|
||||
_, outsiderCall := member(t, s, "outsider")
|
||||
|
||||
path := "/api/teams/" + id64(team.id) + "/oidc-groups"
|
||||
|
||||
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "sre", "owner_group": "sre-leads"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("owner PUT: %d, want 204", resp.StatusCode)
|
||||
}
|
||||
var got struct {
|
||||
MemberGroup string `json:"member_group"`
|
||||
OwnerGroup string `json:"owner_group"`
|
||||
}
|
||||
decode(t, team.call(http.MethodGet, path, nil), &got)
|
||||
if got.MemberGroup != "sre" || got.OwnerGroup != "sre-leads" {
|
||||
t.Errorf("owner GET after PUT: %+v", got)
|
||||
}
|
||||
|
||||
resp = memberCall(http.MethodGet, path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("member GET: %d, want 200", resp.StatusCode)
|
||||
}
|
||||
resp = memberCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("member PUT: %d, want 403", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 404, not 403: whether the team exists is itself something only its
|
||||
// members should learn.
|
||||
resp = outsiderCall(http.MethodGet, path, nil)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("outsider GET: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
resp = outsiderCall(http.MethodPut, path, map[string]string{"member_group": "anything"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("outsider PUT: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
// An administrator who is not a member may still set it, the same bypass
|
||||
// that lets one repair a team whose owner has left.
|
||||
resp = s.req(t, http.MethodPut, path, map[string]string{"member_group": "sre2"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("admin PUT: %d, want 204", resp.StatusCode)
|
||||
}
|
||||
|
||||
// An empty string clears a binding, stored as NULL rather than the literal
|
||||
// empty string, so an empty group claim can never accidentally match it.
|
||||
resp = team.call(http.MethodPut, path, map[string]string{"member_group": "", "owner_group": ""})
|
||||
resp.Body.Close()
|
||||
var cleared struct {
|
||||
MemberGroup string `json:"member_group"`
|
||||
OwnerGroup string `json:"owner_group"`
|
||||
}
|
||||
decode(t, team.call(http.MethodGet, path, nil), &cleared)
|
||||
if cleared.MemberGroup != "" || cleared.OwnerGroup != "" {
|
||||
t.Errorf("cleared: %+v", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
// A team is not somewhere an outsider can look, whatever they know about it.
|
||||
func TestTeams_OutsiderSeesNothing(t *testing.T) {
|
||||
s := newTS(t)
|
||||
|
||||
Reference in New Issue
Block a user