CAT-G2: the packages token is renewed on the forge's refusal, said as a finding, never on a timer
The Go bundle raises packages-token-refused in the module's findings state when the forge answers 401 to a write; the watcher mints afresh on that key's event and clears it (lead's ruling on A3n, map-onto-how-the-mesh-is-built: nothing waits on a timer). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -246,8 +246,10 @@ with the token the TypeScript bundle keeps for it (`MESH_GITEA_TOKEN_FILE`, scop
|
||||
**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
|
||||
the watcher makes it with the administrator's login and mints and keeps its token at its start (`packages-account.ts`).
|
||||
No timer looks at it after that: when the forge refuses a write (401) this bundle raises the finding
|
||||
`packages-token-refused` (key `seat.gitea.packages-token-refused_write` in the module's `findings` state), the watcher,
|
||||
watching that key, mints the token afresh and clears it; a write the forge takes clears it too. 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`.
|
||||
|
||||
|
||||
@@ -27,6 +27,32 @@ type Forge struct {
|
||||
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
|
||||
// Refused says whether the forge refused the write side's token (401), as the finding PackagesTokenRefused in the
|
||||
// module's findings state: the TypeScript bundle mints a new token on that key's event and clears it (no timer
|
||||
// looks at the token). Nil says nothing.
|
||||
Refused func(raised bool)
|
||||
}
|
||||
|
||||
// PackagesTokenRefused is the finding this bundle raises when the forge refuses the packages account's token, and its
|
||||
// key in the module's findings state (packages-account.ts PACKAGES_TOKEN_REFUSED watches that key).
|
||||
const (
|
||||
PackagesTokenRefused = "packages-token-refused"
|
||||
PackagesTokenRefusedKey = "seat.gitea.packages-token-refused_write"
|
||||
)
|
||||
|
||||
// wrote is a write's answer, said on Refused: a 401 raises the finding, a write the forge took clears it.
|
||||
func (f *Forge) wrote(err error) error {
|
||||
if f.Refused == nil {
|
||||
return err
|
||||
}
|
||||
var status *forgehttp.StatusError
|
||||
switch {
|
||||
case errors.As(err, &status) && status.Status == http.StatusUnauthorized:
|
||||
f.Refused(true)
|
||||
case err == nil:
|
||||
f.Refused(false)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// The package types the retention reads: npm packages (the npm seat) and Go modules (the Go seat).
|
||||
@@ -59,7 +85,9 @@ func ForgeFromEnv() (*Forge, error) {
|
||||
missing = append(missing, m)
|
||||
}
|
||||
}
|
||||
f := &Forge{Client: c, Writer: &w, Owner: os.Getenv("MESH_NPM_OWNER")}
|
||||
f := &Forge{Client: c, Writer: &w, Owner: os.Getenv("MESH_NPM_OWNER"), Refused: func(raised bool) {
|
||||
finding(PackagesTokenRefused, "write", "the forge refused the package seats' write token; the module mints it afresh", raised)
|
||||
}}
|
||||
if f.Owner == "" {
|
||||
missing = append(missing, "MESH_NPM_OWNER")
|
||||
}
|
||||
@@ -152,7 +180,7 @@ func (f *Forge) Delete(ctx context.Context, name, version string) error {
|
||||
return err
|
||||
}
|
||||
_, err = w.Do(ctx, http.MethodDelete, f.versionPath(name, version), nil, maxBody)
|
||||
return err
|
||||
return f.wrote(err)
|
||||
}
|
||||
|
||||
// Repository is one repository on the forge, as far as reading its manifests needs.
|
||||
|
||||
@@ -90,14 +90,14 @@ func (s seatStore) Put(seat string, p packageseats.Publish, file []byte) (string
|
||||
return "", err
|
||||
}
|
||||
_, err = w.Send(ctx, http.MethodPut, fmt.Sprintf("/api/packages/%s/go/upload", url.PathEscape(f.Owner)), file, "application/zip", maxBody)
|
||||
return module, err
|
||||
return module, f.wrote(err)
|
||||
}
|
||||
doc, err := npmDocument(f.URL, f.Owner, p, file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, 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
|
||||
return "", f.wrote(err)
|
||||
}
|
||||
|
||||
func (s seatStore) Versions(seat, name string) ([]packageseats.Version, error) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"gitea/internal/forgehttp"
|
||||
"gitea/internal/packageseats"
|
||||
)
|
||||
@@ -63,7 +64,10 @@ func (g *registry) handle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
want = "token writer"
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != want {
|
||||
if got := r.Header.Get("Authorization"); got == "token stale" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
} else if got != want {
|
||||
http.Error(w, r.Method+" "+p+" came as "+got+", not "+want, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
@@ -125,6 +129,19 @@ func TestThePackageSeatsStoreIntoTheForge(t *testing.T) {
|
||||
t.Errorf("a forge handed no write side wrote: %v", err)
|
||||
}
|
||||
s := seatStore{forge: func() (*Forge, error) { return forge, nil }}
|
||||
// The forge refuses the write side's token: the finding the TypeScript bundle renews on is raised under its key,
|
||||
// and the next write the forge takes clears it (no timer looks at the token).
|
||||
var refused []bool
|
||||
forge.Refused = func(raised bool) { refused = append(refused, raised) }
|
||||
if k := health.FindingKey(health.ScopeSeat, packageseats.Self, PackagesTokenRefused+".write"); k != PackagesTokenRefusedKey {
|
||||
t.Errorf("the refusal's key is %s, the watcher watches %s", k, PackagesTokenRefusedKey)
|
||||
}
|
||||
_ = os.WriteFile(writer.TokenFile, []byte("stale\n"), 0o600)
|
||||
if _, err := s.Put(packageseats.GoSeat, packageseats.Publish{Version: "0.1.15"}, moduleZip(t, "git.example/acme/sdk@v0.1.15")); err == nil ||
|
||||
len(refused) != 1 || !refused[0] {
|
||||
t.Errorf("a write the forge refused with 401 raised no finding: %v %v", err, refused)
|
||||
}
|
||||
_ = os.WriteFile(writer.TokenFile, []byte("writer\n"), 0o600)
|
||||
|
||||
tgz := tarball(t, `{"name":"@acme/contracts","version":"0.1.15-feat-x.2","main":"index.js"}`)
|
||||
p := packageseats.Publish{Name: "@acme/contracts", Version: "0.1.15-feat-x.2", Tag: "feat-x", Kind: packageseats.KindPackage, Digest: "sha256:" + sha(tgz)}
|
||||
@@ -174,4 +191,7 @@ func TestThePackageSeatsStoreIntoTheForge(t *testing.T) {
|
||||
g.deleted[0] != "go/git.example%2Facme%2Fsdk/v0.1.15-feat.1" {
|
||||
t.Errorf("remove: %v %v", err, g.deleted)
|
||||
}
|
||||
if len(refused) < 2 || refused[len(refused)-1] {
|
||||
t.Errorf("a write the forge took after the refusal did not clear it: %v", refused)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +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";
|
||||
import { PACKAGES_TOKEN_REFUSED, keepPackagesToken, packagesToken, renewOnRefusal } 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
|
||||
@@ -634,16 +634,15 @@ 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.
|
||||
// The package seats' write token (packages-account.ts): minted at the start; after that renewed only when the Go
|
||||
// bundle that writes with it says the forge refused it (the finding PACKAGES_TOKEN_REFUSED), never on a timer.
|
||||
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);
|
||||
const say = (line: string) => console.log(`[gitea] ${line}`);
|
||||
void keepPackagesToken(source, url).then(say, (err) => console.error(`[gitea] the package seats' write token could not be minted at the start — ${err instanceof Error ? err.message : err}`));
|
||||
const findings = state<unknown>("findings");
|
||||
void bindUntilHeld("findings", () => findings.watch(renewOnRefusal(source, url, findings, say), { key: PACKAGES_TOKEN_REFUSED }));
|
||||
} catch (err) {
|
||||
console.error(`[gitea] the package seats cannot write — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
// 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.
|
||||
// The watcher mints it at its start (keepPackagesToken). No timer looks at it after that: the Go bundle mints nothing,
|
||||
// and when the forge refuses its write it raises the finding PACKAGES_TOKEN_REFUSED in the module's findings state; the
|
||||
// watcher, watching that key, looks again (a refused token is minted afresh) and clears the finding (renewOnRefusal).
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
@@ -86,3 +87,27 @@ export async function keepPackagesToken(source: MintedToken, url: string, owner
|
||||
if (!res.ok) throw new Error(`the forge answered ${res.status} to the package token's read`);
|
||||
return "the package seats' write token holds";
|
||||
}
|
||||
|
||||
/** The finding the Go bundle raises in the module's findings state when the forge refuses the write token (cmd/package-registry PackagesTokenRefusedKey). */
|
||||
export const PACKAGES_TOKEN_REFUSED = "seat.gitea.packages-token-refused_write";
|
||||
|
||||
/** One change of the findings state, as its watch hands it. */
|
||||
export interface FindingChange { readonly key: string; readonly op: "put" | "delete" }
|
||||
|
||||
/**
|
||||
* The watcher's answer to the Go bundle's refusal: on the finding's put, look at the token (a refused one is minted
|
||||
* afresh) and clear the finding; a look that fails leaves the finding standing, said. Other keys are not its own.
|
||||
*/
|
||||
export function renewOnRefusal(source: MintedToken, url: string, findings: { delete(key: string): Promise<void> },
|
||||
log: (line: string) => void, fetchImpl: typeof fetch = fetch): (change: FindingChange) => Promise<void> {
|
||||
return async (change) => {
|
||||
if (change.op !== "put" || change.key !== PACKAGES_TOKEN_REFUSED) return;
|
||||
try {
|
||||
log(await keepPackagesToken(source, url, TOOLS_ORG, fetchImpl));
|
||||
} catch (err) {
|
||||
log(`the package seats' write token was refused and could not be minted afresh; the finding stands — ${err instanceof Error ? err.message : err}`);
|
||||
return;
|
||||
}
|
||||
await findings.delete(change.key);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { join } from "node:path";
|
||||
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 { PACKAGES_ACCOUNT, PACKAGES_TEAM_UNITS, PACKAGES_TOKEN_REFUSED, keepPackagesToken, packagesToken, renewOnRefusal } from "../dist/packages-account.js";
|
||||
import { repoCheckFacts } from "../dist/pulls.js";
|
||||
|
||||
const ADMIN = "mesh-admin";
|
||||
@@ -391,9 +391,18 @@ test("the package seats write as an account of their own: plain, in a team writi
|
||||
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.
|
||||
// The forge drops the token behind the module's back: the Go bundle's refusal (the finding's put) mints it afresh and
|
||||
// clears the finding; no timer looks at it, and another key of the findings state is not its own.
|
||||
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");
|
||||
const cleared: string[] = [];
|
||||
const said: string[] = [];
|
||||
const renew = renewOnRefusal(source, forge.url, { delete: async (k) => { cleared.push(k); } }, (l) => said.push(l));
|
||||
await renew({ key: "seat.gitea.other", op: "put" });
|
||||
await renew({ key: PACKAGES_TOKEN_REFUSED, op: "delete" });
|
||||
assert.equal(forge.tokens.filter((t) => t.name === "mesh-packages").length, 0, "nothing renews but the refusal");
|
||||
await renew({ key: PACKAGES_TOKEN_REFUSED, op: "put" });
|
||||
assert.deepEqual(said, ["the forge rejected the package seats' write token; minted afresh"]);
|
||||
assert.deepEqual(cleared, [PACKAGES_TOKEN_REFUSED]);
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user