diff --git a/modules/gitea/README.md b/modules/gitea/README.md index cb5edb23..c769a732 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -52,7 +52,10 @@ base64-encoded. The Go bundle `cmd/forge` serves the commit, and the reads an ag | `gitea_search_code` | r | the lines of a ref's text files matching a literal string or a regular expression, optionally under a path | **Where a commit may go** is the rule `gitea_api` already holds for a file written (novox/hq ADR 0266): -never the repository's default branch, never a branch a protection rule names. A rule covers a branch as the +never the repository's default branch, never a branch a protection rule names. The tools' account may not read the +rules themselves (the forge keeps them for a repository's administrator, novox/hq ADR 0365): for it an existing branch +is judged by the protection the forge says it applies there, and a branch not made yet by the forge itself, which +refuses the commit that would make a branch a rule guards. A rule covers a branch as the forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob (`*`, `**`, `?`, `[abc]`, `[!abc]`, `{a,b}`, `\` escaping) covers case-sensitively with `/` as separator; a glob the forge cannot compile covers its own text. `cmd/forge/rules.go` and `guard.ts` hold this one rule. The rules are read before anything is sent; rules that cannot be read refuse the @@ -66,7 +69,7 @@ the commit the branch (or, for a new branch, its base) was read at, never at its forge's diff endpoint, which applies it; for a small change to a large file this is far shorter than the file. Neither endpoint takes a commit to build on, only a branch (Gitea 1.27), so the parent answered is the one the forge says it made the commit on; when the branch moved between the read and the commit, the answer -says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the forge's admin account, so its sha is not the +says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the tools' account, so its sha is not the local commit's: the merge check judges the head on the forge. After the write the branch is read back, and a branch not at the commit the forge named is a failure, never a success. @@ -75,8 +78,10 @@ is read once and searched in memory; a binary file is passed over, a file over 4 `skipped`, and at most `max` matches (default 100) are answered, with `truncated` when more were found. A search that reaches the call's deadline stops there and answers what it found, with `stopped_at_deadline`. -It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), and one -tool call takes at most 50 seconds. +It reaches the forge through the same HTTP client as the npm-registry bundle (`internal/forgehttp`), as the +tools' own account (novox/hq ADR 0365 §2, issue 469): the token the TypeScript bundle mints for `mesh-tools` and +keeps (`MESH_GITEA_TOKEN_FILE`), sent only when its scopes line names that account, never the admin login. One tool +call takes at most 50 seconds. `go test ./cmd/forge` runs it against a fake forge: the files of one commit created, updated and deleted on a new branch from its base; an existing branch committed on; the default branch, a protected branch and unreadable rules refused before anything is sent; a patch sent to the forge; a branch read back at another diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 389f3b62..00524c4c 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -8,6 +8,7 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js"; import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js"; import { isCommit } from "./checks.js"; +import { TOOLS_ACCOUNT } from "./tools-account.js"; /** A repository's path on the forge, its owner and name each one segment (guard.ts pathSegment): uniform for every * call, whether the names came from a caller or from the forge's own answer. */ @@ -124,10 +125,12 @@ export interface ReadyMark { /** **The accounts whose "ready at" comment counts** (novox/hq issue 722): the trusted setting ready-authors, which * the mesh writes to the file MESH_GITEA_READY_AUTHORS_FILE names as a line `authors= …`. A file - * that cannot be read leaves the mesh's own forge account alone, said; never an empty list read as everyone. */ + * that cannot be read leaves the mesh's own forge accounts alone, said; never an empty list read as everyone: the + * account the tools act as (novox/hq ADR 0365) and the admin account, whose marks were made before it. */ export function readyAuthors(file = process.env.MESH_GITEA_READY_AUTHORS_FILE, own = process.env.MESH_GITEA_ADMIN_USER, - read: (f: string) => string = (f) => readFileSync(f, "utf8")): string[] { - const fallback = own ? [own] : []; + read: (f: string) => string = (f) => readFileSync(f, "utf8"), + tools = process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT): string[] { + const fallback = [...new Set([tools, own].filter((a): a is string => !!a))]; if (!file) return fallback; try { const line = read(file).split("\n").find((l) => l.startsWith("authors=")); @@ -444,13 +447,15 @@ export class GiteaClient { * fault in the announcer cannot hide itself. Reads only. */ async openPulls(readyAuthors: string[] = []): Promise { const out: OpenPull[] = []; - const rules = new Map(); + const rules = new Map(); for (const found of await this.searchOpenPulls()) { const full = `${found.owner}/${found.name}`; const pull = await this.getPullRequest(found.owner, found.name, found.number); if (pull.state !== "open" || !pull.head_sha) continue; - if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined)); - const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main")); + // The base's protection as the forge applies it to that branch (appliedProtection): readable without administration. + const base = `${full}:${pull.base || "main"}`; + if (!rules.has(base)) rules.set(base, await this.appliedProtection(found.owner, found.name, pull.base || "main").catch(() => undefined)); + const rule = rules.get(base); const checks = await this.headChecks(found.owner, found.name, pull.head_sha); const labels = found.labels ?? []; // The ready mark (novox/hq issue 722): its timeline is read, whole, only for a pull request that carries the label. @@ -673,16 +678,44 @@ export class GiteaClient { // ---- Branch protection ---- - /** A repository's branch protection rules. */ + /** A repository's branch protection rules. The forge answers them to a repository's administrator alone, which the + * tools' account is not (novox/hq ADR 0365 §2): for it this is refused (403), and what a branch's protection asks + * is read with appliedProtection. */ async branchProtections(owner: string, repo: string): Promise { return (await this.request(`${repoPath(owner, repo)}/branch_protections`)) ?? []; } - /** The rule named for a branch, null when it has none. */ + /** The rule named for a branch, null when it has none — from the rules' list, so a repository administrator's. */ async branchProtection(owner: string, repo: string, rule: string): Promise { return (await this.branchProtections(owner, repo)).find((p) => (p.rule_name ?? p.branch_name) === rule) ?? null; } + /** + * The protection that covers an existing branch, as the forge applies it, or null when none does. Read from the + * branch itself, which anyone who reads the code may read — the rules' own list is a repository administrator's + * (novox/hq ADR 0365 §2) — and as the forge matches it, a glob rule included (its name is the rule's). The + * branch's `user_can_push` stands for `enable_push`: whether this account may push there. A branch that does not + * exist is null: nothing covers it yet, and the forge judges a push that makes it. + */ + async appliedProtection(owner: string, repo: string, branch: string): Promise { + let b: any; + try { + b = await this.request(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`); + } catch (err) { + if (/: 404 /.test(String((err as Error)?.message))) return null; + throw err; + } + if (!b?.protected) return null; + return { + rule_name: String(b.effective_branch_protection_name || branch), + branch_name: branch, + enable_push: Boolean(b.user_can_push), + enable_status_check: Boolean(b.enable_status_check), + status_check_contexts: Array.isArray(b.status_check_contexts) ? b.status_check_contexts.map(String) : [], + required_approvals: Number(b.required_approvals ?? 0), + }; + } + /** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the * branch is edited — its required statuses replaced by these, everything else it says kept unless * asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */ @@ -948,12 +981,16 @@ export class GiteaAdmin { // unit the team does not have — so code read must be said here: without it gitea answers a // member's clone of a private repository with "not found", which is how the builder's first // credentialed clone failed against a team that named only packages. - const units = { + return this.ensureTeamWith(org, team, { permission: "read", units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" }, includes_all_repositories: true, can_create_org_repo: false, - }; + }); + } + + /** Ensure a team exists with exactly these units, and return its id (ensureTeam; the tools' team, tools-account.ts). */ + async ensureTeamWith(org: string, team: string, units: Record): Promise { const found = await this.findTeam(org, team); if (found !== null) { const patch = await this.request(`/teams/${found}`, { @@ -988,7 +1025,7 @@ export class GiteaAdmin { * The edit path is taken only when the user actually exists. A 422 from the create is also what * a plain validation failure returns, and reading it as "already there" made the follow-up edit * 404 — burying the create's own message, which is the one that says what is actually wrong. */ - async ensureUser(username: string, password: string, email: string): Promise { + async ensureUser(username: string, password: string, email: string, plain = false): Promise { const res = await this.request("/admin/users", { method: "POST", body: JSON.stringify({ username, email, password, must_change_password: false }), @@ -1002,7 +1039,9 @@ export class GiteaAdmin { // login_name is required by the admin edit endpoint; for a local user it is the username. // active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong // password, so the provisioner's check reports it lost; applying again must undo both. - body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }), + // plain: never a site administrator — one made so by hand is made a plain account again (novox/hq ADR 0365). + body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false, + ...(plain ? { admin: false } : {}) }), }); if (patch.status === 200) return; GiteaAdmin.fail(`/admin/users/${username}`, patch); diff --git a/modules/gitea/cmd/forge/account_test.go b/modules/gitea/cmd/forge/account_test.go new file mode 100644 index 00000000..d37c6594 --- /dev/null +++ b/modules/gitea/cmd/forge/account_test.go @@ -0,0 +1,120 @@ +package main + +// The commit and the reads act as the tools' own account (novox/hq ADR 0365 §2, issue 469): the token the module +// keeps for `mesh-tools`, never the admin login. That account is no repository's administrator, so the forge +// refuses it the protection rules (403, as gitea's reqAdmin does); the commit then judges an existing branch by the +// protection the forge says it applies, and leaves a branch not made yet to the forge's own judgement. + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "gitea/internal/forgehttp" +) + +const toolsToken = "tools-token-never-shown" + +// asTools serves the fake forge to a bundle holding the tools' token: only that token is taken, the rules are +// refused as to a non-administrator, and the branches named protected say so as the forge does. +func asTools(t *testing.T, f *fakeForge, protected map[string]string, scopes string) (*Forge, *[]string) { + dir := t.TempDir() + file := filepath.Join(dir, "token") + if err := os.WriteFile(file, []byte(toolsToken+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file+".scopes", []byte(scopes+"\n"), 0o600); err != nil { + t.Fatal(err) + } + var seen []string + const repo = "/api/v1/repos/acme/app" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = append(seen, r.Method+" "+r.URL.Path) + if _, _, basic := r.BasicAuth(); basic || r.Header.Get("Authorization") != "token "+toolsToken { + http.Error(w, "unauthorized", 401) + return + } + if r.URL.Path == repo+"/branch_protections" { + http.Error(w, `{"message":"token does not have the administration of this repository"}`, 403) + return + } + if name := strings.TrimPrefix(r.URL.Path, repo+"/branches/"); r.Method == "GET" && name != r.URL.Path { + if rule, ok := protected[name]; ok { + _, _ = w.Write([]byte(`{"name":"` + name + `","commit":{"id":"c0"},"protected":true,"effective_branch_protection_name":"` + rule + `"}`)) + return + } + } + r.SetBasicAuth("mesh-admin", f.password) // the fake's own door, past the token checked above + f.handle(w, r) + })) + t.Cleanup(srv.Close) + return &Forge{Client: forgehttp.Client{URL: srv.URL, TokenFile: file, Account: "mesh-tools"}}, &seen +} + +func TestTheToolsAccountCommitsOnANewBranchWithItsTokenThoughTheRulesAreNotItsToRead(t *testing.T) { + f := newFake(t) + forge, _ := asTools(t, f, nil, "read:user,write:issue,write:repository for mesh-tools") + got, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365", + "files": filesArg(map[string]any{"path": "x", "content": "x"})})) + if err != nil { + t.Fatal(err) + } + if len(f.writes) != 1 || !got.Created { + t.Fatalf("%d writes, %+v", len(f.writes), got) + } +} + +func TestTheToolsAccountIsRefusedAProtectedBranchTheForgeSaysItGuardsAndTheDefaultBranch(t *testing.T) { + for branch, rule := range map[string]string{"release/1.0": "release/*", "fix/1-old": "fix/1-old", "main": ""} { + f := newFake(t) + protected := map[string]string{} + if rule != "" { + protected[branch] = rule + } + forge, _ := asTools(t, f, protected, "read:user,write:issue,write:repository for mesh-tools") + _, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": branch, + "files": filesArg(map[string]any{"path": "x", "content": "x"})})) + if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "ADR 0266") { + t.Errorf("%s: %v", branch, err) + } + if rule != "" && !strings.Contains(err.Error(), rule) { + t.Errorf("%s: the rule %s is not named: %v", branch, rule, err) + } + if len(f.writes)+len(f.patches) != 0 { + t.Errorf("%s: something was written", branch) + } + } +} + +func TestATokenKeptForTheAdministratorIsNeverSent(t *testing.T) { + f := newFake(t) + // What the module kept before ADR 0365: the admin's token, its scopes line naming no account. + forge, seen := asTools(t, f, nil, "read:user,write:issue,write:repository") + _, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365", + "files": filesArg(map[string]any{"path": "x", "content": "x"})})) + if err == nil || !strings.Contains(err.Error(), "not mesh-tools's") { + t.Fatalf("%v", err) + } + if len(*seen) != 0 { + t.Fatalf("the forge was asked %v", *seen) + } +} + +func TestTheBundleHandedATokenFileNeedsNoAdminLogin(t *testing.T) { + t.Setenv("MESH_GITEA_URL", "http://127.0.0.1:3000") + t.Setenv("MESH_GITEA_TOKEN_FILE", "/state/token") + t.Setenv("MESH_GITEA_TOOLS_USER", "") + t.Setenv("MESH_GITEA_ADMIN_USER", "") + t.Setenv("MESH_GITEA_ADMIN_PASSWORD_FILE", "") + f, err := ForgeFromEnv() + if err != nil { + t.Fatal(err) + } + if f.TokenFile != "/state/token" || f.Account != "mesh-tools" || f.User != "" || f.PasswordFile != "" { + t.Fatalf("%+v", f.Client) + } +} diff --git a/modules/gitea/cmd/forge/commit.go b/modules/gitea/cmd/forge/commit.go index 6976da57..d3c2af52 100644 --- a/modules/gitea/cmd/forge/commit.go +++ b/modules/gitea/cmd/forge/commit.go @@ -9,6 +9,8 @@ import ( "net/url" "strings" "unicode/utf8" + + "gitea/internal/forgehttp" ) // gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull @@ -211,7 +213,32 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) { // Read the rules, or refuse: a branch the rules could not be read for is never written (fails closed). var rules []Protection if err := f.GetJSON(ctx, repo+"/branch_protections", &rules); err != nil { - return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err) + var status *forgehttp.StatusError + if !errors.As(err, &status) || status.Status != http.StatusForbidden { + return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err) + } + // The rules are a repository administrator's to read, and the tools' account is none (novox/hq ADR 0365 + // §2). What it may read is the protection the forge applies to an existing branch: one protected is + // refused here. A branch not made yet is covered by no rule this account can see, and the forge itself + // judges the commit that makes it against every rule, refusing one a rule guards. + if err := targetRefused(a.Branch, r.DefaultBranch, nil); err != nil { + return nil, err + } + b, err := f.branch(ctx, repo, a.Branch) + if err != nil { + return nil, fmt.Errorf("whether a protection rule guards %s of %s/%s could not be read, so nothing was sent: %w", a.Branch, a.Owner, a.Repo, err) + } + if b != nil && b.Protected { + rule := b.Rule + if rule == "" { + rule = a.Branch + } + rules = []Protection{{RuleName: rule}} + if !ruleCovers(rule, a.Branch) { + return nil, refuse("a commit on %s is refused: the protection rule %s guards it (novox/hq ADR 0266). Put it on a "+ + "branch of its own. Nothing was sent", a.Branch, rule) + } + } } if err := targetRefused(a.Branch, r.DefaultBranch, rules); err != nil { return nil, err diff --git a/modules/gitea/cmd/forge/forge.go b/modules/gitea/cmd/forge/forge.go index 3637116d..a28a915b 100644 --- a/modules/gitea/cmd/forge/forge.go +++ b/modules/gitea/cmd/forge/forge.go @@ -11,8 +11,8 @@ import ( "gitea/internal/forgehttp" ) -// The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin account -// (internal/forgehttp, shared with the npm-registry bundle beside it). +// The forge as this bundle reaches it: its own HTTP interface on the machine, as the tools' own account (novox/hq +// ADR 0365 §2; internal/forgehttp, shared with the npm-registry bundle beside it). // Forge is the forge's HTTP interface. type Forge struct { @@ -91,12 +91,15 @@ func (p Protection) name() string { return p.BranchName } -// Branch is one branch with the commit it points at. +// Branch is one branch with the commit it points at, and the protection the forge applies to it: what an account +// that may not read the rules themselves (the tools' account, novox/hq ADR 0365) knows of them. type Branch struct { Name string `json:"name"` Commit struct { ID string `json:"id"` } `json:"commit"` + Protected bool `json:"protected"` + Rule string `json:"effective_branch_protection_name"` } // branch is the named branch, nil when the forge has none of that name. diff --git a/modules/gitea/cmd/forge/forge_test.go b/modules/gitea/cmd/forge/forge_test.go index 7d9464df..1d315cc3 100644 --- a/modules/gitea/cmd/forge/forge_test.go +++ b/modules/gitea/cmd/forge/forge_test.go @@ -619,11 +619,17 @@ func TestToolsAreTheOnesTheReadmeNamesAndTheManifestBuilds(t *testing.T) { if len(a.Loads) != 1 || a.Loads[0] != a.Binary { t.Errorf("the bundle loads %v, not its binary %s", a.Loads, a.Binary) } - for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} { + // The tools' own account, never the admin login (novox/hq ADR 0365 §2). + for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE"} { if a.Env[k] == "" { t.Errorf("the bundle is not given %s", k) } } + for _, k := range []string{"MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} { + if a.Env[k] != "" { + t.Errorf("the bundle is given %s: it acts as the tools' account, not the administrator", k) + } + } } } if !found { diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index b9c80da7..c730bc2a 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -42,7 +42,7 @@ import { commitStatusTool } from "./delivery.js"; import { mergeTool } from "./merge.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked, - type RepoCheckFacts, reposToLook } from "./pulls.js"; + repoCheckFacts, reposToLook } from "./pulls.js"; import { GateWriter, clonedAs, type BuildStarted } from "./gate.js"; import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js"; import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js"; @@ -400,22 +400,6 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`); } -// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the -// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection -// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads -// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is -// required would let an untested repository merge. -async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | undefined): Promise { - if (c["repo-check"]?.verdict !== "warning") return {}; - const defined = await client.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined); - if (defined !== false) return { defined }; - const rules = await client.branchProtections(c.owner, c.repo).catch(() => undefined); - if (!rules) return { defined }; - const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === (base || "main")); - const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT); - return { defined, required }; -} - // **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words, // where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last // (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending. diff --git a/modules/gitea/internal/forgehttp/forgehttp.go b/modules/gitea/internal/forgehttp/forgehttp.go index 9b76e553..3b3e8c89 100644 --- a/modules/gitea/internal/forgehttp/forgehttp.go +++ b/modules/gitea/internal/forgehttp/forgehttp.go @@ -1,7 +1,17 @@ // Package forgehttp is the forge's HTTP interface as this module's Go bundles reach it (cmd/forge, -// cmd/npm-registry): the forge on its own machine, as its admin account, whose password the vault delivered -// into a file this module owns (novox/hq ADR 0086), by basic authentication. The password is read when a call -// is made and never logged, answered or put in an error. One copy for both bundles (novox/hq issue 539). +// cmd/npm-registry), on the forge's own machine. One copy for both bundles (novox/hq issue 539). +// +// Two ways in, by what the manifest hands the bundle: +// +// - **as the tools' account** (novox/hq ADR 0365 §2), when MESH_GITEA_TOKEN_FILE names the token the module +// keeps for it (token.ts mints it, for `mesh-tools`, and keeps it at 0600 with its scopes line beside it). A +// token whose scopes line does not say it is the tools' account's — one kept from before, the administrator's — +// is never sent: the call fails, saying so, until the module has minted the account's own. This bundle mints +// nothing; a token the forge rejects is renewed by the module's next call, and said here. +// - **as the admin account**, whose password the vault delivered into a file this module owns (novox/hq ADR +// 0086), by basic authentication, when no token file is named. +// +// A credential is read when a call is made and never logged, answered or put in an error. package forgehttp import ( @@ -25,12 +35,31 @@ type Client struct { URL string // e.g. http://127.0.0.1:3000 User string PasswordFile string - HTTP *http.Client // when nil, a client with Timeout - Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero + // TokenFile, when set, is the tools' account's token (MESH_GITEA_TOKEN_FILE), sent instead of the admin login; + // Account the account its scopes line must name (MESH_GITEA_TOOLS_USER, default mesh-tools). + TokenFile string + Account string + HTTP *http.Client // when nil, a client with Timeout + Timeout time.Duration // one request's limit when HTTP is nil; 20 s when zero } -// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset. +// ToolsAccount is the account the forge's tools act as when the manifest names none (token.ts, tools-account.ts). +const ToolsAccount = "mesh-tools" + +// FromEnv is the forge as the manifest hands it to a bundle, and the names of the variables it left unset. A bundle +// handed MESH_GITEA_TOKEN_FILE acts as the tools' account and needs no admin login. func FromEnv() (Client, []string) { + if file := os.Getenv("MESH_GITEA_TOKEN_FILE"); file != "" { + c := Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), TokenFile: file, + Account: os.Getenv("MESH_GITEA_TOOLS_USER")} + if c.Account == "" { + c.Account = ToolsAccount + } + if c.URL == "" { + return c, []string{"MESH_GITEA_URL"} + } + return c, nil + } c := Client{ URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), User: os.Getenv("MESH_GITEA_ADMIN_USER"), @@ -80,14 +109,45 @@ func (c *Client) password() (string, error) { return strings.TrimSpace(string(raw)), nil } +// token is the tools' account's token, refused unless its scopes line names that account. +func (c *Client) token() (string, error) { + raw, err := os.ReadFile(c.TokenFile) + if err != nil { + return "", fmt.Errorf("the tools' token is not kept yet (the module mints it for %s at its first call): %w", c.Account, err) + } + line, err := os.ReadFile(c.TokenFile + ".scopes") + if err != nil || !strings.HasSuffix(strings.TrimSpace(string(line)), " for "+c.Account) { + return "", fmt.Errorf("the token kept at %s is not %s's (novox/hq ADR 0365): the module mints that account's own at its next call; nothing was sent", c.TokenFile, c.Account) + } + token := strings.TrimSpace(string(raw)) + if token == "" { + return "", fmt.Errorf("the token kept at %s is empty; nothing was sent", c.TokenFile) + } + return token, nil +} + +// authorize puts the bundle's credential on a request: the tools' token, or the admin login. +func (c *Client) authorize(req *http.Request) error { + if c.TokenFile != "" { + token, err := c.token() + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+token) + return nil + } + password, err := c.password() + if err != nil { + return err + } + req.SetBasicAuth(c.User, password) + return nil +} + // Do sends one request, with payload as its JSON body when not nil, and answers the body, read up to limit; // 404 is ErrNotFound. One byte past the limit is read, so a body the limit cut is told from one that fits it // exactly: a cut body is refused, never read as if it were whole. func (c *Client) Do(ctx context.Context, method, path string, payload any, limit int64) ([]byte, error) { - password, err := c.password() - if err != nil { - return nil, err - } var body io.Reader if payload != nil { raw, err := json.Marshal(payload) @@ -100,7 +160,9 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit if err != nil { return nil, err } - req.SetBasicAuth(c.User, password) + if err := c.authorize(req); err != nil { + return nil, err + } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") @@ -118,6 +180,9 @@ func (c *Client) Do(ctx context.Context, method, path string, payload any, limit return nil, fmt.Errorf("the forge's answer to %s %s is larger than %d MiB and was not read whole", method, path, limit>>20) } switch { + case res.StatusCode == http.StatusUnauthorized && c.TokenFile != "": + return nil, fmt.Errorf("the forge rejected the tools' token for %s (401): the module mints it afresh at its next call; nothing was done: %w", + method+" "+path, &StatusError{method, path, res.StatusCode, "unauthorized"}) case res.StatusCode == http.StatusNotFound: return nil, fmt.Errorf("%s %s: %w", method, path, ErrNotFound) case res.StatusCode >= 300: diff --git a/modules/gitea/merge.ts b/modules/gitea/merge.ts index 05ee77a2..9c8e2570 100644 --- a/modules/gitea/merge.ts +++ b/modules/gitea/merge.ts @@ -54,9 +54,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition const method = args.method ? String(args.method) : "merge"; if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`); const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch); - // **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here rather than left - // to the forge, whose administrator — the account this tool's token is minted for — may merge past a - // rule that lets it. A rule that cannot be read is no rule passed. + // **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here as well as by the + // forge, which holds the tools' own account to it (novox/hq ADR 0365 §2). The protection is the one the forge + // applies to the base, read from the branch: the rules' own list is a repository administrator's. A rule that + // cannot be read is no rule passed. // **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d, // force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the // head was and nothing told it which head the review had approved. The caller names it; a head that moved @@ -80,7 +81,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` + `${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`); } - const rule = await gitea.branchProtection(owner, repo, pull.base); + const rule = await gitea.appliedProtection(owner, repo, pull.base); // Only into a branch whose protection requires the checks: a pull request whose base was moved to a // branch nothing guards would merge past every check (novox/hq ADR 0266). if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) { diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 210c2437..987cd1d6 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -159,8 +159,8 @@ "settings": { "ready-authors": { "kind": "preference", - "default": "mesh-admin jschoubben", - "why": "the forge accounts whose comment \"ready at \" marks a pull request ready for mesh-delivery to merge, separated by spaces: the mesh's own forge account and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word" + "default": "mesh-tools mesh-admin jschoubben", + "why": "the forge accounts whose comment \"ready at \" marks a pull request ready for mesh-delivery to merge, separated by spaces: the account the forge's tools act as (novox/hq ADR 0365), the admin account for marks made before the tools acted as their own, and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word" } }, "resources": [ @@ -308,6 +308,7 @@ "MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json", "MESH_GITEA_ADMIN_USER": "mesh-admin", "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret", + "MESH_GITEA_TOOLS_USER": "mesh-tools", "MESH_GITEA_STATE_DIR": "${dir:runtime-state}", "MESH_RECEIVES": "${dir:grants}/npm.json", "MESH_GITEA_READY_AUTHORS_FILE": "${dir:mesh-state}/ready-authors", @@ -345,8 +346,8 @@ ], "env": { "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", - "MESH_GITEA_ADMIN_USER": "mesh-admin", - "MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret" + "MESH_GITEA_TOOLS_USER": "mesh-tools", + "MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token" } } ] diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts index a51156a1..2c3a549d 100644 --- a/modules/gitea/pulls.ts +++ b/modules/gitea/pulls.ts @@ -9,7 +9,7 @@ // // Pure functions here, so they are tested without a forge; index.ts does the asking and the setting. -import type { CommitStatus, GiteaPull, GiteaRepo } from "./client.js"; +import type { CommitStatus, GiteaClient, GiteaPull, GiteaRepo } from "./client.js"; import { movedSince } from "./client.js"; /** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */ @@ -176,6 +176,24 @@ export interface RepoCheckFacts { required?: boolean; } +// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the +// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection +// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads +// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is +// required would let an untested repository merge. The base's protection is the one the forge applies, read +// from the branch: the rules' own list is a repository administrator's, which the tools' account is not +// (novox/hq ADR 0365). +export async function repoCheckFacts( + forge: Pick, c: Checked, base: string | undefined): Promise { + if (c["repo-check"]?.verdict !== "warning") return {}; + const defined = await forge.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined); + if (defined !== false) return { defined }; + const rule = await forge.appliedProtection(c.owner, c.repo, base || "main").catch(() => undefined); + if (rule === undefined) return { defined }; + const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT); + return { defined, required }; +} + /** The description of a required repository check that is not defined. */ export const UNDEFINED_REQUIRED = "fail: mesh/repo-check is required here and this head defines no merge-check.sh — " + "a person adds one, or lifts the requirement from the branch's protection"; diff --git a/modules/gitea/test/announcing.test.ts b/modules/gitea/test/announcing.test.ts index 01ce7a05..a7a08c58 100644 --- a/modules/gitea/test/announcing.test.ts +++ b/modules/gitea/test/announcing.test.ts @@ -47,11 +47,18 @@ test("every open pull request is read with what its base requires and what its h if (url.startsWith("/api/v1/repos/novox/mesh-sdk/pulls/14")) { return send({ number: 14, state: "open", base: { ref: "main" }, head: { ref: "y", sha: "bbb2222" }, html_url: "s" }); } - if (url.startsWith("/api/v1/repos/novox/hq/branch_protections")) { - return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/repo-check"] }]); + // The base's protection, read from the branch as the tools' account may (novox/hq ADR 0365); the rules' list is refused it. + if (url.includes("/branch_protections")) { + res.statusCode = 403; + return res.end(JSON.stringify({ message: "the rules are a repository administrator's" })); } - if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branch_protections")) { - return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }]); + if (url.startsWith("/api/v1/repos/novox/hq/branches/main")) { + return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true, + status_check_contexts: ["mesh/repo-check"] }); + } + if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branches/main")) { + return send({ name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true, + status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }); } if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] }); if (url.includes("/commits/bbb2222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending", @@ -251,6 +258,8 @@ test("a long thread hides nothing: every page of the timeline is read", async () test("the accounts that may mark are the setting's, and only the mesh's own when it cannot be read", async () => { const { readyAuthors } = await import("../client.ts"); assert.deepEqual(readyAuthors("f", "mesh-admin", () => "# x\nauthors=mesh-admin jschoubben\n"), ["mesh-admin", "jschoubben"]); - assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }), ["mesh-admin"]); - assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n"), ["mesh-admin"]); + // The tools' account and the admin's, whose marks were made before ADR 0365 (issue 469). + assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }, "mesh-tools"), ["mesh-tools", "mesh-admin"]); + assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n", "mesh-tools"), ["mesh-tools", "mesh-admin"]); + assert.deepEqual(readyAuthors(undefined, "mesh-admin", undefined, "mesh-tools"), ["mesh-tools", "mesh-admin"]); }); diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index f14b50ec..567ad023 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -30,6 +30,9 @@ const state = { // What the tools said on the bus. emitted: [] as { type: string; body: any }[], rules: [] as any[], + // Whether the token is a repository administrator's. The tools' account is not (novox/hq ADR 0365): the forge + // refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch. + admin: false, lastCommit: "2026-01-01T00:00:00Z", // The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's // record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the @@ -59,6 +62,7 @@ beforeEach(() => { state.trunk = TRUNK; state.emitted = []; state.rules = [protectedMain()]; + state.admin = false; state.head = REVIEWED; state.landed = ""; state.merged = false; @@ -87,8 +91,18 @@ const server = createServer(async (req, res) => { if (method !== "GET") state.sent.push({ method, path, body }); const repo = "/repos/novox/mesh-catalog"; if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" }); + if (path.startsWith(`${repo}/branch_protections`) && !state.admin) { + return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" }); + } if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules); - if (method === "GET" && path === `${repo}/branches/main`) return json(res, 200, { name: "main", commit: { id: state.trunk } }); + const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null; + if (branch !== null) { + // As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it. + const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch)); + return json(res, 200, { name: branch, commit: { id: branch === "main" ? state.trunk : "c0ffee" }, protected: !!rule, + ...(rule ? { effective_branch_protection_name: rule.rule_name ?? rule.branch_name, enable_status_check: !!rule.enable_status_check, + status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: false } : {}) }); + } if (method === "GET" && path === `${repo}/pulls/7/files`) { return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []); } @@ -199,6 +213,7 @@ test("a protection rule covers a branch as the forge decides it: classes, altern }); test("gitea_api still writes what an author writes", async () => { + state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365) await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments", method: "POST", body: { body: "hi" } }); await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT" } }); await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/branch_protections" }); @@ -206,6 +221,7 @@ test("gitea_api still writes what an author writes", async () => { }); test("gitea_branch_protection_set only strengthens", async () => { + state.admin = true; // a repository administrator's token: the tools' account is refused the rules by the forge const set = (args: Record) => tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args }); await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/); @@ -396,6 +412,7 @@ test("gitea_merge_pull_request reads back a fast-forward, and says it cannot rea }); test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => { + state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365) const del = (owner: string, name: string, confirm: unknown) => tool("gitea_delete_repo").run({ owner, name, confirm }); await assert.rejects(del("novox", "mesh-catalog", true), /confirm must be the repository's full name/); await assert.rejects(del("novox", "mesh-catalog", "novox/mesh-catalog"), /not a repository of the tools' own user/); @@ -427,6 +444,7 @@ test("gitea_merge_pull_request merges only into a branch whose protection requir }); test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => { + state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365) state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true, status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true }); const set = (args: Record) => diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index 59cacb90..d72202f2 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -45,6 +45,7 @@ interface Forge { function fakeForge(): Promise { const forge = { + mintedFor: "", mints: 0, lastScopes: null as string[] | null, tokens: new Map(), // name -> value @@ -83,7 +84,14 @@ function fakeForge(): Promise { const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/); if (tokens) { const user = basic(req); - if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" }); + // gitea's reqSelfOrAdmin: the account itself, or the administrator for another (the tools' account, ADR 0365). + if (user === null || (user !== decodeURIComponent(tokens[1]) && user !== ADMIN)) return json(res, 401, { message: "auth required" }); + // This fake keeps one account's tokens: the tools' account's when the module mints for it (ADR 0365), and the + // admin's own list is then empty — nothing of it is retired. + if (decodeURIComponent(tokens[1]) === ADMIN && forge.mintedFor === "mesh-tools") { + return req.method === "GET" ? json(res, 200, []) : json(res, 404, { message: "token not found" }); + } + if (req.method === "POST") forge.mintedFor = decodeURIComponent(tokens[1]); if (req.method === "POST") { const { name, scopes } = await body(req); if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" }); @@ -186,6 +194,17 @@ function fakeForge(): Promise { { full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }, ]); } + // What setting up the tools' account asks with the admin login (tools-account.ts, ADR 0365): answered plainly. + if (basic(req) === ADMIN) { + const p = url.pathname; + if (req.method === "GET" && p === "/api/v1/user") return json(res, 200, { login: ADMIN, is_admin: true }); + if (req.method === "GET" && p === "/api/v1/users/mesh-tools") return json(res, 200, { login: "mesh-tools", is_admin: false }); + if (req.method === "POST" && p === "/api/v1/admin/users") return json(res, 422, { message: "user already exists" }); + if (req.method === "PATCH" && p === "/api/v1/admin/users/mesh-tools") return json(res, 200, { login: "mesh-tools" }); + if (req.method === "GET" && p === "/api/v1/orgs/novox/teams") return json(res, 200, [{ id: 7, name: "mesh-tools" }]); + if (req.method === "PATCH" && p === "/api/v1/teams/7") return json(res, 200, { id: 7 }); + if (req.method === "PUT" && p === "/api/v1/teams/7/members/mesh-tools") return json(res, 204, null); + } const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/); if (adminUser && req.method === "PATCH") { const h = req.headers.authorization ?? ""; diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts new file mode 100644 index 00000000..efc6dcfa --- /dev/null +++ b/modules/gitea/test/tools-account.test.ts @@ -0,0 +1,332 @@ +// The forge's tools act as an account of their own, not the site administrator (novox/hq ADR 0365 §2, issue 469): +// the token the tools and the watcher use is minted for `mesh-tools`, a plain account made by the module with the +// administrator's login and given write on the mesh's repositories through one team; the administrator's own +// token by that name goes; a token kept from before, the administrator's, is minted again for the account. +// +// The forge is a fake that keeps accounts, their tokens and the organisation's teams apart, and answers as gitea +// does: an administrator may mint for another account (reqSelfOrAdmin), anyone else only for itself; a protection +// edit needs the repository's administration, which neither a plain account nor its write team holds. + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { mkdtemp, readFile, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { MintedToken } from "../dist/token.js"; +import { GiteaClient } from "../dist/client.js"; +import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } from "../dist/tools-account.js"; +import { repoCheckFacts } from "../dist/pulls.js"; + +const ADMIN = "mesh-admin"; +const PASSWORD = "the-vault-minted-this"; + +interface Account { admin: boolean; password: string; active: boolean } +interface Token { id: number; name: string; sha1: string; user: string; scopes: string[] } +interface Team { id: number; name: string; units: any; members: Set } + +function fakeForge() { + const users = new Map([[ADMIN, { admin: true, password: PASSWORD, active: true }]]); + const tokens: Token[] = []; + const teams: Team[] = []; + const calls: string[] = []; + // What the forge does wrong when a test says so: a token mint or delete it fails, a team another process made + // between this one's look and its create (gitea answers the create 422). + const faults = { mint: false, deleteToken: false, teamRace: false }; + let next = 0; + const json = (res: ServerResponse, status: number, body: unknown): void => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(body === null ? "" : JSON.stringify(body)); + }; + const read = (req: IncomingMessage): Promise => + new Promise((resolve) => { + let t = ""; + req.on("data", (c) => (t += c)); + req.on("end", () => resolve(t ? JSON.parse(t) : null)); + }); + const who = (req: IncomingMessage): string | null => { + const h = req.headers.authorization ?? ""; + if (h.startsWith("Basic ")) { + const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":"); + return users.get(u)?.password === p ? u : null; + } + if (h.startsWith("token ")) return tokens.find((t) => t.sha1 === h.slice(6))?.user ?? null; + return null; + }; + const isAdmin = (u: string | null) => u !== null && users.get(u)?.admin === true; + + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://fake"); + const p = url.pathname.replace(/^\/api\/v1/, ""); + const me = who(req); + calls.push(`${req.method} ${p} as ${me}`); + if (me === null) return json(res, 401, { message: "unauthorized" }); + + let m = p.match(/^\/users\/([^/]+)\/tokens(?:\/(\d+))?$/); + if (m) { + const owner = decodeURIComponent(m[1]); + if (!(req.headers.authorization ?? "").startsWith("Basic ")) return json(res, 401, { message: "basic auth required" }); + if (owner !== me && !isAdmin(me)) return json(res, 403, { message: "reqSelfOrAdmin" }); + if (!users.has(owner)) return json(res, 404, { message: "user does not exist" }); + if (req.method === "POST") { + if (faults.mint) return json(res, 500, { message: "the forge is not well" }); + const { name, scopes } = await read(req); + if (tokens.some((t) => t.user === owner && t.name === name)) return json(res, 400, { message: "token name has already been used" }); + const t = { id: ++next, name, sha1: `sha-${owner}-${next}`, user: owner, scopes }; + tokens.push(t); + return json(res, 201, { id: t.id, name, sha1: t.sha1, scopes }); + } + if (req.method === "GET") { + const page = Number(url.searchParams.get("page") ?? "1"); + const mine = tokens.filter((t) => t.user === owner); + return json(res, 200, page === 1 ? mine.map((t) => ({ id: t.id, name: t.name, sha1: "" })) : []); + } + if (req.method === "DELETE" && m[2]) { + if (faults.deleteToken && owner === ADMIN) return json(res, 500, { message: "the forge is not well" }); + const i = tokens.findIndex((t) => t.user === owner && t.id === Number(m![2])); + if (i < 0) return json(res, 404, { message: "not found" }); + tokens.splice(i, 1); + return json(res, 204, null); + } + } + if (req.method === "GET" && p === "/user") return json(res, 200, { login: me, is_admin: isAdmin(me) }); + m = p.match(/^\/users\/([^/]+)$/); + if (m && req.method === "GET") { + const u = users.get(decodeURIComponent(m[1])); + if (!u) return json(res, 404, { message: "user does not exist" }); + return json(res, 200, { login: m[1], is_admin: u.admin, active: u.active, prohibit_login: false }); + } + if (p.startsWith("/admin/") && !isAdmin(me)) return json(res, 403, { message: "must be a site administrator" }); + if (req.method === "POST" && p === "/admin/users") { + const b = await read(req); + if (users.has(b.username)) return json(res, 422, { message: "user already exists" }); + if (!b.password || b.password.length < 8) return json(res, 422, { message: "password is too short" }); + users.set(b.username, { admin: b.admin === true, password: b.password, active: true }); + return json(res, 201, { login: b.username, is_admin: b.admin === true }); + } + m = p.match(/^\/admin\/users\/([^/]+)$/); + if (m && req.method === "PATCH") { + const u = users.get(decodeURIComponent(m[1])); + if (!u) return json(res, 404, { message: "user does not exist" }); + const b = await read(req); + if (b.admin !== undefined) u.admin = b.admin; + if (b.active !== undefined) u.active = b.active; + return json(res, 200, { login: m[1], is_admin: u.admin }); + } + if (p === "/orgs/novox/teams") { + if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" }); + if (req.method === "GET") return json(res, 200, teams.map((t) => ({ id: t.id, name: t.name }))); + const b = await read(req); + const t = { id: ++next, name: b.name, units: b, members: new Set() }; + teams.push(t); + if (faults.teamRace) return json(res, 422, { message: "team already exists" }); + return json(res, 201, { id: t.id, name: t.name }); + } + m = p.match(/^\/teams\/(\d+)(?:\/members\/([^/]+))?$/); + if (m) { + if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" }); + const t = teams.find((x) => x.id === Number(m![1])); + if (!t) return json(res, 404, { message: "team does not exist" }); + if (req.method === "PATCH") { t.units = await read(req); return json(res, 200, { id: t.id }); } + if (req.method === "PUT" && m[2]) { t.members.add(decodeURIComponent(m[2])); return json(res, 204, null); } + } + // Repository administration: a site administrator, or a team member whose team administers the repository. + m = p.match(/^\/repos\/novox\/([^/]+)\/branches\/(.+)$/); + if (m && req.method === "GET") { + if (m[2] === "main") return json(res, 200, { name: "main", protected: true, effective_branch_protection_name: "main", + enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], user_can_push: false }); + if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false }); + return json(res, 404, { message: "branch does not exist" }); + } + // gitea's reqAdmin on the whole group: reading the rules is administration too. + m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/.*)?$/); + if (m) { + const admins = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.permission === "admin"); + if (!admins) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" }); + return json(res, 201, { rule_name: "main" }); + } + m = p.match(/^\/repos\/novox\/([^/]+)\/statuses\/([0-9a-f]+)$/); + if (m && req.method === "POST") { + const writes = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.units_map?.["repo.code"] === "write"); + if (!writes) return json(res, 403, { message: "write on code required" }); + return json(res, 201, { context: (await read(req)).context, creator: { login: me } }); + } + if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] }); + return json(res, 404, { message: "no such route in the fake" }); + }); + return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[]; + faults: typeof faults; close(): Promise }>((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + resolve({ url: `http://127.0.0.1:${port}`, users, tokens, teams, calls, faults, + close: () => new Promise((r) => server.close(() => r())) }); + }); + }); +} + +const forge = await fakeForge(); +after(() => forge.close()); + +async function environment(): Promise { + const dir = await mkdtemp(join(tmpdir(), "gitea-tools-account-")); + const passwordFile = join(dir, "admin.secret"); + await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 }); + return { + MESH_GITEA_URL: forge.url, + MESH_GITEA_ADMIN_USER: ADMIN, + MESH_GITEA_ADMIN_PASSWORD_FILE: passwordFile, + MESH_GITEA_STATE_DIR: join(dir, "state"), + }; +} + +/** A source as a fresh process builds it from the manifest's environment (fromEnv shares one per file). */ +function fresh(env: NodeJS.ProcessEnv): GiteaClient { + return GiteaClient.fromEnv(env); +} + +test("the tools' token is minted for an account of their own, which is no administrator and writes through its team", async () => { + const env = await environment(); + await fresh(env).listRepos(); + + const account = forge.users.get(TOOLS_ACCOUNT); + assert.ok(account, "the module made the tools' account"); + assert.equal(account!.admin, false, "the tools' account is no administrator"); + const held = forge.tokens.filter((t) => t.name === "mesh-tools"); + assert.deepEqual(held.map((t) => t.user), [TOOLS_ACCOUNT], "the one token is the tools' account's, none is the administrator's"); + assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8"), held[0].sha1 + "\n"); + + const team = forge.teams.find((t) => t.name === "mesh-tools"); + assert.ok(team, "the team is made in the mesh's organisation"); + assert.ok(team!.members.has(TOOLS_ACCOUNT)); + assert.equal(team!.units.permission, "write"); + assert.equal(team!.units.includes_all_repositories, true); + assert.equal(team!.units.can_create_org_repo, false, "a repository's maker would administer it"); + assert.deepEqual(team!.units.units_map, TOOLS_TEAM_UNITS.units_map); + assert.ok(!Object.values(team!.units.units_map).includes("admin")); +}); + +test("with the tools' token, the forge writes a status and refuses a protection edit — the forge itself, not a guard", async () => { + const env = await environment(); + const client = fresh(env); + await client.setCommitStatus("novox", "hq", "abc123def4567890abc123def4567890abc12345", { state: "success", context: "mesh/merge-gate", description: "ok" } as any); + await assert.rejects( + client.setBranchProtection("novox", "hq", "main", { statusChecks: ["mesh/merge-gate"] }), + /403/, + ); +}); + +test("what a base's protection asks is read from the branch, which the tools' account may read, not from the rules' list it may not", async () => { + const env = await environment(); + const client = fresh(env); + await assert.rejects(client.branchProtections("novox", "hq"), /403/); + const rule = await client.appliedProtection("novox", "hq", "main"); + assert.deepEqual(rule?.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]); + assert.equal(rule?.enable_status_check, true); + assert.equal(rule?.rule_name, "main"); + assert.equal(await client.appliedProtection("novox", "hq", "feat/unguarded"), null); + assert.equal(await client.appliedProtection("novox", "hq", "no/such-branch"), null); +}); + +test("the administrator's token from before goes: a kept admin token is minted again for the tools' account, and the forge drops the old one", async () => { + const env = await environment(); + // What the module kept before ADR 0365: a token of the administrator's own, by the same name, its scopes beside it. + const old = { id: 900, name: "mesh-tools", sha1: "sha-admin-old", user: ADMIN, scopes: ["write:repository", "write:issue", "read:user"] }; + forge.tokens.push(old); + await mkdir(env.MESH_GITEA_STATE_DIR, { recursive: true }); + await writeFile(join(env.MESH_GITEA_STATE_DIR, "token"), old.sha1 + "\n", { mode: 0o600 }); + await writeFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "read:user,write:issue,write:repository\n", { mode: 0o600 }); + + await fresh(env).listRepos(); + + assert.ok(!forge.tokens.some((t) => t.user === ADMIN), "the administrator holds no tools' token any more"); + const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim(); + assert.notEqual(kept, old.sha1); + assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT); + assert.match(await readFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "utf8"), / for mesh-tools\n$/); +}); + +test("a tools' account someone made an administrator is made a plain account again at the next mint", async () => { + const env = await environment(); + forge.users.get(TOOLS_ACCOUNT)!.admin = true; + await fresh(env).listRepos(); + assert.equal(forge.users.get(TOOLS_ACCOUNT)!.admin, false); +}); + +test("the tools never act as the administrator: MESH_GITEA_TOOLS_USER naming it is refused", async () => { + const env = await environment(); + assert.throws(() => MintedToken.fromEnv(forge.url, { ...env, MESH_GITEA_TOOLS_USER: ADMIN }), /names the admin account/); +}); + +test("the manifest hands the tools their account, and the merge-ready authors name it rather than the administrator", async () => { + const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8")); + const code = manifest.build.artifacts.find((a: any) => a.name === "code"); + assert.equal(code.env.MESH_GITEA_TOOLS_USER, TOOLS_ACCOUNT); + const authors = String(manifest.settings["ready-authors"].default).split(/\s+/); + assert.ok(authors.includes(TOOLS_ACCOUNT)); + // Kept for now (review of issue 469): a pull request the admin account marked ready before the tools acted as + // their own keeps counting as ready after the delivery. + assert.ok(authors.includes(ADMIN), "marks made by the admin account before ADR 0365 keep counting"); + // The Go forge bundle reads the token the tools' account holds, never the administrator's password. + const forgeBundle = manifest.build.artifacts.find((a: any) => a.name === "forge"); + assert.ok(forgeBundle.env.MESH_GITEA_TOKEN_FILE); + assert.equal(forgeBundle.env.MESH_GITEA_ADMIN_PASSWORD_FILE, undefined); +}); + +// Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token. + +test("a repository check said as a warning on a head without merge-check.sh reads the base's protection from the branch", async () => { + const client = fresh(await environment()); + const forgeOf = { holdsFile: async () => false, appliedProtection: client.appliedProtection.bind(client) }; + const c = { owner: "novox", repo: "hq", commit: "abc123def4567890abc123def4567890abc12345", "repo-check": { verdict: "warning" } } as any; + // main requires mesh/repo-check: required, so a head that defines no check fails it for a person to see. + assert.deepEqual(await repoCheckFacts(forgeOf, c, "main"), { defined: false, required: true }); + // A base nothing guards: not required, so the head's repository check passes rather than fails. + assert.deepEqual(await repoCheckFacts(forgeOf, c, "feat/unguarded"), { defined: false, required: false }); +}); + +test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => { + const env = await environment(); + const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] }; + forge.tokens.push(old); + forge.faults.mint = true; + try { + await assert.rejects(fresh(env).listRepos(), /500/); + } finally { + forge.faults.mint = false; + } + assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's token was deleted before a new one was kept"); + forge.tokens.splice(forge.tokens.indexOf(old), 1); +}); + +test("an old token that cannot be deleted after the mint is said, the new token kept, and deleted at the next start", async () => { + const env = await environment(); + const old = { id: 902, name: "mesh-tools", sha1: "sha-admin-stuck", user: ADMIN, scopes: ["read:user"] }; + forge.tokens.push(old); + forge.faults.deleteToken = true; + try { + await fresh(env).listRepos(); + } finally { + forge.faults.deleteToken = false; + } + const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim(); + assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT, "the account's token is kept and works"); + assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1)); + // The next process reuses the kept token and retires the old one it could not before. + const next = new MintedToken({ url: forge.url, admin: ADMIN, passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!, + file: join(env.MESH_GITEA_STATE_DIR, "token"), account: TOOLS_ACCOUNT, log: () => {} }); + assert.equal(await next.current(), kept); + assert.ok(!forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's old token was deleted at the next start"); +}); + +test("a team another process made between the look and the create is found, not a failure", async () => { + forge.teams.splice(0); + forge.faults.teamRace = true; + try { + await fresh(await environment()).listRepos(); + } finally { + forge.faults.teamRace = false; + } + const team = forge.teams.find((t) => t.name === "mesh-tools"); + assert.ok(team?.members.has(TOOLS_ACCOUNT)); +}); diff --git a/modules/gitea/token.ts b/modules/gitea/token.ts index d34c303e..8d7f7afd 100644 --- a/modules/gitea/token.ts +++ b/modules/gitea/token.ts @@ -22,15 +22,21 @@ // - replaced by id: an old token by this name is found in the account's list and deleted by its id, // every one of them, so a name held twice is cleared rather than refused. // +// - minted for the tools' account, not the administrator (novox/hq ADR 0365 §2, issue 469): the administrator's +// login mints it for `mesh-tools` (tools-account.ts), after making that account and its team, and deletes any +// token by this name the administrator itself still holds — the token the tools used before. A token kept +// for another account than the one asked (the scopes line says whose) is minted again at the next start. +// // An explicitly configured token still wins, and is never minted over: if it is rejected, that is // reported, not repaired — somebody chose it. // // The token is never logged. Lines say that one was minted, reused or renewed, and where it is // kept; never what it is. -import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; +import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { dirname, join } from "node:path"; +import { setUpToolsAccount, TOOLS_ACCOUNT, type AdminCall } from "./tools-account.js"; /** The name the token carries in the forge's own list — one per mesh runtime, found by name. */ export const TOKEN_NAME = "mesh-tools"; @@ -104,6 +110,14 @@ export interface MintedTokenOptions { readonly file: string; readonly name?: string; readonly scopes?: readonly string[]; + /** + * The account the token is minted for: the tools' own (novox/hq ADR 0365 §2). The admin login mints it, after + * `setUp` made the account, and the admin's own tokens by this name are deleted. Left out, the token is the + * admin's own, as it was before. + */ + readonly account?: string; + /** Run with the admin's password before each mint for `account`: makes the account and its team (tools-account.ts). */ + readonly setUp?: (password: string) => Promise; readonly log?: (line: string) => void; readonly fetch?: typeof fetch; } @@ -115,12 +129,14 @@ export class MintedToken implements TokenSource { private inflight: Promise | null = null; private readonly name: string; private readonly scopes: readonly string[]; + private readonly account: string; private readonly log: (line: string) => void; private readonly fetchImpl: typeof fetch; constructor(private readonly opts: MintedTokenOptions) { this.name = opts.name ?? TOKEN_NAME; this.scopes = opts.scopes ?? TOKEN_SCOPES; + this.account = opts.account ?? opts.admin; this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`)); this.fetchImpl = opts.fetch ?? fetch; } @@ -157,7 +173,17 @@ export class MintedToken implements TokenSource { if (missing.length) { throw new Error(`no Gitea token, and nothing to mint one with — set MESH_GITEA_TOKEN, or ${missing.join(", ")}`); } - return { url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token") }; + // The tools' own account, never the administrator (novox/hq ADR 0365 §2): MESH_GITEA_TOOLS_USER names it. + const account = env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT; + if (account === admin) throw new Error(`MESH_GITEA_TOOLS_USER names the admin account "${admin}"; the tools act as an account of their own (novox/hq ADR 0365)`); + return { + url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, "token"), account, + // GiteaAdmin is loaded when first used: client.ts imports this file. + setUp: async (password) => { + const { GiteaAdmin } = await import("./client.js"); + await setUpToolsAccount(new GiteaAdmin(url, admin!, password), account, () => randomBytes(24).toString("base64url")); + }, + }; } async current(): Promise { @@ -168,6 +194,8 @@ export class MintedToken implements TokenSource { if (kept !== null && this.keptScopes() === this.scopesLine()) { this.held = kept; this.log(`reusing the token kept at ${this.opts.file}`); + // A retirement of the admin's old token that failed after the mint is tried again, once a process. + if (existsSync(this.retireFile)) await this.locked(() => this.retireAdmin()).catch((err) => this.log(`${err}`)); return kept; } if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them", kept); @@ -293,9 +321,10 @@ export class MintedToken implements TokenSource { } } - /** The scopes as kept beside the token: sorted, one line. */ + /** The scopes as kept beside the token: sorted, one line, and whose it is when it is not the admin's. */ private scopesLine(): string { - return [...this.scopes].sort().join(","); + const scopes = [...this.scopes].sort().join(","); + return this.account === this.opts.admin ? scopes : `${scopes} for ${this.account}`; } /** The scopes the kept token was minted with, "" when not kept (a token kept before they were). */ @@ -318,17 +347,20 @@ export class MintedToken implements TokenSource { renameSync(`${this.opts.file}.scopes.tmp`, `${this.opts.file}.scopes`); } - private async doMint(): Promise { - let password: string; + /** One call to the forge's API with the admin login, the password read now. */ + private password(): string { try { - password = readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, ""); + return readFileSync(this.opts.passwordFile, "utf8").replace(/\n$/, ""); } catch (err) { throw new Error(`cannot read the admin password at ${this.opts.passwordFile}: ${(err as Error).message}`); } + } + + private adminCall(password = this.password()): AdminCall { const authorization = "Basic " + Buffer.from(`${this.opts.admin}:${password}`).toString("base64"); - const tokens = `${this.opts.url.replace(/\/+$/, "")}/api/v1/users/${encodeURIComponent(this.opts.admin)}/tokens`; - const call = async (method: string, path = "", body?: unknown): Promise<{ status: number; body: any }> => { - const res = await this.fetchImpl(tokens + path, { + const api = `${this.opts.url.replace(/\/+$/, "")}/api/v1`; + const admin: AdminCall = async (method, path, body) => { + const res = await this.fetchImpl(api + path, { method, headers: { "Content-Type": "application/json", Authorization: authorization }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), @@ -340,6 +372,44 @@ export class MintedToken implements TokenSource { } return { status: res.status, body: parsed }; }; + return admin; + } + + /** Where a retirement of the admin's old token that failed is remembered, to be tried again (retireAdmin). */ + private get retireFile(): string { + return `${this.opts.file}.retire-admin`; + } + + /** + * The admin's own token by this name goes: what the tools used before must not outlive the move (novox/hq ADR + * 0365 §2). Run after the account's token is minted and kept, so a failure here never leaves the tools without + * a token: it is said, remembered beside the kept token, and tried again when a process next reads that token. + */ + private async retireAdmin(admin: AdminCall = this.adminCall()): Promise { + if (this.account === this.opts.admin) return; + try { + const retired = await this.dropNamed(admin, `/users/${encodeURIComponent(this.opts.admin)}/tokens`, this.opts.admin); + if (retired > 0) this.log(`deleted ${retired} token(s) named "${this.name}" the admin account "${this.opts.admin}" held: the tools act as "${this.account}" now`); + rmSync(this.retireFile, { force: true }); + } catch (err) { + writeFileSync(this.retireFile, `${new Date().toISOString()}\n`, { mode: 0o600 }); + this.log(`FAILED to delete the token(s) named "${this.name}" the admin account "${this.opts.admin}" holds — tried again at the next start: ${(err as Error).message}`); + } + } + + private async doMint(): Promise { + const password = this.password(); + const admin = this.adminCall(password); + const tokensOf = (account: string) => `/users/${encodeURIComponent(account)}/tokens`; + const call = (method: string, path = "", body?: unknown) => admin(method, tokensOf(this.account) + path, body); + + if (this.account !== this.opts.admin) { + // The tools' account and its team first (novox/hq ADR 0365 §2): a failure here fails the mint, tried again + // at the next call. + const me = await admin("GET", "/user"); + if (me.status === 401 || me.status === 403) throw new AdminRefused(this.opts.admin, me.status); + await this.opts.setUp?.(password); + } let res = await call("POST", "", { name: this.name, scopes: this.scopes }); if (res.status === 401 || res.status === 403) throw new AdminRefused(this.opts.admin, res.status); @@ -348,37 +418,51 @@ export class MintedToken implements TokenSource { // went while the forge's data stayed, or two processes once minted at once and left two. It is // ours to replace: find every one by this name in the account's list and delete each by its id // (gitea refuses a delete by a name held twice: "multiple matches for token name"), then mint. - const held: number[] = []; - for (let page = 1; page <= 100; page++) { - const listed = await call("GET", `?page=${page}&limit=50`); - if (listed.status !== 200 || !Array.isArray(listed.body)) { - throw new Error(`Gitea GET /users/${this.opts.admin}/tokens: ${listed.status} ${detail(listed.body)}`); - } - if (listed.body.length === 0) break; - for (const t of listed.body) if (t?.name === this.name && typeof t.id === "number") held.push(t.id); - } - this.log(held.length === 1 - ? `the forge already holds a token named "${this.name}" — replacing it` - : `the forge holds ${held.length} tokens named "${this.name}" — deleting each by its id and minting one`); - for (const id of held) { - const dropped = await call("DELETE", `/${id}`); - if (dropped.status !== 204 && dropped.status !== 404) { - throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${id} (named ${this.name}): ${dropped.status} ${detail(dropped.body)}`); - } - } + await this.dropNamed(admin, tokensOf(this.account), this.account, true); res = await call("POST", "", { name: this.name, scopes: this.scopes }); } if (res.status !== 201 && res.status !== 200) { - throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} ${detail(res.body)}`); + throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} ${detail(res.body)}`); } const token = typeof res.body?.sha1 === "string" ? res.body.sha1 : null; - if (!token) throw new Error(`Gitea POST /users/${this.opts.admin}/tokens: ${res.status} but no token in the reply`); + if (!token) throw new Error(`Gitea POST /users/${this.account}/tokens: ${res.status} but no token in the reply`); this.keep(token); this.held = token; - this.log(`minted a token for "${this.opts.admin}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`); + this.log(`minted a token for "${this.account}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`); + // Only now, with the account's own token kept, does the admin's old one go. + await this.retireAdmin(admin); return token; } + + /** + * Delete every token by this name an account holds, each by its id (gitea refuses a delete by a name held twice: + * "multiple matches for token name"); answers how many. `replacing` says the line for a mint that found its name + * taken. + */ + private async dropNamed(admin: AdminCall, tokens: string, account: string, replacing = false): Promise { + const held: number[] = []; + for (let page = 1; page <= 100; page++) { + const listed = await admin("GET", `${tokens}?page=${page}&limit=50`); + if (listed.status !== 200 || !Array.isArray(listed.body)) { + throw new Error(`Gitea GET /users/${account}/tokens: ${listed.status} ${detail(listed.body)}`); + } + if (listed.body.length === 0) break; + for (const t of listed.body) if (t?.name === this.name && typeof t.id === "number") held.push(t.id); + } + if (replacing) { + this.log(held.length === 1 + ? `the forge already holds a token named "${this.name}" — replacing it` + : `the forge holds ${held.length} tokens named "${this.name}" — deleting each by its id and minting one`); + } + for (const id of held) { + const dropped = await admin("DELETE", `${tokens}/${id}`); + if (dropped.status !== 204 && dropped.status !== 404) { + throw new Error(`Gitea DELETE /users/${account}/tokens/${id} (named ${this.name}): ${dropped.status} ${detail(dropped.body)}`); + } + } + return held.length; + } } function detail(body: unknown): string { diff --git a/modules/gitea/tools-account.ts b/modules/gitea/tools-account.ts new file mode 100644 index 00000000..03df8e32 --- /dev/null +++ b/modules/gitea/tools-account.ts @@ -0,0 +1,52 @@ +// The forge's tools act as an account of their own, not as the site administrator (novox/hq ADR 0365 §2, +// issue 469). +// +// The administrator's token reached everything the forge holds, so the tools' boundary rested on their own guards +// (guard.ts): a protection edit, say, the forge itself would have taken. Now the tools' token is minted for a plain +// account, `mesh-tools`, which is no administrator and writes to the mesh's repositories through one team of the +// mesh's organisation: +// +// - **the account**: made by the module with the administrator's login (that login sets the forge up and does +// nothing else), never an administrator — one found made an administrator is made a plain account again. Its +// password is random, sent once and kept nowhere: the account is reached only by the tokens the administrator +// mints for it (gitea lets an administrator mint for another account: `reqSelfOrAdmin`). +// - **the team**: `mesh-tools` in the organisation `novox`, on every repository of it, writing code (pushes to a +// branch, commit statuses, merges), issues (notes, comments, labels) and pull requests. No administration of a +// repository, so the forge refuses the account a protection edit, a repository's settings and its deletion; +// no repository made in the organisation, since its maker would be that repository's administrator. +// +// Every step is idempotent (GiteaAdmin's, client.ts), and run with the administrator's login before each mint of the tools' token, so a team +// or a membership lost behind the module's back is made again at the next mint. + +/** The account the tools act as. */ +export const TOOLS_ACCOUNT = "mesh-tools"; +/** The organisation whose repositories are the mesh's, and the team through which the tools' account writes there. */ +export const TOOLS_ORG = "novox"; +export const TOOLS_TEAM = "mesh-tools"; + +/** What the team grants: write on code, issues and pull requests of every repository of the organisation, and no more. */ +export const TOOLS_TEAM_UNITS = { + permission: "write", + units_map: { "repo.code": "write", "repo.issues": "write", "repo.pulls": "write" }, + includes_all_repositories: true, + can_create_org_repo: false, +} as const; + +/** One call to the forge's API with the administrator's login: a path under /api/v1, the status and body answered. */ +export type AdminCall = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: any }>; + +/** What setting the account up needs of the forge's admin API: GiteaAdmin's own (client.ts), shared with the + * package provisioner, so a create race (422) and a user made by hand are handled once. */ +export interface ToolsForge { + ensureUser(username: string, password: string, email: string, plain?: boolean): Promise; + ensureTeamWith(org: string, team: string, units: Record): Promise; + addUserToTeam(teamId: number, username: string): Promise; +} + +/** Make the tools' account, plain, a member of its team on every repository of the organisation. Its password is + * set afresh each time and kept nowhere: the account is reached only by the tokens the administrator mints. */ +export async function setUpToolsAccount(forge: ToolsForge, account = TOOLS_ACCOUNT, password: () => string): Promise { + await forge.ensureUser(account, password(), `${account}@noreply.localhost`, true); + const team = await forge.ensureTeamWith(TOOLS_ORG, TOOLS_TEAM, { name: TOOLS_TEAM, ...TOOLS_TEAM_UNITS }); + await forge.addUserToTeam(team, account); +} diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index b8c46151..70d4d445 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -504,7 +504,7 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { base = base || "main"; let rule: BranchProtection | null = null, unreadable: string | undefined; try { - rule = await gitea.branchProtection(owner, repo, base); + rule = await gitea.appliedProtection(owner, repo, base); } catch (err) { unreadable = err instanceof Error ? err.message : String(err); } @@ -589,8 +589,12 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { const { owner, repo, branch, newBranch } = judged.contents; const found = await gitea.api<{ default_branch?: string }>(`/repos/${checkName("owner", owner)}/${checkName("name", repo)}`); const defaultBranch = String(found?.default_branch ?? "main"); - const refused = judgeContentsWrite(newBranch ?? branch ?? defaultBranch, defaultBranch, - await gitea.branchProtections(owner, repo)); + // The protection the forge applies to the branch written, read from the branch: the rules' own list is a + // repository administrator's, which the tools' account is not (novox/hq ADR 0365). A branch not made yet is + // left to the forge, which refuses a write that would make a branch a rule guards. + const target = newBranch ?? branch ?? defaultBranch; + const applied = target === defaultBranch ? null : await gitea.appliedProtection(owner, repo, target); + const refused = judgeContentsWrite(target, defaultBranch, applied ? [applied] : []); if (refused) throw new Refused(refused); } // The path judged is the path sent: normalised, with only the allow-listed query keys (guard.ts).