From 537f4cbb84b42c9e71fb2018e61919c052f0d8cc Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Sun, 2 Aug 2026 08:15:49 +0800 Subject: [PATCH] feat(admin): scope groups to specific GitHub orgs/users Add an optional owners[] field to groups (super-admin only). When set, only webhook events whose repository owner or organization login matches enter that group's routes; empty owners means no restriction, keeping existing routes backward compatible. - types: Group.owners?, groups.ts groupAcceptsOwners() - webhook.ts eventOwners() extracts repo owner + org login - discord.ts dispatch skips routes whose group rejects the event owner - admin-routes.ts validateGroups() validates owners list --- src/admin-routes.ts | 7 +++++++ src/discord.ts | 12 +++++++++++- src/groups.ts | 11 +++++++++++ src/types.ts | 6 ++++++ src/webhook.ts | 11 +++++++++++ 5 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/admin-routes.ts b/src/admin-routes.ts index a244685..f710b4d 100644 --- a/src/admin-routes.ts +++ b/src/admin-routes.ts @@ -106,6 +106,13 @@ function validateGroups( ) { return { ok: false, error: `group "${g.id}".adminIds must be a list of strings` }; } + if ( + g.owners !== undefined && + (!Array.isArray(g.owners) || + !g.owners.every((o) => typeof o === "string" && o.trim().length > 0)) + ) { + return { ok: false, error: `group "${g.id}".owners must be a list of strings` }; + } } return { ok: true, groups: groups as Group[] }; } diff --git a/src/discord.ts b/src/discord.ts index 499a47b..09f3ea8 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -1,10 +1,11 @@ import type { Config, FormattedMessage, WebhookEvent, Env } from "./types"; import { formatEvent } from "./formatter"; -import { matchRoute } from "./webhook"; +import { matchRoute, eventOwners } from "./webhook"; import { log } from "./log"; import { loadTranslations, type Translations } from "./i18n"; import { sendMessage } from "./discord-rest"; import { recordSend } from "./send-log"; +import { loadGroups, groupAcceptsOwners } from "./groups"; export function isGatewayEnabled(env: Env): boolean { return env.DISCORD_GATEWAY_ENABLED === "true"; @@ -37,9 +38,18 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En }), ); + const groups = await loadGroups(env.KV); + const groupById = new Map(groups.map((g) => [g.id, g])); + const owners = eventOwners(event); + for (const route of config.routes) { if (!matchRoute(route, event)) continue; + if (route.groupId) { + const group = groupById.get(route.groupId); + if (group && !groupAcceptsOwners(group, owners)) continue; + } + const target = route.target.threadId ? `${route.target.channelId}/${route.target.threadId}` : route.target.channelId; diff --git a/src/groups.ts b/src/groups.ts index fbb6cd0..ca7b934 100644 --- a/src/groups.ts +++ b/src/groups.ts @@ -29,6 +29,17 @@ export function isGroupAdmin(group: Group, userId: string, login: string): boole return identityMatches(group.adminIds ?? [], userId, login); } +/** + * Whether an event originating from `owners` (org/user logins) is allowed into + * this group. A group with no owner restriction accepts everything. + */ +export function groupAcceptsOwners(group: Group, owners: string[]): boolean { + const restrict = (group.owners ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean); + if (restrict.length === 0) return true; + const seen = owners.map((s) => s.trim().toLowerCase()).filter(Boolean); + return seen.some((o) => restrict.includes(o)); +} + export interface AccessScope { isSuper: boolean; /** Groups the user may view/edit. When isSuper, this is every group. */ diff --git a/src/types.ts b/src/types.ts index 62db692..a8eb9af 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,6 +53,12 @@ export interface Group { * Super admins (ADMIN_USER_IDS) always have access regardless of this list. */ adminIds: string[]; + /** + * GitHub organization/user logins (case-insensitive) whose webhook events are + * allowed into this group's routes. Empty/omitted = no owner restriction. + * Only super admins may edit this field. + */ + owners?: string[]; } export interface Filter { diff --git a/src/webhook.ts b/src/webhook.ts index 312dd1e..03cf02b 100644 --- a/src/webhook.ts +++ b/src/webhook.ts @@ -131,6 +131,17 @@ function matchFilter(filter: Filter, event: WebhookEvent, keywordBody?: string): return filter.exclude ? !matches : matches; } +/** Org/user logins that own the event (repository owner + organization). */ +export function eventOwners(event: WebhookEvent): string[] { + const owners = new Set(); + const repoOwner = (event.payload.repository as { owner?: { login?: string } } | undefined)?.owner + ?.login; + if (repoOwner) owners.add(repoOwner); + const org = (event.payload.organization as { login?: string } | undefined)?.login; + if (org) owners.add(org); + return [...owners]; +} + export function matchRoute(route: Route, event: WebhookEvent): boolean { if (!route.enabled) return false; const hasKeyword = route.filters.some((f) => f.type === "keyword");