From 2935ed995e9ec83d850bd3b02c3479c7aabd51d3 Mon Sep 17 00:00:00 2001 From: Ian Coutinho <75703845+IanCou@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:33:09 -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 and complete webhook payloads. End to end on Gitea 1.24.3 (as in production): a new personal repository was deleted within seconds of creation, and a new team repository in the same run was made public and had main protected on its first push. --- .../server/src/clients/GiteaClient/index.ts | 21 +- .../server/src/controllers/HooksController.ts | 69 +++++ .../test/unit/gitea-personal-repos.test.js | 290 ++++++++++++++++++ 3 files changed, 379 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 f788ea0..01d3346 100644 --- a/peopleportal/server/src/clients/GiteaClient/index.ts +++ b/peopleportal/server/src/clients/GiteaClient/index.ts @@ -666,6 +666,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, @@ -684,7 +703,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 0f1a2d1..bb68d35 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..8a1f93a --- /dev/null +++ b/peopleportal/server/test/unit/gitea-personal-repos.test.js @@ -0,0 +1,290 @@ +/** + * Personal repositories are deleted only after the API confirms their owner, + * identity, and creation time. Exercise each guard through the strict routes. + */ +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"); + +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).json(err.fields); + return res.status(500).end(); + }); + return app; +} + +/* HooksController reads the client classes from these module exports when + constructing each controller. Restore them even when an assertion fails. */ +async function withFakeGitea({ isOrganization, apiRepository }, run) { + const RealGiteaClient = giteaModule.GiteaClient; + const RealAuthentikClient = authentikModule.AuthentikClient; + const calls = { organizations: [], fetched: [], deleted: [], provisioned: [] }; + giteaModule.GiteaClient = class { + async isOrganization(name) { + calls.organizations.push(name); + if (isOrganization instanceof Error) throw isOrganization; + return isOrganization; + } + async getRepository(owner, repo) { + calls.fetched.push(`${owner}/${repo}`); + 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"; + }; + try { + await run(calls); + } finally { + giteaModule.GiteaClient = RealGiteaClient; + authentikModule.AuthentikClient = RealAuthentikClient; + } +} + +/* Include every required model field so tsoa's strict validation reaches the + controller. Neither missing fields nor extra properties are accepted. */ +function user(username) { + return { + id: 7, + login: username, + login_name: username, + source_id: 0, + full_name: username, + email: `${username}@example.com`, + avatar_url: "", + html_url: `https://git.example.com/${username}`, + language: "en-US", + is_admin: false, + last_login: "2026-01-01T00:00:00Z", + created: "2026-01-01T00:00:00Z", + restricted: false, + active: true, + prohibit_login: false, + location: "", + website: "", + description: "", + visibility: "public", + followers_count: 0, + following_count: 0, + starred_repos_count: 0, + username, + }; +} + +function repository(owner = "ianc", overrides = {}) { + const url = `https://git.example.com/${owner}/scratch`; + const now = new Date().toISOString(); + return { + owner: user(owner), + name: "scratch", + id: 42, + full_name: `${owner}/scratch`, + description: "", + empty: true, + private: false, + fork: false, + template: false, + mirror: false, + size: 0, + language: "", + languages_url: `${url}/languages`, + html_url: url, + url, + link: url, + ssh_url: `git@git.example.com:${owner}/scratch.git`, + clone_url: `${url}.git`, + original_url: "", + website: "", + stars_count: 0, + forks_count: 0, + watchers_count: 0, + open_issues_count: 0, + open_pr_counter: 0, + release_counter: 0, + default_branch: "main", + archived: false, + created_at: now, + updated_at: now, + archived_at: "", + permissions: { admin: true, push: true, pull: true }, + has_issues: true, + internal_tracker: { + enable_time_tracker: true, + allow_only_contributors_to_track_time: true, + enable_issue_dependencies: true, + }, + has_wiki: true, + has_pull_requests: true, + has_projects: true, + projects_mode: "all", + has_releases: true, + has_packages: true, + has_actions: true, + ignore_whitespace_conflicts: false, + allow_merge_commits: true, + allow_rebase: true, + allow_rebase_explicit: true, + allow_squash_merge: true, + allow_fast_forward_only_merge: false, + allow_rebase_update: true, + default_delete_branch_after_merge: false, + default_merge_style: "merge", + default_allow_maintainer_edit: false, + avatar_url: "", + internal: false, + mirror_interval: "", + object_format_name: "sha1", + mirror_updated: "", + topics: [], + licenses: [], + ...overrides, + }; +} + +function createdEvent(owner = "ianc") { + return { + action: "created", + repository: repository(owner), + organization: user(owner), + sender: user(owner), + }; +} + +const post = (body) => + request(buildApp()).post("/api/webhook/git/repoevent").send(body); + +test("a fresh personal repository is deleted once and never provisioned", () => + withFakeGitea( + { isOrganization: false, apiRepository: repository() }, + async (calls) => { + const response = await post(createdEvent()); + assert.equal(response.status, 200); + assert.equal(response.body, "OK (Personal Repository Deleted)"); + assert.deepEqual(calls.organizations, ["ianc"]); + assert.deepEqual(calls.fetched, ["ianc/scratch"]); + assert.deepEqual(calls.deleted, ["ianc/scratch"]); + assert.deepEqual(calls.provisioned, []); + }, + )); + +test("an organization repository is provisioned and never deleted", () => { + const apiRepository = repository("TechOps"); + return withFakeGitea( + { isOrganization: true, apiRepository }, + async (calls) => { + const response = await post(createdEvent("TechOps")); + assert.equal(response.status, 200); + assert.deepEqual(calls.organizations, ["TechOps"]); + assert.deepEqual(calls.deleted, []); + assert.equal(calls.provisioned.length, 1); + assert.deepEqual(calls.provisioned[0].repository, apiRepository); + }, + ); +}); + +test("no delete when the API repository id differs from the event", () => + withFakeGitea( + { isOrganization: false, apiRepository: repository("ianc", { id: 99 }) }, + async (calls) => { + const response = await post(createdEvent()); + assert.equal(response.status, 200); + assert.equal(response.body, "OK (Actions Failed)"); + assert.deepEqual(calls.deleted, []); + assert.deepEqual(calls.provisioned, []); + }, + )); + +test("no delete when the API repository owner differs from the event", () => + withFakeGitea( + { + isOrganization: false, + apiRepository: repository("ianc", { owner: user("someone-else") }), + }, + async (calls) => { + const response = await post(createdEvent()); + assert.equal(response.status, 200); + assert.equal(response.body, "OK (Actions Failed)"); + assert.deepEqual(calls.deleted, []); + assert.deepEqual(calls.provisioned, []); + }, + )); + +test("no delete for a repository created before the deletion window", () => { + const created_at = new Date( + Date.now() - HooksController.PERSONAL_REPO_DELETE_WINDOW_MS - 60_000, + ).toISOString(); + return withFakeGitea( + { isOrganization: false, apiRepository: repository("ianc", { created_at }) }, + async (calls) => { + const response = await post(createdEvent()); + assert.equal(response.status, 200); + assert.equal(response.body, "OK (Actions Failed)"); + assert.deepEqual(calls.deleted, []); + assert.deepEqual(calls.provisioned, []); + }, + ); +}); + +test("no delete when the API creation time cannot be parsed", () => + withFakeGitea( + { + isOrganization: false, + apiRepository: repository("ianc", { created_at: "not a date" }), + }, + async (calls) => { + const response = await post(createdEvent()); + assert.equal(response.status, 200); + assert.equal(response.body, "OK (Actions Failed)"); + assert.deepEqual(calls.deleted, []); + assert.deepEqual(calls.provisioned, []); + }, + )); + +test("an organization lookup error prevents deletion and provisioning", () => + withFakeGitea( + { + isOrganization: new Error("Gitea unavailable"), + apiRepository: repository(), + }, + async (calls) => { + const response = await post(createdEvent()); + assert.equal(response.status, 200); + assert.equal(response.body, "OK (Actions Failed)"); + assert.deepEqual(calls.fetched, []); + assert.deepEqual(calls.deleted, []); + assert.deepEqual(calls.provisioned, []); + }, + )); + +test("non-created actions never delete or provision repositories", () => + withFakeGitea( + { isOrganization: false, apiRepository: repository() }, + async (calls) => { + for (const action of ["deleted", "archived"]) { + const response = await post({ ...createdEvent(), action }); + assert.equal(response.status, 200); + assert.equal(response.body, "OK"); + } + assert.deepEqual(calls.organizations, []); + assert.deepEqual(calls.fetched, []); + assert.deepEqual(calls.deleted, []); + assert.deepEqual(calls.provisioned, []); + }, + ));