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

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