Files
mcpctl/src/mcpd/src/repositories/audit-event.repository.ts
Michal 2ddb493bb0 feat(mcpd): McpToken schema + CRUD routes + introspection
Adds a new McpToken Prisma model (project-scoped, SHA-256 hashed at rest,
optional expiry, revocable) plus backing repository, service, and REST
routes. Tokens are a first-class RBAC subject: new 'McpToken' kind is
added to the subject enum and the service auto-creates an RbacDefinition
with subject McpToken:<sha> when bindings are provided.

Creator-permission ceiling: the service rejects any requested binding
the creator cannot already satisfy themselves (re-uses
rbacService.canAccess / canRunOperation). rbacMode=clone snapshots the
creator's full permissions into the token.

Routes:
  POST   /api/v1/mcptokens              create (returns raw token once)
  GET    /api/v1/mcptokens              list (filter by project)
  GET    /api/v1/mcptokens/:id          describe (no secret in response)
  POST   /api/v1/mcptokens/:id/revoke   soft-delete + remove RbacDef
  DELETE /api/v1/mcptokens/:id          hard-delete
  GET    /api/v1/mcptokens/introspect   validate raw bearer (used by mcplocal)

Extends AuditEvent with optional tokenName/tokenSha fields (indexed) so
token-driven activity can be filtered later. Adds token helpers in
@mcpctl/shared: TOKEN_PREFIX='mcpctl_pat_', generateToken, hashToken,
isMcpToken, timingSafeEqualHex.

Follow-up PRs add the auth-hook dispatch on the prefix, the CLI verbs,
and the HTTP-mode mcplocal that calls /introspect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 01:00:04 +01:00

149 lines
5.6 KiB
TypeScript

import type { PrismaClient, AuditEvent, Prisma } from '@prisma/client';
import type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput, AuditSessionSummary } from './interfaces.js';
export class AuditEventRepository implements IAuditEventRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(filter?: AuditEventFilter): Promise<AuditEvent[]> {
const where = buildWhere(filter);
return this.prisma.auditEvent.findMany({
where,
orderBy: { timestamp: 'desc' },
take: filter?.limit ?? 100,
skip: filter?.offset ?? 0,
});
}
async findById(id: string): Promise<AuditEvent | null> {
return this.prisma.auditEvent.findUnique({ where: { id } });
}
async createMany(events: AuditEventCreateInput[]): Promise<number> {
const data = events.map((e) => ({
timestamp: new Date(e.timestamp),
sessionId: e.sessionId,
projectName: e.projectName,
eventKind: e.eventKind,
source: e.source,
verified: e.verified,
serverName: e.serverName ?? null,
correlationId: e.correlationId ?? null,
parentEventId: e.parentEventId ?? null,
userName: e.userName ?? null,
tokenName: e.tokenName ?? null,
tokenSha: e.tokenSha ?? null,
payload: e.payload as Prisma.InputJsonValue,
}));
const result = await this.prisma.auditEvent.createMany({ data });
return result.count;
}
async count(filter?: AuditEventFilter): Promise<number> {
const where = buildWhere(filter);
return this.prisma.auditEvent.count({ where });
}
async listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise<AuditSessionSummary[]> {
const where: Prisma.AuditEventWhereInput = {};
if (filter?.projectName !== undefined) where.projectName = filter.projectName;
if (filter?.userName !== undefined) where.userName = filter.userName;
if (filter?.from !== undefined || filter?.to !== undefined) {
const timestamp: Prisma.DateTimeFilter = {};
if (filter?.from !== undefined) timestamp.gte = filter.from;
if (filter?.to !== undefined) timestamp.lte = filter.to;
where.timestamp = timestamp;
}
const groups = await this.prisma.auditEvent.groupBy({
by: ['sessionId', 'projectName'],
where,
_min: { timestamp: true },
_max: { timestamp: true },
_count: true,
orderBy: { _max: { timestamp: 'desc' } },
take: filter?.limit ?? 50,
skip: filter?.offset ?? 0,
});
// Fetch distinct eventKinds + first userName per session
const sessionIds = groups.map((g) => g.sessionId);
const [kindRows, userNameRows] = sessionIds.length > 0
? await Promise.all([
this.prisma.auditEvent.findMany({
where: { sessionId: { in: sessionIds } },
select: { sessionId: true, eventKind: true },
distinct: ['sessionId', 'eventKind'],
}),
this.prisma.auditEvent.findMany({
where: { sessionId: { in: sessionIds }, userName: { not: null } },
select: { sessionId: true, userName: true },
distinct: ['sessionId'],
}),
])
: [[], []];
const kindMap = new Map<string, string[]>();
for (const row of kindRows) {
const list = kindMap.get(row.sessionId) ?? [];
list.push(row.eventKind);
kindMap.set(row.sessionId, list);
}
const userMap = new Map<string, string>();
for (const row of userNameRows) {
if (row.userName) userMap.set(row.sessionId, row.userName);
}
return groups.map((g) => ({
sessionId: g.sessionId,
projectName: g.projectName,
userName: userMap.get(g.sessionId) ?? null,
firstSeen: g._min.timestamp!,
lastSeen: g._max.timestamp!,
eventCount: g._count,
eventKinds: kindMap.get(g.sessionId) ?? [],
}));
}
async countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number> {
const where: Prisma.AuditEventWhereInput = {};
if (filter?.projectName !== undefined) where.projectName = filter.projectName;
if (filter?.userName !== undefined) where.userName = filter.userName;
if (filter?.from !== undefined || filter?.to !== undefined) {
const timestamp: Prisma.DateTimeFilter = {};
if (filter?.from !== undefined) timestamp.gte = filter.from;
if (filter?.to !== undefined) timestamp.lte = filter.to;
where.timestamp = timestamp;
}
const groups = await this.prisma.auditEvent.groupBy({
by: ['sessionId'],
where,
});
return groups.length;
}
}
function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput {
const where: Prisma.AuditEventWhereInput = {};
if (!filter) return where;
if (filter.sessionId !== undefined) where.sessionId = filter.sessionId;
if (filter.projectName !== undefined) where.projectName = filter.projectName;
if (filter.eventKind !== undefined) where.eventKind = filter.eventKind;
if (filter.serverName !== undefined) where.serverName = filter.serverName;
if (filter.correlationId !== undefined) where.correlationId = filter.correlationId;
if (filter.userName !== undefined) where.userName = filter.userName;
if (filter.tokenName !== undefined) where.tokenName = filter.tokenName;
if (filter.tokenSha !== undefined) where.tokenSha = filter.tokenSha;
if (filter.from !== undefined || filter.to !== undefined) {
const timestamp: Prisma.DateTimeFilter = {};
if (filter.from !== undefined) timestamp.gte = filter.from;
if (filter.to !== undefined) timestamp.lte = filter.to;
where.timestamp = timestamp;
}
return where;
}