CAT-G2: the package seats write as an account of their own, the tools' token reads packages
Design 51 keeps each holder's write side as an own secret beside a read-only tool token. The Go bundle wrote as the forge's administrator; it now reads with the tools' token (read:package added) and writes with the mesh-packages account's token (write:package alone), which the watcher makes, mints, keeps and renews. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
+10
-5
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 <module>@<version>/, as Go's module
|
||||
|
||||
@@ -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"}`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<void> {
|
||||
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<string> {
|
||||
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";
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user