mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-28 10:21:39 +00:00
🛡️ feat: Audit grant removals from the role-deletion cascade
Closes the forensic gap Codex flagged (R2-6/R3-4): deleting a role removed its SystemGrants with no audit entries. `deleteGrantsForPrincipal` now returns the removed grants, and the role-deletion handler emits a `grant.removed` audit entry per removed grant (actor = caller, target = role, metadata.capability, request context), matching the explicit revoke endpoint. Fail-open — the role is already deleted, so a failed audit is logged, not propagated; sequential to keep the per-tenant hash chain ordered. Extracted `buildAuditContext` to admin/context.ts (shared by grants + roles). Tests: role-deletion emits one entry per grant / none when no grants; ds 110, api admin 202 green.
This commit is contained in:
parent
4633460d9a
commit
15472127d6
6 changed files with 132 additions and 26 deletions
|
|
@ -29,6 +29,7 @@ const handlers = createAdminRolesHandlers({
|
|||
deleteConfig: db.deleteConfig,
|
||||
deleteAclEntries: db.deleteAclEntries,
|
||||
deleteGrantsForPrincipal: db.deleteGrantsForPrincipal,
|
||||
recordAuditEntry: db.recordAuditEntry,
|
||||
});
|
||||
|
||||
router.use(requireJwtAuth, requireAdminAccess);
|
||||
|
|
|
|||
21
packages/api/src/admin/context.ts
Normal file
21
packages/api/src/admin/context.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { AuditContext } from '@librechat/data-schemas';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
|
||||
/** Normalizes a possibly-repeated header to its first string value. */
|
||||
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/** Extracts forensic request context (IP, user agent, correlation id) for an
|
||||
* audit record. Fields are undefined when the data isn't available. */
|
||||
export function buildAuditContext(req: ServerRequest): AuditContext {
|
||||
const headers = req.headers ?? {};
|
||||
const forwarded = firstHeaderValue(headers['x-forwarded-for']);
|
||||
const forwardedIp = forwarded ? forwarded.split(',')[0]?.trim() : undefined;
|
||||
return {
|
||||
ip: req.ip || forwardedIp || req.socket?.remoteAddress || undefined,
|
||||
userAgent: firstHeaderValue(headers['user-agent']),
|
||||
requestId: firstHeaderValue(headers['x-request-id'] ?? headers['x-correlation-id']),
|
||||
};
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import type { Types } from 'mongoose';
|
|||
import type { ResolvedPrincipal } from '~/types/principal';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
import { parsePagination } from './pagination';
|
||||
import { buildAuditContext } from './context';
|
||||
|
||||
interface GrantRequestBody {
|
||||
principalType?: string;
|
||||
|
|
@ -94,25 +95,6 @@ export interface AdminGrantsDeps {
|
|||
auditFailClosed?: boolean;
|
||||
}
|
||||
|
||||
/** Normalizes a possibly-repeated header to its first string value. */
|
||||
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/** Extracts forensic request context (IP, user agent, correlation id) for the
|
||||
* audit record. Fields are undefined when the data isn't available. */
|
||||
function buildAuditContext(req: ServerRequest): AuditContext {
|
||||
const headers = req.headers ?? {};
|
||||
const forwarded = firstHeaderValue(headers['x-forwarded-for']);
|
||||
const forwardedIp = forwarded ? forwarded.split(',')[0]?.trim() : undefined;
|
||||
return {
|
||||
ip: req.ip || forwardedIp || req.socket?.remoteAddress || undefined,
|
||||
userAgent: firstHeaderValue(headers['user-agent']),
|
||||
requestId: firstHeaderValue(headers['x-request-id'] ?? headers['x-correlation-id']),
|
||||
};
|
||||
}
|
||||
|
||||
/** Currently ROLE-only; Record/Set structure preserved for future principal-type expansion. */
|
||||
export type GrantPrincipalType = PrincipalType.ROLE;
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function createDeps(overrides: Partial<AdminRolesDeps> = {}): AdminRolesDeps {
|
|||
countUsersByRole: jest.fn().mockResolvedValue(0),
|
||||
deleteConfig: jest.fn().mockResolvedValue(null),
|
||||
deleteAclEntries: jest.fn().mockResolvedValue(undefined),
|
||||
deleteGrantsForPrincipal: jest.fn().mockResolvedValue(undefined),
|
||||
deleteGrantsForPrincipal: jest.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -990,6 +990,51 @@ describe('createAdminRolesHandlers', () => {
|
|||
expect(json).toHaveBeenCalledWith({ success: true });
|
||||
});
|
||||
|
||||
it('emits a grant.removed audit entry for each grant removed by the cascade', async () => {
|
||||
const recordAuditEntry = jest.fn().mockResolvedValue(undefined);
|
||||
const deps = createDeps({
|
||||
deleteGrantsForPrincipal: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ capability: 'manage:users' }, { capability: 'read:users' }]),
|
||||
recordAuditEntry,
|
||||
});
|
||||
const handlers = createAdminRolesHandlers(deps);
|
||||
const userId = new Types.ObjectId();
|
||||
const { req, res, status } = createReqRes({
|
||||
params: { name: 'editor' },
|
||||
user: { _id: userId, role: 'admin', tenantId: 'tenant-1' },
|
||||
});
|
||||
|
||||
await handlers.deleteRole(req, res);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(recordAuditEntry).toHaveBeenCalledTimes(2);
|
||||
expect(recordAuditEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'grant.removed',
|
||||
actor: expect.objectContaining({ type: 'user', id: userId.toString() }),
|
||||
target: { type: PrincipalType.ROLE, id: 'editor', name: 'editor' },
|
||||
metadata: { capability: 'manage:users' },
|
||||
tenantId: 'tenant-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not audit when the deleted role had no grants', async () => {
|
||||
const recordAuditEntry = jest.fn().mockResolvedValue(undefined);
|
||||
const deps = createDeps({ recordAuditEntry });
|
||||
const handlers = createAdminRolesHandlers(deps);
|
||||
const { req, res, status } = createReqRes({
|
||||
params: { name: 'editor' },
|
||||
user: { _id: new Types.ObjectId(), role: 'admin' },
|
||||
});
|
||||
|
||||
await handlers.deleteRole(req, res);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(200);
|
||||
expect(recordAuditEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('succeeds even when all cascade operations fail', async () => {
|
||||
const deps = createDeps({
|
||||
deleteConfig: jest.fn().mockRejectedValue(new Error('config cleanup failed')),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import { PrincipalType, SystemRoles } from 'librechat-data-provider';
|
||||
import { logger, isValidObjectIdString, RoleConflictError } from '@librechat/data-schemas';
|
||||
import type { IRole, IUser, IConfig, AdminMember } from '@librechat/data-schemas';
|
||||
import type {
|
||||
IRole,
|
||||
IUser,
|
||||
IConfig,
|
||||
AdminMember,
|
||||
ISystemGrant,
|
||||
RecordAuditEntryInput,
|
||||
RecordAuditEntryOptions,
|
||||
} from '@librechat/data-schemas';
|
||||
import type { FilterQuery, Types } from 'mongoose';
|
||||
import type { Response } from 'express';
|
||||
import type { ServerRequest } from '~/types/http';
|
||||
import { parsePagination } from './pagination';
|
||||
import { buildAuditContext } from './context';
|
||||
|
||||
const systemRoleValues = new Set<string>(Object.values(SystemRoles));
|
||||
|
||||
|
|
@ -115,11 +124,18 @@ export interface AdminRolesDeps {
|
|||
principalType: PrincipalType;
|
||||
principalId: string | Types.ObjectId;
|
||||
}) => Promise<void>;
|
||||
/** Removes all system capability grants held by this principal. */
|
||||
/** Removes all system capability grants held by this principal and returns
|
||||
* the removed grants so each can be audited. */
|
||||
deleteGrantsForPrincipal: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
options?: { tenantId?: string },
|
||||
) => Promise<ISystemGrant[]>;
|
||||
/** Optional audit emission for grants removed by the role-deletion cascade.
|
||||
* Failure is logged but never blocks the deletion (which has already happened). */
|
||||
recordAuditEntry?: (
|
||||
input: RecordAuditEntryInput,
|
||||
options?: RecordAuditEntryOptions,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -152,8 +168,42 @@ export function createAdminRolesHandlers(deps: AdminRolesDeps): {
|
|||
deleteConfig,
|
||||
deleteAclEntries,
|
||||
deleteGrantsForPrincipal,
|
||||
recordAuditEntry,
|
||||
} = deps;
|
||||
|
||||
/** Emits a `grant.removed` audit entry for each grant the role-deletion cascade
|
||||
* removed, so the cascade leaves the same forensic trail as an explicit revoke.
|
||||
* Fail-open: the role is already deleted, so a failed audit is logged, not
|
||||
* propagated. Sequential to keep the per-tenant hash chain ordered. */
|
||||
async function emitGrantRemovals(
|
||||
req: ServerRequest,
|
||||
roleName: string,
|
||||
grants: ISystemGrant[],
|
||||
): Promise<void> {
|
||||
if (!recordAuditEntry || grants.length === 0) return;
|
||||
const user = req.user;
|
||||
const userId = user?._id?.toString() ?? user?.id;
|
||||
if (!user || !userId) return;
|
||||
const actorName = user.name || user.username || user.email || userId;
|
||||
const context = buildAuditContext(req);
|
||||
for (const grant of grants) {
|
||||
try {
|
||||
await recordAuditEntry({
|
||||
action: 'grant.removed',
|
||||
outcome: 'success',
|
||||
severity: 'warning',
|
||||
actor: { type: 'user', id: userId, name: actorName },
|
||||
target: { type: PrincipalType.ROLE, id: roleName, name: roleName },
|
||||
metadata: { capability: grant.capability },
|
||||
context,
|
||||
tenantId: user.tenantId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[adminRoles] grant.removed audit failed during role deletion', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function listRolesHandler(req: ServerRequest, res: Response) {
|
||||
try {
|
||||
const { limit, offset } = parsePagination(req.query);
|
||||
|
|
@ -398,16 +448,19 @@ export function createAdminRolesHandlers(deps: AdminRolesDeps): {
|
|||
}
|
||||
|
||||
const tenantId = req.user?.tenantId;
|
||||
const cleanupResults = await Promise.allSettled([
|
||||
const [configResult, aclResult, grantsResult] = await Promise.allSettled([
|
||||
deleteConfig(PrincipalType.ROLE, name),
|
||||
deleteAclEntries({ principalType: PrincipalType.ROLE, principalId: name }),
|
||||
deleteGrantsForPrincipal(PrincipalType.ROLE, name, { tenantId }),
|
||||
]);
|
||||
for (const result of cleanupResults) {
|
||||
for (const result of [configResult, aclResult, grantsResult]) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.error('[adminRoles] cascade cleanup failed for role:', name, result.reason);
|
||||
}
|
||||
}
|
||||
if (grantsResult.status === 'fulfilled') {
|
||||
await emitGrantRemovals(req, name, grantsResult.value);
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export function createSystemGrantMethods(mongoose: typeof import('mongoose')): {
|
|||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
options?: { tenantId?: string; session?: ClientSession },
|
||||
) => Promise<void>;
|
||||
) => Promise<ISystemGrant[]>;
|
||||
} {
|
||||
function tenantCondition(tenantId?: string): FilterQuery<ISystemGrant> {
|
||||
return tenantId != null
|
||||
|
|
@ -512,7 +512,7 @@ export function createSystemGrantMethods(mongoose: typeof import('mongoose')): {
|
|||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
options?: { tenantId?: string; session?: ClientSession },
|
||||
): Promise<void> {
|
||||
): Promise<ISystemGrant[]> {
|
||||
const SystemGrant = mongoose.models.SystemGrant as Model<ISystemGrant>;
|
||||
const normalizedPrincipalId = normalizePrincipalId(principalId, principalType);
|
||||
|
||||
|
|
@ -522,7 +522,11 @@ export function createSystemGrantMethods(mongoose: typeof import('mongoose')): {
|
|||
...(options?.tenantId != null && { tenantId: options.tenantId }),
|
||||
};
|
||||
const queryOptions = options?.session ? { session: options.session } : {};
|
||||
/** Read the matching grants before deleting so cascade callers (e.g. role
|
||||
* deletion) can emit a `grant.removed` audit entry for each one. */
|
||||
const removed = await SystemGrant.find(filter, null, queryOptions).lean<ISystemGrant[]>();
|
||||
await SystemGrant.deleteMany(filter, queryOptions);
|
||||
return removed;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue