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>
149 lines
5.6 KiB
TypeScript
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;
|
|
}
|