mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue