Files
mesh-catalog/modules/gitea/cmd/agent-teams/retire.go
T
jschoubben 64c893e2c2 F-catalog-B: the gitea module removes the npm pair's teams packages and readers and their accounts
ADR 0605 §5 revokes the npm write token, and the 14:04 deferral is withdrawn (security review F5): the
accounts the removed pair provisioner made (mesh_<node>_<consumer>) still logged in with write on the
organisation's packages. cmd/agent-teams, run at each start of the module and change of its teams value,
removes both teams and each account of that form whose only teams they are, never purging: one the forge
refuses to delete because it owns something keeps it and has its login prohibited.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:15:41 +01:00

108 lines
4.4 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"slices"
"sort"
"strings"
"gitea/internal/forgehttp"
)
// The forge accounts and teams the module made once and declares no more (novox/hq ADR 0605 §5: no build module holds
// a registry's write credential; the npm write token is revoked). The npm pair provisioner, removed when the package
// seats came, made each consumer of npm-package-registry an account under the mesh's derived login
// (mesh_<node>_<consumer>) and put it in the organisation's team packages (read and write) or readers (read only).
// Reads of the packages are anonymous and every write goes through the seats, so both teams go, and each account of
// that form in no team but them goes with them. Nothing else is touched: a team of another name, an account of
// another form or one still in a team the module does not retire stays as it is.
var RetiredTeams = []string{"packages", "readers"}
// retiredAccount is the login form the npm pair provisioner made its accounts under.
var retiredAccount = regexp.MustCompile(`^mesh_[a-z0-9-]+_[a-z0-9_]+$`)
// Retire removes the retired teams in every organisation, and each account of the pair's form whose only teams they
// are. An account the forge refuses to delete (it owns a repository or an organisation, so a delete would take what
// it owns) has its login prohibited instead, said, so its password no longer reaches anything. Each removal is said
// in one line; a run with nothing left to retire says nothing.
func (s *Syncer) Retire(ctx context.Context, orgs []string) error {
teamsOf := map[string][]string{} // login -> every team it is in, as org/team
type retired struct {
org string
team forgeTeam
}
var gone []retired
for _, org := range orgs {
teams, err := s.teams(ctx, org)
if err != nil {
return err
}
for _, t := range teams {
var members []forgeMember
if err := s.list(ctx, fmt.Sprintf("/api/v1/teams/%d/members", t.ID), &members); err != nil {
return fmt.Errorf("the members of %s in %s could not be read, so nothing is retired: %w", t.Name, org, err)
}
for _, m := range members {
teamsOf[m.Login] = append(teamsOf[m.Login], org+"/"+t.Name)
}
if slices.Contains(RetiredTeams, t.Name) {
gone = append(gone, retired{org, t})
}
}
}
isRetired := func(orgTeam string) bool {
_, team, _ := strings.Cut(orgTeam, "/")
return slices.Contains(RetiredTeams, team)
}
var accounts []string
for login, in := range teamsOf {
if retiredAccount.MatchString(login) && slices.ContainsFunc(in, isRetired) && !slices.ContainsFunc(in, func(t string) bool { return !isRetired(t) }) {
accounts = append(accounts, login)
}
}
sort.Strings(accounts)
var errs []string
for _, a := range accounts {
if err := s.removeAccount(ctx, a); err != nil {
errs = append(errs, err.Error())
}
}
for _, r := range gone {
if err := s.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/teams/%d", r.team.ID), nil, nil); err != nil && !errors.Is(err, forgehttp.ErrNotFound) {
errs = append(errs, fmt.Sprintf("the retired team %s in %s could not be removed: %v", r.team.Name, r.org, err))
continue
}
s.say("removed the retired team %s in %s (the npm pair's, ADR 0605 §5)", r.team.Name, r.org)
}
if len(errs) > 0 {
return errors.New(strings.Join(errs, "; "))
}
return nil
}
// removeAccount deletes a retired account, never purging what it owns; one the forge refuses to delete has its login
// prohibited instead.
func (s *Syncer) removeAccount(ctx context.Context, login string) error {
err := s.Forge.SendJSON(ctx, http.MethodDelete, "/api/v1/admin/users/"+url.PathEscape(login), nil, nil)
if err == nil || errors.Is(err, forgehttp.ErrNotFound) {
s.say("removed the retired account %s (the npm pair's, ADR 0605 §5)", login)
return nil
}
var se *forgehttp.StatusError
if !errors.As(err, &se) || se.Status != http.StatusUnprocessableEntity {
return fmt.Errorf("the retired account %s could not be removed: %w", login, err)
}
if perr := s.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+url.PathEscape(login), map[string]any{
"login_name": login, "source_id": 0, "prohibit_login": true,
}, nil); perr != nil {
return fmt.Errorf("the retired account %s could not be removed (%v) nor its login prohibited: %w", login, err, perr)
}
s.say("the retired account %s owns something, so the forge keeps it (%v): its login is prohibited instead", login, err)
return nil
}