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
This commit is contained in:
2026-11-04 17:15:41 +01:00
parent bccaf75556
commit 64c893e2c2
5 changed files with 184 additions and 4 deletions
+6 -3
View File
@@ -13,8 +13,10 @@ trusts that root. On a machine with no public domain the key is left out, and th
**Both package provisions carry no credential** (`credential.form` none, design 51 *Publishing*): reads of the
organisation's packages are anonymous, and every write goes through the seats' verbs, so no consumer is given an
account of the forge. The registry accounts the TypeScript pair provisioner made before stay on the forge until
removed by hand; nothing hands their passwords out any more.
account of the forge. The registry accounts the TypeScript pair provisioner made before (`mesh_<node>_<consumer>`)
and its two teams `packages` and `readers` are removed by `cmd/agent-teams` at each of its runs (ADR 0605 §5 revokes the
npm write token): both teams go, and each account of that form whose only teams they are; one the forge refuses to
delete because it owns something keeps its data and has its login prohibited.
## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347)
@@ -142,7 +144,8 @@ teams exist and who is in them is the operator's, and it is one trusted config v
whenever that file changes. For each team it makes `agent-read-<team>` in the organisation of the first repository's
owner, with permission read and no other right, its repositories exactly the listed ones and its members exactly the
listed accounts (an account that does not exist yet is skipped and said). A team named `agent-read-*` that has left
the value is emptied, never deleted. A team without that prefix is never touched. There are no verbs: a change is a
the value is emptied, never deleted. A team without that prefix is never touched, but the two the module made once
and declares no more, `packages` and `readers` (above). There are no verbs: a change is a
change of the value. It says each change in one line on stdout, and never a secret. `go test ./cmd/agent-teams`
runs it against a fake forge.
+3 -1
View File
@@ -2,7 +2,9 @@
// runtime runs once when the module starts and again whenever the module's config value `agent-read-teams` changes:
// it reads the file the mesh renders from that value, and makes the forge's teams named agent-read-* match it. Each
// team reads only; its repositories and its members (agent accounts, agent-<node>) are exactly the value's. It
// touches nothing outside the value: no team without the prefix, no repository or account of another kind.
// touches nothing else of the value's kind: no team without the prefix, no repository or account of another kind. Beside
// the value it removes what the module made once and declares no more (retire.go): the npm pair's teams packages and
// readers and the accounts in them alone.
//
// It says each change on stdout in one line, and never a secret.
package main
+107
View File
@@ -0,0 +1,107 @@
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
}
+4
View File
@@ -135,6 +135,10 @@ func (s *Syncer) Sync(ctx context.Context, v Value) error {
}
}
}
if err := s.Retire(ctx, orgs); err != nil {
s.say("not retired: %v", err)
failed = append(failed, "the retired teams and accounts")
}
if len(failed) > 0 {
return fmt.Errorf("not applied: %s", strings.Join(failed, ", "))
}
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
"strings"
@@ -34,6 +35,9 @@ type fakeForge struct {
users map[string]bool
teams []*fTeam
next int64
// owners are accounts that own a repository: the forge refuses to delete them (422); prohibited, whose login
// was prohibited.
owners, prohibited map[string]bool
}
func newFake(orgs []string, users ...string) *fakeForge {
@@ -122,6 +126,26 @@ func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
out = nil
}
reply(200, out)
case strings.HasPrefix(p, "/api/v1/admin/users/"):
u := strings.TrimPrefix(p, "/api/v1/admin/users/")
switch {
case !f.users[u]:
reply(404, nil)
case r.Method == http.MethodDelete && f.owners[u]:
reply(422, map[string]string{"message": "user still owns one or more repositories"})
case r.Method == http.MethodDelete:
delete(f.users, u)
for _, t := range f.teams {
delete(t.Members, u)
}
reply(204, nil)
case r.Method == http.MethodPatch && in["prohibit_login"] == true:
if f.prohibited == nil {
f.prohibited = map[string]bool{}
}
f.prohibited[u] = true
reply(200, map[string]string{})
}
case strings.HasPrefix(p, "/api/v1/users/"):
if !f.users[strings.TrimPrefix(p, "/api/v1/users/")] {
reply(404, nil)
@@ -155,6 +179,10 @@ func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
reply(200, out)
case teamPath.MatchString(p) && r.Method == http.MethodDelete:
t := f.find(teamPath.FindStringSubmatch(p)[1])
f.teams = slices.DeleteFunc(f.teams, func(x *fTeam) bool { return x == t })
reply(204, nil)
case teamPath.MatchString(p):
t := f.find(teamPath.FindStringSubmatch(p)[1])
t.Perm = in["permission"].(string)
@@ -331,3 +359,39 @@ func TestReadValue(t *testing.T) {
t.Fatal("an unknown field was accepted")
}
}
// Security review F5 (ADR 0605 §5): the npm pair's teams packages and readers go, and each account of the pair's form
// whose only teams they are; one that owns a repository is kept with its login prohibited; an account of the pair's
// form still in another team, an account of another form and every other team stay; a second run changes nothing.
func TestTheNpmPairsTeamsAndAccountsAreRetired(t *testing.T) {
f := newFake([]string{"novox"}, "mesh_ace_mesh_build_node", "mesh_shanks_lab", "mesh_g14_agent", "mesh_novox_owner",
"mesh-packages", "agent-ace", "jschoubben")
f.owners = map[string]bool{"mesh_novox_owner": true}
f.add("novox", "packages", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_g14_agent", "mesh_novox_owner", "jschoubben"})
f.add("novox", "readers", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_shanks_lab"})
f.add("novox", "agents", "none", nil, []string{"mesh_g14_agent", "agent-ace"})
f.add("novox", "mesh-packages", "none", nil, []string{"mesh-packages"})
s, said := syncer(t, f)
if err := s.Sync(context.Background(), Value{}); err != nil {
t.Fatal(err)
}
if f.byName("novox", "packages") != nil || f.byName("novox", "readers") != nil {
t.Errorf("the retired teams stay: %+v", f.teams)
}
if f.byName("novox", "agents") == nil || f.byName("novox", "mesh-packages") == nil {
t.Errorf("a team the module did not retire was removed: %+v", f.teams)
}
for u, want := range map[string]bool{"mesh_ace_mesh_build_node": false, "mesh_shanks_lab": false, "mesh_g14_agent": true,
"mesh_novox_owner": true, "mesh-packages": true, "agent-ace": true, "jschoubben": true} {
if f.users[u] != want {
t.Errorf("account %s kept %v, want %v", u, f.users[u], want)
}
}
if !f.prohibited["mesh_novox_owner"] || len(f.prohibited) != 1 {
t.Errorf("prohibited %v, want mesh_novox_owner alone", f.prohibited)
}
before := len(*said)
if err := s.Sync(context.Background(), Value{}); err != nil || len(*said) != before {
t.Errorf("a second run: %v, said %v; want nothing", err, (*said)[before:])
}
}