mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
feat: per-group webhook ingress, custom webhooks, GitHub App tenant isolation
Add POST /webhook/{groupId} with per-group secrets (KV tenant:{groupId}), a custom provider (X-WebHooker-Signature HMAC, arbitrary JSON -> custom events through the route pipeline), and GitHub App installation isolation (Group.installationId) with automatic provisioning on installation.created (inst-{id} groups or binding matching owners groups). Includes WebhookPanel admin UI, custom route template, docs and 157 passing tests.
This commit is contained in:
parent
0b078d938b
commit
b600f02027
34 changed files with 1711 additions and 183 deletions
|
|
@ -7,8 +7,20 @@ import {
|
|||
adminCookie,
|
||||
clearAdminCookie,
|
||||
} from "../web/session";
|
||||
import { groupAcceptsProvider } from "../web/groups";
|
||||
import {
|
||||
groupAcceptsProvider,
|
||||
groupAcceptsInstallation,
|
||||
ensureInstallationGroup,
|
||||
loadGroups,
|
||||
saveGroups,
|
||||
} from "../web/groups";
|
||||
import { validateGroups } from "../web/admin-routes";
|
||||
import {
|
||||
getTenantSecret,
|
||||
setTenantSecret,
|
||||
deleteTenantSecret,
|
||||
generateTenantSecret,
|
||||
} from "../web/tenants";
|
||||
import { loadRoutes, saveRoutes, loadConfig } from "../config";
|
||||
import type { Env, Route, Group } from "../types";
|
||||
|
||||
|
|
@ -199,6 +211,105 @@ describe("validateGroups logTarget", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("validateGroups installationId", () => {
|
||||
const baseGroup = {
|
||||
id: "g",
|
||||
name: "G",
|
||||
members: [{ login: "boss", role: "owner" }],
|
||||
};
|
||||
|
||||
it("accepts a positive integer installation id", () => {
|
||||
const res = validateGroups([{ ...baseGroup, installationId: 42 }]);
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) expect(res.groups[0]!.installationId).toBe(42);
|
||||
});
|
||||
|
||||
it("rejects non-integer installation ids", () => {
|
||||
expect(validateGroups([{ ...baseGroup, installationId: "42" }]).ok).toBe(false);
|
||||
expect(validateGroups([{ ...baseGroup, installationId: 42.5 }]).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a null installation id", () => {
|
||||
const res = validateGroups([{ ...baseGroup, installationId: null }]);
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) expect(res.groups[0]!.installationId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupAcceptsInstallation", () => {
|
||||
const base: Group = { id: "g", name: "G", adminIds: [] };
|
||||
|
||||
it("accepts everything when the group is not bound to an installation", () => {
|
||||
expect(groupAcceptsInstallation(base, 101)).toBe(true);
|
||||
expect(groupAcceptsInstallation(base, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("only accepts events from the bound installation", () => {
|
||||
const bound = { ...base, installationId: 101 };
|
||||
expect(groupAcceptsInstallation(bound, 101)).toBe(true);
|
||||
expect(groupAcceptsInstallation(bound, 202)).toBe(false);
|
||||
expect(groupAcceptsInstallation(bound, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureInstallationGroup", () => {
|
||||
it("creates an inst-{id} group when nothing is bound", async () => {
|
||||
const kv = createMockKV();
|
||||
const group = await ensureInstallationGroup(kv, 555, "myorg");
|
||||
expect(group?.id).toBe("inst-555");
|
||||
expect(group?.installationId).toBe(555);
|
||||
expect(group?.name).toBe("myorg");
|
||||
const groups = await loadGroups(kv);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]!.installationId).toBe(555);
|
||||
});
|
||||
|
||||
it("binds existing groups whose owners match the installing account", async () => {
|
||||
const kv = createMockKV();
|
||||
await saveGroups(kv, [
|
||||
{ id: "backend", name: "Backend", adminIds: [], owners: ["myorg"], members: [] },
|
||||
{ id: "other", name: "Other", adminIds: [], owners: ["another-org"], members: [] },
|
||||
]);
|
||||
await ensureInstallationGroup(kv, 555, "MyOrg");
|
||||
const groups = await loadGroups(kv);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.find((g) => g.id === "backend")?.installationId).toBe(555);
|
||||
expect(groups.find((g) => g.id === "other")?.installationId).toBeUndefined();
|
||||
expect(groups.some((g) => g.id.startsWith("inst-"))).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent when a group is already bound", async () => {
|
||||
const kv = createMockKV();
|
||||
await ensureInstallationGroup(kv, 555, "myorg");
|
||||
await ensureInstallationGroup(kv, 555, "myorg");
|
||||
expect((await loadGroups(kv)).filter((g) => g.installationId === 555)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenant webhook secrets", () => {
|
||||
it("generates 64-char hex secrets", () => {
|
||||
const s = generateTenantSecret();
|
||||
expect(s).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("creates, reads, and deletes a tenant secret", async () => {
|
||||
const kv = createMockKV();
|
||||
expect(await getTenantSecret(kv, "g1")).toBeNull();
|
||||
const secret = await setTenantSecret(kv, "g1");
|
||||
expect(await getTenantSecret(kv, "g1")).toBe(secret);
|
||||
await deleteTenantSecret(kv, "g1");
|
||||
expect(await getTenantSecret(kv, "g1")).toBeNull();
|
||||
});
|
||||
|
||||
it("regenerating replaces the previous secret", async () => {
|
||||
const kv = createMockKV();
|
||||
const a = await setTenantSecret(kv, "g1");
|
||||
const b = await setTenantSecret(kv, "g1");
|
||||
expect(a).not.toBe(b);
|
||||
expect(await getTenantSecret(kv, "g1")).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config routes persistence", () => {
|
||||
it("saves and loads routes from KV", async () => {
|
||||
const kv = createMockKV();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { describe, it, expect } from "bun:test";
|
||||
import { createHmac } from "crypto";
|
||||
import { detectProvider } from "../providers";
|
||||
import { customProvider } from "../providers/custom";
|
||||
import { verifyGiteaSignature } from "../providers/gitea/verify";
|
||||
import { parseGiteaEvent } from "../providers/gitea/parse";
|
||||
import { parseEvent } from "../providers/github/parse";
|
||||
import { formatEvent } from "../formatters";
|
||||
import type { Route } from "../types";
|
||||
import type { Env, Route } from "../types";
|
||||
|
||||
function giteaSign(body: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(body).digest("hex");
|
||||
|
|
@ -151,3 +153,74 @@ describe("gitea event parsing", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom provider", () => {
|
||||
const secret = "tenant-secret";
|
||||
const body = JSON.stringify({
|
||||
title: "Deploy failed",
|
||||
repo: "acme/widget",
|
||||
color: "red",
|
||||
description: "prod down",
|
||||
});
|
||||
|
||||
function env(overrides: Record<string, unknown> = {}): Env {
|
||||
return { GITHUB_WEBHOOK_SECRET: secret, ...overrides } as Env;
|
||||
}
|
||||
|
||||
it("matches only requests with X-WebHooker-Signature and no forge headers", () => {
|
||||
expect(customProvider.matches({ "x-webhooker-signature": "sha256=abc" })).toBe(true);
|
||||
expect(customProvider.matches({})).toBe(false);
|
||||
expect(customProvider.matches({ "x-github-event": "push" })).toBe(false);
|
||||
expect(customProvider.matches({ "x-gitea-event": "push" })).toBe(false);
|
||||
});
|
||||
|
||||
it("detectProvider finds the custom provider", () => {
|
||||
expect(detectProvider({ "x-webhooker-signature": "sha256=abc" })?.id).toBe("custom");
|
||||
});
|
||||
|
||||
it("accepts a valid sha256 HMAC signature", async () => {
|
||||
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
||||
expect(await customProvider.verify(body, { "x-webhooker-signature": sig }, env())).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid signature", async () => {
|
||||
expect(
|
||||
await customProvider.verify(body, { "x-webhooker-signature": "sha256=wrong" }, env()),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when the secret is missing", async () => {
|
||||
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
||||
expect(
|
||||
(await customProvider.verify(body, { "x-webhooker-signature": sig }, {})) as unknown as Env,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("parses the body into a custom event with optional deliveryId", () => {
|
||||
const event = customProvider.parse(body, {});
|
||||
expect(event).not.toBeNull();
|
||||
expect(event!.event).toBe("custom");
|
||||
expect(event!.provider).toBe("custom");
|
||||
expect((event!.payload as { title: string }).title).toBe("Deploy failed");
|
||||
|
||||
const withId = customProvider.parse(JSON.stringify({ title: "x", deliveryId: "alert-1" }), {});
|
||||
expect(withId!.deliveryId).toBe("alert-1");
|
||||
});
|
||||
|
||||
it("returns null for invalid JSON", () => {
|
||||
expect(customProvider.parse("not json", {})).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts the GitHub App installation id on github events", () => {
|
||||
const event = parseEvent(
|
||||
{ "x-github-event": "push", "x-github-delivery": "d1" },
|
||||
JSON.stringify({ installation: { id: 42 }, repository: { full_name: "a/b" } }),
|
||||
);
|
||||
expect(event!.installationId).toBe(42);
|
||||
const noInstall = parseEvent(
|
||||
{ "x-github-event": "push" },
|
||||
JSON.stringify({ repository: { full_name: "a/b" } }),
|
||||
);
|
||||
expect(noInstall!.installationId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
391
src/__tests__/webhook-tenant.test.ts
Normal file
391
src/__tests__/webhook-tenant.test.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { createHmac } from "crypto";
|
||||
import { processWebhook } from "../webhook";
|
||||
import { invalidateConfigCache } from "../config";
|
||||
import { loadGroups } from "../web/groups";
|
||||
import type { Env, Route } from "../types";
|
||||
|
||||
function githubSign(body: string, secret: string): string {
|
||||
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
||||
}
|
||||
|
||||
function createMockKV(): KVNamespace {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get: async (key: string, type?: string) => {
|
||||
const v = store.get(key);
|
||||
if (v == null) return null;
|
||||
if (type === "json") return JSON.parse(v);
|
||||
return v;
|
||||
},
|
||||
put: async (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
list: async () => ({
|
||||
keys: [...store.keys()].map((k) => ({ name: k })),
|
||||
list_complete: true,
|
||||
cacheStatus: null,
|
||||
}),
|
||||
} as unknown as KVNamespace;
|
||||
}
|
||||
|
||||
function createMockDB(): D1Database {
|
||||
return {
|
||||
prepare: () => ({
|
||||
bind: () => ({
|
||||
run: async () => ({ success: true }),
|
||||
all: async () => ({ results: [] }),
|
||||
}),
|
||||
}),
|
||||
} as unknown as D1Database;
|
||||
}
|
||||
|
||||
function createEnv(overrides: Partial<Env> = {}): Env {
|
||||
return {
|
||||
GITHUB_WEBHOOK_SECRET: "global-secret",
|
||||
KV: createMockKV(),
|
||||
DB: createMockDB(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("processWebhook", () => {
|
||||
let fetched: Array<{ url: string; body: string }>;
|
||||
const restoredFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = restoredFetch;
|
||||
});
|
||||
|
||||
function mockFetch(): void {
|
||||
fetched = [];
|
||||
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
fetched.push({ url: String(input), body: String(init?.body ?? "") });
|
||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invalidateConfigCache();
|
||||
});
|
||||
|
||||
/** waitUntil collector: lets the test await the dispatched work. */
|
||||
function makeWait(): {
|
||||
waitUntil: (p: Promise<unknown>) => void;
|
||||
flush: () => Promise<void>;
|
||||
} {
|
||||
const pending: Promise<unknown>[] = [];
|
||||
return {
|
||||
waitUntil: (p: Promise<unknown>): void => {
|
||||
pending.push(p);
|
||||
},
|
||||
flush: (): Promise<void> => Promise.allSettled(pending).then(() => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function setupTenant(secret: string, extraRoutes: Route[] = []): Promise<Env> {
|
||||
const kv = createMockKV();
|
||||
await kv.put("config:groups", JSON.stringify([{ id: "team-a", name: "Team A", adminIds: [] }]));
|
||||
await kv.put("tenant:team-a", secret);
|
||||
await kv.put(
|
||||
"config:routes",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "team-a-push",
|
||||
name: "Team A Push",
|
||||
enabled: true,
|
||||
groupId: "team-a",
|
||||
filters: [{ type: "event", match: "push" }],
|
||||
targets: [{ channelId: "111" }],
|
||||
},
|
||||
{
|
||||
id: "other-push",
|
||||
name: "Other Push",
|
||||
enabled: true,
|
||||
groupId: "other",
|
||||
filters: [{ type: "event", match: "push" }],
|
||||
targets: [{ channelId: "222" }],
|
||||
},
|
||||
...extraRoutes,
|
||||
] as Route[]),
|
||||
);
|
||||
return createEnv({ KV: kv });
|
||||
}
|
||||
|
||||
it("dispatches only the tenant group's routes on a valid signature", async () => {
|
||||
mockFetch();
|
||||
const secret = "tenant-secret-1";
|
||||
const env = await setupTenant(secret);
|
||||
const body = JSON.stringify({ ref: "refs/heads/main", commits: [] });
|
||||
const wait = makeWait();
|
||||
const res = await processWebhook(
|
||||
env,
|
||||
body,
|
||||
{
|
||||
"x-github-event": "push",
|
||||
"x-hub-signature-256": githubSign(body, secret),
|
||||
"x-github-delivery": "deliv-1",
|
||||
},
|
||||
wait.waitUntil,
|
||||
"team-a",
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
await wait.flush();
|
||||
expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true);
|
||||
expect(fetched.some((f) => f.url.includes("/222/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an invalid signature with 401", async () => {
|
||||
const env = await setupTenant("tenant-secret-1");
|
||||
const body = JSON.stringify({ ref: "refs/heads/main" });
|
||||
const wait = makeWait();
|
||||
const res = await processWebhook(
|
||||
env,
|
||||
body,
|
||||
{
|
||||
"x-github-event": "push",
|
||||
"x-hub-signature-256": "sha256=wrong",
|
||||
"x-github-delivery": "deliv-2",
|
||||
},
|
||||
wait.waitUntil,
|
||||
"team-a",
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 for an unknown group", async () => {
|
||||
const env = await setupTenant("tenant-secret-1");
|
||||
const wait = makeWait();
|
||||
const res = await processWebhook(
|
||||
env,
|
||||
"{}",
|
||||
{ "x-github-event": "push" },
|
||||
wait.waitUntil,
|
||||
"nope",
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when the group has no tenant secret", async () => {
|
||||
const kv = createMockKV();
|
||||
await kv.put("config:groups", JSON.stringify([{ id: "g", name: "G", adminIds: [] }]));
|
||||
const env = createEnv({ KV: kv });
|
||||
const wait = makeWait();
|
||||
const res = await processWebhook(
|
||||
env,
|
||||
"{}",
|
||||
{ "x-github-event": "push", "x-hub-signature-256": "sha256=x" },
|
||||
wait.waitUntil,
|
||||
"g",
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("dedupes delivery ids per tenant", async () => {
|
||||
mockFetch();
|
||||
const secret = "tenant-secret-1";
|
||||
const env = await setupTenant(secret);
|
||||
const body = JSON.stringify({ ref: "refs/heads/main", commits: [] });
|
||||
const headers = {
|
||||
"x-github-event": "push",
|
||||
"x-hub-signature-256": githubSign(body, secret),
|
||||
"x-github-delivery": "same-deliv",
|
||||
};
|
||||
const first = await processWebhook(env, body, headers, makeWait().waitUntil, "team-a");
|
||||
const second = await processWebhook(env, body, headers, makeWait().waitUntil, "team-a");
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body).toEqual({ ok: true, duplicate: true });
|
||||
expect(fetched).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts custom webhooks signed with the tenant secret", async () => {
|
||||
mockFetch();
|
||||
const secret = "tenant-secret-1";
|
||||
const env = await setupTenant(secret, [
|
||||
{
|
||||
id: "custom-alerts",
|
||||
name: "Custom Alerts",
|
||||
enabled: true,
|
||||
groupId: "team-a",
|
||||
filters: [{ type: "event", match: "custom" }],
|
||||
targets: [{ channelId: "333" }],
|
||||
},
|
||||
]);
|
||||
const body = JSON.stringify({
|
||||
title: "Deploy failed",
|
||||
repo: "acme/widget",
|
||||
color: "red",
|
||||
description: "prod down",
|
||||
});
|
||||
const wait = makeWait();
|
||||
const res = await processWebhook(
|
||||
env,
|
||||
body,
|
||||
{ "x-webhooker-signature": githubSign(body, secret) },
|
||||
wait.waitUntil,
|
||||
"team-a",
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
await wait.flush();
|
||||
const sent = fetched.filter((f) => f.url.includes("/333/"));
|
||||
expect(sent).toHaveLength(1);
|
||||
const parsed = JSON.parse(sent[0]!.body) as {
|
||||
embeds?: Array<{ title?: string; color?: number; description?: string }>;
|
||||
};
|
||||
expect(parsed.embeds?.[0]?.title).toBe("acme/widget: Deploy failed");
|
||||
expect(parsed.embeds?.[0]?.color).toBe(0xf85149);
|
||||
expect(parsed.embeds?.[0]?.description).toBe("prod down");
|
||||
});
|
||||
|
||||
it("rejects custom webhooks without a signature header", async () => {
|
||||
const env = await setupTenant("tenant-secret-1");
|
||||
const wait = makeWait();
|
||||
const res = await processWebhook(
|
||||
env,
|
||||
JSON.stringify({ title: "x" }),
|
||||
{},
|
||||
wait.waitUntil,
|
||||
"team-a",
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("keeps the legacy global endpoint working with the global secret", async () => {
|
||||
mockFetch();
|
||||
const kv = createMockKV();
|
||||
await kv.put(
|
||||
"config:routes",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "all",
|
||||
name: "All",
|
||||
enabled: true,
|
||||
filters: [],
|
||||
targets: [{ channelId: "111" }],
|
||||
},
|
||||
] as Route[]),
|
||||
);
|
||||
const env = createEnv({ KV: kv });
|
||||
const body = JSON.stringify({ ref: "refs/heads/main", commits: [] });
|
||||
const wait = makeWait();
|
||||
const res = await processWebhook(
|
||||
env,
|
||||
body,
|
||||
{
|
||||
"x-github-event": "push",
|
||||
"x-hub-signature-256": githubSign(body, "global-secret"),
|
||||
"x-github-delivery": "g-1",
|
||||
},
|
||||
wait.waitUntil,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
await wait.flush();
|
||||
expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a group's routes isolated by GitHub App installation id", async () => {
|
||||
mockFetch();
|
||||
const kv = createMockKV();
|
||||
await kv.put(
|
||||
"config:groups",
|
||||
JSON.stringify([
|
||||
{ id: "inst-1", name: "Install 1", adminIds: [], installationId: 101 },
|
||||
{ id: "inst-2", name: "Install 2", adminIds: [], installationId: 202 },
|
||||
]),
|
||||
);
|
||||
await kv.put(
|
||||
"config:routes",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
enabled: true,
|
||||
groupId: "inst-1",
|
||||
filters: [],
|
||||
targets: [{ channelId: "111" }],
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
name: "R2",
|
||||
enabled: true,
|
||||
groupId: "inst-2",
|
||||
filters: [],
|
||||
targets: [{ channelId: "222" }],
|
||||
},
|
||||
] as Route[]),
|
||||
);
|
||||
const env = createEnv({ KV: kv });
|
||||
|
||||
// Global endpoint: installation 101's event may only reach group inst-1.
|
||||
const body = JSON.stringify({
|
||||
installation: { id: 101 },
|
||||
repository: { full_name: "org-a/repo" },
|
||||
ref: "refs/heads/main",
|
||||
commits: [],
|
||||
});
|
||||
const wait = makeWait();
|
||||
const res = await processWebhook(
|
||||
env,
|
||||
body,
|
||||
{
|
||||
"x-github-event": "push",
|
||||
"x-hub-signature-256": githubSign(body, "global-secret"),
|
||||
"x-github-delivery": "inst-1-deliv",
|
||||
},
|
||||
wait.waitUntil,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
await wait.flush();
|
||||
expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true);
|
||||
expect(fetched.some((f) => f.url.includes("/222/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-provisions a group on installation.created", async () => {
|
||||
mockFetch();
|
||||
const kv = createMockKV();
|
||||
await kv.put(
|
||||
"config:routes",
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "install-events",
|
||||
name: "Install Events",
|
||||
enabled: true,
|
||||
filters: [{ type: "event", match: "installation" }],
|
||||
targets: [{ channelId: "111" }],
|
||||
},
|
||||
] as Route[]),
|
||||
);
|
||||
const env = createEnv({ KV: kv });
|
||||
const body = JSON.stringify({
|
||||
action: "created",
|
||||
installation: { id: 555, account: { login: "myorg", type: "Organization" } },
|
||||
repositories: [{ full_name: "myorg/repo" }],
|
||||
sender: { login: "admin" },
|
||||
});
|
||||
const wait = makeWait();
|
||||
const res = await processWebhook(
|
||||
env,
|
||||
body,
|
||||
{
|
||||
"x-github-event": "installation",
|
||||
"x-hub-signature-256": githubSign(body, "global-secret"),
|
||||
"x-github-delivery": "inst-event-1",
|
||||
},
|
||||
wait.waitUntil,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
await wait.flush();
|
||||
|
||||
const groups = await loadGroups(kv);
|
||||
const bound = groups.find((g) => g.installationId === 555);
|
||||
expect(bound).toBeDefined();
|
||||
expect(bound?.id).toBe("inst-555");
|
||||
expect(bound?.name).toBe("myorg");
|
||||
// The auto-created group is only visible to super admins (no members).
|
||||
expect(bound?.members ?? []).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -33,6 +33,11 @@ export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise<void
|
|||
configCache = null;
|
||||
}
|
||||
|
||||
/** Drop the in-memory route/config cache (used by the admin API and tests). */
|
||||
export function invalidateConfigCache(): void {
|
||||
configCache = null;
|
||||
}
|
||||
|
||||
export async function loadConfig(env: Env): Promise<Config> {
|
||||
if (configCache && Date.now() < configCache.expiresAt) {
|
||||
return configCache.config;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import { matchRoute, eventOwners } from "../events/match";
|
|||
import { log } from "../lib/log";
|
||||
import { loadTranslations, t as translate, type Translations } from "../lib/i18n";
|
||||
import { recordSend } from "../lib/send-log";
|
||||
import { loadGroups, groupAcceptsOwners, groupAcceptsProvider } from "../web/groups";
|
||||
import {
|
||||
loadGroups,
|
||||
groupAcceptsOwners,
|
||||
groupAcceptsProvider,
|
||||
groupAcceptsInstallation,
|
||||
} from "../web/groups";
|
||||
import { getDriver } from "../drivers";
|
||||
import type { SendResult } from "../drivers/types";
|
||||
|
||||
|
|
@ -37,6 +42,7 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
|
|||
if (!route.groupId) return true;
|
||||
const group = groupById.get(route.groupId);
|
||||
if (!group) return true;
|
||||
if (!groupAcceptsInstallation(group, event.installationId)) return false;
|
||||
if (!groupAcceptsOwners(group, owners)) return false;
|
||||
return groupAcceptsProvider(group, event.provider);
|
||||
};
|
||||
|
|
|
|||
89
src/formatters/custom.ts
Normal file
89
src/formatters/custom.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { NeutralAuthor, NeutralField, NeutralMessage } from "../types";
|
||||
import { type T, buildMessage } from "./helpers";
|
||||
|
||||
const COLOR_WORDS: Record<string, number> = {
|
||||
red: 0xf85149,
|
||||
green: 0x3fb950,
|
||||
yellow: 0xd29922,
|
||||
blue: 0x58a6ff,
|
||||
purple: 0xbc8cff,
|
||||
orange: 0xdb6d28,
|
||||
cyan: 0x39c5cf,
|
||||
gray: 0x6e7681,
|
||||
};
|
||||
|
||||
function parseColor(color: unknown): number | undefined {
|
||||
if (typeof color !== "string") return undefined;
|
||||
const key = color.trim().toLowerCase();
|
||||
if (COLOR_WORDS[key]) return COLOR_WORDS[key];
|
||||
const hex = /^#?([0-9a-f]{6})$/i.exec(key);
|
||||
return hex ? parseInt(hex[1]!, 16) : undefined;
|
||||
}
|
||||
|
||||
function parseFields(raw: unknown): NeutralField[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const fields: NeutralField[] = [];
|
||||
for (const f of raw) {
|
||||
if (!f || typeof f !== "object") continue;
|
||||
const name = (f as Record<string, unknown>).name;
|
||||
const value = (f as Record<string, unknown>).value;
|
||||
if (typeof name !== "string" || typeof value !== "string") continue;
|
||||
fields.push({
|
||||
name: name || "\u200b",
|
||||
value,
|
||||
inline: (f as Record<string, unknown>).inline === true,
|
||||
});
|
||||
}
|
||||
return fields.length > 0 ? fields : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a `custom` webhook payload (the message schema documented in
|
||||
* docs/guide/configuration.md) into a NeutralMessage. Unlike forge events the
|
||||
* title is not required to start with a repo; an optional `repo` field is
|
||||
* used as the `{repo}: ` prefix and footer.
|
||||
*/
|
||||
export function formatCustom(
|
||||
payload: Record<string, unknown>,
|
||||
repo: string | undefined,
|
||||
author: NeutralAuthor,
|
||||
t: T,
|
||||
_showEmoji: boolean,
|
||||
): NeutralMessage {
|
||||
const title =
|
||||
typeof payload.title === "string" && payload.title.trim()
|
||||
? payload.title.trim()
|
||||
: t("custom.title_fallback");
|
||||
const payloadRepo =
|
||||
typeof payload.repo === "string" && payload.repo.trim() ? payload.repo.trim() : undefined;
|
||||
const effectiveRepo = payloadRepo ?? repo;
|
||||
const fullTitle = effectiveRepo ? `${effectiveRepo}: ${title}` : title;
|
||||
const description = typeof payload.description === "string" ? payload.description : undefined;
|
||||
const url = typeof payload.url === "string" ? payload.url : undefined;
|
||||
const footer = typeof payload.footer === "string" ? payload.footer : undefined;
|
||||
|
||||
const rawAuthor = payload.author as Record<string, unknown> | undefined;
|
||||
const customAuthor: NeutralAuthor | undefined =
|
||||
rawAuthor && typeof rawAuthor === "object"
|
||||
? {
|
||||
name: typeof rawAuthor.name === "string" && rawAuthor.name ? rawAuthor.name : author.name,
|
||||
iconUrl: typeof rawAuthor.iconUrl === "string" ? rawAuthor.iconUrl : author.iconUrl,
|
||||
url: typeof rawAuthor.url === "string" ? rawAuthor.url : author.url,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return buildMessage(
|
||||
{
|
||||
author: customAuthor,
|
||||
title: fullTitle,
|
||||
url,
|
||||
color: parseColor(payload.color) ?? 0x6e7681,
|
||||
description,
|
||||
fields: parseFields(payload.fields),
|
||||
// A custom message without a repo gets no `{repo}` footer at all.
|
||||
footer: footer ?? (effectiveRepo ? undefined : ""),
|
||||
},
|
||||
t,
|
||||
effectiveRepo,
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import { formatRepository } from "./repository";
|
|||
import { formatCodeScanningAlert, formatDependabotAlert } from "./security";
|
||||
import { formatGeneric } from "./generic";
|
||||
import { formatPing } from "./ping";
|
||||
import { formatCustom } from "./custom";
|
||||
|
||||
export function formatEvent(
|
||||
route: Route,
|
||||
|
|
@ -100,6 +101,8 @@ export function formatEvent(
|
|||
return formatCodeScanningAlert(payload, repo, author, t, showEmoji);
|
||||
case "dependabot_alert":
|
||||
return formatDependabotAlert(payload, repo, author, t, showEmoji);
|
||||
case "custom":
|
||||
return formatCustom(payload, repo, author, t, showEmoji);
|
||||
default:
|
||||
return formatGeneric(eventType, payload, repo, author, t, showEmoji);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,9 @@ export const en = {
|
|||
title: "{repo}: {event}{action}",
|
||||
},
|
||||
},
|
||||
custom: {
|
||||
title_fallback: "Custom message",
|
||||
},
|
||||
log: {
|
||||
title: "{repo}: {event}{action}",
|
||||
routes: "Routes",
|
||||
|
|
|
|||
|
|
@ -195,6 +195,9 @@ export const zh = {
|
|||
title: "{repo}: {event}{action}",
|
||||
},
|
||||
},
|
||||
custom: {
|
||||
title_fallback: "自定义消息",
|
||||
},
|
||||
log: {
|
||||
title: "{repo}: {event}{action}",
|
||||
routes: "路由",
|
||||
|
|
|
|||
44
src/providers/custom/index.ts
Normal file
44
src/providers/custom/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { Env, WebhookEvent } from "../../types";
|
||||
import type { Provider } from "../types";
|
||||
import { verifySignature } from "../github/verify";
|
||||
|
||||
/**
|
||||
* Custom webhook provider: accepts arbitrary JSON posts (monitoring, CI,
|
||||
* scripts, ...) that are not signed by a forge. The sender signs the raw body
|
||||
* with the tenant's secret using the GitHub-style `sha256=<hex>` HMAC header
|
||||
* `X-WebHooker-Signature`. Payloads become `custom` events that flow through
|
||||
* the normal route matching pipeline (a route with `event: custom`).
|
||||
*/
|
||||
export const customProvider: Provider = {
|
||||
id: "custom",
|
||||
|
||||
matches(headers) {
|
||||
return (
|
||||
headers["x-github-event"] === undefined &&
|
||||
headers["x-gitea-event"] === undefined &&
|
||||
headers["x-webhooker-signature"] !== undefined
|
||||
);
|
||||
},
|
||||
|
||||
async verify(body, headers, env: Env) {
|
||||
// The tenant webhook handler overrides GITHUB_WEBHOOK_SECRET with the
|
||||
// group's secret; on the legacy global endpoint this falls back to the
|
||||
// operator's global secret.
|
||||
return verifySignature(body, headers["x-webhooker-signature"], env.GITHUB_WEBHOOK_SECRET);
|
||||
},
|
||||
|
||||
parse(body, _headers): WebhookEvent | null {
|
||||
try {
|
||||
const payload = JSON.parse(body) as Record<string, unknown>;
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
// Optional id for sender-side dedup (retries from monitoring systems).
|
||||
const deliveryId =
|
||||
typeof payload.deliveryId === "string" && payload.deliveryId
|
||||
? payload.deliveryId
|
||||
: undefined;
|
||||
return { event: "custom", provider: "custom", payload, deliveryId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -8,8 +8,12 @@ export function parseEvent(headers: Record<string, string>, body: string): Webho
|
|||
if (!event) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(body);
|
||||
return { provider: "github", event, payload, signature, deliveryId };
|
||||
const payload = JSON.parse(body) as Record<string, unknown>;
|
||||
const installationId =
|
||||
typeof (payload.installation as { id?: unknown } | undefined)?.id === "number"
|
||||
? (payload.installation as { id: number }).id
|
||||
: undefined;
|
||||
return { provider: "github", event, payload, signature, deliveryId, installationId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Provider } from "./types";
|
||||
import { githubProvider } from "./github";
|
||||
import { giteaProvider } from "./gitea";
|
||||
import { customProvider } from "./custom";
|
||||
|
||||
export type { Provider } from "./types";
|
||||
export { verifySignature } from "./github/verify";
|
||||
|
|
@ -9,9 +10,10 @@ export { verifySignature } from "./github/verify";
|
|||
* Detection order matters: Gitea webhooks also send GitHub-compatible headers
|
||||
* (`X-GitHub-Event`, `X-Hub-Signature-256`, ...), so a Gitea request would
|
||||
* match the GitHub provider too. Check Gitea first — real GitHub requests
|
||||
* never send `X-Gitea-Event`.
|
||||
* never send `X-Gitea-Event`. Custom requests carry none of the forge headers,
|
||||
* only `X-WebHooker-Signature`, so they are checked last.
|
||||
*/
|
||||
const providers: Provider[] = [giteaProvider, githubProvider];
|
||||
const providers: Provider[] = [giteaProvider, githubProvider, customProvider];
|
||||
|
||||
/**
|
||||
* Pick the webhook provider for a request based on its headers (e.g.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { Env, WebhookEvent } from "../types";
|
|||
* {@link WebhookEvent} and never knows which forge produced it.
|
||||
*/
|
||||
export interface Provider {
|
||||
readonly id: "github" | "gitea" | "gitlab";
|
||||
readonly id: "github" | "gitea" | "gitlab" | "custom";
|
||||
/**
|
||||
* Whether the request headers belong to this provider (e.g. checks the
|
||||
* `X-Gitea-Event` header).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Hono } from "hono";
|
||||
import type { Env } from "./types";
|
||||
import { detectProvider } from "./providers";
|
||||
import { dispatchEvent } from "./core/dispatch";
|
||||
import { handleWebhook } from "./webhook";
|
||||
import { handleInteractionRequest } from "./drivers/discord/interactions";
|
||||
import { handleTelegramWebhookRequest } from "./drivers/telegram/updates";
|
||||
import { createOAuthRoutes } from "./web/oauth-routes";
|
||||
|
|
@ -10,10 +9,6 @@ import { createAdminRoutes } from "./web/admin-routes";
|
|||
import { createLegalRoutes } from "./web/legal-routes";
|
||||
import { createHomeRoutes } from "./web/home-routes";
|
||||
import { createRichHeaderRoutes } from "./web/richheader-routes";
|
||||
import { loadConfig } from "./config";
|
||||
import { log } from "./lib/log";
|
||||
|
||||
const MAX_BODY_SIZE = 1024 * 1024;
|
||||
|
||||
export function createServer(): Hono<{ Bindings: Env }> {
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
|
@ -27,52 +22,8 @@ export function createServer(): Hono<{ Bindings: Env }> {
|
|||
app.route("/admin", createAdminRoutes());
|
||||
app.route("/api", createRichHeaderRoutes());
|
||||
|
||||
app.post("/webhook", async (c) => {
|
||||
const contentLength = Number(c.req.header("content-length") ?? 0);
|
||||
if (contentLength > MAX_BODY_SIZE) {
|
||||
return c.json({ error: "Request too large" }, 413);
|
||||
}
|
||||
|
||||
const body = await c.req.text();
|
||||
if (body.length > MAX_BODY_SIZE) {
|
||||
return c.json({ error: "Request too large" }, 413);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
c.req.raw.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
const provider = detectProvider(headers);
|
||||
if (!provider) {
|
||||
return c.json({ error: "Unknown webhook provider" }, 400);
|
||||
}
|
||||
|
||||
if (!(await provider.verify(body, headers, c.env))) {
|
||||
return c.json({ error: "Invalid signature" }, 401);
|
||||
}
|
||||
|
||||
const event = provider.parse(body, headers);
|
||||
if (!event) {
|
||||
return c.json({ error: "Invalid event" }, 400);
|
||||
}
|
||||
|
||||
if (event.deliveryId) {
|
||||
const seen = await c.env.KV.get(`delivery:${event.deliveryId}`);
|
||||
if (seen) {
|
||||
return c.json({ ok: true, duplicate: true });
|
||||
}
|
||||
await c.env.KV.put(`delivery:${event.deliveryId}`, "1", { expirationTtl: 300 });
|
||||
}
|
||||
|
||||
const config = await loadConfig(c.env);
|
||||
const dispatch = dispatchEvent(config, event, c.env).catch((err) =>
|
||||
log.error(err, "Dispatch failed"),
|
||||
);
|
||||
c.executionCtx.waitUntil(dispatch);
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
app.post("/webhook", (c) => handleWebhook(c));
|
||||
app.post("/webhook/:groupId", (c) => handleWebhook(c, c.req.param("groupId")));
|
||||
|
||||
app.post("/discord/interactions", (c) => handleInteractionRequest(c.req.raw, c.env));
|
||||
app.post("/telegram/webhook", (c) => handleTelegramWebhookRequest(c.req.raw, c.env));
|
||||
|
|
|
|||
14
src/types.ts
14
src/types.ts
|
|
@ -114,6 +114,13 @@ export interface Group {
|
|||
* (e.g. `["github"]`, `["gitea"]`). Empty/omitted = all providers.
|
||||
*/
|
||||
providers?: WebhookProvider[];
|
||||
/**
|
||||
* GitHub App installation id bound to this group. When set, only webhook
|
||||
* events coming from that installation (org/user) are accepted into the
|
||||
* group's routes — hard tenant isolation on top of (or instead of) the
|
||||
* `owners` list. Empty/omitted = no installation restriction.
|
||||
*/
|
||||
installationId?: number;
|
||||
/**
|
||||
* Whether to include emoji in messages sent through this group's routes.
|
||||
* Defaults to true when omitted.
|
||||
|
|
@ -137,7 +144,7 @@ export interface Filter {
|
|||
exclude?: boolean;
|
||||
}
|
||||
|
||||
export type WebhookProvider = "github" | "gitea" | "gitlab";
|
||||
export type WebhookProvider = "github" | "gitea" | "gitlab" | "custom";
|
||||
|
||||
export interface WebhookEvent {
|
||||
event: string;
|
||||
|
|
@ -145,6 +152,11 @@ export interface WebhookEvent {
|
|||
signature?: string;
|
||||
deliveryId?: string;
|
||||
provider?: WebhookProvider;
|
||||
/**
|
||||
* GitHub App installation id that produced this event (extracted from
|
||||
* `payload.installation.id`). Gitea/custom events have none.
|
||||
*/
|
||||
installationId?: number;
|
||||
}
|
||||
|
||||
export interface NeutralAuthor {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { getSendLog, getSendLogById } from "../lib/send-log";
|
||||
import { getAuditLog, recordAudit } from "../lib/audit";
|
||||
import { createInvite, listInvites, revokeInvite, getInvite, acceptInvite } from "./invites";
|
||||
import { getTenantSecret, setTenantSecret, deleteTenantSecret } from "./tenants";
|
||||
import { log } from "../lib/log";
|
||||
|
||||
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
|
||||
|
|
@ -277,6 +278,13 @@ export function validateGroups(
|
|||
error: `group "${g.id}".providers must be a list of "github" | "gitea" | "gitlab"`,
|
||||
};
|
||||
}
|
||||
if (g.installationId !== undefined && g.installationId !== null) {
|
||||
if (typeof g.installationId !== "number" || !Number.isInteger(g.installationId)) {
|
||||
return { ok: false, error: `group "${g.id}".installationId must be an integer` };
|
||||
}
|
||||
} else {
|
||||
delete g.installationId;
|
||||
}
|
||||
if (g.emoji !== undefined && typeof g.emoji !== "boolean") {
|
||||
return { ok: false, error: `group "${g.id}".emoji must be a boolean` };
|
||||
}
|
||||
|
|
@ -463,6 +471,7 @@ export function createAdminRoutes(): Hono<AuthEnv> {
|
|||
if (prev.lang !== g.lang) fields.push("lang");
|
||||
if (!deepEqual(prev.logTarget, g.logTarget)) fields.push("logTarget");
|
||||
if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers");
|
||||
if (prev.installationId !== g.installationId) fields.push("installationId");
|
||||
if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners");
|
||||
if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members))
|
||||
fields.push("members");
|
||||
|
|
@ -490,6 +499,7 @@ export function createAdminRoutes(): Hono<AuthEnv> {
|
|||
groupId: g.id,
|
||||
ip: clientIp(c),
|
||||
});
|
||||
await deleteTenantSecret(c.env.KV, g.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -746,6 +756,76 @@ export function createAdminRoutes(): Hono<AuthEnv> {
|
|||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---- Group webhook ingress (owner +) ----
|
||||
app.get("/api/groups/:groupId/webhook", requireAnyAccess(), async (c) => {
|
||||
const groupId = param(c, "groupId");
|
||||
const access = requireGroupRole(c, groupId, "owner");
|
||||
if (!access.ok) {
|
||||
return c.json(
|
||||
{ error: access.status === 404 ? "Group not found" : "Forbidden" },
|
||||
access.status,
|
||||
);
|
||||
}
|
||||
const origin = c.env.BASE_URL ?? new URL(c.req.url).origin;
|
||||
return c.json({
|
||||
url: `${origin.replace(/\/$/, "")}/webhook/${groupId}`,
|
||||
hasSecret: (await getTenantSecret(c.env.KV, groupId)) != null,
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/groups/:groupId/webhook/regenerate", requireAnyAccess(), async (c) => {
|
||||
const groupId = param(c, "groupId");
|
||||
const access = requireGroupRole(c, groupId, "owner");
|
||||
if (!access.ok) {
|
||||
return c.json(
|
||||
{ error: access.status === 404 ? "Group not found" : "Forbidden" },
|
||||
access.status,
|
||||
);
|
||||
}
|
||||
const secret = await setTenantSecret(c.env.KV, groupId);
|
||||
const auth = currentAuth(c);
|
||||
await recordAudit(c.env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: auth.session.userId,
|
||||
actorLogin: auth.session.login,
|
||||
action: "webhook.secret.regenerate",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
groupId,
|
||||
ip: clientIp(c),
|
||||
});
|
||||
const origin = c.env.BASE_URL ?? new URL(c.req.url).origin;
|
||||
return c.json({
|
||||
ok: true,
|
||||
url: `${origin.replace(/\/$/, "")}/webhook/${groupId}`,
|
||||
secret,
|
||||
});
|
||||
});
|
||||
|
||||
app.delete("/api/groups/:groupId/webhook", requireAnyAccess(), async (c) => {
|
||||
const groupId = param(c, "groupId");
|
||||
const access = requireGroupRole(c, groupId, "owner");
|
||||
if (!access.ok) {
|
||||
return c.json(
|
||||
{ error: access.status === 404 ? "Group not found" : "Forbidden" },
|
||||
access.status,
|
||||
);
|
||||
}
|
||||
await deleteTenantSecret(c.env.KV, groupId);
|
||||
const auth = currentAuth(c);
|
||||
await recordAudit(c.env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: auth.session.userId,
|
||||
actorLogin: auth.session.login,
|
||||
action: "webhook.secret.delete",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
groupId,
|
||||
ip: clientIp(c),
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---- Audit log (any access; group admins see only their groups) ----
|
||||
app.get("/api/audit", requireAnyAccess(), async (c) => {
|
||||
const auth = currentAuth(c);
|
||||
|
|
|
|||
|
|
@ -96,6 +96,62 @@ export function groupAcceptsProvider(group: Group, provider?: string): boolean {
|
|||
return allowed.includes(provider ?? "github");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event from a GitHub App `installationId` is allowed into this
|
||||
* group. A group bound to an installation only accepts events from that
|
||||
* installation (hard tenant isolation). Unbound groups accept everything here
|
||||
* (their access is governed by `owners`/`providers` instead).
|
||||
*/
|
||||
export function groupAcceptsInstallation(group: Group, installationId?: number): boolean {
|
||||
if (group.installationId == null) return true;
|
||||
return installationId != null && group.installationId === installationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-provision a GitHub App installation on `installation.created`:
|
||||
* 1. No-op when a group is already bound to this installation id.
|
||||
* 2. Otherwise bind every unbound group whose `owners` match the installing
|
||||
* account login (so existing org groups light up automatically).
|
||||
* 3. Otherwise create a dedicated `inst-{installationId}` group bound to the
|
||||
* installation. Returns the group that now owns the installation.
|
||||
*/
|
||||
export async function ensureInstallationGroup(
|
||||
kv: KVNamespace,
|
||||
installationId: number,
|
||||
accountLogin: string,
|
||||
): Promise<Group | null> {
|
||||
const groups = await loadGroups(kv);
|
||||
const existing = groups.find((g) => g.installationId === installationId);
|
||||
if (existing) return existing;
|
||||
|
||||
const login = accountLogin.trim().toLowerCase();
|
||||
const candidates = groups.filter(
|
||||
(g) =>
|
||||
g.installationId == null &&
|
||||
login.length > 0 &&
|
||||
(g.owners ?? []).some((o) => o.trim().toLowerCase() === login),
|
||||
);
|
||||
if (candidates.length > 0) {
|
||||
const next = groups.map((g) => (candidates.includes(g) ? { ...g, installationId } : g));
|
||||
await saveGroups(kv, next);
|
||||
return next.find((g) => g.id === candidates[0]!.id) ?? null;
|
||||
}
|
||||
|
||||
const gid = `inst-${installationId}`;
|
||||
const dedicated = groups.find((g) => g.id === gid);
|
||||
const group: Group = {
|
||||
id: gid,
|
||||
name: accountLogin.trim() || `Installation ${installationId}`,
|
||||
adminIds: [],
|
||||
installationId,
|
||||
};
|
||||
await saveGroups(
|
||||
kv,
|
||||
dedicated ? groups.map((g) => (g.id === gid ? { ...g, ...group } : g)) : [...groups, group],
|
||||
);
|
||||
return group;
|
||||
}
|
||||
|
||||
export interface AccessScope {
|
||||
isSuper: boolean;
|
||||
/** Groups the user may view. When isSuper, this is every group. */
|
||||
|
|
|
|||
38
src/web/tenants.ts
Normal file
38
src/web/tenants.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { log } from "../lib/log";
|
||||
|
||||
/**
|
||||
* Per-group webhook tenant secrets. A group can opt into its own webhook
|
||||
* ingress (`POST /webhook/{groupId}`) with an independent secret, so SaaS
|
||||
* users can configure their own GitHub/Gitea/custom webhooks without sharing
|
||||
* (or even knowing) the operator's global secrets.
|
||||
*/
|
||||
const TENANT_KEY = (groupId: string): string => `tenant:${groupId}`;
|
||||
|
||||
/** 32 random bytes → 64 hex chars. */
|
||||
export function generateTenantSecret(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function getTenantSecret(kv: KVNamespace, groupId: string): Promise<string | null> {
|
||||
try {
|
||||
return await kv.get(TENANT_KEY(groupId), "text");
|
||||
} catch (err) {
|
||||
log.warn({ err, groupId }, "Failed to read tenant webhook secret");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate (or regenerate) the group's webhook secret. */
|
||||
export async function setTenantSecret(kv: KVNamespace, groupId: string): Promise<string> {
|
||||
const secret = generateTenantSecret();
|
||||
await kv.put(TENANT_KEY(groupId), secret);
|
||||
return secret;
|
||||
}
|
||||
|
||||
export async function deleteTenantSecret(kv: KVNamespace, groupId: string): Promise<void> {
|
||||
await kv.delete(TENANT_KEY(groupId));
|
||||
}
|
||||
144
src/webhook.ts
Normal file
144
src/webhook.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import type { Context } from "hono";
|
||||
import type { Env } from "./types";
|
||||
import { detectProvider } from "./providers";
|
||||
import { dispatchEvent } from "./core/dispatch";
|
||||
import { loadConfig } from "./config";
|
||||
import { loadGroups, ensureInstallationGroup } from "./web/groups";
|
||||
import { getTenantSecret } from "./web/tenants";
|
||||
import { recordAudit } from "./lib/audit";
|
||||
import { log } from "./lib/log";
|
||||
|
||||
const MAX_BODY_SIZE = 1024 * 1024;
|
||||
|
||||
export interface WebhookResult {
|
||||
status: 200 | 400 | 401 | 404 | 413;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core webhook processing. Without `tenantId` this is the legacy global
|
||||
* endpoint (`POST /webhook`): events verify against the operator's global
|
||||
* secrets and may dispatch into every route. With a `tenantId` (a group id,
|
||||
* `POST /webhook/{groupId}`) the group's own secret is used for verification
|
||||
* (GITHUB_WEBHOOK_SECRET/GITEA_WEBHOOK_SECRET are overridden per request) and
|
||||
* only that group's routes are eligible.
|
||||
*/
|
||||
export async function processWebhook(
|
||||
env: Env,
|
||||
body: string,
|
||||
headers: Record<string, string>,
|
||||
waitUntil: (promise: Promise<unknown>) => void,
|
||||
tenantId?: string,
|
||||
): Promise<WebhookResult> {
|
||||
let effectiveEnv = env;
|
||||
if (tenantId) {
|
||||
const groups = await loadGroups(env.KV);
|
||||
if (!groups.some((g) => g.id === tenantId)) {
|
||||
return { status: 404, body: { error: "Group not found" } };
|
||||
}
|
||||
const secret = await getTenantSecret(env.KV, tenantId);
|
||||
if (!secret) {
|
||||
return { status: 404, body: { error: "Webhook disabled for this group" } };
|
||||
}
|
||||
effectiveEnv = { ...env, GITHUB_WEBHOOK_SECRET: secret, GITEA_WEBHOOK_SECRET: secret };
|
||||
}
|
||||
|
||||
const provider = detectProvider(headers);
|
||||
if (!provider) {
|
||||
return { status: 400, body: { error: "Unknown webhook provider" } };
|
||||
}
|
||||
|
||||
if (!(await provider.verify(body, headers, effectiveEnv))) {
|
||||
return { status: 401, body: { error: "Invalid signature" } };
|
||||
}
|
||||
|
||||
const event = provider.parse(body, headers);
|
||||
if (!event) {
|
||||
return { status: 400, body: { error: "Invalid event" } };
|
||||
}
|
||||
|
||||
// Auto-provision GitHub App installations so tenant isolation is configured
|
||||
// without manual id entry: a group is created (or existing matching groups
|
||||
// are bound) before the event is dispatched.
|
||||
if (
|
||||
provider.id === "github" &&
|
||||
event.event === "installation" &&
|
||||
event.payload.action === "created" &&
|
||||
event.installationId != null
|
||||
) {
|
||||
const install = event.payload.installation as { account?: { login?: string } } | undefined;
|
||||
const account = install?.account?.login ?? "";
|
||||
try {
|
||||
const group = await ensureInstallationGroup(env.KV, event.installationId, account);
|
||||
if (group) {
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorLogin: account || undefined,
|
||||
action: "installation.created",
|
||||
targetType: "group",
|
||||
targetId: group.id,
|
||||
groupId: group.id,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err, installationId: event.installationId },
|
||||
"Failed to auto-provision installation group",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.deliveryId) {
|
||||
// Tenant-scoped dedup keys: different accounts can reuse the same
|
||||
// delivery id, so the global key would wrongly dedupe across tenants.
|
||||
const key = tenantId
|
||||
? `delivery:${tenantId}:${event.deliveryId}`
|
||||
: `delivery:${event.deliveryId}`;
|
||||
const seen = await env.KV.get(key);
|
||||
if (seen) {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
await env.KV.put(key, "1", { expirationTtl: 300 });
|
||||
}
|
||||
|
||||
const config = await loadConfig(env);
|
||||
if (tenantId) {
|
||||
config.routes = config.routes.filter((r) => r.groupId === tenantId);
|
||||
}
|
||||
|
||||
const dispatch = dispatchEvent(config, event, env).catch((err) =>
|
||||
log.error(err, "Dispatch failed"),
|
||||
);
|
||||
waitUntil(dispatch);
|
||||
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
export async function handleWebhook(
|
||||
c: Context<{ Bindings: Env }>,
|
||||
tenantId?: string,
|
||||
): Promise<Response> {
|
||||
const contentLength = Number(c.req.header("content-length") ?? 0);
|
||||
if (contentLength > MAX_BODY_SIZE) {
|
||||
return c.json({ error: "Request too large" }, 413);
|
||||
}
|
||||
|
||||
const body = await c.req.text();
|
||||
if (body.length > MAX_BODY_SIZE) {
|
||||
return c.json({ error: "Request too large" }, 413);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
c.req.raw.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
const result = await processWebhook(
|
||||
c.env,
|
||||
body,
|
||||
headers,
|
||||
(p) => c.executionCtx.waitUntil(p),
|
||||
tenantId,
|
||||
);
|
||||
return c.json(result.body, result.status);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue