mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
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:
parent
f8cb3a4ddb
commit
9b8533cabc
10 changed files with 964 additions and 153 deletions
15
migrations/0005_audit_logs.sql
Normal file
15
migrations/0005_audit_logs.sql
Normal 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);
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createServer } from "./server";
|
import { createServer } from "./server";
|
||||||
import { syncCommands } from "./drivers/discord/commands";
|
import { syncCommands } from "./drivers/discord/commands";
|
||||||
import { syncTelegramWebhook } from "./drivers/telegram/commands";
|
import { syncTelegramWebhook } from "./drivers/telegram/commands";
|
||||||
|
import { pruneAuditLogs } from "./lib/audit";
|
||||||
import type { Env } from "./types";
|
import type { Env } from "./types";
|
||||||
import { log } from "./lib/log";
|
import { log } from "./lib/log";
|
||||||
|
|
||||||
|
|
@ -22,5 +23,12 @@ export default {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error({ err }, "Telegram webhook sync from cron failed");
|
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
106
src/lib/audit.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/types.ts
28
src/types.ts
|
|
@ -17,6 +17,13 @@ export interface Env {
|
||||||
TELEGRAM_TOKEN?: string;
|
TELEGRAM_TOKEN?: string;
|
||||||
TELEGRAM_WEBHOOK_SECRET?: string;
|
TELEGRAM_WEBHOOK_SECRET?: string;
|
||||||
TELEGRAM_RICH_HEADER_HOST?: 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;
|
ASSETS?: Fetcher;
|
||||||
KV: KVNamespace;
|
KV: KVNamespace;
|
||||||
DB: D1Database;
|
DB: D1Database;
|
||||||
|
|
@ -72,14 +79,31 @@ export interface Route {
|
||||||
discordRoleIds?: string[];
|
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 {
|
export interface Group {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
/**
|
/**
|
||||||
* GitHub user ids or logins (case-insensitive) allowed to manage this group.
|
* Deprecated legacy field: GitHub user ids or logins allowed to manage this
|
||||||
* Super admins (ADMIN_USER_IDS) always have access regardless of this list.
|
* group. Kept for backward compatibility — when `members` is absent, these
|
||||||
|
* are normalized to `members` with role "owner". New writes use `members`.
|
||||||
*/
|
*/
|
||||||
adminIds: string[];
|
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
|
* GitHub organization/user logins (case-insensitive) whose webhook events are
|
||||||
* allowed into this group's routes. Empty/omitted = no owner restriction.
|
* allowed into this group's routes. Empty/omitted = no owner restriction.
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { getUserOctokit } from "../github/oauth";
|
import { getUserOctokit } from "../github/oauth";
|
||||||
import { findUserIdByToken } from "../github/store";
|
import { bearerAuthMiddleware, type AuthEnv } from "./auth";
|
||||||
import type { Env } from "../types";
|
|
||||||
import { log } from "../lib/log";
|
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 {
|
function isNonEmptyString(value: unknown): value is string {
|
||||||
return typeof value === "string" && value.length > 0;
|
return typeof value === "string" && value.length > 0;
|
||||||
}
|
}
|
||||||
|
|
@ -30,15 +21,11 @@ async function readJson(c: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createActionRoutes(): Hono<{ Bindings: Env }> {
|
export function createActionRoutes(): Hono<AuthEnv> {
|
||||||
const app = new Hono<{ Bindings: Env }>();
|
const app = new Hono<AuthEnv>();
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
|
app.post("/api/comment", bearerAuthMiddleware(), async (c) => {
|
||||||
|
const userId = c.get("userId");
|
||||||
const body = await readJson(c);
|
const body = await readJson(c);
|
||||||
if (
|
if (
|
||||||
!body ||
|
!body ||
|
||||||
|
|
@ -67,12 +54,8 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> {
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/merge", async (c) => {
|
app.post("/api/merge", bearerAuthMiddleware(), async (c) => {
|
||||||
const token = extractBearerToken(c);
|
const userId = c.get("userId");
|
||||||
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);
|
|
||||||
|
|
||||||
const body = await readJson(c);
|
const body = await readJson(c);
|
||||||
if (
|
if (
|
||||||
!body ||
|
!body ||
|
||||||
|
|
@ -104,12 +87,8 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> {
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/close", async (c) => {
|
app.post("/api/close", bearerAuthMiddleware(), async (c) => {
|
||||||
const token = extractBearerToken(c);
|
const userId = c.get("userId");
|
||||||
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);
|
|
||||||
|
|
||||||
const body = await readJson(c);
|
const body = await readJson(c);
|
||||||
if (
|
if (
|
||||||
!body ||
|
!body ||
|
||||||
|
|
@ -137,12 +116,8 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> {
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/react", async (c) => {
|
app.post("/api/react", bearerAuthMiddleware(), async (c) => {
|
||||||
const token = extractBearerToken(c);
|
const userId = c.get("userId");
|
||||||
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);
|
|
||||||
|
|
||||||
const body = await readJson(c);
|
const body = await readJson(c);
|
||||||
const reactions = [
|
const reactions = [
|
||||||
"+1",
|
"+1",
|
||||||
|
|
@ -173,7 +148,14 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> {
|
||||||
repo: body.repo,
|
repo: body.repo,
|
||||||
issue_number: body.issueNumber,
|
issue_number: body.issueNumber,
|
||||||
content: body.reaction as
|
content: body.reaction as
|
||||||
"+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes",
|
| "+1"
|
||||||
|
| "-1"
|
||||||
|
| "laugh"
|
||||||
|
| "confused"
|
||||||
|
| "heart"
|
||||||
|
| "hooray"
|
||||||
|
| "rocket"
|
||||||
|
| "eyes",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error({ err }, "Failed to create reaction");
|
log.error({ err }, "Failed to create reaction");
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import { Hono } from "hono";
|
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 { loadRoutes, saveRoutes } from "../config";
|
||||||
|
import { getAdminSession, destroyAdminSession, clearAdminCookie } from "./session";
|
||||||
|
import { saveGroups, loadGroups, identityMatches, normalizeGroupMembers } from "./groups";
|
||||||
import {
|
import {
|
||||||
getAdminSession,
|
sessionMiddleware,
|
||||||
destroyAdminSession,
|
requireAnyAccess,
|
||||||
clearAdminCookie,
|
currentAuth,
|
||||||
type AdminSession,
|
requireGroup,
|
||||||
} from "./session";
|
requireGroupRole,
|
||||||
import { loadGroups, saveGroups, resolveScope, hasAnyAccess, type AccessScope } from "./groups";
|
roleAt,
|
||||||
|
clientIp,
|
||||||
|
type AuthEnv,
|
||||||
|
} from "./auth";
|
||||||
import { getSendLog, getSendLogById } from "../lib/send-log";
|
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";
|
import { log } from "../lib/log";
|
||||||
|
|
||||||
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
|
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(
|
function validateGroups(
|
||||||
groups: unknown,
|
groups: unknown,
|
||||||
): { ok: true; groups: Group[] } | { ok: false; error: string } {
|
): { ok: true; groups: Group[] } | { ok: false; error: string } {
|
||||||
|
|
@ -196,12 +255,12 @@ function validateGroups(
|
||||||
if (typeof g.name !== "string" || g.name.trim().length === 0) {
|
if (typeof g.name !== "string" || g.name.trim().length === 0) {
|
||||||
return { ok: false, error: `group "${g.id}" needs a name` };
|
return { ok: false, error: `group "${g.id}" needs a name` };
|
||||||
}
|
}
|
||||||
if (
|
const mres = validateMembers(g, g.id);
|
||||||
!Array.isArray(g.adminIds) ||
|
if (!mres.ok) return mres;
|
||||||
!g.adminIds.every((a) => typeof a === "string" && a.trim().length > 0)
|
// `members` is the single source of truth; adminIds stays in sync so
|
||||||
) {
|
// legacy consumers (isGroupAdmin, older UI) keep working.
|
||||||
return { ok: false, error: `group "${g.id}".adminIds must be a list of strings` };
|
g.members = mres.members;
|
||||||
}
|
g.adminIds = mres.members.filter((m) => m.role === "owner").map((m) => m.login);
|
||||||
if (
|
if (
|
||||||
g.owners !== undefined &&
|
g.owners !== undefined &&
|
||||||
(!Array.isArray(g.owners) ||
|
(!Array.isArray(g.owners) ||
|
||||||
|
|
@ -228,52 +287,95 @@ function validateGroups(
|
||||||
return { ok: true, groups: groups as Group[] };
|
return { ok: true, groups: groups as Group[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAdminRoutes(): Hono<{ Bindings: Env }> {
|
function ownerCount(members: GroupMember[]): number {
|
||||||
const app = new Hono<{ Bindings: Env }>();
|
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) => {
|
app.get("/login", (c) => {
|
||||||
return c.redirect("/auth/github?redirect=/admin");
|
return c.redirect("/auth/github?redirect=/admin");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/logout", async (c) => {
|
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"));
|
await destroyAdminSession(c.env.KV, c.req.header("cookie"));
|
||||||
c.header("Set-Cookie", clearAdminCookie());
|
c.header("Set-Cookie", clearAdminCookie());
|
||||||
return c.redirect("/admin");
|
return c.redirect("/admin");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/me", async (c) => {
|
// Browser page that accepts a group invite. Not logged in <20>?OAuth first,
|
||||||
const s = await loadScope(c);
|
// carrying the same invite URL as the redirect target.
|
||||||
if (!s) return c.json({ error: "Unauthorized" }, 401);
|
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({
|
return c.json({
|
||||||
login: s.session.login,
|
login: auth.session.login,
|
||||||
userId: s.session.userId,
|
userId: auth.session.userId,
|
||||||
isSuper: s.scope.isSuper,
|
isSuper: auth.scope.isSuper,
|
||||||
groups: s.scope.groups,
|
groups: auth.scope.groups,
|
||||||
|
roles,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/groups", async (c) => {
|
app.get("/api/groups", requireAnyAccess(), async (c) => {
|
||||||
const s = await loadScope(c);
|
const auth = currentAuth(c);
|
||||||
if (!s) return c.json({ error: "Unauthorized" }, 401);
|
const roles: Record<string, GroupRole> = {};
|
||||||
return c.json({ groups: s.scope.groups, isSuper: s.scope.isSuper });
|
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) => {
|
app.put("/api/groups", requireAnyAccess(), async (c) => {
|
||||||
const s = await loadScope(c);
|
const auth = currentAuth(c);
|
||||||
if (!s) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
if (!s.scope.isSuper) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await c.req.json();
|
body = await c.req.json();
|
||||||
|
|
@ -282,57 +384,124 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
|
||||||
}
|
}
|
||||||
const result = validateGroups((body as { groups?: unknown })?.groups);
|
const result = validateGroups((body as { groups?: unknown })?.groups);
|
||||||
if (!result.ok) return c.json({ error: result.error }, 400);
|
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 {
|
try {
|
||||||
await saveGroups(c.env.KV, result.groups);
|
await saveGroups(c.env.KV, nextAll);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error({ err }, "Failed to save groups");
|
log.error({ err }, "Failed to save groups");
|
||||||
return c.json({ error: "Failed to save groups" }, 500);
|
return c.json({ error: "Failed to save groups" }, 500);
|
||||||
}
|
}
|
||||||
log.info({ count: result.groups.length }, "Groups updated via admin UI");
|
log.info({ count: nextAll.length }, "Groups updated via admin UI");
|
||||||
return c.json({ ok: true, count: result.groups.length });
|
return c.json({ ok: true, count: nextAll.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/routes", async (c) => {
|
app.get("/api/routes", requireAnyAccess(), async (c) => {
|
||||||
const s = await loadScope(c);
|
const auth = currentAuth(c);
|
||||||
if (!s) return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
const all = await loadRoutes(c.env.KV);
|
const all = await loadRoutes(c.env.KV);
|
||||||
const routes = s.scope.isSuper
|
const routes = auth.scope.isSuper
|
||||||
? all
|
? 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 });
|
return c.json({ routes });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/api/logs", async (c) => {
|
app.put("/api/routes", requireAnyAccess(), async (c) => {
|
||||||
const s = await loadScope(c);
|
const auth = currentAuth(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);
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = await c.req.json();
|
body = await c.req.json();
|
||||||
|
|
@ -346,14 +515,17 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
|
||||||
|
|
||||||
let nextAll: Route[];
|
let nextAll: Route[];
|
||||||
|
|
||||||
if (s.scope.isSuper) {
|
if (auth.scope.isSuper) {
|
||||||
// Super admins see and submit every route: full replace.
|
// Super admins see and submit every route: full replace.
|
||||||
nextAll = result.routes;
|
nextAll = result.routes;
|
||||||
} else {
|
} else {
|
||||||
// Group admins may only write routes inside their own groups. Reject any
|
// Owners and admins may only write routes inside groups they manage.
|
||||||
// submitted route that targets a group they do not manage, then splice
|
// Reject any submitted route that targets a group they cannot edit,
|
||||||
// their groups' routes in place while preserving all other groups' routes.
|
// then splice their groups' routes in place while preserving all others.
|
||||||
const writable = s.scope.groupIds;
|
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) {
|
for (const r of result.routes) {
|
||||||
if (!r.groupId || !writable.has(r.groupId)) {
|
if (!r.groupId || !writable.has(r.groupId)) {
|
||||||
return c.json({ error: `route "${r.id}" is outside your groups` }, 403);
|
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");
|
log.error({ err }, "Failed to save routes");
|
||||||
return c.json({ error: "Failed to save routes" }, 500);
|
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");
|
log.info({ count: nextAll.length }, "Routes updated via admin UI");
|
||||||
return c.json({ ok: true, count: nextAll.length });
|
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
|
// 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.
|
// enters a group and then lists / edits only that group's routes.
|
||||||
function groupAccess(
|
app.get("/api/groups/:groupId/routes", requireAnyAccess(), async (c) => {
|
||||||
s: { scope: AccessScope; groups: Group[] },
|
const groupId = param(c, "groupId");
|
||||||
groupId: string,
|
const access = requireGroup(c, groupId);
|
||||||
): { 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);
|
|
||||||
if (!access.ok) {
|
if (!access.ok) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: access.status === 404 ? "Group not found" : "Forbidden" },
|
{ 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) });
|
return c.json({ group: access.group, routes: all.filter((r) => r.groupId === groupId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put("/api/groups/:groupId/routes", async (c) => {
|
app.put("/api/groups/:groupId/routes", requireAnyAccess(), async (c) => {
|
||||||
const s = await loadScope(c);
|
const groupId = param(c, "groupId");
|
||||||
if (!s) return c.json({ error: "Unauthorized" }, 401);
|
const access = requireGroupRole(c, groupId, "admin");
|
||||||
const groupId = c.req.param("groupId");
|
|
||||||
const access = groupAccess(s, groupId);
|
|
||||||
if (!access.ok) {
|
if (!access.ok) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: access.status === 404 ? "Group not found" : "Forbidden" },
|
{ 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");
|
log.error({ err }, "Failed to save routes");
|
||||||
return c.json({ error: "Failed to save routes" }, 500);
|
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");
|
log.info({ groupId, count: result.routes.length }, "Group routes updated via admin UI");
|
||||||
return c.json({ ok: true, count: result.routes.length });
|
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;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
src/web/auth.ts
Normal file
112
src/web/auth.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,41 @@
|
||||||
import type { Env, Group } from "../types";
|
import type { Env, Group, GroupMember, GroupRole } from "../types";
|
||||||
import { isAdminUser } from "./session";
|
import { isAdminUser } from "./session";
|
||||||
import { log } from "../lib/log";
|
import { log } from "../lib/log";
|
||||||
|
|
||||||
const GROUPS_KEY = "config:groups";
|
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[]> {
|
export async function loadGroups(kv: KVNamespace): Promise<Group[]> {
|
||||||
try {
|
try {
|
||||||
const stored = await kv.get<Group[]>(GROUPS_KEY, "json");
|
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);
|
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
|
* Whether an event originating from `owners` (org/user logins) is allowed into
|
||||||
* this group. A group with no owner restriction accepts everything.
|
* 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 {
|
export interface AccessScope {
|
||||||
isSuper: boolean;
|
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[];
|
groups: Group[];
|
||||||
/** Ids of accessible groups, for quick membership checks. */
|
/** Ids of accessible groups, for quick membership checks. */
|
||||||
groupIds: Set<string>;
|
groupIds: Set<string>;
|
||||||
|
/** The user's role per accessible group (absent for super admins). */
|
||||||
|
roles: Map<string, GroupRole>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveScope(
|
export function resolveScope(
|
||||||
|
|
@ -66,14 +113,44 @@ export function resolveScope(
|
||||||
login: string,
|
login: string,
|
||||||
): AccessScope {
|
): AccessScope {
|
||||||
const isSuper = isAdminUser(env, userId, login);
|
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 {
|
return {
|
||||||
isSuper,
|
isSuper,
|
||||||
groups: visible,
|
groups: visible,
|
||||||
groupIds: new Set(visible.map((g) => g.id)),
|
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. */
|
/** True if the user is a super admin or manages at least one group. */
|
||||||
export function hasAnyAccess(scope: AccessScope): boolean {
|
export function hasAnyAccess(scope: AccessScope): boolean {
|
||||||
return scope.isSuper || scope.groups.length > 0;
|
return scope.isSuper || scope.groups.length > 0;
|
||||||
|
|
|
||||||
114
src/web/invites.ts
Normal file
114
src/web/invites.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,11 @@ import { Hono } from "hono";
|
||||||
import { getOAuthURL, handleOAuthCallback } from "../github/oauth";
|
import { getOAuthURL, handleOAuthCallback } from "../github/oauth";
|
||||||
import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store";
|
import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store";
|
||||||
import { createAdminSession, adminCookie, getAdminSession } from "./session";
|
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 { sendMessage } from "../drivers/telegram/rest";
|
||||||
import type { Env } from "../types";
|
import type { Env, Group } from "../types";
|
||||||
|
|
||||||
interface PendingState {
|
interface PendingState {
|
||||||
redirectTo: string;
|
redirectTo: string;
|
||||||
|
|
@ -34,6 +36,41 @@ function safeRedirectPath(value: string | undefined): string {
|
||||||
return value;
|
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 }> {
|
export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
|
||||||
const app = new 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");
|
const isBrowser = (c.req.header("accept") ?? "").includes("text/html");
|
||||||
if (isBrowser) {
|
if (isBrowser) {
|
||||||
const groups = await loadGroups(c.env.KV);
|
// Invite accept flow: the redirect target is the invite page, which
|
||||||
const scope = resolveScope(c.env, groups, result.userId, result.login);
|
// processes the token after the session exists. Skip the access gate so
|
||||||
if (!hasAnyAccess(scope)) {
|
// 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");
|
return c.redirect("/admin?error=forbidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = await createAdminSession(c.env.KV, result.userId, result.login);
|
const sessionId = await createAdminSession(c.env.KV, result.userId, result.login);
|
||||||
c.header("Set-Cookie", adminCookie(sessionId));
|
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);
|
return c.redirect(pending.redirectTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,7 +189,17 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> {
|
||||||
app.delete("/token/:userId", async (c) => {
|
app.delete("/token/:userId", async (c) => {
|
||||||
const session = await getAdminSession(c.env.KV, c.req.header("Cookie"));
|
const session = await getAdminSession(c.env.KV, c.req.header("Cookie"));
|
||||||
if (!session) return c.json({ error: "Unauthorized" }, 401);
|
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 });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue