// Package forgeaccount is the forge accounts the mesh makes for its agents (novox/hq ADR 0365 clauses 1 and 3, ADR // 0398 §1, ADR 0399, ADR 0400; issue 955): an account of the agent's own, no administrator, in the agents write // team. Both the token tool (cmd/agent-token) and the forge-account provider use it, so there is one place that says // what an agent's account is. package forgeaccount import ( "context" "crypto/rand" "encoding/base64" "errors" "fmt" "net/http" "os" "regexp" "gitea/internal/forgehttp" ) const ( // AccountPrefix names an agent's account for its node (ADR 0365 §1). AccountPrefix = "agent-" // TeamOrg is the organisation whose repositories are the mesh's. TeamOrg = "novox" // TeamName is the one team every agent's account is in (ADR 0398 §1). TeamName = "agents" ) // teamShape is what the agents team grants: write on code, issues and pull requests of every repository of the // organisation and no more, the shape of the forge tools' team (tools-account.ts). func teamShape() map[string]any { return map[string]any{ "name": TeamName, "permission": "write", "includes_all_repositories": true, "can_create_org_repo": false, "units_map": map[string]string{"repo.code": "write", "repo.issues": "write", "repo.pulls": "write"}, } } // User is gitea's account as the admin reads it. type User struct { IsAdmin bool `json:"is_admin"` Active bool `json:"active"` ProhibitLogin bool `json:"prohibit_login"` Restricted bool `json:"restricted"` } // Accounts makes and keeps the agents' accounts with the administrator's login. type Accounts struct { Forge forgehttp.Client // Password makes the password an account is created with: random, sent once and kept nowhere. Password func() string Log func(format string, a ...any) } // EnsureAccount makes the agent's account, plain, when there is none, and puts back what was changed by hand: never // an administrator, active, able to use its token. Its team is EnsureInTeam's. func (m *Accounts) EnsureAccount(ctx context.Context, account string) error { var u User err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u) switch { case errors.Is(err, forgehttp.ErrNotFound): password := m.password() err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users", map[string]any{ "username": account, "email": account + "@noreply.localhost", "password": password, "must_change_password": false, "visibility": "private", }, nil) password = "" if err != nil { return fmt.Errorf("the account %s could not be made: %w", account, err) } m.say("made the forge account %s for that machine's agent, in no team yet", account) return nil case err != nil: return fmt.Errorf("the account %s could not be read: %w", account, err) } if u.IsAdmin || !u.Active || u.ProhibitLogin { if err := m.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+account, map[string]any{ "login_name": account, "source_id": 0, "admin": false, "active": true, "prohibit_login": false, }, nil); err != nil { return fmt.Errorf("the account %s could not be made a plain account again: %w", account, err) } m.say("the account %s was changed by hand (administrator %t, active %t, login prohibited %t); made a plain account again", account, u.IsAdmin, u.Active, u.ProhibitLogin) } return nil } // EnsureInTeam makes the agents team when there is none, puts a team that is there back to its shape, and adds the // account to it; an account already in it stays (ADR 0398 §1, issue 948). func (m *Accounts) EnsureInTeam(ctx context.Context, account string) error { var id int64 for page := 1; page <= 20 && id == 0; page++ { var teams []struct { ID int64 `json:"id"` Name string `json:"name"` } if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/orgs/%s/teams?limit=50&page=%d", TeamOrg, page), &teams); err != nil { return fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err) } for _, t := range teams { if t.Name == TeamName { id = t.ID } } if len(teams) < 50 { break } } if id != 0 { if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), teamShape(), nil); err != nil { return fmt.Errorf("the team %s could not be put back to its shape: %w", TeamName, err) } } else { var made struct { ID int64 `json:"id"` } if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", teamShape(), &made); err != nil { return fmt.Errorf("the team %s could not be made: %w", TeamName, err) } id = made.ID m.say("made the forge team %s in %s: write on every repository", TeamName, TeamOrg) } if id == 0 { return fmt.Errorf("the forge answered no id for the team %s", TeamName) } if err := m.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", id, account), nil, nil); err != nil { return fmt.Errorf("the account %s could not be added to the team %s: %w", account, TeamName, err) } return nil } func (m *Accounts) password() string { if m.Password != nil { return m.Password() } b := make([]byte, 24) _, _ = rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } func (m *Accounts) say(format string, a ...any) { if m.Log != nil { m.Log(format, a...) return } fmt.Fprintf(os.Stderr, format+"\n", a...) } // NodeName is what a machine's name is, for it to name an account. var NodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) // ForgeUser is the forge user of a machine's key of the given kind (ADR 0400 §3): `agent-` for the machine's // own account (kind machine). A kind not built yet has none, and the answer is empty. func ForgeUser(node, kind string) string { if kind == "machine" && NodeName.MatchString(node) { return AccountPrefix + node } return "" }