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; }