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
108 lines
4.4 KiB
Go
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
|
|
}
|