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
This commit is contained in:
RhenCloud 2026-08-02 08:15:49 +08:00
parent 667b8038af
commit 537f4cbb84
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
5 changed files with 46 additions and 1 deletions

View file

@ -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[] };
}

View file

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

View file

@ -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. */

View file

@ -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 {

View file

@ -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<string>();
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");