feat(admin): role-based group members (owner/admin/viewer), invites and audit log

- Group model: members[] with roles owner/admin/viewer; legacy adminIds
  resolve to owners (backward compatible); last-owner and self-demotion
  guards
- Unified permission middleware (src/web/auth.ts): requireAnyAccess,
  requireGroup(Role), bearerAuthMiddleware replace per-route loadScope
- Single-use 7-day invite links (invite:{token}); accept via /admin/invite
  or the OAuth callback; self-signup personal group via ALLOW_SELF_SIGNUP
- D1 audit_logs (migration 0005): login/logout, group/route/member/invite
  changes; GET /admin/api/audit; scheduled prune after AUDIT_RETENTION_DAYS
- Env: ALLOW_SELF_SIGNUP, AUDIT_RETENTION_DAYS
This commit is contained in:
RhenCloud 2026-08-11 23:37:19 +08:00
parent f8cb3a4ddb
commit 9b8533cabc
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
10 changed files with 964 additions and 153 deletions

View file

@ -0,0 +1,15 @@
-- Audit trail for admin operations (logins, group/route/member/invite changes).
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
actor_id TEXT,
actor_login TEXT,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
group_id TEXT,
detail TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_logs (ts DESC);
CREATE INDEX IF NOT EXISTS idx_audit_group ON audit_logs (group_id);

View file

@ -1,6 +1,7 @@
import { createServer } from "./server";
import { syncCommands } from "./drivers/discord/commands";
import { syncTelegramWebhook } from "./drivers/telegram/commands";
import { pruneAuditLogs } from "./lib/audit";
import type { Env } from "./types";
import { log } from "./lib/log";
@ -22,5 +23,12 @@ export default {
} catch (err) {
log.error({ err }, "Telegram webhook sync from cron failed");
}
try {
const days = Math.max(Number(env.AUDIT_RETENTION_DAYS ?? 90) || 90, 1);
const removed = await pruneAuditLogs(env.DB, days);
if (removed > 0) log.info({ removed }, "Pruned audit logs");
} catch (err) {
log.error({ err }, "Audit log prune from cron failed");
}
},
};

106
src/lib/audit.ts Normal file
View file

@ -0,0 +1,106 @@
import { log } from "./log";
export interface AuditEntry {
id?: number;
ts: number;
actorId?: string;
actorLogin?: string;
/** Machine-readable action, e.g. "session.login", "group.update", "invite.create". */
action: string;
targetType?: string;
targetId?: string;
groupId?: string;
/** Free-form metadata. Never include secrets or message bodies. */
detail?: Record<string, unknown>;
ip?: string;
}
const COLUMNS = "id, ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip";
interface AuditRow {
id: number;
ts: number;
actor_id: string | null;
actor_login: string | null;
action: string;
target_type: string | null;
target_id: string | null;
group_id: string | null;
detail: string | null;
ip: string | null;
}
function toEntry(r: AuditRow): AuditEntry {
return {
id: r.id,
ts: r.ts,
actorId: r.actor_id ?? undefined,
actorLogin: r.actor_login ?? undefined,
action: r.action,
targetType: r.target_type ?? undefined,
targetId: r.target_id ?? undefined,
groupId: r.group_id ?? undefined,
detail: r.detail ? (JSON.parse(r.detail) as Record<string, unknown>) : undefined,
ip: r.ip ?? undefined,
};
}
/** Best-effort write; failures never break the business flow. */
export async function recordAudit(db: D1Database, entry: AuditEntry): Promise<void> {
try {
await db
.prepare(
`INSERT INTO audit_logs (ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
entry.ts,
entry.actorId ?? null,
entry.actorLogin ?? null,
entry.action,
entry.targetType ?? null,
entry.targetId ?? null,
entry.groupId ?? null,
entry.detail ? JSON.stringify(entry.detail) : null,
entry.ip ?? null,
)
.run();
} catch (err) {
log.warn({ err, action: entry.action }, "Failed to record audit entry");
}
}
export async function getAuditLog(
db: D1Database,
opts: { groupId?: string; limit?: number } = {},
): Promise<AuditEntry[]> {
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
try {
if (opts.groupId) {
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM audit_logs WHERE group_id = ? ORDER BY ts DESC LIMIT ?`)
.bind(opts.groupId, limit)
.all<AuditRow>();
return results.map(toEntry);
}
const { results } = await db
.prepare(`SELECT ${COLUMNS} FROM audit_logs ORDER BY ts DESC LIMIT ?`)
.bind(limit)
.all<AuditRow>();
return results.map(toEntry);
} catch (err) {
log.warn({ err }, "Failed to load audit log");
return [];
}
}
export async function pruneAuditLogs(db: D1Database, retentionDays: number): Promise<number> {
const cutoff = Date.now() - retentionDays * 86400_000;
try {
const { meta } = await db.prepare("DELETE FROM audit_logs WHERE ts < ?").bind(cutoff).run();
return meta.changes;
} catch (err) {
log.warn({ err }, "Failed to prune audit logs");
return 0;
}
}

View file

@ -17,6 +17,13 @@ export interface Env {
TELEGRAM_TOKEN?: string;
TELEGRAM_WEBHOOK_SECRET?: string;
TELEGRAM_RICH_HEADER_HOST?: string;
/**
* When enabled ("1"/"true"), GitHub users without any group access get a
* personal group on first login instead of being blocked.
*/
ALLOW_SELF_SIGNUP?: string;
/** Audit log retention in days (default 90). */
AUDIT_RETENTION_DAYS?: string;
ASSETS?: Fetcher;
KV: KVNamespace;
DB: D1Database;
@ -72,14 +79,31 @@ export interface Route {
discordRoleIds?: string[];
}
export type GroupRole = "owner" | "admin" | "viewer";
export interface GroupMember {
/**
* GitHub login (case-insensitive). Ids are matched when stored, logins
* otherwise; identityMatches handles both.
*/
login: string;
role: GroupRole;
}
export interface Group {
id: string;
name: string;
/**
* GitHub user ids or logins (case-insensitive) allowed to manage this group.
* Super admins (ADMIN_USER_IDS) always have access regardless of this list.
* Deprecated legacy field: GitHub user ids or logins allowed to manage this
* group. Kept for backward compatibility when `members` is absent, these
* are normalized to `members` with role "owner". New writes use `members`.
*/
adminIds: string[];
/**
* Group members with roles. Roles: owner (manage group + members + invites),
* admin (manage routes), viewer (read-only). Super admins always bypass.
*/
members?: GroupMember[];
/**
* GitHub organization/user logins (case-insensitive) whose webhook events are
* allowed into this group's routes. Empty/omitted = no owner restriction.

View file

@ -1,17 +1,8 @@
import { Hono } from "hono";
import { getUserOctokit } from "../github/oauth";
import { findUserIdByToken } from "../github/store";
import type { Env } from "../types";
import { bearerAuthMiddleware, type AuthEnv } from "./auth";
import { log } from "../lib/log";
function extractBearerToken(c: {
req: { header: (name: string) => string | undefined };
}): string | null {
const auth = c.req.header("authorization");
if (!auth?.startsWith("Bearer ")) return null;
return auth.slice(7);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
@ -30,15 +21,11 @@ async function readJson(c: {
}
}
export function createActionRoutes(): Hono<{ Bindings: Env }> {
const app = new Hono<{ Bindings: Env }>();
app.post("/api/comment", async (c) => {
const token = extractBearerToken(c);
if (!token) return c.json({ error: "Missing authorization" }, 401);
const userId = await findUserIdByToken(c.env.KV, token);
if (!userId) return c.json({ error: "Invalid or expired token" }, 401);
export function createActionRoutes(): Hono<AuthEnv> {
const app = new Hono<AuthEnv>();
app.post("/api/comment", bearerAuthMiddleware(), async (c) => {
const userId = c.get("userId");
const body = await readJson(c);
if (
!body ||
@ -67,12 +54,8 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> {
return c.json({ ok: true });
});
app.post("/api/merge", async (c) => {
const token = extractBearerToken(c);
if (!token) return c.json({ error: "Missing authorization" }, 401);
const userId = await findUserIdByToken(c.env.KV, token);
if (!userId) return c.json({ error: "Invalid or expired token" }, 401);
app.post("/api/merge", bearerAuthMiddleware(), async (c) => {
const userId = c.get("userId");
const body = await readJson(c);
if (
!body ||
@ -104,12 +87,8 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> {
return c.json({ ok: true });
});
app.post("/api/close", async (c) => {
const token = extractBearerToken(c);
if (!token) return c.json({ error: "Missing authorization" }, 401);
const userId = await findUserIdByToken(c.env.KV, token);
if (!userId) return c.json({ error: "Invalid or expired token" }, 401);
app.post("/api/close", bearerAuthMiddleware(), async (c) => {
const userId = c.get("userId");
const body = await readJson(c);
if (
!body ||
@ -137,12 +116,8 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> {
return c.json({ ok: true });
});
app.post("/api/react", async (c) => {
const token = extractBearerToken(c);
if (!token) return c.json({ error: "Missing authorization" }, 401);
const userId = await findUserIdByToken(c.env.KV, token);
if (!userId) return c.json({ error: "Invalid or expired token" }, 401);
app.post("/api/react", bearerAuthMiddleware(), async (c) => {
const userId = c.get("userId");
const body = await readJson(c);
const reactions = [
"+1",
@ -173,7 +148,14 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> {
repo: body.repo,
issue_number: body.issueNumber,
content: body.reaction as
"+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes",
| "+1"
| "-1"
| "laugh"
| "confused"
| "heart"
| "hooray"
| "rocket"
| "eyes",
});
} catch (err) {
log.error({ err }, "Failed to create reaction");

View file

@ -1,14 +1,21 @@
import { Hono } from "hono";
import type { Env, Route, Group } from "../types";
import type { Route, Group, GroupMember, GroupRole } from "../types";
import { loadRoutes, saveRoutes } from "../config";
import { getAdminSession, destroyAdminSession, clearAdminCookie } from "./session";
import { saveGroups, loadGroups, identityMatches, normalizeGroupMembers } from "./groups";
import {
getAdminSession,
destroyAdminSession,
clearAdminCookie,
type AdminSession,
} from "./session";
import { loadGroups, saveGroups, resolveScope, hasAnyAccess, type AccessScope } from "./groups";
sessionMiddleware,
requireAnyAccess,
currentAuth,
requireGroup,
requireGroupRole,
roleAt,
clientIp,
type AuthEnv,
} from "./auth";
import { getSendLog, getSendLogById } from "../lib/send-log";
import { getAuditLog, recordAudit } from "../lib/audit";
import { createInvite, listInvites, revokeInvite, getInvite, acceptInvite } from "./invites";
import { log } from "../lib/log";
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
@ -178,6 +185,58 @@ function validateTarget(
};
}
function validateMembers(
g: Record<string, unknown>,
gid: string,
): { ok: true; members: GroupMember[] } | { ok: false; error: string } {
const raw = g.members;
if (raw === undefined || raw === null) {
// Legacy payload without members: derive owners from adminIds.
const adminIds = g.adminIds;
if (!Array.isArray(adminIds)) {
return { ok: false, error: `group "${gid}" needs a members list` };
}
if (!adminIds.every((a) => typeof a === "string" && a.trim().length > 0)) {
return { ok: false, error: `group "${gid}".adminIds must be a list of strings` };
}
const members: GroupMember[] = [];
const seen = new Set<string>();
for (const a of adminIds as string[]) {
const login = a.trim();
if (!login || seen.has(login.toLowerCase())) continue;
seen.add(login.toLowerCase());
members.push({ login, role: "owner" });
}
return { ok: true, members };
}
if (!Array.isArray(raw)) return { ok: false, error: `group "${gid}".members must be an array` };
const seen = new Set<string>();
const members: GroupMember[] = [];
for (let i = 0; i < raw.length; i++) {
const m = raw[i] as Record<string, unknown>;
if (!m || typeof m !== "object")
return { ok: false, error: `group "${gid}".members[${i}] is not an object` };
const login = typeof m.login === "string" ? m.login.trim() : "";
if (!login) return { ok: false, error: `group "${gid}".members[${i}].login is required` };
const role = m.role;
if (role !== "owner" && role !== "admin" && role !== "viewer") {
return {
ok: false,
error: `group "${gid}".members[${i}].role must be "owner" | "admin" | "viewer"`,
};
}
if (seen.has(login.toLowerCase())) {
return { ok: false, error: `group "${gid}" has duplicate member "${login}"` };
}
seen.add(login.toLowerCase());
members.push({ login, role });
}
if (members.length > 0 && !members.some((m) => m.role === "owner")) {
return { ok: false, error: `group "${gid}" needs at least one owner` };
}
return { ok: true, members };
}
function validateGroups(
groups: unknown,
): { ok: true; groups: Group[] } | { ok: false; error: string } {
@ -196,12 +255,12 @@ function validateGroups(
if (typeof g.name !== "string" || g.name.trim().length === 0) {
return { ok: false, error: `group "${g.id}" needs a name` };
}
if (
!Array.isArray(g.adminIds) ||
!g.adminIds.every((a) => typeof a === "string" && a.trim().length > 0)
) {
return { ok: false, error: `group "${g.id}".adminIds must be a list of strings` };
}
const mres = validateMembers(g, g.id);
if (!mres.ok) return mres;
// `members` is the single source of truth; adminIds stays in sync so
// legacy consumers (isGroupAdmin, older UI) keep working.
g.members = mres.members;
g.adminIds = mres.members.filter((m) => m.role === "owner").map((m) => m.login);
if (
g.owners !== undefined &&
(!Array.isArray(g.owners) ||
@ -228,52 +287,95 @@ function validateGroups(
return { ok: true, groups: groups as Group[] };
}
export function createAdminRoutes(): Hono<{ Bindings: Env }> {
const app = new Hono<{ Bindings: Env }>();
function ownerCount(members: GroupMember[]): number {
return members.filter((m) => m.role === "owner").length;
}
async function loadScope(c: {
env: Env;
req: { header: (name: string) => string | undefined };
}): Promise<{ session: AdminSession; scope: AccessScope; groups: Group[] } | null> {
const session = await getAdminSession(c.env.KV, c.req.header("cookie"));
if (!session) return null;
const groups = await loadGroups(c.env.KV);
const scope = resolveScope(c.env, groups, session.userId, session.login);
if (!hasAnyAccess(scope)) return null;
return { session, scope, groups };
}
/** Route params are always present for matched paths; keeps Hono's loose typing honest. */
function param(
c: { req: { param: (name: string) => string | undefined } },
name: string,
): string {
return c.req.param(name) ?? "";
}
export function createAdminRoutes(): Hono<AuthEnv> {
const app = new Hono<AuthEnv>();
app.get("/login", (c) => {
return c.redirect("/auth/github?redirect=/admin");
});
app.get("/logout", async (c) => {
const session = await getAdminSession(c.env.KV, c.req.header("cookie"));
if (session) {
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: session.userId,
actorLogin: session.login,
action: "session.logout",
ip: clientIp(c),
});
}
await destroyAdminSession(c.env.KV, c.req.header("cookie"));
c.header("Set-Cookie", clearAdminCookie());
return c.redirect("/admin");
});
app.get("/api/me", async (c) => {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);
// Browser page that accepts a group invite. Not logged in <20>?OAuth first,
// carrying the same invite URL as the redirect target.
app.get("/invite", sessionMiddleware(), async (c) => {
const token = c.req.query("token");
if (!token) return c.redirect("/admin");
const auth = c.get("auth");
if (!auth) {
return c.redirect(`/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`);
}
const result = await acceptInvite(c.env.KV, token, auth.session.userId, auth.session.login);
if (result.ok) {
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "invite.accept",
targetType: "group",
targetId: result.groupId,
groupId: result.groupId,
detail: { role: result.role },
ip: clientIp(c),
});
}
return c.redirect(`/admin?invite=${result.ok ? "ok" : result.reason}`);
});
app.get("/api/me", requireAnyAccess(), async (c) => {
const auth = currentAuth(c);
const roles: Record<string, GroupRole> = {};
for (const g of auth.scope.groups) {
const role = roleAt(auth.scope, g.id);
if (role) roles[g.id] = role;
}
return c.json({
login: s.session.login,
userId: s.session.userId,
isSuper: s.scope.isSuper,
groups: s.scope.groups,
login: auth.session.login,
userId: auth.session.userId,
isSuper: auth.scope.isSuper,
groups: auth.scope.groups,
roles,
});
});
app.get("/api/groups", async (c) => {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);
return c.json({ groups: s.scope.groups, isSuper: s.scope.isSuper });
app.get("/api/groups", requireAnyAccess(), async (c) => {
const auth = currentAuth(c);
const roles: Record<string, GroupRole> = {};
for (const g of auth.scope.groups) {
const role = roleAt(auth.scope, g.id);
if (role) roles[g.id] = role;
}
return c.json({ groups: auth.scope.groups, isSuper: auth.scope.isSuper, roles });
});
app.put("/api/groups", async (c) => {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);
if (!s.scope.isSuper) return c.json({ error: "Forbidden" }, 403);
app.put("/api/groups", requireAnyAccess(), async (c) => {
const auth = currentAuth(c);
let body: unknown;
try {
body = await c.req.json();
@ -282,57 +384,124 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
}
const result = validateGroups((body as { groups?: unknown })?.groups);
if (!result.ok) return c.json({ error: result.error }, 400);
const existing = await loadGroups(c.env.KV);
const prevById = new Map(existing.map((g) => [g.id, g]));
let nextAll: Group[];
if (auth.scope.isSuper) {
// Super admins see and submit every group: full replace.
nextAll = result.groups;
} else {
// Owners may only write groups they own. Preserve every other group and
// never let a submission drop the last owner of a group.
const mine = new Set<string>();
for (const [gid, role] of auth.scope.roles) {
if (role === "owner") mine.add(gid);
}
for (const g of result.groups) {
if (!mine.has(g.id)) {
return c.json({ error: `group "${g.id}" is outside your ownership` }, 403);
}
const members = g.members ?? normalizeGroupMembers(g);
const stillMine = members.some(
(m) =>
m.role === "owner" &&
identityMatches([m.login], auth.session.userId, auth.session.login),
);
const otherOwner = ownerCount(members) > 1;
if (!stillMine && !otherOwner) {
return c.json(
{ error: `group "${g.id}" would be left without an owner by you` },
403,
);
}
}
nextAll = [
...existing.filter((g) => !mine.has(g.id)),
...result.groups.map((g) => {
const prev = prevById.get(g.id);
if (prev && prev.owners !== undefined && g.owners === undefined) {
// Owners cannot edit the `owners` scope; keep the stored value.
return { ...g, owners: prev.owners };
}
return g;
}),
];
}
// Audit every create / update / delete.
const prevGroups = existing;
const nextById = new Map(nextAll.map((g) => [g.id, g]));
const actor = { actorId: auth.session.userId, actorLogin: auth.session.login };
for (const g of nextAll) {
const prev = prevById.get(g.id);
if (!prev) {
await recordAudit(c.env.DB, {
ts: Date.now(),
...actor,
action: "group.create",
targetType: "group",
targetId: g.id,
groupId: g.id,
ip: clientIp(c),
});
continue;
}
const fields: string[] = [];
if (prev.name !== g.name) fields.push("name");
if (prev.emoji !== g.emoji) fields.push("emoji");
if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers");
if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners");
if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members)) fields.push("members");
if (fields.length > 0) {
await recordAudit(c.env.DB, {
ts: Date.now(),
...actor,
action: "group.update",
targetType: "group",
targetId: g.id,
groupId: g.id,
detail: { fields },
ip: clientIp(c),
});
}
}
for (const g of prevGroups) {
if (!nextById.has(g.id)) {
await recordAudit(c.env.DB, {
ts: Date.now(),
...actor,
action: "group.delete",
targetType: "group",
targetId: g.id,
groupId: g.id,
ip: clientIp(c),
});
}
}
try {
await saveGroups(c.env.KV, result.groups);
await saveGroups(c.env.KV, nextAll);
} catch (err) {
log.error({ err }, "Failed to save groups");
return c.json({ error: "Failed to save groups" }, 500);
}
log.info({ count: result.groups.length }, "Groups updated via admin UI");
return c.json({ ok: true, count: result.groups.length });
log.info({ count: nextAll.length }, "Groups updated via admin UI");
return c.json({ ok: true, count: nextAll.length });
});
app.get("/api/routes", async (c) => {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);
app.get("/api/routes", requireAnyAccess(), async (c) => {
const auth = currentAuth(c);
const all = await loadRoutes(c.env.KV);
const routes = s.scope.isSuper
const routes = auth.scope.isSuper
? all
: all.filter((r) => r.groupId != null && s.scope.groupIds.has(r.groupId));
: all.filter((r) => r.groupId != null && auth.scope.groupIds.has(r.groupId));
return c.json({ routes });
});
app.get("/api/logs", async (c) => {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);
const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 100);
const filterGroupId = c.req.query("groupId") || undefined;
const allLogs = await getSendLog(c.env.DB, 200);
const allowed = s.scope.isSuper
? allLogs
: allLogs.filter((l) => l.groupId != null && s.scope.groupIds.has(l.groupId));
const logs = filterGroupId ? allowed.filter((l) => l.groupId === filterGroupId) : allowed;
return c.json({ logs: logs.slice(0, limit) });
});
app.get("/api/logs/:id", async (c) => {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);
const id = Number(c.req.param("id"));
if (!Number.isInteger(id) || id <= 0) return c.json({ error: "Invalid log id" }, 400);
const entry = await getSendLogById(c.env.DB, id);
if (!entry) return c.json({ error: "Log entry not found" }, 404);
if (!s.scope.isSuper) {
if (!entry.groupId || !s.scope.groupIds.has(entry.groupId)) {
return c.json({ error: "Forbidden" }, 403);
}
}
return c.json({ log: entry });
});
app.put("/api/routes", async (c) => {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);
app.put("/api/routes", requireAnyAccess(), async (c) => {
const auth = currentAuth(c);
let body: unknown;
try {
body = await c.req.json();
@ -346,14 +515,17 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
let nextAll: Route[];
if (s.scope.isSuper) {
if (auth.scope.isSuper) {
// Super admins see and submit every route: full replace.
nextAll = result.routes;
} else {
// Group admins may only write routes inside their own groups. Reject any
// submitted route that targets a group they do not manage, then splice
// their groups' routes in place while preserving all other groups' routes.
const writable = s.scope.groupIds;
// Owners and admins may only write routes inside groups they manage.
// Reject any submitted route that targets a group they cannot edit,
// then splice their groups' routes in place while preserving all others.
const writable = new Set<string>();
for (const [gid, role] of auth.scope.roles) {
if (role === "owner" || role === "admin") writable.add(gid);
}
for (const r of result.routes) {
if (!r.groupId || !writable.has(r.groupId)) {
return c.json({ error: `route "${r.id}" is outside your groups` }, 403);
@ -371,27 +543,51 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
log.error({ err }, "Failed to save routes");
return c.json({ error: "Failed to save routes" }, 500);
}
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "routes.update",
targetType: "routes",
targetId: "all",
detail: { count: nextAll.length },
ip: clientIp(c),
});
log.info({ count: nextAll.length }, "Routes updated via admin UI");
return c.json({ ok: true, count: nextAll.length });
});
app.get("/api/logs", requireAnyAccess(), async (c) => {
const auth = currentAuth(c);
const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 100);
const filterGroupId = c.req.query("groupId") || undefined;
const allLogs = await getSendLog(c.env.DB, 200);
const allowed = auth.scope.isSuper
? allLogs
: allLogs.filter((l) => l.groupId != null && auth.scope.groupIds.has(l.groupId));
const logs = filterGroupId ? allowed.filter((l) => l.groupId === filterGroupId) : allowed;
return c.json({ logs: logs.slice(0, limit) });
});
app.get("/api/logs/:id", requireAnyAccess(), async (c) => {
const auth = currentAuth(c);
const id = Number(param(c, "id"));
if (!Number.isInteger(id) || id <= 0) return c.json({ error: "Invalid log id" }, 400);
const entry = await getSendLogById(c.env.DB, id);
if (!entry) return c.json({ error: "Log entry not found" }, 404);
if (!auth.scope.isSuper) {
if (!entry.groupId || !auth.scope.groupIds.has(entry.groupId)) {
return c.json({ error: "Forbidden" }, 403);
}
}
return c.json({ log: entry });
});
// Routes scoped to a single group. The group is the container: the console
// enters a group and then lists / edits only that group's routes.
function groupAccess(
s: { scope: AccessScope; groups: Group[] },
groupId: string,
): { ok: true; group: Group } | { ok: false; status: 403 | 404 } {
const group = s.groups.find((g) => g.id === groupId);
if (!group) return { ok: false, status: 404 };
if (!s.scope.isSuper && !s.scope.groupIds.has(groupId)) return { ok: false, status: 403 };
return { ok: true, group };
}
app.get("/api/groups/:groupId/routes", async (c) => {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);
const groupId = c.req.param("groupId");
const access = groupAccess(s, groupId);
app.get("/api/groups/:groupId/routes", requireAnyAccess(), async (c) => {
const groupId = param(c, "groupId");
const access = requireGroup(c, groupId);
if (!access.ok) {
return c.json(
{ error: access.status === 404 ? "Group not found" : "Forbidden" },
@ -402,11 +598,9 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
return c.json({ group: access.group, routes: all.filter((r) => r.groupId === groupId) });
});
app.put("/api/groups/:groupId/routes", async (c) => {
const s = await loadScope(c);
if (!s) return c.json({ error: "Unauthorized" }, 401);
const groupId = c.req.param("groupId");
const access = groupAccess(s, groupId);
app.put("/api/groups/:groupId/routes", requireAnyAccess(), async (c) => {
const groupId = param(c, "groupId");
const access = requireGroupRole(c, groupId, "admin");
if (!access.ok) {
return c.json(
{ error: access.status === 404 ? "Group not found" : "Forbidden" },
@ -442,9 +636,118 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
log.error({ err }, "Failed to save routes");
return c.json({ error: "Failed to save routes" }, 500);
}
const auth = currentAuth(c);
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "group.routes.update",
targetType: "group",
targetId: groupId,
groupId,
detail: { count: result.routes.length },
ip: clientIp(c),
});
log.info({ groupId, count: result.routes.length }, "Group routes updated via admin UI");
return c.json({ ok: true, count: result.routes.length });
});
// ---- Group invites (owner +) ----
app.post("/api/groups/:groupId/invites", 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,
);
}
let body: unknown;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
const role = (body as { role?: unknown })?.role;
if (role !== "admin" && role !== "viewer") {
return c.json({ error: 'role must be "admin" or "viewer"' }, 400);
}
const note = (body as { note?: unknown })?.note;
const auth = currentAuth(c);
const token = await createInvite(c.env.KV, {
groupId,
role,
expiresAt: Date.now() + 7 * 86400_000,
createdBy: auth.session.login,
note: typeof note === "string" && note.trim() ? note.trim() : undefined,
});
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "invite.create",
targetType: "group",
targetId: groupId,
groupId,
detail: { role },
ip: clientIp(c),
});
return c.json({
ok: true,
token,
url: `/admin/invite?token=${token}`,
expiresAt: Date.now() + 7 * 86400_000,
});
});
app.get("/api/groups/:groupId/invites", 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 invites = await listInvites(c.env.KV, groupId);
return c.json({ invites });
});
app.delete("/api/invites/:token", requireAnyAccess(), async (c) => {
const token = param(c, "token");
const invite = await getInvite(c.env.KV, token);
if (!invite) return c.json({ error: "Invite not found" }, 404);
const access = requireGroupRole(c, invite.groupId, "owner");
if (!access.ok) return c.json({ error: "Forbidden" }, 403);
await revokeInvite(c.env.KV, token);
const auth = currentAuth(c);
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "invite.revoke",
targetType: "group",
targetId: invite.groupId,
groupId: invite.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);
const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 200);
const groupId = c.req.query("groupId") || undefined;
if (groupId && !auth.scope.isSuper && !auth.scope.groupIds.has(groupId)) {
return c.json({ error: "Forbidden" }, 403);
}
const entries = await getAuditLog(c.env.DB, { groupId, limit });
const visible = auth.scope.isSuper
? entries
: entries.filter((e) => e.groupId != null && auth.scope.groupIds.has(e.groupId));
return c.json({ audit: visible });
});
return app;
}

112
src/web/auth.ts Normal file
View file

@ -0,0 +1,112 @@
import type { Context } from "hono";
import type { Env, Group, GroupRole } from "../types";
import { getAdminSession, type AdminSession } from "./session";
import {
loadGroups,
resolveScope,
hasAnyAccess,
canEditGroup,
canEditRoutes,
roleAtLeast,
roleAt,
type AccessScope,
} from "./groups";
import { findUserIdByToken } from "../github/store";
export interface AuthContext {
session: AdminSession;
scope: AccessScope;
groups: Group[];
}
export type AuthEnv = {
Bindings: Env;
Variables: {
auth: AuthContext;
userId: string;
};
};
export const AUTH_KEY = "auth";
async function loadAuth(c: Context<AuthEnv>): Promise<AuthContext | null> {
const session = await getAdminSession(c.env.KV, c.req.header("cookie"));
if (!session) return null;
const groups = await loadGroups(c.env.KV);
const scope = resolveScope(c.env, groups, session.userId, session.login);
return { session, scope, groups };
}
/** Populates `auth` whenever a valid session exists (does not reject). */
export function sessionMiddleware() {
return async (c: Context<AuthEnv>, next: () => Promise<void>): Promise<Response | void> => {
const auth = await loadAuth(c);
if (auth) c.set(AUTH_KEY, auth);
await next();
};
}
/** 401 when not logged in, 403 when the account has no group access at all. */
export function requireAnyAccess() {
return async (c: Context<AuthEnv>, next: () => Promise<void>): Promise<Response | void> => {
const auth = await loadAuth(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
if (!hasAnyAccess(auth.scope)) return c.json({ error: "Forbidden" }, 403);
c.set(AUTH_KEY, auth);
await next();
};
}
/** The authenticated context; only valid behind an auth middleware. */
export function currentAuth(c: Context<AuthEnv>): AuthContext {
return c.get(AUTH_KEY);
}
export type GroupAccess =
| { ok: true; group: Group }
| { ok: false; status: 403 | 404 };
/** Resolves the group and checks the user can at least view it. */
export function requireGroup(c: Context<AuthEnv>, groupId: string): GroupAccess {
const auth = currentAuth(c);
const group = auth.groups.find((g) => g.id === groupId);
if (!group) return { ok: false, status: 404 };
if (!auth.scope.isSuper && !auth.scope.groupIds.has(groupId)) {
return { ok: false, status: 403 };
}
return { ok: true, group };
}
/** Requires at least `min` role in the group (owner|admin|viewer). */
export function requireGroupRole(
c: Context<AuthEnv>,
groupId: string,
min: GroupRole,
): GroupAccess {
const access = requireGroup(c, groupId);
if (!access.ok) return access;
const auth = currentAuth(c);
if (!auth.scope.isSuper && !roleAtLeast(roleAt(auth.scope, groupId), min)) {
return { ok: false, status: 403 };
}
return access;
}
export { canEditGroup, canEditRoutes, roleAt };
/** Best-effort client IP for audit entries (Cloudflare header first). */
export function clientIp(c: { req: { header: (n: string) => string | undefined } }): string | undefined {
return c.req.header("cf-connecting-ip") ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim();
}
/** Bearer-token auth for machine-to-machine action endpoints. */
export function bearerAuthMiddleware() {
return async (c: Context<AuthEnv>, next: () => Promise<void>): Promise<Response | void> => {
const auth = c.req.header("authorization");
if (!auth?.startsWith("Bearer ")) return c.json({ error: "Missing authorization" }, 401);
const userId = await findUserIdByToken(c.env.KV, auth.slice(7));
if (!userId) return c.json({ error: "Invalid or expired token" }, 401);
c.set("userId", userId);
await next();
};
}

View file

@ -1,9 +1,41 @@
import type { Env, Group } from "../types";
import type { Env, Group, GroupMember, GroupRole } from "../types";
import { isAdminUser } from "./session";
import { log } from "../lib/log";
const GROUPS_KEY = "config:groups";
/**
* Normalizes a group's member list. Groups stored with the legacy `adminIds`
* field (or with neither field) get their admins as role "owner" members, so
* every existing group keeps full control after the member-model migration.
* Never mutates the input; returns a fresh array.
*/
export function normalizeGroupMembers(group: Group): GroupMember[] {
const members = group.members;
if (Array.isArray(members) && members.length > 0) {
const seen = new Set<string>();
const out: GroupMember[] = [];
for (const m of members) {
const login = String(m?.login ?? "").trim();
if (!login || seen.has(login.toLowerCase())) continue;
const role: GroupRole = m?.role === "admin" || m?.role === "viewer" ? m.role : "owner";
seen.add(login.toLowerCase());
out.push({ login, role });
}
return out;
}
// Legacy path: adminIds → owners.
const seen = new Set<string>();
const out: GroupMember[] = [];
for (const a of group.adminIds ?? []) {
const login = a.trim();
if (!login || seen.has(login.toLowerCase())) continue;
seen.add(login.toLowerCase());
out.push({ login, role: "owner" });
}
return out;
}
export async function loadGroups(kv: KVNamespace): Promise<Group[]> {
try {
const stored = await kv.get<Group[]>(GROUPS_KEY, "json");
@ -29,6 +61,19 @@ export function isGroupAdmin(group: Group, userId: string, login: string): boole
return identityMatches(group.adminIds ?? [], userId, login);
}
/**
* The user's role in a group, derived from normalized members. Legacy
* `adminIds` groups resolve to "owner" for every listed admin.
*/
export function memberRole(group: Group, userId: string, login: string): GroupRole | undefined {
const members = normalizeGroupMembers(group);
const wanted = members.filter((m) => {
const candidate = m.login.trim().toLowerCase();
return candidate === login.toLowerCase() || candidate === userId;
});
return wanted.length > 0 ? wanted[0]!.role : undefined;
}
/**
* Whether an event originating from `owners` (org/user logins) is allowed into
* this group. A group with no owner restriction accepts everything.
@ -53,10 +98,12 @@ export function groupAcceptsProvider(group: Group, provider?: string): boolean {
export interface AccessScope {
isSuper: boolean;
/** Groups the user may view/edit. When isSuper, this is every group. */
/** Groups the user may view. When isSuper, this is every group. */
groups: Group[];
/** Ids of accessible groups, for quick membership checks. */
groupIds: Set<string>;
/** The user's role per accessible group (absent for super admins). */
roles: Map<string, GroupRole>;
}
export function resolveScope(
@ -66,14 +113,44 @@ export function resolveScope(
login: string,
): AccessScope {
const isSuper = isAdminUser(env, userId, login);
const visible = isSuper ? groups : groups.filter((g) => isGroupAdmin(g, userId, login));
const visible = isSuper ? groups : groups.filter((g) => memberRole(g, userId, login) != null);
const roles = new Map<string, GroupRole>();
for (const g of visible) {
const role = memberRole(g, userId, login);
if (role) roles.set(g.id, role);
}
return {
isSuper,
groups: visible,
groupIds: new Set(visible.map((g) => g.id)),
roles,
};
}
/** The user's role in a group, or undefined when they have no access. */
export function roleAt(scope: AccessScope, groupId: string): GroupRole | undefined {
if (scope.isSuper) return "owner";
return scope.roles.get(groupId);
}
/** Role hierarchy: viewer < admin < owner. */
const ROLE_RANK: Record<GroupRole, number> = { viewer: 1, admin: 2, owner: 3 };
export function roleAtLeast(role: GroupRole | undefined, min: GroupRole): boolean {
if (!role) return false;
return ROLE_RANK[role] >= ROLE_RANK[min];
}
/** owner | admin can edit routes; viewers are read-only. */
export function canEditRoutes(scope: AccessScope, groupId: string): boolean {
return roleAtLeast(roleAt(scope, groupId), "admin");
}
/** Only owners (and super admins) may manage a group's settings and members. */
export function canEditGroup(scope: AccessScope, groupId: string): boolean {
return roleAtLeast(roleAt(scope, groupId), "owner");
}
/** True if the user is a super admin or manages at least one group. */
export function hasAnyAccess(scope: AccessScope): boolean {
return scope.isSuper || scope.groups.length > 0;

114
src/web/invites.ts Normal file
View file

@ -0,0 +1,114 @@
import { loadGroups, saveGroups, identityMatches, normalizeGroupMembers } from "./groups";
import { log } from "../lib/log";
export interface Invite {
groupId: string;
/** Invited users can never be owners; only admins or viewers. */
role: "admin" | "viewer";
expiresAt: number;
createdBy: string;
note?: string;
}
const INVITE_TTL = 7 * 24 * 3600;
function generateInviteToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function inviteKey(token: string): string {
return `invite:${token}`;
}
export async function createInvite(kv: KVNamespace, invite: Invite): Promise<string> {
const token = generateInviteToken();
await kv.put(inviteKey(token), JSON.stringify(invite), { expirationTtl: INVITE_TTL });
return token;
}
export async function getInvite(kv: KVNamespace, token: string): Promise<Invite | null> {
try {
const raw = await kv.get<Invite>(inviteKey(token), "json");
if (!raw) return null;
if (Date.now() > raw.expiresAt) {
await kv.delete(inviteKey(token));
return null;
}
return raw;
} catch (err) {
log.warn({ err }, "Failed to load invite");
return null;
}
}
export async function consumeInvite(kv: KVNamespace, token: string): Promise<void> {
await kv.delete(inviteKey(token));
}
/** All pending (unexpired) invites of a group. */
export async function listInvites(kv: KVNamespace, groupId: string): Promise<Array<Invite & { token: string }>> {
try {
const { keys } = await kv.list({ prefix: "invite:" });
const out: Array<Invite & { token: string }> = [];
for (const key of keys) {
const token = key.name.slice("invite:".length);
const invite = await getInvite(kv, token);
if (invite && invite.groupId === groupId) out.push({ ...invite, token });
}
return out;
} catch (err) {
log.warn({ err }, "Failed to list invites");
return [];
}
}
export async function revokeInvite(kv: KVNamespace, token: string): Promise<void> {
await kv.delete(inviteKey(token));
}
/**
* Adds the accepting user to the invited group (or upgrades their role when
* they are already a viewer) and consumes the invite. The invited role is
* never an owner ownership stays with the inviter's discretion.
*/
export async function acceptInvite(
kv: KVNamespace,
token: string,
userId: string,
login: string,
): Promise<
| { ok: true; groupId: string; role: "admin" | "viewer" }
| { ok: false; reason: "invalid" | "group-missing" }
> {
const invite = await getInvite(kv, token);
if (!invite) return { ok: false, reason: "invalid" };
const groups = await loadGroups(kv);
const group = groups.find((g) => g.id === invite.groupId);
if (!group) return { ok: false, reason: "group-missing" };
const members = normalizeGroupMembers(group);
const idx = members.findIndex((m) => identityMatches([m.login], userId, login));
if (idx >= 0) {
if (invite.role === "admin" && members[idx]!.role === "viewer") {
members[idx]!.role = "admin";
}
} else {
members.push({ login, role: invite.role });
}
const next = groups.map((g) =>
g.id === group.id
? {
...g,
members,
adminIds: members.filter((m) => m.role === "owner").map((m) => m.login),
}
: g,
);
await saveGroups(kv, next);
await consumeInvite(kv, token);
return { ok: true, groupId: group.id, role: invite.role };
}

View file

@ -2,9 +2,11 @@ import { Hono } from "hono";
import { getOAuthURL, handleOAuthCallback } from "../github/oauth";
import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store";
import { createAdminSession, adminCookie, getAdminSession } from "./session";
import { loadGroups, resolveScope, hasAnyAccess } from "./groups";
import { loadGroups, saveGroups, resolveScope, hasAnyAccess } from "./groups";
import { clientIp } from "./auth";
import { recordAudit } from "../lib/audit";
import { sendMessage } from "../drivers/telegram/rest";
import type { Env } from "../types";
import type { Env, Group } from "../types";
interface PendingState {
redirectTo: string;
@ -34,6 +36,41 @@ function safeRedirectPath(value: string | undefined): string {
return value;
}
function selfSignupEnabled(env: Env): boolean {
const flag = (env.ALLOW_SELF_SIGNUP ?? "").trim().toLowerCase();
return flag === "1" || flag === "true" || flag === "yes" || flag === "on";
}
/**
* Opt-in self service: users without any group access get a personal group
* they own, so they can configure their own routing without a super admin.
* The group id is deterministic (`u-{userId}`), so it is created at most once.
*/
async function ensurePersonalGroup(env: Env, userId: string, login: string): Promise<boolean> {
if (!selfSignupEnabled(env)) return false;
const groups = await loadGroups(env.KV);
const gid = `u-${userId}`;
if (groups.some((g) => g.id === gid)) return true;
const personal: Group = {
id: gid,
name: `@${login}`,
members: [{ login, role: "owner" }],
adminIds: [login],
};
await saveGroups(env.KV, [...groups, personal]);
await recordAudit(env.DB, {
ts: Date.now(),
actorId: userId,
actorLogin: login,
action: "group.create",
targetType: "group",
targetId: gid,
groupId: gid,
detail: { auto: true },
});
return true;
}
export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
const app = new Hono<{ Bindings: Env }>();
@ -109,13 +146,36 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
const isBrowser = (c.req.header("accept") ?? "").includes("text/html");
if (isBrowser) {
const groups = await loadGroups(c.env.KV);
const scope = resolveScope(c.env, groups, result.userId, result.login);
if (!hasAnyAccess(scope)) {
// Invite accept flow: the redirect target is the invite page, which
// processes the token after the session exists. Skip the access gate so
// non-members can get in and accept.
const isInviteFlow =
pending.redirectTo.startsWith("/admin/invite") ||
pending.redirectTo.startsWith("/admin/invite?");
let groups = await loadGroups(c.env.KV);
let scope = resolveScope(c.env, groups, result.userId, result.login);
if (!hasAnyAccess(scope) && !isInviteFlow) {
const created = await ensurePersonalGroup(c.env, result.userId, result.login);
if (created) {
groups = await loadGroups(c.env.KV);
scope = resolveScope(c.env, groups, result.userId, result.login);
}
}
if (!hasAnyAccess(scope) && !isInviteFlow) {
return c.redirect("/admin?error=forbidden");
}
const sessionId = await createAdminSession(c.env.KV, result.userId, result.login);
c.header("Set-Cookie", adminCookie(sessionId));
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: result.userId,
actorLogin: result.login,
action: "session.login",
ip: clientIp(c),
});
return c.redirect(pending.redirectTo);
}
@ -129,7 +189,17 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
app.delete("/token/:userId", async (c) => {
const session = await getAdminSession(c.env.KV, c.req.header("Cookie"));
if (!session) return c.json({ error: "Unauthorized" }, 401);
await removeToken(c.env.KV, c.req.param("userId"));
const target = c.req.param("userId");
await removeToken(c.env.KV, target);
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: session.userId,
actorLogin: session.login,
action: "token.delete",
targetType: "token",
targetId: target,
ip: clientIp(c),
});
return c.json({ ok: true });
});