A person who later makes a team named packages or readers would have lost it at the next start. A team counts as the provisioner's only in its organisation and when every member is one of its accounts (it put its own accounts there alone, and they go with the first run), so a team made later stays. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
115 lines
5.2 KiB
Go
115 lines
5.2 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. Only what the provisioner made goes: a team of those names in its
|
|
// organisation (RetiredOrg) is the provisioner's when every member of it is an account of the provisioner's form and
|
|
// it has one at least, since the provisioner put its own accounts in them alone and nobody else ever made one. A team
|
|
// a person makes later under such a name holds no such account (they are gone with the first run), so it stays, and
|
|
// so does a team of those names with a person in it or with no member. Nothing else is touched: a team of another
|
|
// name or organisation, 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"}
|
|
|
|
// RetiredOrg is the organisation the npm pair provisioner made its teams in (its ORG, the npm registry's owner).
|
|
const RetiredOrg = "novox"
|
|
|
|
// 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
|
|
retiring := map[string]bool{} // org/team
|
|
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)
|
|
}
|
|
made := len(members) > 0 && !slices.ContainsFunc(members, func(m forgeMember) bool { return !retiredAccount.MatchString(m.Login) })
|
|
if org == RetiredOrg && slices.Contains(RetiredTeams, t.Name) && made {
|
|
gone = append(gone, retired{org, t})
|
|
retiring[org+"/"+t.Name] = true
|
|
}
|
|
}
|
|
}
|
|
isRetired := func(orgTeam string) bool { return retiring[orgTeam] }
|
|
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
|
|
}
|