2026-03-05 00:04:26 -06:00
|
|
|
import { type AuthenticatedUser } from "./auth";
|
2026-05-19 00:46:30 -05:00
|
|
|
import {
|
|
|
|
|
canBuildClients,
|
|
|
|
|
canChatWrite,
|
2026-07-21 13:18:30 -05:00
|
|
|
canUploadFiles,
|
2026-05-19 00:46:30 -05:00
|
|
|
canUserAccessClient,
|
|
|
|
|
canUserAccessFeature,
|
|
|
|
|
canUserAccessPlugin,
|
2026-07-21 13:18:30 -05:00
|
|
|
getUserExtraPermissions,
|
2026-05-19 00:46:30 -05:00
|
|
|
getUserGrantedPermissions,
|
2026-07-21 13:18:30 -05:00
|
|
|
getUserGroupIds,
|
|
|
|
|
listPermissionGroups,
|
2026-05-19 00:46:30 -05:00
|
|
|
type FeatureName,
|
|
|
|
|
type UserRole,
|
|
|
|
|
} from "./users";
|
2026-03-05 00:04:26 -06:00
|
|
|
|
2026-05-19 00:46:30 -05:00
|
|
|
type PermissionDef = {
|
|
|
|
|
description: string;
|
|
|
|
|
roles: readonly UserRole[];
|
|
|
|
|
userOverride?: (userId: number, role: UserRole) => boolean;
|
2026-07-21 13:18:30 -05:00
|
|
|
overrideSourceLabel?: string;
|
2026-05-19 00:46:30 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const PERMISSIONS = {
|
|
|
|
|
"users:manage": {
|
|
|
|
|
description: "Manage users and roles",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"clients:control": {
|
|
|
|
|
description: "Control clients (execute commands, desktop, console, files)",
|
|
|
|
|
roles: ["admin", "operator"],
|
|
|
|
|
},
|
|
|
|
|
"clients:build": {
|
|
|
|
|
description: "Build client binaries",
|
|
|
|
|
roles: ["admin", "operator"],
|
|
|
|
|
userOverride: canBuildClients,
|
2026-07-21 13:18:30 -05:00
|
|
|
overrideSourceLabel: "Build permission toggle",
|
2026-05-19 00:46:30 -05:00
|
|
|
},
|
|
|
|
|
"clients:enroll": {
|
|
|
|
|
description: "Manage client enrollment approvals",
|
|
|
|
|
roles: ["admin", "operator"],
|
|
|
|
|
},
|
|
|
|
|
"clients:silent-exec": {
|
|
|
|
|
description: "Silently execute arbitrary commands on clients",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"clients:disconnect": {
|
|
|
|
|
description: "Force a client to disconnect",
|
|
|
|
|
roles: ["admin", "operator"],
|
|
|
|
|
},
|
|
|
|
|
"clients:reconnect": {
|
|
|
|
|
description: "Force a client to drop its session and reconnect",
|
|
|
|
|
roles: ["admin", "operator"],
|
|
|
|
|
},
|
|
|
|
|
"clients:metadata": {
|
|
|
|
|
description: "Edit client metadata (nickname, tag, group, bookmark, mute)",
|
|
|
|
|
roles: ["admin", "operator"],
|
|
|
|
|
},
|
|
|
|
|
"clients:uninstall": {
|
|
|
|
|
description: "Uninstall the agent on a client (removes persistence and deletes from dashboard)",
|
|
|
|
|
roles: ["admin", "operator"],
|
|
|
|
|
},
|
|
|
|
|
"audit:view": {
|
|
|
|
|
description: "View audit logs",
|
|
|
|
|
roles: ["admin", "operator"],
|
|
|
|
|
},
|
|
|
|
|
"chat:write": {
|
|
|
|
|
description: "Send messages in team chat",
|
|
|
|
|
roles: ["admin", "operator"],
|
|
|
|
|
userOverride: canChatWrite,
|
2026-07-21 13:18:30 -05:00
|
|
|
overrideSourceLabel: "Chat write permission toggle",
|
|
|
|
|
},
|
|
|
|
|
"files:upload": {
|
|
|
|
|
description: "Upload shared files and build artifacts",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
userOverride: canUploadFiles,
|
|
|
|
|
overrideSourceLabel: "File upload permission toggle",
|
2026-05-19 00:46:30 -05:00
|
|
|
},
|
|
|
|
|
"scripts:manage": {
|
|
|
|
|
description: "Manage auto-run scripts",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"deploys:manage": {
|
|
|
|
|
description: "Manage deploys and auto-deploys",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"plugins:manage": {
|
|
|
|
|
description: "Upload, enable, and delete plugins",
|
|
|
|
|
roles: ["admin", "operator"],
|
|
|
|
|
},
|
|
|
|
|
"plugins:configure": {
|
|
|
|
|
description: "Configure plugin trust, auto-load, and direct execution",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"network:manage-bans": {
|
|
|
|
|
description: "Manage IP bans",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"system:configure": {
|
2026-06-04 19:44:36 -05:00
|
|
|
description: "Legacy full server settings access (grants all system:* settings permissions)",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"system:security": {
|
|
|
|
|
description: "Change security policy settings",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"system:tls": {
|
|
|
|
|
description: "Change TLS and certificate settings",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
2026-07-08 20:34:25 -05:00
|
|
|
"system:oidc": {
|
|
|
|
|
description: "Change OIDC and SSO login settings",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
2026-06-04 19:44:36 -05:00
|
|
|
"system:registration": {
|
|
|
|
|
description: "Change user registration policy settings",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"system:notifications": {
|
|
|
|
|
description: "Change global notification delivery settings",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"system:chat": {
|
|
|
|
|
description: "Change team chat settings",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"system:appearance": {
|
|
|
|
|
description: "Change custom CSS and appearance settings",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"system:thumbnails": {
|
|
|
|
|
description: "Change screenshot thumbnail settings",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
2026-06-10 19:54:18 -05:00
|
|
|
"system:input-archive": {
|
|
|
|
|
description: "Change input log archive settings",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
2026-06-04 19:44:36 -05:00
|
|
|
"system:build-limits": {
|
|
|
|
|
description: "Change build rate limit settings",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"system:export-import": {
|
|
|
|
|
description: "Export and import server settings",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"system:health": {
|
|
|
|
|
description: "View server health diagnostics",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"system:health:manage": {
|
|
|
|
|
description: "Run server health maintenance actions",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
"system:profiler": {
|
|
|
|
|
description: "Run server CPU and memory profiler captures",
|
2026-05-19 00:46:30 -05:00
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
2026-05-20 15:26:41 -05:00
|
|
|
"clients:elevate": {
|
|
|
|
|
description: "Elevate agent privileges (UAC on Windows, sudo on macOS)",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
2026-05-19 00:46:30 -05:00
|
|
|
"clients:winre": {
|
|
|
|
|
description: "Install or uninstall WinRE persistence on clients",
|
|
|
|
|
roles: ["admin"],
|
|
|
|
|
},
|
|
|
|
|
} as const satisfies Record<string, PermissionDef>;
|
|
|
|
|
|
|
|
|
|
export type Permission = keyof typeof PERMISSIONS;
|
|
|
|
|
|
2026-05-19 09:57:47 -05:00
|
|
|
export function listAllPermissions(): Permission[] {
|
|
|
|
|
return Object.keys(PERMISSIONS) as Permission[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 00:46:30 -05:00
|
|
|
function lookupPermission(permission: string): PermissionDef | undefined {
|
|
|
|
|
return (PERMISSIONS as Record<string, PermissionDef>)[permission];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function hasPermission(
|
|
|
|
|
role: UserRole,
|
|
|
|
|
permission: Permission | string,
|
|
|
|
|
userId?: number,
|
|
|
|
|
): boolean {
|
|
|
|
|
const def = lookupPermission(permission);
|
|
|
|
|
if (!def) return false;
|
|
|
|
|
// The legacy role-level gate. Per-permission userOverride (canBuild,
|
|
|
|
|
// canChatWrite) is authoritative when present + userId is known — it can
|
|
|
|
|
// both grant AND deny relative to the role default (an operator with
|
|
|
|
|
// can_build=0 loses clients:build at the role layer). Without a userOverride,
|
|
|
|
|
// membership in def.roles decides.
|
|
|
|
|
let grantedByRole: boolean;
|
|
|
|
|
if (def.userOverride && userId !== undefined) {
|
|
|
|
|
grantedByRole = def.userOverride(userId, role);
|
|
|
|
|
} else {
|
|
|
|
|
grantedByRole = def.roles.includes(role);
|
|
|
|
|
}
|
|
|
|
|
if (grantedByRole) return true;
|
|
|
|
|
// Permission groups + per-user extras are *additive only*: they can grant a
|
|
|
|
|
// permission the role doesn't have, but they cannot revoke one the role does.
|
2026-06-04 19:44:36 -05:00
|
|
|
if (userId !== undefined) {
|
|
|
|
|
const granted = getUserGrantedPermissions(userId);
|
|
|
|
|
if (granted.has(permission)) return true;
|
|
|
|
|
if (
|
|
|
|
|
typeof permission === "string" &&
|
|
|
|
|
permission.startsWith("system:") &&
|
|
|
|
|
permission !== "system:configure" &&
|
|
|
|
|
granted.has("system:configure")
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-05-19 00:46:30 -05:00
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
export function checkPermission(
|
|
|
|
|
user: AuthenticatedUser | null,
|
|
|
|
|
permission: Permission,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (!user) return false;
|
2026-03-12 21:26:53 -05:00
|
|
|
return hasPermission(user.role, permission, user.userId);
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function checkAnyPermission(
|
|
|
|
|
user: AuthenticatedUser | null,
|
|
|
|
|
permissions: Permission[],
|
|
|
|
|
): boolean {
|
|
|
|
|
if (!user) return false;
|
2026-03-12 21:26:53 -05:00
|
|
|
return permissions.some((p) => hasPermission(user.role, p, user.userId));
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function checkAllPermissions(
|
|
|
|
|
user: AuthenticatedUser | null,
|
|
|
|
|
permissions: Permission[],
|
|
|
|
|
): boolean {
|
|
|
|
|
if (!user) return false;
|
2026-03-12 21:26:53 -05:00
|
|
|
return permissions.every((p) => hasPermission(user.role, p, user.userId));
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function requireAuth(user: AuthenticatedUser | null): AuthenticatedUser {
|
|
|
|
|
if (!user) {
|
|
|
|
|
throw new Response("Unauthorized", { status: 401 });
|
|
|
|
|
}
|
|
|
|
|
return user;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function requirePermission(
|
|
|
|
|
user: AuthenticatedUser | null,
|
|
|
|
|
permission: Permission,
|
|
|
|
|
): AuthenticatedUser {
|
|
|
|
|
const authedUser = requireAuth(user);
|
|
|
|
|
|
|
|
|
|
if (!checkPermission(authedUser, permission)) {
|
2026-07-21 13:18:30 -05:00
|
|
|
throw new Response(`Forbidden: missing permission ${permission}`, { status: 403 });
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return authedUser;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function requireAnyPermission(
|
|
|
|
|
user: AuthenticatedUser | null,
|
|
|
|
|
permissions: Permission[],
|
|
|
|
|
): AuthenticatedUser {
|
|
|
|
|
const authedUser = requireAuth(user);
|
|
|
|
|
|
|
|
|
|
if (!checkAnyPermission(authedUser, permissions)) {
|
|
|
|
|
throw new Response("Forbidden: Insufficient permissions", { status: 403 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return authedUser;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 00:46:30 -05:00
|
|
|
export function getPermissionDescription(permission: Permission | string): string {
|
|
|
|
|
return lookupPermission(permission)?.description ?? "Unknown permission";
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getRoleDescription(role: UserRole): string {
|
|
|
|
|
switch (role) {
|
|
|
|
|
case "admin":
|
|
|
|
|
return "Full access - can manage users and control all clients";
|
|
|
|
|
case "operator":
|
|
|
|
|
return "Can control clients but cannot manage users";
|
|
|
|
|
case "viewer":
|
|
|
|
|
return "Read-only access to view clients";
|
|
|
|
|
default:
|
|
|
|
|
return "Unknown role";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 00:46:30 -05:00
|
|
|
export function requireClientAccess(
|
|
|
|
|
user: AuthenticatedUser | null,
|
|
|
|
|
clientId: string,
|
|
|
|
|
): AuthenticatedUser {
|
|
|
|
|
const authed = requireAuth(user);
|
|
|
|
|
if (!canUserAccessClient(authed.userId, authed.role, clientId)) {
|
|
|
|
|
throw new Response("Forbidden: You do not have access to this client", { status: 403 });
|
|
|
|
|
}
|
|
|
|
|
return authed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function requirePluginAccess(
|
|
|
|
|
user: AuthenticatedUser | null,
|
|
|
|
|
pluginId: string,
|
|
|
|
|
): AuthenticatedUser {
|
|
|
|
|
const authed = requireAuth(user);
|
|
|
|
|
if (!canUserAccessPlugin(authed.userId, authed.role, pluginId)) {
|
|
|
|
|
throw new Response("Forbidden: You do not have access to this plugin", { status: 403 });
|
|
|
|
|
}
|
|
|
|
|
return authed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function requireFeatureAccess(
|
|
|
|
|
user: AuthenticatedUser | null,
|
|
|
|
|
feature: FeatureName,
|
|
|
|
|
): AuthenticatedUser {
|
|
|
|
|
const authed = requireAuth(user);
|
|
|
|
|
if (!canUserAccessFeature(authed.userId, authed.role, feature)) {
|
|
|
|
|
throw new Response("Forbidden: feature access denied", { status: 403 });
|
|
|
|
|
}
|
|
|
|
|
return authed;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
export function getRolePermissions(role: UserRole): Permission[] {
|
2026-05-19 00:46:30 -05:00
|
|
|
const result: Permission[] = [];
|
|
|
|
|
for (const perm of Object.keys(PERMISSIONS) as Permission[]) {
|
|
|
|
|
const def = lookupPermission(perm);
|
|
|
|
|
if (def && def.roles.includes(role)) result.push(perm);
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
|
2026-05-19 00:46:30 -05:00
|
|
|
export function getUserPermissions(userId: number, role: UserRole): Permission[] {
|
|
|
|
|
const result: Permission[] = [];
|
|
|
|
|
for (const perm of Object.keys(PERMISSIONS) as Permission[]) {
|
|
|
|
|
if (hasPermission(role, perm, userId)) result.push(perm);
|
|
|
|
|
}
|
|
|
|
|
return result;
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
2026-07-21 13:18:30 -05:00
|
|
|
|
|
|
|
|
export type EffectivePermissionSource =
|
|
|
|
|
| { type: "role"; label: string; role: UserRole }
|
|
|
|
|
| { type: "group"; label: string; groupId: number }
|
|
|
|
|
| { type: "extra"; label: string }
|
|
|
|
|
| { type: "system-configure"; label: string };
|
|
|
|
|
|
|
|
|
|
export type EffectivePermissionDetail = {
|
|
|
|
|
id: Permission;
|
|
|
|
|
description: string;
|
|
|
|
|
allowed: boolean;
|
|
|
|
|
sources: EffectivePermissionSource[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function getUserEffectivePermissionDetails(
|
|
|
|
|
userId: number,
|
|
|
|
|
role: UserRole,
|
|
|
|
|
): EffectivePermissionDetail[] {
|
|
|
|
|
const assignedGroupIds = new Set(getUserGroupIds(userId));
|
|
|
|
|
const groups = listPermissionGroups().filter((group) => assignedGroupIds.has(group.id));
|
|
|
|
|
const extras = getUserExtraPermissions(userId);
|
|
|
|
|
|
|
|
|
|
return listAllPermissions().map((permission) => {
|
|
|
|
|
const def = lookupPermission(permission);
|
|
|
|
|
const sources: EffectivePermissionSource[] = [];
|
|
|
|
|
|
|
|
|
|
if (def) {
|
|
|
|
|
const roleGrants = def.userOverride
|
|
|
|
|
? def.userOverride(userId, role)
|
|
|
|
|
: def.roles.includes(role);
|
|
|
|
|
if (roleGrants) {
|
|
|
|
|
sources.push({
|
|
|
|
|
type: "role",
|
|
|
|
|
label: def.overrideSourceLabel || `${role} role`,
|
|
|
|
|
role,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const group of groups) {
|
|
|
|
|
if (group.permissions.includes(permission)) {
|
|
|
|
|
sources.push({
|
|
|
|
|
type: "group",
|
|
|
|
|
label: group.name,
|
|
|
|
|
groupId: group.id,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (extras.has(permission)) {
|
|
|
|
|
sources.push({
|
|
|
|
|
type: "extra",
|
|
|
|
|
label: "Direct extra permission",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
sources.length === 0 &&
|
|
|
|
|
permission.startsWith("system:") &&
|
|
|
|
|
permission !== "system:configure"
|
|
|
|
|
) {
|
|
|
|
|
const configureGroup = groups.find((group) => group.permissions.includes("system:configure"));
|
|
|
|
|
if (configureGroup) {
|
|
|
|
|
sources.push({
|
|
|
|
|
type: "system-configure",
|
|
|
|
|
label: `Via ${configureGroup.name} → system:configure`,
|
|
|
|
|
});
|
|
|
|
|
} else if (extras.has("system:configure")) {
|
|
|
|
|
sources.push({
|
|
|
|
|
type: "system-configure",
|
|
|
|
|
label: "Via direct system:configure",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: permission,
|
|
|
|
|
description: getPermissionDescription(permission),
|
|
|
|
|
allowed: hasPermission(role, permission, userId),
|
|
|
|
|
sources,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|