Packages: membership alone doesn't grant push to others' packages (#27)
As on ghcr.io, organization members create packages and pull the organization's, but an unlinked package is administered only by the member who created it (or whose agent did) and the org's owners and admins; linked packages follow their repository. A workflow's GITHUB_TOKEN pushes only to its own repository's packages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ruyx3Qr4jYrZEpPi466deb
Canberk Demirci authored · GitHub committed signed1 parent 1d950bacommit 5018b53bda62b1dcdabb74bedd4f06f9404747e6Browse files
3 files changed+65−8
modifiedapps/diffinery/src/registry/access.ts+14−5View file
| @@ -9,11 +9,13 @@ | ||
| 9 | 9 | * |
| 10 | 10 | * - A package linked to a repository shares its permissions: readers pull, writers push, |
| 11 | 11 | * admins delete. The owner (the user, or the organization's owners and admins) always |
| 12 | - * administers its packages; organization members can push to unlinked ones. | |
| 12 | + * administers its packages. Organization members can create packages and pull the | |
| 13 | + * organization's packages; an unlinked package is administered by whoever created it. | |
| 13 | 14 | * - Packages are private unless made public. |
| 14 | 15 | * - A workflow's GITHUB_TOKEN follows the job's `packages` permission. It pulls packages |
| 15 | 16 | * of its own owner, pushes to its repository's packages (creating them, linked to the |
| 16 | - * repository, on first push) and deletes their versions. | |
| 17 | + * repository, on first push) and deletes their versions. Packages that aren't linked to | |
| 18 | + * its repository are read-only to it. | |
| 17 | 19 | * - Personal tokens are capped by their scopes (read pulls, write pushes, admin deletes) |
| 18 | 20 | * and by their repository list. |
| 19 | 21 | */ |
| @@ -65,8 +67,8 @@ | ||
| 65 | 67 | const linked = pkg.repo_id === repo.id; |
| 66 | 68 | if (perm !== "none" && (linked || sameOwner || pkg.visibility === "public")) role = "pull"; |
| 67 | 69 | if (perm === "write" && linked) role = "admin"; |
| 68 | - else if (perm === "write" && sameOwner && !pkg.repo_id) role = "push"; | |
| 69 | 70 | else if (pkg.repo_id && !linked) hint = `${owner.login}/${pkg.name} belongs to the ${owner.login}/${pkg.repo_name ?? "?"} repository, so only its workflows can push to it`; |
| 71 | + else if (!pkg.repo_id) hint = `${owner.login}/${pkg.name} isn't linked to a repository; link it to ${repo.owner_login}/${repo.name} in its settings so this workflow can push to it`; | |
| 70 | 72 | } else if (perm === "write" && sameOwner) role = "push"; |
| 71 | 73 | else if (!sameOwner) hint = `workflows in ${repo.owner_login}/${repo.name} can only create packages under ${repo.owner_login}`; |
| 72 | 74 | if (perm === "none") hint = "the workflow's GITHUB_TOKEN has no packages permission; add `permissions: packages: write`"; |
| @@ -79,7 +81,9 @@ | ||
| 79 | 81 | const actor = { id: a.id, login: a.login }; |
| 80 | 82 | const subject = a.kind === "agent" ? a.owner_id : a.id; |
| 81 | 83 | |
| 82 | - // The owner administers its packages; organization members push to unlinked ones. | |
| 84 | + // The owner administers its packages. Organization members create packages and pull the | |
| 85 | + // organization's; an unlinked package is administered by whoever created it (like ghcr.io, | |
| 86 | + // membership alone doesn't let one member push to another's package). | |
| 83 | 87 | let role: PackageRole = "none"; |
| 84 | 88 | if (a.site_admin) role = "admin"; |
| 85 | 89 | else if (owner.kind === "user" && subject === owner.id) role = a.kind === "agent" ? "push" : "admin"; |
| @@ -88,7 +92,12 @@ | ||
| 88 | 92 | else if (subject) { |
| 89 | 93 | const r = await dir.orgRole(owner.id, subject); |
| 90 | 94 | if (r === "owner" || r === "admin") role = a.kind === "agent" ? "push" : "admin"; |
| 91 | - else if (r === "member") role = "push"; | |
| 95 | + else if (r === "member") { | |
| 96 | + // The creator, or the member whose agent created it. | |
| 97 | + let creator = !!pkg?.created_by && (pkg.created_by === a.id || pkg.created_by === subject); | |
| 98 | + if (pkg?.created_by && !pkg.repo_id && !creator) creator = (await dir.account(pkg.created_by))?.owner_id === subject; | |
| 99 | + role = !pkg ? "push" : !pkg.repo_id && creator ? (a.kind === "agent" ? "push" : "admin") : "pull"; | |
| 100 | + } | |
| 92 | 101 | } |
| 93 | 102 | } |
| 94 | 103 |
modifiedapps/diffinery/test/e2e/registry.test.mjs+45−0View file
| @@ -48,6 +48,9 @@ | ||
| 48 | 48 | } |
| 49 | 49 | |
| 50 | 50 | const sha = (b) => `sha256:${createHash("sha256").update(b).digest("hex")}`; |
| 51 | +const hex = (s) => createHash("sha256").update(s).digest("hex"); | |
| 52 | +/** Tokens of the seeded "crew" organization's owner and members, and of an outside collaborator. */ | |
| 53 | +const CREW = Object.fromEntries(["olga", "mira", "nils", "otto"].map((n) => [n, `dfy_${n}_${randomBytes(12).toString("hex")}`])); | |
| 51 | 54 | const auth = (tok = token) => ({ authorization: `Basic ${Buffer.from(`anyone:${tok}`).toString("base64")}` }); |
| 52 | 55 | |
| 53 | 56 | /** A registry call: path is below /v2/. */ |
| @@ -111,6 +114,17 @@ | ||
| 111 | 114 | host = `127.0.0.1:${port}`; |
| 112 | 115 | const persist = join(work, "state"); |
| 113 | 116 | execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 117 | + const at = new Date().toISOString(); | |
| 118 | + const later = new Date(Date.now() + 30 * 86400_000).toISOString(); | |
| 119 | + const seed = [ | |
| 120 | + `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('o-crew', 'crew', 'org', 'crew', 'active', '${at}')`, | |
| 121 | + ...Object.entries(CREW).flatMap(([n, t]) => [ | |
| 122 | + `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('u-${n}', '${n}', 'user', '${n}', 'active', '${at}')`, | |
| 123 | + `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t-${n}', 'u-${n}', 'e2e', '${hex(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`, | |
| 124 | + ]), | |
| 125 | + `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-crew', 'u-olga', 'owner', '${at}'), ('o-crew', 'u-mira', 'member', '${at}'), ('o-crew', 'u-nils', 'member', '${at}')`, | |
| 126 | + ]; | |
| 127 | + execFileSync("pnpm", ["exec", "wrangler", "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", seed.join("; ")], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 114 | 128 | dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], { |
| 115 | 129 | cwd: APP, |
| 116 | 130 | env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, |
| @@ -330,6 +344,37 @@ | ||
| 330 | 344 | } |
| 331 | 345 | }); |
| 332 | 346 | |
| 347 | +test("registry: organization packages follow ghcr.io's permissions", async () => { | |
| 348 | + // A member creates a package and administers it, since it isn't linked to a repository. | |
| 349 | + const img = await pushImage("crew/mira-tool", "v1", { tok: CREW.mira }); | |
| 350 | + assert.equal((await ok("PATCH", "/orgs/crew/packages/container/mira-tool", { visibility: "private" }, CREW.mira)).visibility, "private"); | |
| 351 | + // Another member pulls it but can't push to it, delete it or change it. | |
| 352 | + assert.equal((await reg("GET", "crew/mira-tool/manifests/v1", { tok: CREW.nils })).status, 200); | |
| 353 | + const push = await reg("POST", "crew/mira-tool/blobs/uploads/", { tok: CREW.nils }); | |
| 354 | + assert.equal(push.status, 403, JSON.stringify(push.json)); | |
| 355 | + assert.equal((await reg("DELETE", `crew/mira-tool/manifests/${img.digest}`, { tok: CREW.nils })).status, 403); | |
| 356 | + assert.equal((await api("PATCH", "/orgs/crew/packages/container/mira-tool", { visibility: "public" }, CREW.nils)).status, 403); | |
| 357 | + assert.equal((await api("DELETE", "/orgs/crew/packages/container/mira-tool", undefined, CREW.nils)).status, 403); | |
| 358 | + // Members still create their own packages, and the organization's owner administers them all. | |
| 359 | + await pushImage("crew/nils-tool", "v1", { tok: CREW.nils }); | |
| 360 | + await pushImage("crew/mira-tool", "v2", { tok: CREW.olga }); | |
| 361 | + assert.deepEqual((await reg("GET", "crew/mira-tool/tags/list", { tok: CREW.mira })).json.tags, ["v1", "v2"]); | |
| 362 | + | |
| 363 | + // A linked package follows its repository: an outside collaborator with read pulls, with write pushes. | |
| 364 | + await ok("POST", "/orgs/crew/repos", { name: "app" }, CREW.olga); | |
| 365 | + await pushImage("crew/app", "v1", { tok: CREW.olga }); | |
| 366 | + assert.equal((await ok("GET", "/orgs/crew/packages/container/app", undefined, CREW.olga)).repository.full_name, "crew/app"); | |
| 367 | + assert.equal((await reg("GET", "crew/app/manifests/v1", { tok: CREW.otto })).status, 404); | |
| 368 | + await ok("PUT", "/repos/crew/app/collaborators/otto", { permission: "read" }, CREW.olga); | |
| 369 | + assert.equal((await reg("GET", "crew/app/manifests/v1", { tok: CREW.otto })).status, 200); | |
| 370 | + assert.equal((await reg("POST", "crew/app/blobs/uploads/", { tok: CREW.otto })).status, 403); | |
| 371 | + await ok("PUT", "/repos/crew/app/collaborators/otto", { permission: "write" }, CREW.olga); | |
| 372 | + await pushImage("crew/app", "v2", { tok: CREW.otto }); | |
| 373 | + // ...but not to the members' unlinked packages, which he can't see. | |
| 374 | + assert.equal((await reg("GET", "crew/mira-tool/manifests/v1", { tok: CREW.otto })).status, 404); | |
| 375 | + assert.deepEqual((await ok("GET", "/orgs/crew/packages", undefined, CREW.otto)).map((p) => p.name), ["app"]); | |
| 376 | +}); | |
| 377 | + | |
| 333 | 378 | function git(cwd, args) { |
| 334 | 379 | const a = Buffer.from(`x-token:${token}`).toString("base64"); |
| 335 | 380 | return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${a}`, "-c", "protocol.version=2", ...args], { |
modifieddocs/container-registry.md+6−3View file
| @@ -45,7 +45,8 @@ | ||
| 45 | 45 | |
| 46 | 46 | With write, a workflow pushes to packages linked to its own repository, |
| 47 | 47 | creates new packages under its repository's owner (linked to the repository), |
| 48 | -and deletes versions of its repository's packages. With read, it pulls its | |
| 48 | +and deletes versions of its repository's packages. Other packages are | |
| 49 | +read-only to it, including ones not linked to any repository. With read, it pulls its | |
| 49 | 50 | own repository's packages and any other package of the same owner that it can |
| 50 | 51 | see. |
| 51 | 52 | |
| @@ -103,8 +104,10 @@ | ||
| 103 | 104 | |
| 104 | 105 | A package linked to a repository shares the repository's roles: readers pull, |
| 105 | 106 | writers push, admins delete versions and change settings. The owner (a user, |
| 106 | -or an organization's owners and admins) always administers its packages, and | |
| 107 | -organization members can push to packages that aren't linked to a repository. | |
| 107 | +or an organization's owners and admins) always administers its packages. | |
| 108 | +Organization members can create packages and pull the organization's packages; | |
| 109 | +a package that isn't linked to a repository is administered by the member who | |
| 110 | +created it, and other members can't push to it. | |
| 108 | 111 | |
| 109 | 112 | A package is linked when it is created: |
| 110 | 113 |