Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

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 @@
99 *
1010 * - A package linked to a repository shares its permissions: readers pull, writers push,
1111 * 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.
1314 * - Packages are private unless made public.
1415 * - A workflow's GITHUB_TOKEN follows the job's `packages` permission. It pulls packages
1516 * 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.
1719 * - Personal tokens are capped by their scopes (read pulls, write pushes, admin deletes)
1820 * and by their repository list.
1921 */
@@ -65,8 +67,8 @@
6567 const linked = pkg.repo_id === repo.id;
6668 if (perm !== "none" && (linked || sameOwner || pkg.visibility === "public")) role = "pull";
6769 if (perm === "write" && linked) role = "admin";
68- else if (perm === "write" && sameOwner && !pkg.repo_id) role = "push";
6970 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`;
7072 } else if (perm === "write" && sameOwner) role = "push";
7173 else if (!sameOwner) hint = `workflows in ${repo.owner_login}/${repo.name} can only create packages under ${repo.owner_login}`;
7274 if (perm === "none") hint = "the workflow's GITHUB_TOKEN has no packages permission; add `permissions: packages: write`";
@@ -79,7 +81,9 @@
7981 const actor = { id: a.id, login: a.login };
8082 const subject = a.kind === "agent" ? a.owner_id : a.id;
8183
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).
8387 let role: PackageRole = "none";
8488 if (a.site_admin) role = "admin";
8589 else if (owner.kind === "user" && subject === owner.id) role = a.kind === "agent" ? "push" : "admin";
@@ -88,7 +92,12 @@
8892 else if (subject) {
8993 const r = await dir.orgRole(owner.id, subject);
9094 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+ }
92101 }
93102 }
94103
modifiedapps/diffinery/test/e2e/registry.test.mjs+45−0View file
@@ -48,6 +48,9 @@
4848 }
4949
5050 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")}`]));
5154 const auth = (tok = token) => ({ authorization: `Basic ${Buffer.from(`anyone:${tok}`).toString("base64")}` });
5255
5356 /** A registry call: path is below /v2/. */
@@ -111,6 +114,17 @@
111114 host = `127.0.0.1:${port}`;
112115 const persist = join(work, "state");
113116 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" } });
114128 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"], {
115129 cwd: APP,
116130 env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
@@ -330,6 +344,37 @@
330344 }
331345 });
332346
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+
333378 function git(cwd, args) {
334379 const a = Buffer.from(`x-token:${token}`).toString("base64");
335380 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 @@
4545
4646 With write, a workflow pushes to packages linked to its own repository,
4747 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
4950 own repository's packages and any other package of the same owner that it can
5051 see.
5152
@@ -103,8 +104,10 @@
103104
104105 A package linked to a repository shares the repository's roles: readers pull,
105106 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.
108111
109112 A package is linked when it is created:
110113