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__) 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 }