From a34fd915986d797d83abd1a37309d4335ce7c6bd Mon Sep 17 00:00:00 2001 From: Ian Coutinho <75703845+IanCou@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:17:00 -0400 Subject: [PATCH] Delete personal Gitea repositories on creation again, behind API checks Team code belongs in team organizations. Until 0c1c3bb, any repository whose provisioning failed was deleted, which is how personal repositories were kept out. That commit stopped deleting because the webhook was unauthenticated and the delete used the owner and name from the body, so a forged event could delete any repository. Since then people could create personal repositories, and those get no branch protection. Webhooks are now authenticated, and this brings the delete back for personal repositories only. The owner's kind comes from the API, since Gitea fills the body's `organization` with the owner even for personal repositories. The repository is deleted only when the API's copy has the event's id and owner, belongs to a user, and was created within 10 minutes; a replayed or forged event can't delete an older or different repository. Any failure, including not being able to tell whether the owner is an organization, logs and leaves the repository alone. Organization repositories go through provisioning exactly as before, and a failed provisioning still never deletes. Tests cover each guard with faked clients. End to end on Gitea 1.27.3: a new personal repository was deleted within seconds of creation, and a new team repository in the same run was provisioned as before. --- .../server/src/clients/GiteaClient/index.ts | 21 ++- .../server/src/controllers/HooksController.ts | 69 +++++++ .../test/unit/gitea-personal-repos.test.js | 168 ++++++++++++++++++ .../test/unit/gitea-webhook-payloads.test.js | 3 + 4 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 peopleportal/server/test/unit/gitea-personal-repos.test.js diff --git a/peopleportal/server/src/clients/GiteaClient/index.ts b/peopleportal/server/src/clients/GiteaClient/index.ts index c3341c6..af1c9c2 100644 --- a/peopleportal/server/src/clients/GiteaClient/index.ts +++ b/peopleportal/server/src/clients/GiteaClient/index.ts @@ -668,6 +668,25 @@ export class GiteaClient implements SharedResourceClient { } } + /** + * Whether `name` is an organization rather than a user. Only a 404 means + * "not an organization"; any other failure throws, so callers that act on + * the answer fail closed. + */ + public async isOrganization(name: string): Promise { + try { + await axios.request({ + ...this.GiteaBaseConfig, + method: "get", + url: `/api/v1/orgs/${encodeURIComponent(name)}`, + }); + return true; + } catch (e) { + if ((e as AxiosError).response?.status == 404) return false; + throw e; + } + } + /* Repository Management */ public async getRepository( owner: string, @@ -686,7 +705,7 @@ export class GiteaClient implements SharedResourceClient { var RequestConfig: any = { ...this.GiteaBaseConfig, method: "delete", - url: `/api/v1/repos/${owner}/${repo}`, + url: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, }; try { diff --git a/peopleportal/server/src/controllers/HooksController.ts b/peopleportal/server/src/controllers/HooksController.ts index 1e79c17..f95cfd1 100644 --- a/peopleportal/server/src/controllers/HooksController.ts +++ b/peopleportal/server/src/controllers/HooksController.ts @@ -34,6 +34,12 @@ import { AuthentikClient } from "../clients/AuthentikClient"; @Route("/api/webhook") export class HooksController extends Controller { + /* How recently a personal repository must have been created for its + creation event to delete it. Webhooks arrive within seconds; the margin + covers queueing and clock skew, and an older repository is never touched + by a replayed or forged event. */ + static readonly PERSONAL_REPO_DELETE_WINDOW_MS = 10 * 60 * 1000; + private readonly giteaClient: GiteaClient; private readonly authentikClient: AuthentikClient; @@ -104,6 +110,23 @@ export class HooksController extends Controller { } private async handleRepoCreation(repoEvent: GiteaHookRepositoryTrigger) { + /* Gitea fills `organization` with the owner even for personal + repositories, so ask the API which kind of owner this is. */ + let ownerIsOrganization: boolean; + try { + ownerIsOrganization = await this.giteaClient.isOrganization( + repoEvent.repository.owner.username, + ); + } catch (e: any) { + console.error( + `Failed resolving the owner of ${repoEvent.repository.full_name}: ${e.message}`, + ); + return "OK (Actions Failed)"; + } + + if (!ownerIsOrganization) + return this.handlePersonalRepoCreation(repoEvent); + try { const organizationName = repoEvent.organization.username; if (repoEvent.repository.owner.username !== organizationName) { @@ -137,4 +160,50 @@ export class HooksController extends Controller { return "OK (Actions Failed)"; } } + + /** + * Team code lives in team organizations, so personal repositories are + * deleted as soon as they are created. The delete only happens once the API + * confirms the event describes a repository that exists with the same id + * and owner, belongs to a user rather than an organization, and was + * created within PERSONAL_REPO_DELETE_WINDOW_MS. Anything else is logged + * and left alone, so a forged or replayed event cannot delete an older or + * different repository. + */ + private async handlePersonalRepoCreation( + repoEvent: GiteaHookRepositoryTrigger, + ) { + const ownerName = repoEvent.repository.owner.username; + try { + const repository = await this.giteaClient.getRepository( + ownerName, + repoEvent.repository.name, + ); + if ( + repository.id !== repoEvent.repository.id || + repository.owner.username !== ownerName + ) { + throw new Error("Repository does not match webhook event"); + } + + const age = Date.now() - Date.parse(repository.created_at); + if ( + !Number.isFinite(age) || + Math.abs(age) > HooksController.PERSONAL_REPO_DELETE_WINDOW_MS + ) { + throw new Error("Repository was not created just now"); + } + + await this.giteaClient.deleteRepository(ownerName, repository.name); + console.warn( + `Deleted personal repository ${repository.full_name}: team code belongs in a team organization`, + ); + return "OK (Personal Repository Deleted)"; + } catch (e: any) { + console.error( + `Not deleting personal repository ${repoEvent.repository.full_name}: ${e.message}`, + ); + return "OK (Actions Failed)"; + } + } } diff --git a/peopleportal/server/test/unit/gitea-personal-repos.test.js b/peopleportal/server/test/unit/gitea-personal-repos.test.js new file mode 100644 index 0000000..8a4e1ce --- /dev/null +++ b/peopleportal/server/test/unit/gitea-personal-repos.test.js @@ -0,0 +1,168 @@ +/** + * Personal repositories are deleted on creation; team code belongs in team + * organizations. The delete is destructive, so each guard gets a test: the + * owner must be a user per the API, the API's repository must match the + * event's id and owner, and it must have been created just now. Everything + * else is left alone. + */ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const express = require("express"); +const request = require("supertest"); +const { ValidateError } = require("tsoa"); + +const { RegisterRoutes } = require("../../dist/routes.js"); +const giteaModule = require("../../dist/clients/GiteaClient/index.js"); +const authentikModule = require("../../dist/clients/AuthentikClient/index.js"); +const { HooksController } = require("../../dist/controllers/HooksController.js"); + +const RealGiteaClient = giteaModule.GiteaClient; +const RealAuthentikClient = authentikModule.AuthentikClient; + +function buildApp() { + const app = express(); + app.use(express.json()); + RegisterRoutes(app); + app.use((err, _req, res, _next) => { + if (err instanceof ValidateError) return res.status(422).end(); + return res.status(500).end(); + }); + return app; +} + +/* HooksController builds new clients per request and reads the classes off + these module objects at call time, so swapping the exports reaches it. */ +function withFakeGitea({ isOrganization, apiRepository }, run) { + const calls = { deleted: [], provisioned: [] }; + giteaModule.GiteaClient = class { + async isOrganization() { + if (isOrganization instanceof Error) throw isOrganization; + return isOrganization; + } + async getRepository() { + return apiRepository; + } + async deleteRepository(owner, repo) { + calls.deleted.push(`${owner}/${repo}`); + } + async handleRepoProvisioning(req) { + calls.provisioned.push(req); + } + }; + authentikModule.AuthentikClient = class { + getGroupPkFromName = async () => "team-pk"; + }; + return run(calls).finally(() => { + giteaModule.GiteaClient = RealGiteaClient; + authentikModule.AuthentikClient = RealAuthentikClient; + }); +} + +const user = (username) => ({ id: 7, username }); + +function createdEvent(owner) { + return { + action: "created", + repository: { + id: 42, + name: "scratch", + full_name: `${owner}/scratch`, + owner: user(owner), + }, + organization: user(owner), + sender: user(owner), + }; +} + +const justNow = () => new Date().toISOString(); + +const apiPersonalRepo = (overrides = {}) => ({ + id: 42, + name: "scratch", + full_name: "ianc/scratch", + owner: { username: "ianc" }, + created_at: justNow(), + ...overrides, +}); + +const post = (body) => + request(buildApp()).post("/api/webhook/git/repoevent").send(body); + +test("a just-created personal repository is deleted", () => + withFakeGitea({ isOrganization: false, apiRepository: apiPersonalRepo() }, async (calls) => { + const response = await post(createdEvent("ianc")); + + assert.equal(response.status, 200); + assert.deepEqual(calls.deleted, ["ianc/scratch"]); + assert.equal(calls.provisioned.length, 0); + })); + +test("an organization repository is provisioned, never deleted", () => + withFakeGitea( + { + isOrganization: true, + apiRepository: { id: 42, name: "scratch", owner: { username: "TechOps" } }, + }, + async (calls) => { + const response = await post(createdEvent("TechOps")); + + assert.equal(response.status, 200); + assert.deepEqual(calls.deleted, []); + assert.equal(calls.provisioned.length, 1); + }, + )); + +test("no delete when the API's repository has a different id", () => + withFakeGitea({ isOrganization: false, apiRepository: apiPersonalRepo({ id: 99 }) }, async (calls) => { + await post(createdEvent("ianc")); + assert.deepEqual(calls.deleted, []); + })); + +test("no delete when the API's repository has a different owner", () => + withFakeGitea( + { isOrganization: false, apiRepository: apiPersonalRepo({ owner: { username: "someone-else" } }) }, + async (calls) => { + await post(createdEvent("ianc")); + assert.deepEqual(calls.deleted, []); + }, + )); + +test("no delete for a repository created before the window (replayed or forged event)", () => { + const tooOld = new Date( + Date.now() - HooksController.PERSONAL_REPO_DELETE_WINDOW_MS - 60_000, + ).toISOString(); + return withFakeGitea( + { isOrganization: false, apiRepository: apiPersonalRepo({ created_at: tooOld }) }, + async (calls) => { + await post(createdEvent("ianc")); + assert.deepEqual(calls.deleted, []); + }, + ); +}); + +test("no delete when the creation time can't be read", () => + withFakeGitea( + { isOrganization: false, apiRepository: apiPersonalRepo({ created_at: "not a date" }) }, + async (calls) => { + await post(createdEvent("ianc")); + assert.deepEqual(calls.deleted, []); + }, + )); + +test("no delete when Gitea can't say whether the owner is an organization", () => + withFakeGitea( + { isOrganization: new Error("Gitea unavailable"), apiRepository: apiPersonalRepo() }, + async (calls) => { + const response = await post(createdEvent("ianc")); + assert.equal(response.status, 200); + assert.deepEqual(calls.deleted, []); + assert.equal(calls.provisioned.length, 0); + }, + )); + +test("events other than creation never delete", () => + withFakeGitea({ isOrganization: false, apiRepository: apiPersonalRepo() }, async (calls) => { + await post({ ...createdEvent("ianc"), action: "deleted" }); + await post({ ...createdEvent("ianc"), action: "archived" }); + assert.deepEqual(calls.deleted, []); + })); diff --git a/peopleportal/server/test/unit/gitea-webhook-payloads.test.js b/peopleportal/server/test/unit/gitea-webhook-payloads.test.js index 45023a3..b596a1f 100644 --- a/peopleportal/server/test/unit/gitea-webhook-payloads.test.js +++ b/peopleportal/server/test/unit/gitea-webhook-payloads.test.js @@ -104,6 +104,9 @@ function withFakeClients(apiRepository, run) { giteaModule.GiteaClient = class { static GIT_REF_MAINBRANCH = RealGiteaClient.GIT_REF_MAINBRANCH; static GIT_NULL_COMMITID = RealGiteaClient.GIT_NULL_COMMITID; + async isOrganization() { + return true; + } async getRepository() { return apiRepository; }