From 48f0662fdea295ac81609eb3bda01a8655e535aa Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 22:28:25 +0000 Subject: [PATCH] Make the forge's tools act as an account of their own, not the administrator (issue 469, ADR 0365 clause 2) The tools' token reached everything the administrator may do, so their boundary rested on their own guards. The gitea module now makes a plain account, mesh-tools, with the admin login, gives it write on code, issues and pull requests of every novox repository through one team, and mints the tools' token for it; the admin's old token goes only after the new one is kept. The Go forge bundle sends that token, never the admin login. The forge shows protection rules only to a repository administrator, so every check reads a branch's protection from the branch. mesh-admin stays a ready author, so marks made before the move keep counting. Co-Authored-By: Claude Opus 5.5 --- modules/gitea/README.md | 13 +- modules/gitea/client.ts | 63 +++- modules/gitea/cmd/forge/account_test.go | 120 +++++++ modules/gitea/cmd/forge/commit.go | 29 +- modules/gitea/cmd/forge/forge.go | 9 +- modules/gitea/cmd/forge/forge_test.go | 8 +- modules/gitea/index.ts | 18 +- modules/gitea/internal/forgehttp/forgehttp.go | 87 ++++- modules/gitea/merge.ts | 9 +- modules/gitea/module.json | 9 +- modules/gitea/pulls.ts | 20 +- modules/gitea/test/announcing.test.ts | 21 +- modules/gitea/test/guard.test.ts | 20 +- modules/gitea/test/token.test.ts | 21 +- modules/gitea/test/tools-account.test.ts | 332 ++++++++++++++++++ modules/gitea/token.ts | 146 ++++++-- modules/gitea/tools-account.ts | 52 +++ modules/gitea/tools/index.ts | 10 +- 18 files changed, 887 insertions(+), 100 deletions(-) create mode 100644 modules/gitea/cmd/forge/account_test.go create mode 100644 modules/gitea/test/tools-account.test.ts create mode 100644 modules/gitea/tools-account.ts 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).