audit log permissions
This commit is contained in:
@@ -78,6 +78,16 @@ export enum AuditAction {
|
||||
ENROLLMENT_BULK = "enrollment_bulk",
|
||||
WINRE_INSTALL = "winre_install",
|
||||
WINRE_UNINSTALL = "winre_uninstall",
|
||||
PERMISSION_GROUP_CREATE = "permission_group_create",
|
||||
PERMISSION_GROUP_UPDATE = "permission_group_update",
|
||||
PERMISSION_GROUP_DELETE = "permission_group_delete",
|
||||
USER_GROUPS_CHANGE = "user_groups_change",
|
||||
USER_EXTRA_PERMISSIONS_CHANGE = "user_extra_permissions_change",
|
||||
USER_FEATURE_PERMISSIONS_CHANGE = "user_feature_permissions_change",
|
||||
USER_ROLE_CHANGE = "user_role_change",
|
||||
USER_BUILD_TOGGLE = "user_build_toggle",
|
||||
USER_UPLOAD_TOGGLE = "user_upload_toggle",
|
||||
USER_CHAT_WRITE_TOGGLE = "user_chat_write_toggle",
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { generateToken } from "../../auth";
|
||||
import * as clientManager from "../../clientManager";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserById,
|
||||
setUserClientAccessScope,
|
||||
setUserFeaturePermission,
|
||||
} from "../../users";
|
||||
import { upsertClientRow, deleteClientRow } from "../../db";
|
||||
import { handleClientRoutes } from "./client-routes";
|
||||
|
||||
const PASSWORD = "Aa1!RouteGateTestPass123";
|
||||
|
||||
const mockServer = {
|
||||
requestIP: () => ({ address: "127.0.0.1" }),
|
||||
};
|
||||
|
||||
const deps = {
|
||||
CORS_HEADERS: {} as Record<string, string>,
|
||||
pendingScripts: new Map<string, any>(),
|
||||
pendingCommandReplies: new Map<string, any>(),
|
||||
broadcastNotificationsCleared: () => {},
|
||||
};
|
||||
|
||||
const createdUserIds: number[] = [];
|
||||
const createdClientIds: string[] = [];
|
||||
|
||||
async function makeUserWithToken(role: "admin" | "operator" | "viewer", scopeAll = true) {
|
||||
const username = `gate_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
const result = await createUser(username, PASSWORD, role, "test");
|
||||
expect(result.success).toBe(true);
|
||||
const user = getUserById(result.userId!);
|
||||
expect(user).not.toBeNull();
|
||||
createdUserIds.push(user!.id);
|
||||
|
||||
if (scopeAll && role !== "admin") {
|
||||
setUserClientAccessScope(user!.id, "all");
|
||||
}
|
||||
|
||||
const token = await generateToken(user!);
|
||||
return { user: user!, token };
|
||||
}
|
||||
|
||||
function registerTestClient(): string {
|
||||
const clientId = `gate-client-${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
// Insert a row so clientExists() / DB-backed checks pass; also keep an
|
||||
// in-memory entry with a no-op ws.send() so the route's target.ws.send() is
|
||||
// exercised without actually trying to write to a socket.
|
||||
upsertClientRow({
|
||||
id: clientId,
|
||||
os: "windows",
|
||||
arch: "x64",
|
||||
online: 1,
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
clientManager.addClient(clientId, {
|
||||
id: clientId,
|
||||
lastSeen: Date.now(),
|
||||
role: "client",
|
||||
ws: { send: () => {} },
|
||||
} as any);
|
||||
createdClientIds.push(clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
async function command(token: string, clientId: string, action: string, extra: Record<string, unknown> = {}) {
|
||||
const url = new URL(`https://localhost/api/clients/${encodeURIComponent(clientId)}/command`);
|
||||
return handleClientRoutes(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ action, ...extra }),
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
}
|
||||
|
||||
async function patchNickname(token: string, clientId: string, nickname: string) {
|
||||
const url = new URL(`https://localhost/api/clients/${encodeURIComponent(clientId)}/nickname`);
|
||||
return handleClientRoutes(
|
||||
new Request(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ nickname }),
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
deps,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (createdClientIds.length > 0) {
|
||||
const id = createdClientIds.pop();
|
||||
if (typeof id === "string") {
|
||||
clientManager.deleteClient(id);
|
||||
try { deleteClientRow(id); } catch {}
|
||||
}
|
||||
}
|
||||
while (createdUserIds.length > 0) {
|
||||
const id = createdUserIds.pop();
|
||||
if (typeof id === "number") deleteUser(id);
|
||||
}
|
||||
});
|
||||
|
||||
describe("clients:disconnect gates", () => {
|
||||
test("admin can disconnect", async () => {
|
||||
const auth = await makeUserWithToken("admin");
|
||||
const clientId = registerTestClient();
|
||||
const res = await command(auth.token, clientId, "disconnect");
|
||||
expect(res!.status).toBe(200);
|
||||
});
|
||||
|
||||
test("operator (default) can disconnect", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
const clientId = registerTestClient();
|
||||
const res = await command(auth.token, clientId, "disconnect");
|
||||
expect(res!.status).toBe(200);
|
||||
});
|
||||
|
||||
test("operator with disconnect feature OFF gets 403", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
const clientId = registerTestClient();
|
||||
setUserFeaturePermission(auth.user.id, "disconnect", false);
|
||||
const res = await command(auth.token, clientId, "disconnect");
|
||||
expect(res!.status).toBe(403);
|
||||
});
|
||||
|
||||
test("viewer cannot reach the dispatch (blocked by outer clients:control gate)", async () => {
|
||||
const auth = await makeUserWithToken("viewer");
|
||||
const clientId = registerTestClient();
|
||||
const res = await command(auth.token, clientId, "disconnect");
|
||||
expect(res!.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clients:reconnect gates", () => {
|
||||
test("operator (default) can reconnect", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
const clientId = registerTestClient();
|
||||
const res = await command(auth.token, clientId, "reconnect");
|
||||
expect(res!.status).toBe(200);
|
||||
});
|
||||
|
||||
test("operator with reconnect feature OFF gets 403", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
const clientId = registerTestClient();
|
||||
setUserFeaturePermission(auth.user.id, "reconnect", false);
|
||||
const res = await command(auth.token, clientId, "reconnect");
|
||||
expect(res!.status).toBe(403);
|
||||
});
|
||||
|
||||
test("revoking disconnect does not revoke reconnect (feature toggles are independent)", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
const clientId = registerTestClient();
|
||||
setUserFeaturePermission(auth.user.id, "disconnect", false);
|
||||
const res = await command(auth.token, clientId, "reconnect");
|
||||
expect(res!.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clients:uninstall gates", () => {
|
||||
test("admin can uninstall", async () => {
|
||||
const auth = await makeUserWithToken("admin");
|
||||
const clientId = registerTestClient();
|
||||
const res = await command(auth.token, clientId, "uninstall");
|
||||
expect(res!.status).toBe(200);
|
||||
});
|
||||
|
||||
test("operator with uninstall feature OFF gets 403", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
const clientId = registerTestClient();
|
||||
setUserFeaturePermission(auth.user.id, "uninstall", false);
|
||||
const res = await command(auth.token, clientId, "uninstall");
|
||||
expect(res!.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clients:metadata gates", () => {
|
||||
test("operator (default) can set nickname", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
const clientId = registerTestClient();
|
||||
const res = await patchNickname(auth.token, clientId, "test-nick");
|
||||
expect(res!.status).toBe(200);
|
||||
});
|
||||
|
||||
test("operator with client_metadata feature OFF gets 403", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
const clientId = registerTestClient();
|
||||
setUserFeaturePermission(auth.user.id, "client_metadata", false);
|
||||
const res = await patchNickname(auth.token, clientId, "test-nick");
|
||||
expect(res!.status).toBe(403);
|
||||
});
|
||||
|
||||
test("viewer cannot set nickname", async () => {
|
||||
const auth = await makeUserWithToken("viewer");
|
||||
const clientId = registerTestClient();
|
||||
const res = await patchNickname(auth.token, clientId, "test-nick");
|
||||
expect(res!.status).toBe(403);
|
||||
});
|
||||
|
||||
test("revoking disconnect does not block metadata edits", async () => {
|
||||
const auth = await makeUserWithToken("operator");
|
||||
const clientId = registerTestClient();
|
||||
setUserFeaturePermission(auth.user.id, "disconnect", false);
|
||||
const res = await patchNickname(auth.token, clientId, "test-nick");
|
||||
expect(res!.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layered semantics: groups + feature toggles", () => {
|
||||
test("admin always bypasses both gates regardless of feature toggles", async () => {
|
||||
const auth = await makeUserWithToken("admin");
|
||||
const clientId = registerTestClient();
|
||||
// Admin's feature toggle is irrelevant (canUserAccessFeature returns true for admin).
|
||||
setUserFeaturePermission(auth.user.id, "disconnect", false);
|
||||
const res = await command(auth.token, clientId, "disconnect");
|
||||
expect(res!.status).toBe(200);
|
||||
});
|
||||
|
||||
test("viewer is denied even though feature toggle defaults look on (canUserAccessFeature returns false for viewer)", async () => {
|
||||
const auth = await makeUserWithToken("viewer");
|
||||
const clientId = registerTestClient();
|
||||
// Even if we tried to flip the toggle on, the feature gate denies viewers.
|
||||
setUserFeaturePermission(auth.user.id, "disconnect", true);
|
||||
const res = await command(auth.token, clientId, "disconnect");
|
||||
expect(res!.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,15 @@ function sanitizePermissions(raw: unknown): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
function diffArrays<T>(before: readonly T[], after: readonly T[]): { added: T[]; removed: T[] } {
|
||||
const b = new Set(before);
|
||||
const a = new Set(after);
|
||||
return {
|
||||
added: after.filter((x) => !b.has(x)),
|
||||
removed: before.filter((x) => !a.has(x)),
|
||||
};
|
||||
}
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
};
|
||||
@@ -110,8 +119,13 @@ export async function handlePermissionGroupsRoutes(
|
||||
timestamp: Date.now(),
|
||||
username: authed.username,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Created permission group "${result.group!.name}" with ${result.group!.permissions.length} permissions`,
|
||||
action: AuditAction.PERMISSION_GROUP_CREATE,
|
||||
details: JSON.stringify({
|
||||
groupId: result.group!.id,
|
||||
name: result.group!.name,
|
||||
description: result.group!.description,
|
||||
permissions: result.group!.permissions,
|
||||
}),
|
||||
success: true,
|
||||
});
|
||||
|
||||
@@ -139,6 +153,9 @@ export async function handlePermissionGroupsRoutes(
|
||||
if (req.method === "PATCH" || req.method === "PUT") {
|
||||
let body: any;
|
||||
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
|
||||
const before = getPermissionGroup(groupId);
|
||||
if (!before) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const updates: Parameters<typeof updatePermissionGroup>[1] = {};
|
||||
if (body?.name !== undefined) updates.name = String(body.name);
|
||||
if (body?.description !== undefined) updates.description = body.description === null ? null : String(body.description);
|
||||
@@ -146,12 +163,27 @@ export async function handlePermissionGroupsRoutes(
|
||||
const result = updatePermissionGroup(groupId, updates);
|
||||
if (!result.success) return Response.json({ error: result.error }, { status: 400 });
|
||||
|
||||
const after = result.group!;
|
||||
const permsDiff = diffArrays(before.permissions, after.permissions);
|
||||
const auditDetails: Record<string, unknown> = {
|
||||
groupId,
|
||||
name: after.name,
|
||||
};
|
||||
if (before.name !== after.name) auditDetails.nameChanged = { from: before.name, to: after.name };
|
||||
if (before.description !== after.description) {
|
||||
auditDetails.descriptionChanged = { from: before.description, to: after.description };
|
||||
}
|
||||
if (permsDiff.added.length || permsDiff.removed.length) {
|
||||
auditDetails.permissionsAdded = permsDiff.added;
|
||||
auditDetails.permissionsRemoved = permsDiff.removed;
|
||||
}
|
||||
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authed.username,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Updated permission group "${result.group!.name}"`,
|
||||
action: AuditAction.PERMISSION_GROUP_UPDATE,
|
||||
details: JSON.stringify(auditDetails),
|
||||
success: true,
|
||||
});
|
||||
return Response.json({ group: result.group });
|
||||
@@ -166,8 +198,12 @@ export async function handlePermissionGroupsRoutes(
|
||||
timestamp: Date.now(),
|
||||
username: authed.username,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Deleted permission group "${existing?.name ?? groupId}"`,
|
||||
action: AuditAction.PERMISSION_GROUP_DELETE,
|
||||
details: JSON.stringify({
|
||||
groupId,
|
||||
name: existing?.name ?? null,
|
||||
permissions: existing?.permissions ?? [],
|
||||
}),
|
||||
success: true,
|
||||
});
|
||||
return Response.json({ ok: true });
|
||||
@@ -200,15 +236,24 @@ export async function handlePermissionGroupsRoutes(
|
||||
const ids = Array.isArray(body?.groupIds)
|
||||
? body.groupIds.map((g: any) => Number(g)).filter((g: number) => Number.isFinite(g))
|
||||
: [];
|
||||
const targetUser = getUserById(targetUserId);
|
||||
const before = getUserGroupIds(targetUserId);
|
||||
const result = setUserGroups(targetUserId, ids);
|
||||
if (!result.success) return Response.json({ error: result.error }, { status: 400 });
|
||||
|
||||
const groupDiff = diffArrays(before, ids);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authed.username,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Assigned ${ids.length} permission group(s) to user #${targetUserId}`,
|
||||
action: AuditAction.USER_GROUPS_CHANGE,
|
||||
details: JSON.stringify({
|
||||
targetUserId,
|
||||
targetUsername: targetUser?.username,
|
||||
groupsAdded: groupDiff.added,
|
||||
groupsRemoved: groupDiff.removed,
|
||||
groupsAfter: ids,
|
||||
}),
|
||||
success: true,
|
||||
});
|
||||
return Response.json({ ok: true, groupIds: ids });
|
||||
@@ -239,15 +284,24 @@ export async function handlePermissionGroupsRoutes(
|
||||
let body: any;
|
||||
try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }
|
||||
const perms = sanitizePermissions(body?.permissions);
|
||||
const targetUser = getUserById(targetUserId);
|
||||
const before = Array.from(getUserExtraPermissions(targetUserId));
|
||||
const result = setUserExtraPermissions(targetUserId, perms);
|
||||
if (!result.success) return Response.json({ error: result.error }, { status: 400 });
|
||||
|
||||
const permsDiff = diffArrays(before, perms);
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authed.username,
|
||||
ip: server.requestIP(req)?.address || "unknown",
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Set ${perms.length} extra permission(s) on user #${targetUserId}`,
|
||||
action: AuditAction.USER_EXTRA_PERMISSIONS_CHANGE,
|
||||
details: JSON.stringify({
|
||||
targetUserId,
|
||||
targetUsername: targetUser?.username,
|
||||
permissionsAdded: permsDiff.added,
|
||||
permissionsRemoved: permsDiff.removed,
|
||||
permissionsAfter: perms,
|
||||
}),
|
||||
success: true,
|
||||
});
|
||||
return Response.json({ ok: true, permissions: perms });
|
||||
|
||||
@@ -191,6 +191,8 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "Cannot change your own role" }, { status: 400 });
|
||||
}
|
||||
|
||||
const beforeUser = getUserById(userId);
|
||||
const previousRole = beforeUser?.role ?? null;
|
||||
const result = updateUserRole(userId, role);
|
||||
|
||||
if (result.success) {
|
||||
@@ -207,8 +209,14 @@ export async function handleUsersRoutes(
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Updated role for user: ${targetUser?.username} to ${role}`,
|
||||
action: AuditAction.USER_ROLE_CHANGE,
|
||||
details: JSON.stringify({
|
||||
targetUserId: userId,
|
||||
targetUsername: targetUser?.username,
|
||||
from: previousRole,
|
||||
to: role,
|
||||
revokedSessions: existingSessions.length,
|
||||
}),
|
||||
success: true,
|
||||
});
|
||||
|
||||
@@ -376,6 +384,7 @@ export async function handleUsersRoutes(
|
||||
|
||||
const body = await req.json();
|
||||
const canBuild = !!body?.canBuild;
|
||||
const previous = Boolean(targetUser.can_build);
|
||||
const result = setUserCanBuild(userId, canBuild);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
@@ -386,8 +395,13 @@ export async function handleUsersRoutes(
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `${canBuild ? "Granted" : "Revoked"} build permission for ${targetUser.username}`,
|
||||
action: AuditAction.USER_BUILD_TOGGLE,
|
||||
details: JSON.stringify({
|
||||
targetUserId: userId,
|
||||
targetUsername: targetUser.username,
|
||||
from: previous,
|
||||
to: canBuild,
|
||||
}),
|
||||
success: true,
|
||||
});
|
||||
|
||||
@@ -404,6 +418,7 @@ export async function handleUsersRoutes(
|
||||
|
||||
const body = await req.json();
|
||||
const canUploadFiles = !!body?.canUploadFiles;
|
||||
const previous = Boolean(targetUser.can_upload_files);
|
||||
const result = setUserCanUploadFiles(userId, canUploadFiles);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
@@ -414,8 +429,13 @@ export async function handleUsersRoutes(
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `${canUploadFiles ? "Granted" : "Revoked"} file upload permission for ${targetUser.username}`,
|
||||
action: AuditAction.USER_UPLOAD_TOGGLE,
|
||||
details: JSON.stringify({
|
||||
targetUserId: userId,
|
||||
targetUsername: targetUser.username,
|
||||
from: previous,
|
||||
to: canUploadFiles,
|
||||
}),
|
||||
success: true,
|
||||
});
|
||||
|
||||
@@ -455,22 +475,33 @@ export async function handleUsersRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const before = getUserFeaturePermissions(userId);
|
||||
const result = setUserFeaturePermissions(userId, validated);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
const after = getUserFeaturePermissions(userId);
|
||||
|
||||
const changes: Record<string, { from: boolean; to: boolean }> = {};
|
||||
for (const f of Object.keys(after) as Array<keyof typeof after>) {
|
||||
if (before[f] !== after[f]) changes[f as string] = { from: before[f], to: after[f] };
|
||||
}
|
||||
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Updated feature permissions for ${targetUser.username}: ${JSON.stringify(validated)}`,
|
||||
action: AuditAction.USER_FEATURE_PERMISSIONS_CHANGE,
|
||||
details: JSON.stringify({
|
||||
targetUserId: userId,
|
||||
targetUsername: targetUser.username,
|
||||
changes,
|
||||
}),
|
||||
success: true,
|
||||
});
|
||||
|
||||
return Response.json({ success: true, permissions: getUserFeaturePermissions(userId) });
|
||||
return Response.json({ success: true, permissions: after });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+\/feature-permissions$/)) {
|
||||
@@ -481,18 +512,30 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const before = getUserFeaturePermissions(userId);
|
||||
const result = resetUserFeaturePermissions(userId);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
const after = getUserFeaturePermissions(userId);
|
||||
|
||||
const changes: Record<string, { from: boolean; to: boolean }> = {};
|
||||
for (const f of Object.keys(after) as Array<keyof typeof after>) {
|
||||
if (before[f] !== after[f]) changes[f as string] = { from: before[f], to: after[f] };
|
||||
}
|
||||
|
||||
const ip = server.requestIP(req)?.address || "unknown";
|
||||
logAudit({
|
||||
timestamp: Date.now(),
|
||||
username: authedUser.username,
|
||||
ip,
|
||||
action: AuditAction.COMMAND,
|
||||
details: `Reset feature permissions for ${targetUser.username} to defaults`,
|
||||
action: AuditAction.USER_FEATURE_PERMISSIONS_CHANGE,
|
||||
details: JSON.stringify({
|
||||
targetUserId: userId,
|
||||
targetUsername: targetUser.username,
|
||||
reset: true,
|
||||
changes,
|
||||
}),
|
||||
success: true,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user