diff --git a/modules/gitea/README.md b/modules/gitea/README.md index b4634cd5..4a4e356f 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -240,11 +240,16 @@ version already gone counts as deleted. ### How it reaches the forge -Over the forge's HTTP interface on the machine (`MESH_GITEA_URL`), as the admin account the vault -delivered (`MESH_GITEA_ADMIN_USER`, the password in `MESH_GITEA_ADMIN_PASSWORD_FILE` — the same file -the TypeScript bundle mints its token with), by basic authentication: this bundle mints no token and -keeps nothing. The password is read per call and never logged, answered or put in an error. The -registry's owner is `MESH_NPM_OWNER`, the owner in the seat's `npm-path`. +Over the forge's HTTP interface on the machine (`MESH_GITEA_URL`), never as the admin account (design 51: each +holder keeps its write side as an own secret beside a read-only tool token). It **reads** as the tools' account, +with the token the TypeScript bundle keeps for it (`MESH_GITEA_TOKEN_FILE`, scopes including `read:package`), and +**writes** (a publish, a remove, retention's deletion) as the packages account `mesh-packages` +(`MESH_GITEA_PACKAGES_USER`), with its own token (`MESH_GITEA_PACKAGES_TOKEN_FILE`, the one scope `write:package`). +That account is plain, a member of the team `mesh-packages` that writes the organisation's packages and nothing else; +the watcher makes it with the administrator's login and mints and keeps its token at its start, looks at it every ten +minutes and mints it afresh when the forge rejects it (`packages-account.ts`). This bundle mints nothing; a token is +read per call and never logged, answered or put in an error. The registry's owner is `MESH_NPM_OWNER`, the owner in +the seat's `npm-path`. Calls to the forge run eight at a time, and one tool call reads for at most 50 seconds, inside a module's 60-second ask. diff --git a/modules/gitea/cmd/forge/forge_test.go b/modules/gitea/cmd/forge/forge_test.go index 7b3284b9..42733087 100644 --- a/modules/gitea/cmd/forge/forge_test.go +++ b/modules/gitea/cmd/forge/forge_test.go @@ -688,28 +688,34 @@ func TestToolsAreTheOnesTheReadmeNamesAndTheManifestBuilds(t *testing.T) { if !goProvided { t.Error("go-package-registry is not provided at mesh scope with no credential") } - found := false + // The tools' own account, never the admin login (novox/hq ADR 0365 §2); the package registry's bundle also writes + // as the packages account (design 51: the write side an own secret beside a read-only tool token). + wants := map[string][]string{ + "cmd/forge": {"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE"}, + "cmd/package-registry": {"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE", "MESH_GITEA_PACKAGES_USER", "MESH_GITEA_PACKAGES_TOKEN_FILE"}, + } for _, a := range m.Build.Artifacts { - if a.From == "cmd/forge" { - found = true - if len(a.Loads) != 1 || a.Loads[0] != a.Binary { - t.Errorf("the bundle loads %v, not its binary %s", a.Loads, a.Binary) + want, ok := wants[a.From] + if !ok { + continue + } + delete(wants, a.From) + if len(a.Loads) != 1 || a.Loads[0] != a.Binary { + t.Errorf("%s loads %v, not its binary %s", a.From, a.Loads, a.Binary) + } + for _, k := range want { + if a.Env[k] == "" { + t.Errorf("%s is not given %s", a.From, k) } - // 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) - } + } + for _, k := range []string{"MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} { + if a.Env[k] != "" { + t.Errorf("%s is given %s: it acts as an account of its own, not the administrator", a.From, k) } } } - if !found { - t.Error("the manifest builds no cmd/forge bundle") + for from := range wants { + t.Errorf("the manifest builds no %s bundle", from) } } diff --git a/modules/gitea/cmd/package-registry/forge.go b/modules/gitea/cmd/package-registry/forge.go index 223e336c..8853b031 100644 --- a/modules/gitea/cmd/package-registry/forge.go +++ b/modules/gitea/cmd/package-registry/forge.go @@ -13,13 +13,17 @@ 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 forge bundle beside it). Basic authentication, so this bundle mints -// nothing and keeps nothing: the TypeScript bundle's token is its own. +// The forge as this bundle reaches it: its own HTTP interface on the machine (internal/forgehttp, shared with the +// forge bundle beside it), never as the admin account. It reads as the tools' account, whose token carries +// read:package, and writes (a publish, a remove, retention's deletion) as the packages account, whose token carries +// write:package (design 51: each holder keeps its write side as an own secret beside a read-only tool token). The +// TypeScript bundle mints and keeps both tokens (token.ts, packages-account.ts); this bundle mints nothing. // Forge is the forge's HTTP interface. type Forge struct { - forgehttp.Client + forgehttp.Client // reads, as the tools' account + // Writer writes into the package registry, as the packages account; nil writes nothing. + Writer *forgehttp.Client Owner string // the package registry's owner, as the seat serves it // Type is the registry's package type retention reads: TypeNpm when empty, or TypeGo. Type string @@ -49,7 +53,13 @@ func (f *Forge) As(typ string) *Forge { // ForgeFromEnv is the forge as the manifest hands it to this bundle. func ForgeFromEnv() (*Forge, error) { c, missing := forgehttp.FromEnv() - f := &Forge{Client: c, Owner: os.Getenv("MESH_NPM_OWNER")} + w, writeMissing := forgehttp.WriterFromEnv() + for _, m := range writeMissing { + if m != "MESH_GITEA_URL" { + missing = append(missing, m) + } + } + f := &Forge{Client: c, Writer: &w, Owner: os.Getenv("MESH_NPM_OWNER")} if f.Owner == "" { missing = append(missing, "MESH_NPM_OWNER") } @@ -127,9 +137,21 @@ func (f *Forge) DistTags(ctx context.Context, name string) (map[string]string, e return meta.DistTags, nil } -// Delete removes one version from the registry. +// write is the registry's write side, refused when the manifest handed none. +func (f *Forge) write() (*forgehttp.Client, error) { + if f.Writer == nil { + return nil, errors.New("the package registry's write side is not handed to this bundle (MESH_GITEA_PACKAGES_TOKEN_FILE); nothing was written") + } + return f.Writer, nil +} + +// Delete removes one version from the registry, as the packages account. func (f *Forge) Delete(ctx context.Context, name, version string) error { - _, err := f.do(ctx, http.MethodDelete, f.versionPath(name, version)) + w, err := f.write() + if err != nil { + return err + } + _, err = w.Do(ctx, http.MethodDelete, f.versionPath(name, version), nil, maxBody) return err } diff --git a/modules/gitea/cmd/package-registry/retention_test.go b/modules/gitea/cmd/package-registry/retention_test.go index c869b663..b70c643c 100644 --- a/modules/gitea/cmd/package-registry/retention_test.go +++ b/modules/gitea/cmd/package-registry/retention_test.go @@ -42,7 +42,8 @@ func (f *fakeForge) serve() *Forge { if err := os.WriteFile(file, []byte(f.password+"\n"), 0o600); err != nil { f.t.Fatal(err) } - return &Forge{Client: forgehttp.Client{URL: srv.URL, User: "admin", PasswordFile: file}, Owner: "acme"} + c := forgehttp.Client{URL: srv.URL, User: "admin", PasswordFile: file} + return &Forge{Client: c, Writer: &c, Owner: "acme"} } var ( @@ -482,7 +483,7 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { for _, a := range m.Build.Artifacts { if a.From == "cmd/package-registry" { found = true - for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE", "MESH_NPM_OWNER"} { + for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_TOKEN_FILE", "MESH_GITEA_PACKAGES_TOKEN_FILE", "MESH_NPM_OWNER"} { if a.Env[k] == "" { t.Errorf("the bundle is not given %s", k) } diff --git a/modules/gitea/cmd/package-registry/seats.go b/modules/gitea/cmd/package-registry/seats.go index 6e5b9efc..a20f6a32 100644 --- a/modules/gitea/cmd/package-registry/seats.go +++ b/modules/gitea/cmd/package-registry/seats.go @@ -79,20 +79,24 @@ func (s seatStore) Put(seat string, p packageseats.Publish, file []byte) (string if err != nil { return "", err } + w, err := f.write() + if err != nil { + return "", err + } ctx := context.Background() if seat == packageseats.GoSeat { module, err := goModuleOf(file, forgeVersion(seat, p.Version)) if err != nil { return "", err } - _, err = f.Send(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/go/upload", url.PathEscape(f.Owner)), file, "application/zip", maxBody) + _, err = w.Send(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/go/upload", url.PathEscape(f.Owner)), file, "application/zip", maxBody) return module, err } doc, err := npmDocument(f.URL, f.Owner, p, file) if err != nil { return "", err } - _, err = f.Do(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/npm/%s", url.PathEscape(f.Owner), url.PathEscape(p.Name)), doc, maxBody) + _, err = w.Do(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/npm/%s", url.PathEscape(f.Owner), url.PathEscape(p.Name)), doc, maxBody) return "", err } @@ -136,8 +140,7 @@ func (s seatStore) Remove(seat, name, version string) error { if err != nil { return err } - _, err = f.do(context.Background(), http.MethodDelete, f.packagePath(forgeType(seat), name, forgeVersion(seat, version))) - return err + return f.As(forgeType(seat)).Delete(context.Background(), name, forgeVersion(seat, version)) } // goModuleOf is the Go module path of a module zip whose every file sits under @/, as Go's module diff --git a/modules/gitea/cmd/package-registry/seats_test.go b/modules/gitea/cmd/package-registry/seats_test.go index 0bff6366..155141e7 100644 --- a/modules/gitea/cmd/package-registry/seats_test.go +++ b/modules/gitea/cmd/package-registry/seats_test.go @@ -58,6 +58,15 @@ func (g *registry) handle(w http.ResponseWriter, r *http.Request) { defer g.mu.Unlock() body, _ := io.ReadAll(r.Body) p := r.URL.EscapedPath() + // Reads come as the tools' account (read:package), writes as the packages account (write:package), never the admin. + want := "token reader" + if r.Method != http.MethodGet { + want = "token writer" + } + if got := r.Header.Get("Authorization"); got != want { + http.Error(w, r.Method+" "+p+" came as "+got+", not "+want, http.StatusForbidden) + return + } switch { case r.Method == http.MethodPut && p == "/api/packages/acme/go/upload": if r.Header.Get("Content-Type") != "application/zip" { @@ -101,9 +110,20 @@ func TestThePackageSeatsStoreIntoTheForge(t *testing.T) { g := ®istry{uploaded: map[string][]byte{}, held: map[string][]byte{}} srv := httptest.NewServer(http.HandlerFunc(g.handle)) defer srv.Close() - file := filepath.Join(t.TempDir(), "admin.secret") - _ = os.WriteFile(file, []byte("pw\n"), 0o600) - forge := &Forge{Client: forgehttp.Client{URL: srv.URL, User: "admin", PasswordFile: file}, Owner: "acme"} + dir := t.TempDir() + kept := func(name, token, line string) string { + file := filepath.Join(dir, name) + _ = os.WriteFile(file, []byte(token+"\n"), 0o600) + _ = os.WriteFile(file+".scopes", []byte(line+"\n"), 0o600) + return file + } + reader := forgehttp.Client{URL: srv.URL, TokenFile: kept("token", "reader", "read:package,read:user for mesh-tools"), Account: "mesh-tools"} + writer := forgehttp.Client{URL: srv.URL, TokenFile: kept("packages-token", "writer", "write:package for mesh-packages"), Account: "mesh-packages"} + forge := &Forge{Client: reader, Writer: &writer, Owner: "acme"} + if _, err := (seatStore{forge: func() (*Forge, error) { return &Forge{Client: reader, Owner: "acme"}, nil }}).Put(packageseats.GoSeat, packageseats.Publish{}, nil); err == nil || + !strings.Contains(err.Error(), "write side") { + t.Errorf("a forge handed no write side wrote: %v", err) + } s := seatStore{forge: func() (*Forge, error) { return forge, nil }} tgz := tarball(t, `{"name":"@acme/contracts","version":"0.1.15-feat-x.2","main":"index.js"}`) diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 9271f010..3994116d 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -64,6 +64,7 @@ import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommi import { ReadAll, readAllTools } from "./readall.js"; import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js"; import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js"; +import { keepPackagesToken, packagesToken } from "./packages-account.js"; // Without a way to a token — configured, or mintable with the admin account (token.ts) — there is // nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints @@ -633,6 +634,19 @@ if (gitea) { // Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is // minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2). // Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set. + // The package seats' write token (packages-account.ts): minted at the start and looked at every ten minutes, since the + // Go bundle that writes with it mints nothing; a token the forge rejects is minted afresh at the next look. + try { + const url = process.env.MESH_GITEA_URL ?? "http://127.0.0.1:3000"; + const source = packagesToken(url); + let last = ""; + tick(() => keepPackagesToken(source, url).then((said) => { + if (said !== last) console.log(`[gitea] ${said}`); + last = said; + }), 600_000); + } catch (err) { + console.error(`[gitea] the package seats cannot write — ${err instanceof Error ? err.message : err}`); + } tick(mainRulesStep(keepMainRules(), () => GiteaAdmin.fromEnv().ensureMainMerges("novox"), (line) => console.log(line)), 600_000); tick(() => pollRepos(client), 60_000); // **No timer asks the forge for pull requests or merges** (issue 1422): the forge's events announce them. One pass over diff --git a/modules/gitea/internal/forgehttp/forgehttp.go b/modules/gitea/internal/forgehttp/forgehttp.go index 0e770499..3951ce34 100644 --- a/modules/gitea/internal/forgehttp/forgehttp.go +++ b/modules/gitea/internal/forgehttp/forgehttp.go @@ -46,6 +46,29 @@ type Client struct { // ToolsAccount is the account the forge's tools act as when the manifest names none (token.ts, tools-account.ts). const ToolsAccount = "mesh-tools" +// PackagesAccount is the account the package seats write as when the manifest names none (packages-account.ts): +// its token carries write:package and nothing else (design 51: each holder keeps its write side as an own secret +// beside a read-only tool token). +const PackagesAccount = "mesh-packages" + +// WriterFromEnv is the package registry's write side as the manifest hands it to a bundle: the packages account's +// token (MESH_GITEA_PACKAGES_TOKEN_FILE, kept by the module beside the tools' token) and the names of the variables +// it left unset. Never the admin login: a bundle handed no writer's token writes nothing. +func WriterFromEnv() (Client, []string) { + c := Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"), TokenFile: os.Getenv("MESH_GITEA_PACKAGES_TOKEN_FILE"), + Account: os.Getenv("MESH_GITEA_PACKAGES_USER")} + if c.Account == "" { + c.Account = PackagesAccount + } + var missing []string + for _, kv := range [][2]string{{"MESH_GITEA_URL", c.URL}, {"MESH_GITEA_PACKAGES_TOKEN_FILE", c.TokenFile}} { + if kv[1] == "" { + missing = append(missing, kv[0]) + } + } + return c, missing +} + // 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) { @@ -113,7 +136,7 @@ func (c *Client) password() (string, error) { 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) + return "", fmt.Errorf("the token for %s is not kept yet (the module mints it): %w", c.Account, err) } line, err := os.ReadFile(c.TokenFile + ".scopes") if err != nil || !strings.HasSuffix(strings.TrimSpace(string(line)), " for "+c.Account) { @@ -190,8 +213,8 @@ func (c *Client) Send(ctx context.Context, method, path string, raw []byte, cont } 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"}) + return nil, fmt.Errorf("the forge rejected %s's token for %s (401): the module mints it afresh; nothing was done: %w", + c.Account, 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/module.json b/modules/gitea/module.json index baeeb3c0..43fa1159 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -502,8 +502,10 @@ ], "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", + "MESH_GITEA_PACKAGES_USER": "mesh-packages", + "MESH_GITEA_PACKAGES_TOKEN_FILE": "${dir:runtime-state}/packages-token", "MESH_NPM_OWNER": "novox" } }, diff --git a/modules/gitea/packages-account.ts b/modules/gitea/packages-account.ts new file mode 100644 index 00000000..01a55eae --- /dev/null +++ b/modules/gitea/packages-account.ts @@ -0,0 +1,88 @@ +// The package seats write as an account of their own, never the administrator (design 51: each holder keeps its write +// side as an own secret beside a read-only tool token; the forge module's is `write:package`, its tool token +// `read:package`). +// +// The Go bundle (cmd/package-registry) serves both package seats' verbs: it reads the registry with the tools' token +// (token.ts, which carries `read:package`) and writes (a publish, a remove, retention's deletion) with this one: +// +// - **the account**: `mesh-packages`, plain, made by the module with the administrator's login as tools-account.ts +// makes `mesh-tools`; its password random, sent once and kept nowhere. +// - **the team**: `mesh-packages` in the organisation that owns the registry, writing packages and nothing else: +// no code, no issues, no pull requests, no administration. +// - **the token**: minted for the account by the administrator's login with the one scope `write:package`, kept at +// 0600 in the module's runtime state as `packages-token`, its scopes line beside it saying whose it is +// (MintedToken: one mint at a time across the bundle's processes, re-minted when the kept one is gone or other). +// +// The watcher mints it at its start and looks again every ten minutes (keepPackagesToken): the Go bundle mints +// nothing, so a token the forge rejects is renewed here, at the next look, and said. + +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { MintedToken } from "./token.js"; +import { TOOLS_ORG, type ToolsForge } from "./tools-account.js"; + +/** The account the package seats write as (internal/forgehttp PackagesAccount). */ +export const PACKAGES_ACCOUNT = "mesh-packages"; +/** Its team in the registry's organisation, and the token's name in the account's list. */ +export const PACKAGES_TEAM = "mesh-packages"; +export const PACKAGES_TOKEN_NAME = "mesh-packages"; +/** The one scope the write side needs; reads go with the tools' token. */ +export const PACKAGES_TOKEN_SCOPES: readonly string[] = ["write:package"]; +/** Where the token is kept in the module's runtime state (the manifest's MESH_GITEA_PACKAGES_TOKEN_FILE). */ +export const PACKAGES_TOKEN_FILE = "packages-token"; + +/** What the team grants: write on the organisation's packages, and no more. */ +export const PACKAGES_TEAM_UNITS = { + units_map: { "repo.packages": "write" }, + includes_all_repositories: false, + can_create_org_repo: false, +} as const; + +/** Make the packages account, plain, a member of its team. Its password is set afresh each time and kept nowhere. */ +export async function setUpPackagesAccount(forge: ToolsForge, account = PACKAGES_ACCOUNT, password: () => string): Promise { + await forge.ensureUser(account, password(), `${account}@noreply.localhost`, true); + const team = await forge.ensureTeamWith(TOOLS_ORG, PACKAGES_TEAM, { name: PACKAGES_TEAM, ...PACKAGES_TEAM_UNITS }); + await forge.addUserToTeam(team, account); +} + +/** The packages account's token, minted by the administrator's login from what the manifest hands the watcher. */ +export function packagesToken(url: string, env: NodeJS.ProcessEnv = process.env, log?: (line: string) => void): MintedToken { + const admin = env.MESH_GITEA_ADMIN_USER; + const passwordFile = env.MESH_GITEA_ADMIN_PASSWORD_FILE; + const stateDir = env.MESH_GITEA_STATE_DIR; + const missing = [ + admin ? null : "MESH_GITEA_ADMIN_USER", + passwordFile ? null : "MESH_GITEA_ADMIN_PASSWORD_FILE", + stateDir ? null : "MESH_GITEA_STATE_DIR", + ].filter((v): v is string => v !== null); + if (missing.length) throw new Error(`the package seats' write token cannot be minted: ${missing.join(", ")} not set by the manifest`); + const account = env.MESH_GITEA_PACKAGES_USER || PACKAGES_ACCOUNT; + if (account === admin) throw new Error(`MESH_GITEA_PACKAGES_USER names the admin account "${admin}"; the package seats write as an account of their own`); + return new MintedToken({ + url, admin: admin!, passwordFile: passwordFile!, file: join(stateDir!, PACKAGES_TOKEN_FILE), account, + name: PACKAGES_TOKEN_NAME, scopes: PACKAGES_TOKEN_SCOPES, log, + setUp: async (password) => { + const { GiteaAdmin } = await import("./client.js"); + await setUpPackagesAccount(new GiteaAdmin(url, admin!, password), account, () => randomBytes(24).toString("base64url")); + }, + }); +} + +/** + * One look at the packages token: mint it when none is kept, then ask the forge one read the scope covers (the + * organisation's package list); a 401 renews it. Answers what was done, for the watcher's line. + */ +export async function keepPackagesToken(source: MintedToken, url: string, owner = TOOLS_ORG, fetchImpl: typeof fetch = fetch): Promise { + const token = await source.current(); + const look = (t: string) => fetchImpl(`${url.replace(/\/+$/, "")}/api/v1/packages/${encodeURIComponent(owner)}?limit=1`, + { headers: { Authorization: `token ${t}`, Accept: "application/json" } }); + const res = await look(token); + if (res.status === 401 || MintedToken.lacksScope(res.status, await res.text())) { + const fresh = await source.renew(token); + const again = await look(fresh); + if (!again.ok) throw new Error(`the forge answered ${again.status} to the renewed package token's read`); + return "the forge rejected the package seats' write token; minted afresh"; + } + if (!res.ok) throw new Error(`the forge answered ${res.status} to the package token's read`); + return "the package seats' write token holds"; +} diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index c6399fce..617221b4 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -293,11 +293,11 @@ test("first start: mints with the admin account, keeps the token at 0600, asks f assert.equal(repos[0]?.full_name, "novox/hq"); assert.equal(forge.mints, 1); // Never write:admin (novox/hq ADR 0266): the forge's administration is the operator's, not a tool's. - assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]); + assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user", "read:package"]); assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]); const token = forge.tokens.get("mesh-tools")!; assert.equal(await readFile(file, "utf8"), token + "\n"); - assert.equal(await readFile(file + ".scopes", "utf8"), "read:user,write:issue,write:repository\n"); + assert.equal(await readFile(file + ".scopes", "utf8"), "read:package,read:user,write:issue,write:repository\n"); assert.equal((await stat(file)).mode & 0o777, 0o600); // Said that it minted, and where it keeps it — never what it is. assert.ok(logs.some((l) => l.startsWith("minted a token")), logs.join("\n")); diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts index c69edb77..f3a9316b 100644 --- a/modules/gitea/test/tools-account.test.ts +++ b/modules/gitea/test/tools-account.test.ts @@ -14,9 +14,10 @@ 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 { MintedToken, TOKEN_SCOPES } from "../dist/token.js"; import { GiteaClient } from "../dist/client.js"; import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js"; +import { PACKAGES_ACCOUNT, PACKAGES_TEAM_UNITS, keepPackagesToken, packagesToken } from "../dist/packages-account.js"; import { repoCheckFacts } from "../dist/pulls.js"; const ADMIN = "mesh-admin"; @@ -117,6 +118,13 @@ function fakeForge() { } } if (req.method === "GET" && p === "/user") return json(res, 200, { login: me, is_admin: isAdmin(me) }); + // The organisation's packages: a token whose scope reaches packages, of a member of a team writing them. + if (req.method === "GET" && p === "/packages/novox") { + const scopes = tokens.find((t) => `token ${t.sha1}` === req.headers.authorization)?.scopes ?? []; + if (!scopes.some((s) => /:package$/.test(s))) return json(res, 403, { message: "token does not have at least one of required scope(s): [read:package]" }); + if (!teams.some((t) => t.members.has(me) && t.units.units_map?.["repo.packages"] === "write")) return json(res, 403, { message: "no access to the packages" }); + return json(res, 200, []); + } m = p.match(/^\/users\/([^/]+)$/); if (m && req.method === "GET") { const u = users.get(decodeURIComponent(m[1])); @@ -360,6 +368,35 @@ test("the manifest hands the tools their account, and the Go forge bundle holds 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); + // The package registry's bundle reads with the tools' token and writes with the packages account's (design 51). + const packages = manifest.build.artifacts.find((a: any) => a.name === "package-registry"); + assert.equal(packages.env.MESH_GITEA_TOKEN_FILE, forgeBundle.env.MESH_GITEA_TOKEN_FILE); + assert.equal(packages.env.MESH_GITEA_PACKAGES_USER, PACKAGES_ACCOUNT); + assert.match(packages.env.MESH_GITEA_PACKAGES_TOKEN_FILE, /^\$\{dir:runtime-state\}\/packages-token$/); + assert.equal(packages.env.MESH_GITEA_ADMIN_PASSWORD_FILE, undefined); + assert.equal(code.env.MESH_GITEA_STATE_DIR, "${dir:runtime-state}", "the watcher mints the packages token where the bundle reads it"); +}); + +test("the package seats write as an account of their own: plain, in a team writing only packages, its token write:package alone, renewed when the forge rejects it", async () => { + const env = await environment(); + const source = packagesToken(forge.url, env, () => {}); + assert.equal(await keepPackagesToken(source, forge.url), "the package seats' write token holds"); + const account = forge.users.get(PACKAGES_ACCOUNT); + assert.ok(account, "the module made the packages account"); + assert.equal(account!.admin, false, "the packages account is no administrator"); + const held = forge.tokens.filter((t) => t.name === "mesh-packages"); + assert.deepEqual(held.map((t) => [t.user, t.scopes]), [[PACKAGES_ACCOUNT, ["write:package"]]]); + assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "packages-token"), "utf8"), held[0].sha1 + "\n"); + assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "packages-token.scopes"), "utf8"), "write:package for mesh-packages\n"); + const team = forge.teams.find((t) => t.name === "mesh-packages"); + assert.ok(team?.members.has(PACKAGES_ACCOUNT)); + assert.deepEqual(team!.units.units_map, PACKAGES_TEAM_UNITS.units_map, "the team writes packages and nothing else"); + // The forge drops the token behind the module's back: the next look mints it afresh. + forge.tokens.splice(forge.tokens.indexOf(held[0]), 1); + assert.equal(await keepPackagesToken(source, forge.url), "the forge rejected the package seats' write token; minted afresh"); + assert.equal(forge.tokens.filter((t) => t.name === "mesh-packages").length, 1); + // The tools' token reads packages too: read:package (design 51's read-only tool token). + assert.ok(TOKEN_SCOPES.includes("read:package")); }); // Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token. diff --git a/modules/gitea/token.ts b/modules/gitea/token.ts index 48592f33..7002b7f1 100644 --- a/modules/gitea/token.ts +++ b/modules/gitea/token.ts @@ -47,6 +47,9 @@ export const TOKEN_NAME = "mesh-tools"; * The least the fifteen tools and the watcher need (gitea's route groups, 1.20+ scoped tokens): * write:repository — create/delete repositories, pull requests (list/get/open/merge); * write:issue — issues, comments, labels; + * read:package — the package registry's reads by the Go bundle (cmd/package-registry): the package seats' + * held, versions and the retention's survey (design 51: a read-only tool token; the + * writes go with the packages account's own token, packages-account.ts); * read:user — GET /user/repos, which the watcher's poll and gitea_list_repos both call. * It sits under the `user` category despite listing repositories, not `repository` * — confirmed against the running forge (1.27.3), which answered @@ -62,7 +65,7 @@ export const TOKEN_NAME = "mesh-tools"; * `403 token does not have at least one of required scope(s)`, and the client treats that like a * 401 — the source re-mints by name, with the whole list, and the call is retried once. */ -export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"]; +export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user", "read:package"]; /** Where a client's token comes from, and what to do when the forge says it is wrong. */ export interface TokenSource {