238 lines
8.6 KiB
Go
238 lines
8.6 KiB
Go
// 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. It also makes the one account of the build modules' keys, nox-builder, in the builders
|
|
// team that reads the code of every repository and nothing else (ADR 0466 §3; issue 1481).
|
|
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"
|
|
)
|
|
|
|
// BuildersTeamName is the team of the build modules' account (ADR 0466 §3).
|
|
const BuildersTeamName = "builders"
|
|
|
|
// Team is a team of the organisation as the mesh keeps it: its name and the shape it is put back to.
|
|
type Team struct {
|
|
Name string
|
|
Shape func() map[string]any
|
|
}
|
|
|
|
// AgentsTeam is the team of the machines' agents: 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).
|
|
var AgentsTeam = Team{Name: TeamName, Shape: teamShape}
|
|
|
|
// BuildersTeam is the team of the build modules' account: it may read the code of every repository of the organisation
|
|
// and has no other right (ADR 0466 §3).
|
|
var BuildersTeam = Team{Name: BuildersTeamName, Shape: buildersTeamShape}
|
|
|
|
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"},
|
|
}
|
|
}
|
|
|
|
func buildersTeamShape() map[string]any {
|
|
return map[string]any{
|
|
"name": BuildersTeamName, "permission": "read", "includes_all_repositories": true, "can_create_org_repo": false,
|
|
"units_map": map[string]string{"repo.code": "read"},
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
return m.EnsureInTeamOf(ctx, AgentsTeam, account)
|
|
}
|
|
|
|
// EnsureInTeamOf is EnsureInTeam for any team of the mesh's: it makes the team when there is none, puts a team that is
|
|
// there back to its shape, and adds the account to it.
|
|
func (m *Accounts) EnsureInTeamOf(ctx context.Context, team Team, account string) error {
|
|
id, err := m.teamID(ctx, team.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id != 0 {
|
|
if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), team.Shape(), nil); err != nil {
|
|
return fmt.Errorf("the team %s could not be put back to its shape: %w", team.Name, err)
|
|
}
|
|
} else {
|
|
var made struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", team.Shape(), &made); err != nil {
|
|
return fmt.Errorf("the team %s could not be made: %w", team.Name, err)
|
|
}
|
|
id = made.ID
|
|
m.say("made the forge team %s in %s", team.Name, TeamOrg)
|
|
}
|
|
if id == 0 {
|
|
return fmt.Errorf("the forge answered no id for the team %s", team.Name)
|
|
}
|
|
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, team.Name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// teamID is the id of the team of that name, or 0 when there is none.
|
|
func (m *Accounts) teamID(ctx context.Context, name string) (int64, 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 0, fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err)
|
|
}
|
|
for _, t := range teams {
|
|
if t.Name == name {
|
|
id = t.ID
|
|
}
|
|
}
|
|
if len(teams) < 50 {
|
|
break
|
|
}
|
|
}
|
|
return id, 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}$`)
|
|
|
|
// AgentUser is the one forge user of every machine's agents (ADR 0410 §1), in the agents write team.
|
|
const AgentUser = "nox-agent"
|
|
|
|
// BuilderUser is the one forge user of every build module's key (ADR 0466 §3), in the builders team: it reads and
|
|
// pushes nothing.
|
|
const BuilderUser = "nox-builder"
|
|
|
|
// BuildModules are the modules whose keys are the builders' (kind builder), by their name, which is also the name of
|
|
// the account each runs as (ADR 0466 §1).
|
|
var BuildModules = []string{"mesh-build-go", "mesh-build-node"}
|
|
|
|
// KindOfModule is the kind of key a consumer module's entry is made for: builder for a build module, machine for any
|
|
// other (ssh-client's).
|
|
func KindOfModule(module string) string {
|
|
for _, m := range BuildModules {
|
|
if m == module {
|
|
return "builder"
|
|
}
|
|
}
|
|
return "machine"
|
|
}
|
|
|
|
// ForgeUser is the forge user of a key of the given kind (ADR 0410 §1, ADR 0466 §3): `nox-agent` for every machine
|
|
// and account (kind machine), `nox-builder` for every build module (kind builder). A kind not built yet has none, and
|
|
// the answer is empty. The machine's name must still be one an account could be named for.
|
|
func ForgeUser(node, kind string) string {
|
|
if !NodeName.MatchString(node) {
|
|
return ""
|
|
}
|
|
switch kind {
|
|
case "machine":
|
|
return AgentUser
|
|
case "builder":
|
|
return BuilderUser
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// TeamOfUser is the team a forge user of the mesh is in.
|
|
func TeamOfUser(user string) Team {
|
|
if user == BuilderUser {
|
|
return BuildersTeam
|
|
}
|
|
return AgentsTeam
|
|
}
|
|
|
|
// NodeAccount is the forge account the token tool (cmd/agent-token) keeps for a node's agent: `agent-<node>` (ADR
|
|
// 0365 §1). It is not the forge user of the machine's ssh key (ForgeUser), and its behaviour is unchanged.
|
|
func NodeAccount(node string) string { return AccountPrefix + node }
|