diff --git a/backend/drizzle/0020_create-audit-logs.sql b/backend/drizzle/0020_create-audit-logs.sql new file mode 100644 index 000000000..9763e81ba --- /dev/null +++ b/backend/drizzle/0020_create-audit-logs.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "organization_id" varchar(191), + "actor_id" varchar(191), + "actor_type" varchar(32) NOT NULL, + "actor_display" varchar(191), + "action" varchar(64) NOT NULL, + "resource_type" varchar(32) NOT NULL, + "resource_id" varchar(191), + "resource_name" varchar(191), + "metadata" jsonb, + "ip" varchar(64), + "user_agent" text, + "created_at" timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS "audit_logs_org_created_at_idx" ON "audit_logs" ("organization_id", "created_at" DESC); +CREATE INDEX IF NOT EXISTS "audit_logs_org_resource_idx" ON "audit_logs" ("organization_id", "resource_type", "resource_id"); +CREATE INDEX IF NOT EXISTS "audit_logs_org_action_created_at_idx" ON "audit_logs" ("organization_id", "action", "created_at" DESC); +CREATE INDEX IF NOT EXISTS "audit_logs_org_actor_created_at_idx" ON "audit_logs" ("organization_id", "actor_id", "created_at" DESC); + diff --git a/backend/scripts/generate-openapi.ts b/backend/scripts/generate-openapi.ts index 140afcaec..fd39d0c55 100644 --- a/backend/scripts/generate-openapi.ts +++ b/backend/scripts/generate-openapi.ts @@ -11,6 +11,12 @@ async function generateOpenApi() { // Skip ingest services that require external connections during OpenAPI generation process.env.SKIP_INGEST_SERVICES = 'true'; process.env.SHIPSEC_SKIP_MIGRATION_CHECK = 'true'; + // Ensure encryption services can bootstrap during schema generation. + // This key is only used to construct the Nest application for OpenAPI output. + process.env.SECRET_STORE_MASTER_KEY = + process.env.SECRET_STORE_MASTER_KEY ?? 'shipsec-openapi-master-key-32bxx'; + process.env.INTEGRATION_STORE_MASTER_KEY = + process.env.INTEGRATION_STORE_MASTER_KEY ?? 'shipsec-openapi-master-key-32bxx'; const { AppModule } = await import('../src/app.module'); diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts index a8cef78ac..e4df18e38 100644 --- a/backend/src/analytics/analytics.controller.ts +++ b/backend/src/analytics/analytics.controller.ts @@ -22,6 +22,7 @@ import { UpdateAnalyticsSettingsDto, TIER_LIMITS, } from './dto/analytics-settings.dto'; +import { AuditLogService } from '../audit/audit-log.service'; import { CurrentAuth } from '../auth/auth-context.decorator'; import { Public } from '../auth/public.decorator'; import type { AuthContext } from '../auth/types'; @@ -43,6 +44,7 @@ export class AnalyticsController { private readonly organizationSettingsService: OrganizationSettingsService, private readonly openSearchTenantService: OpenSearchTenantService, private readonly configService: ConfigService, + private readonly auditLogService: AuditLogService, ) { this.internalServiceToken = this.configService.get('INTERNAL_SERVICE_TOKEN') || ''; } @@ -106,6 +108,19 @@ export class AnalyticsController { throw new BadRequestException(`Invalid from: maximum is ${MAX_QUERY_FROM}`); } + this.auditLogService.record(auth, { + action: 'analytics.query', + resourceType: 'analytics', + resourceId: null, + resourceName: null, + metadata: { + size, + from, + hasQuery: Boolean(queryDto.query), + hasAggs: Boolean(queryDto.aggs), + }, + }); + // Call the service to execute the query return this.securityAnalyticsService.query(auth.organizationId, { query: queryDto.query, diff --git a/backend/src/api-keys/api-keys.service.ts b/backend/src/api-keys/api-keys.service.ts index ccf013986..cd01833ed 100644 --- a/backend/src/api-keys/api-keys.service.ts +++ b/backend/src/api-keys/api-keys.service.ts @@ -14,6 +14,7 @@ import * as crypto from 'crypto'; import * as bcrypt from 'bcryptjs'; import type { CreateApiKeyDto, ListApiKeysQueryDto, UpdateApiKeyDto } from './dto/api-key.dto'; import type { AuthContext } from '../auth/types'; +import { AuditLogService } from '../audit/audit-log.service'; const KEY_PREFIX = 'sk_live_'; @@ -24,6 +25,7 @@ export class ApiKeysService { constructor( @Inject(DRIZZLE_TOKEN) private readonly db: NodePgDatabase, + private readonly auditLogService: AuditLogService, ) {} async create(auth: AuthContext, dto: CreateApiKeyDto) { @@ -51,6 +53,17 @@ export class ApiKeysService { }) .returning(); + this.auditLogService.record(auth, { + action: 'api_key.create', + resourceType: 'api_key', + resourceId: apiKey.id, + resourceName: apiKey.name, + metadata: { + isActive: apiKey.isActive, + expiresAt: apiKey.expiresAt?.toISOString() ?? null, + }, + }); + return { apiKey, plainKey }; } @@ -109,6 +122,23 @@ export class ApiKeysService { throw new NotFoundException('API key not found'); } + const action = + dto.isActive === false + ? 'api_key.revoke' + : dto.isActive === true + ? 'api_key.reactivate' + : 'api_key.update'; + this.auditLogService.record(auth, { + action, + resourceType: 'api_key', + resourceId: apiKey.id, + resourceName: apiKey.name, + metadata: { + updatedFields: Object.keys(dto), + isActive: apiKey.isActive, + }, + }); + return apiKey; } @@ -117,6 +147,13 @@ export class ApiKeysService { throw new NotFoundException('API key not found'); } + const existing = await this.db + .select() + .from(apiKeys) + .where(and(eq(apiKeys.id, id), eq(apiKeys.organizationId, auth.organizationId))) + .limit(1) + .then((rows) => rows[0] ?? null); + const result = await this.db .delete(apiKeys) .where(and(eq(apiKeys.id, id), eq(apiKeys.organizationId, auth.organizationId))); @@ -124,6 +161,13 @@ export class ApiKeysService { if (result.rowCount === 0) { throw new NotFoundException('API key not found'); } + + this.auditLogService.record(auth, { + action: 'api_key.delete', + resourceType: 'api_key', + resourceId: id, + resourceName: existing?.name ?? null, + }); } async validateKey(plainKey: string): Promise { diff --git a/backend/src/api-keys/dto/api-key.dto.ts b/backend/src/api-keys/dto/api-key.dto.ts index 96ee33f17..36c56fa00 100644 --- a/backend/src/api-keys/dto/api-key.dto.ts +++ b/backend/src/api-keys/dto/api-key.dto.ts @@ -13,6 +13,9 @@ export const ApiKeyPermissionsSchema = z.object({ read: z.boolean(), cancel: z.boolean(), }), + audit: z.object({ + read: z.boolean(), + }), }); export const CreateApiKeySchema = z.object({ diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2d0553d99..d2271cb4b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -27,6 +27,7 @@ import { SchedulesModule } from './schedules/schedules.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { McpModule } from './mcp/mcp.module'; import { StudioMcpModule } from './studio-mcp/studio-mcp.module'; +import { AuditModule } from './audit/audit.module'; import { ApiKeysModule } from './api-keys/api-keys.module'; import { WebhooksModule } from './webhooks/webhooks.module'; @@ -52,6 +53,7 @@ const coreModules = [ McpGroupsModule, McpModule, StudioMcpModule, + AuditModule, ]; const testingModules = process.env.NODE_ENV === 'production' ? [] : [TestingSupportModule]; diff --git a/backend/src/audit/__tests__/audit-log.service.spec.ts b/backend/src/audit/__tests__/audit-log.service.spec.ts new file mode 100644 index 000000000..b3c523fb2 --- /dev/null +++ b/backend/src/audit/__tests__/audit-log.service.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'bun:test'; + +import type { AuthContext } from '../../auth/types'; +import { AuditLogService } from '../audit-log.service'; +import type { AuditLogRepository } from '../audit-log.repository'; + +function makeAuth(overrides: Partial = {}): AuthContext { + return { + userId: 'user-1', + organizationId: 'org-1', + roles: ['MEMBER'], + isAuthenticated: true, + provider: 'local', + ...overrides, + }; +} + +describe('AuditLogService', () => { + it('allows org admins to read audit logs', () => { + const repo: AuditLogRepository = { + insert: async () => {}, + list: async () => [], + } as any; + const service = new AuditLogService(repo); + expect(service.canRead(makeAuth({ roles: ['ADMIN'] }))).toBe(true); + }); + + it('allows API keys with audit.read=true to read audit logs', () => { + const repo: AuditLogRepository = { + insert: async () => {}, + list: async () => [], + } as any; + const service = new AuditLogService(repo); + expect( + service.canRead( + makeAuth({ + provider: 'api-key', + roles: ['MEMBER'], + apiKeyPermissions: { + workflows: { run: false, list: false, read: false }, + runs: { read: false, cancel: false }, + audit: { read: true }, + }, + }), + ), + ).toBe(true); + }); + + it('denies API keys without audit.read', () => { + const repo: AuditLogRepository = { + insert: async () => {}, + list: async () => [], + } as any; + const service = new AuditLogService(repo); + expect( + service.canRead( + makeAuth({ + provider: 'api-key', + roles: ['MEMBER'], + apiKeyPermissions: { + workflows: { run: true, list: true, read: true }, + runs: { read: true, cancel: true }, + audit: { read: false }, + }, + }), + ), + ).toBe(false); + }); + + it('record() never throws even if repository insert fails', async () => { + let called = false; + const repo: AuditLogRepository = { + insert: async () => { + called = true; + throw new Error('db down'); + }, + list: async () => [], + } as any; + const service = new AuditLogService(repo); + + expect(() => + service.record(makeAuth({ roles: ['ADMIN'] }), { + action: 'secret.access', + resourceType: 'secret', + resourceId: 'secret-1', + resourceName: 'foo', + metadata: { requestedVersion: 1 }, + }), + ).not.toThrow(); + + // Flush microtasks (record uses queueMicrotask). + await Promise.resolve(); + expect(called).toBe(true); + }); +}); diff --git a/backend/src/audit/audit-log.repository.ts b/backend/src/audit/audit-log.repository.ts new file mode 100644 index 000000000..3f739a7a3 --- /dev/null +++ b/backend/src/audit/audit-log.repository.ts @@ -0,0 +1,99 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { and, desc, eq, gte, lt, lte, or, sql, inArray, type SQL } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { DRIZZLE_TOKEN } from '../database/database.module'; +import { auditLogsTable, type AuditLogInsert, type AuditLogRecord } from '../database/schema'; + +export interface ListAuditLogFilters { + organizationId: string; + resourceType?: string | string[]; + resourceId?: string; + action?: string | string[]; + actorId?: string; + from?: Date; + to?: Date; + limit: number; + cursor?: { + createdAt: Date; + id: string; + }; +} + +@Injectable() +export class AuditLogRepository { + constructor( + @Inject(DRIZZLE_TOKEN) + private readonly db: NodePgDatabase, + ) {} + + async insert(values: Omit): Promise { + await this.db.insert(auditLogsTable).values(values); + } + + async list(filters: ListAuditLogFilters): Promise { + const conditions: SQL[] = []; + + // Always scope by organization. + conditions.push(eq(auditLogsTable.organizationId, filters.organizationId)); + + if (filters.resourceType) { + if (Array.isArray(filters.resourceType)) { + if (filters.resourceType.length > 0) { + conditions.push(inArray(auditLogsTable.resourceType, filters.resourceType as any[])); + } + } else { + conditions.push(eq(auditLogsTable.resourceType, filters.resourceType as any)); + } + } + if (filters.resourceId) { + conditions.push(eq(auditLogsTable.resourceId, filters.resourceId)); + } + if (filters.action) { + if (Array.isArray(filters.action)) { + if (filters.action.length > 0) { + conditions.push(inArray(auditLogsTable.action, filters.action)); + } + } else { + conditions.push(eq(auditLogsTable.action, filters.action)); + } + } + if (filters.actorId) { + conditions.push(eq(auditLogsTable.actorId, filters.actorId)); + } + if (filters.from) { + conditions.push(gte(auditLogsTable.createdAt, filters.from)); + } + if (filters.to) { + conditions.push(lte(auditLogsTable.createdAt, filters.to)); + } + + if (filters.cursor) { + const cursorCreatedAt = filters.cursor.createdAt; + const cursorId = filters.cursor.id; + // Pagination for DESC order: fetch items "older" than the cursor. + conditions.push( + or( + lt(auditLogsTable.createdAt, cursorCreatedAt), + and( + eq(auditLogsTable.createdAt, cursorCreatedAt), + sql`${auditLogsTable.id}::text < ${cursorId}`, + ), + )!, + ); + } + + let whereCondition: SQL = conditions[0]!; + for (let index = 1; index < conditions.length; index += 1) { + const next = and(whereCondition, conditions[index]!); + whereCondition = next ?? whereCondition; + } + + return this.db + .select() + .from(auditLogsTable) + .where(whereCondition) + .orderBy(desc(auditLogsTable.createdAt), desc(auditLogsTable.id)) + .limit(filters.limit); + } +} diff --git a/backend/src/audit/audit-log.service.ts b/backend/src/audit/audit-log.service.ts new file mode 100644 index 000000000..ffa450d8a --- /dev/null +++ b/backend/src/audit/audit-log.service.ts @@ -0,0 +1,152 @@ +import { ForbiddenException, Injectable, Logger } from '@nestjs/common'; + +import type { AuthContext } from '../auth/types'; +import { DEFAULT_ORGANIZATION_ID } from '../auth/constants'; +import type { AuditActorType, AuditResourceType } from '../database/schema/audit-logs'; +import { AuditLogRepository } from './audit-log.repository'; + +export interface AuditRequestMeta { + ip?: string | null; + userAgent?: string | null; +} + +export interface AuditEventInput { + action: string; + resourceType: AuditResourceType; + resourceId?: string | null; + resourceName?: string | null; + metadata?: Record | null; +} + +export interface ListAuditLogsInput { + resourceType?: string | string[]; + resourceId?: string; + action?: string | string[]; + actorId?: string; + from?: Date; + to?: Date; + limit: number; + cursor?: string; +} + +function actorTypeFromAuth(auth: AuthContext | null): AuditActorType { + if (!auth) return 'unknown'; + if (auth.provider === 'api-key') return 'api-key'; + if (auth.provider === 'internal') return 'internal'; + if (auth.isAuthenticated) return 'user'; + return 'unknown'; +} + +function decodeCursor(cursor: string): { createdAt: Date; id: string } | null { + try { + const raw = Buffer.from(cursor, 'base64url').toString('utf8'); + const [createdAtIso, id] = raw.split('|'); + if (!createdAtIso || !id) return null; + const createdAt = new Date(createdAtIso); + if (Number.isNaN(createdAt.getTime())) return null; + return { createdAt, id }; + } catch { + return null; + } +} + +function encodeCursor(createdAt: Date, id: string): string { + return Buffer.from(`${createdAt.toISOString()}|${id}`, 'utf8').toString('base64url'); +} + +@Injectable() +export class AuditLogService { + private readonly logger = new Logger(AuditLogService.name); + + constructor(private readonly repository: AuditLogRepository) {} + + canRead(auth: AuthContext | null): boolean { + if (!auth?.isAuthenticated) return false; + + if (auth.roles.includes('ADMIN')) { + return true; + } + + if (auth.provider === 'api-key') { + return Boolean(auth.apiKeyPermissions?.audit?.read); + } + + return false; + } + + record( + auth: AuthContext | null, + event: AuditEventInput, + meta?: AuditRequestMeta, + organizationIdOverride?: string | null, + ): void { + const organizationId = + organizationIdOverride ?? auth?.organizationId ?? DEFAULT_ORGANIZATION_ID; + const actorType = actorTypeFromAuth(auth); + const actorId = auth?.userId ?? null; + + const values = { + organizationId, + actorId, + actorType, + actorDisplay: null, + action: event.action, + resourceType: event.resourceType, + resourceId: event.resourceId ?? null, + resourceName: event.resourceName ?? null, + metadata: event.metadata ?? null, + ip: meta?.ip ?? null, + userAgent: meta?.userAgent ?? null, + }; + + // Non-blocking: audit logging must never affect API latency or success. + queueMicrotask(() => { + this.repository.insert(values).catch((error) => { + this.logger.warn( + `Failed to write audit log action=${event.action} resourceType=${event.resourceType}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + }); + } + + async list(auth: AuthContext | null, input: ListAuditLogsInput) { + if (!this.canRead(auth)) { + throw new ForbiddenException('Audit log access denied'); + } + + const organizationId = auth?.organizationId ?? DEFAULT_ORGANIZATION_ID; + const cursor = input.cursor ? decodeCursor(input.cursor) : null; + + const resourceTypes = input.resourceType + ? (Array.isArray(input.resourceType) + ? input.resourceType + : input.resourceType.split(',') + ).map((s) => s.trim()) + : undefined; + + const actions = input.action + ? (Array.isArray(input.action) ? input.action : input.action.split(',')).map((s) => s.trim()) + : undefined; + + const items = await this.repository.list({ + organizationId, + resourceType: resourceTypes, + resourceId: input.resourceId, + action: actions, + actorId: input.actorId, + from: input.from, + to: input.to, + limit: input.limit, + cursor: cursor ?? undefined, + }); + + const nextCursor = + items.length === input.limit + ? encodeCursor(items[items.length - 1]!.createdAt, items[items.length - 1]!.id) + : null; + + return { items, nextCursor }; + } +} diff --git a/backend/src/audit/audit-logs.controller.ts b/backend/src/audit/audit-logs.controller.ts new file mode 100644 index 000000000..7a35ad044 --- /dev/null +++ b/backend/src/audit/audit-logs.controller.ts @@ -0,0 +1,61 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { ZodValidationPipe } from 'nestjs-zod'; + +import { CurrentAuth } from '../auth/auth-context.decorator'; +import type { AuthContext } from '../auth/types'; +import { AuditLogService } from './audit-log.service'; +import { + ListAuditLogsQuerySchema, + type ListAuditLogsQueryDto, + ListAuditLogsResponseDto, +} from './dto/audit-logs.dto'; + +@ApiTags('audit-logs') +@Controller('audit-logs') +export class AuditLogsController { + constructor(private readonly auditLogService: AuditLogService) {} + + @Get() + @ApiOkResponse({ + description: 'List audit log events for the authenticated organization', + type: ListAuditLogsResponseDto, + }) + async list( + @CurrentAuth() auth: AuthContext | null, + @Query(new ZodValidationPipe(ListAuditLogsQuerySchema)) query: ListAuditLogsQueryDto, + ): Promise { + const from = query.from ? new Date(query.from) : undefined; + const to = query.to ? new Date(query.to) : undefined; + + const result = await this.auditLogService.list(auth, { + resourceType: query.resourceType, + resourceId: query.resourceId, + action: query.action, + actorId: query.actorId, + from, + to, + limit: query.limit, + cursor: query.cursor, + }); + + return { + items: result.items.map((item) => ({ + id: item.id, + organizationId: item.organizationId ?? null, + actorId: item.actorId ?? null, + actorType: item.actorType, + actorDisplay: item.actorDisplay ?? null, + action: item.action, + resourceType: item.resourceType, + resourceId: item.resourceId ?? null, + resourceName: item.resourceName ?? null, + metadata: (item.metadata as any) ?? null, + ip: item.ip ?? null, + userAgent: item.userAgent ?? null, + createdAt: item.createdAt.toISOString(), + })), + nextCursor: result.nextCursor, + }; + } +} diff --git a/backend/src/audit/audit.module.ts b/backend/src/audit/audit.module.ts new file mode 100644 index 000000000..f39863ba4 --- /dev/null +++ b/backend/src/audit/audit.module.ts @@ -0,0 +1,15 @@ +import { Global, Module } from '@nestjs/common'; + +import { DatabaseModule } from '../database/database.module'; +import { AuditLogRepository } from './audit-log.repository'; +import { AuditLogService } from './audit-log.service'; +import { AuditLogsController } from './audit-logs.controller'; + +@Global() +@Module({ + imports: [DatabaseModule], + controllers: [AuditLogsController], + providers: [AuditLogRepository, AuditLogService], + exports: [AuditLogService], +}) +export class AuditModule {} diff --git a/backend/src/audit/dto/audit-logs.dto.ts b/backend/src/audit/dto/audit-logs.dto.ts new file mode 100644 index 000000000..8c12ea6da --- /dev/null +++ b/backend/src/audit/dto/audit-logs.dto.ts @@ -0,0 +1,60 @@ +import { createZodDto } from 'nestjs-zod'; +import { z } from 'zod'; + +export const AuditActorTypeSchema = z.enum(['user', 'api-key', 'internal', 'unknown']); +export const AuditResourceTypeSchema = z.enum([ + 'workflow', + 'secret', + 'api_key', + 'webhook', + 'artifact', + 'analytics', + 'schedule', + 'mcp_server', + 'mcp_group', + 'human_input', +]); + +export const AuditLogEntrySchema = z.object({ + id: z.string().uuid(), + organizationId: z.string().nullable(), + actorId: z.string().nullable(), + actorType: AuditActorTypeSchema, + actorDisplay: z.string().nullable(), + action: z.string(), + resourceType: AuditResourceTypeSchema, + resourceId: z.string().nullable(), + resourceName: z.string().nullable(), + // Note: provide explicit key schema to keep zod->json-schema conversion stable. + metadata: z.record(z.string(), z.unknown()).nullable(), + ip: z.string().nullable(), + userAgent: z.string().nullable(), + createdAt: z.string().datetime(), +}); + +export class AuditLogEntryDto extends createZodDto(AuditLogEntrySchema) {} + +export const ListAuditLogsQuerySchema = z.object({ + resourceType: z.string().optional(), + resourceId: z.string().optional(), + action: z.string().optional(), + actorId: z.string().optional(), + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), + limit: z + .string() + .regex(/^\d+$/) + .default('50') + .transform(Number) + .refine((n) => n >= 1 && n <= 200, 'limit must be between 1 and 200'), + cursor: z.string().optional(), +}); + +export class ListAuditLogsQueryDto extends createZodDto(ListAuditLogsQuerySchema) {} + +export const ListAuditLogsResponseSchema = z.object({ + items: AuditLogEntrySchema.array(), + nextCursor: z.string().nullable(), +}); + +export class ListAuditLogsResponseDto extends createZodDto(ListAuditLogsResponseSchema) {} diff --git a/backend/src/auth/__tests__/auth.guard.spec.ts b/backend/src/auth/__tests__/auth.guard.spec.ts index 042f5548f..827a483e2 100644 --- a/backend/src/auth/__tests__/auth.guard.spec.ts +++ b/backend/src/auth/__tests__/auth.guard.spec.ts @@ -114,6 +114,7 @@ describe('AuthGuard', () => { permissions: { workflows: { run: true, list: true, read: true }, runs: { read: true, cancel: false }, + audit: { read: false }, }, isActive: true, expiresAt: null, @@ -258,6 +259,7 @@ describe('AuthGuard', () => { permissions: { workflows: { run: true, list: true, read: true }, runs: { read: true, cancel: true }, + audit: { read: false }, }, isActive: true, expiresAt: null, @@ -367,6 +369,7 @@ describe('AuthGuard', () => { permissions: { workflows: { run: true, list: true, read: true }, runs: { read: true, cancel: false }, + audit: { read: false }, }, isActive: true, expiresAt: null, @@ -464,6 +467,7 @@ describe('AuthGuard', () => { permissions: { workflows: { run: true, list: true, read: true }, runs: { read: true, cancel: false }, + audit: { read: false }, }, isActive: true, expiresAt: null, @@ -507,6 +511,7 @@ describe('AuthGuard', () => { permissions: { workflows: { run: true, list: true, read: true }, runs: { read: true, cancel: false }, + audit: { read: false }, }, isActive: true, expiresAt: null, @@ -546,6 +551,7 @@ describe('AuthGuard', () => { permissions: { workflows: { run: true, list: true, read: true }, runs: { read: true, cancel: false }, + audit: { read: false }, }, isActive: true, expiresAt: null, diff --git a/backend/src/auth/auth.guard.ts b/backend/src/auth/auth.guard.ts index 1d5b966fb..eb9c5a97f 100644 --- a/backend/src/auth/auth.guard.ts +++ b/backend/src/auth/auth.guard.ts @@ -118,13 +118,29 @@ export class AuthGuard implements CanActivate { return null; } + const permissions = (apiKey.permissions ?? {}) as any; + const normalizedPermissions = { + workflows: { + run: Boolean(permissions.workflows?.run), + list: Boolean(permissions.workflows?.list), + read: Boolean(permissions.workflows?.read), + }, + runs: { + read: Boolean(permissions.runs?.read), + cancel: Boolean(permissions.runs?.cancel), + }, + audit: { + read: Boolean(permissions.audit?.read), + }, + }; + return { userId: apiKey.id, organizationId: apiKey.organizationId, roles: ['MEMBER'], // API keys have MEMBER role by default isAuthenticated: true, provider: 'api-key', - apiKeyPermissions: apiKey.permissions, + apiKeyPermissions: normalizedPermissions, }; } } diff --git a/backend/src/auth/types.ts b/backend/src/auth/types.ts index 45af28b59..63a7a6f16 100644 --- a/backend/src/auth/types.ts +++ b/backend/src/auth/types.ts @@ -1,9 +1,7 @@ -export type AuthRole = 'ADMIN' | 'MEMBER'; +import type { ApiKeyPermissions } from '../database/schema/api-keys'; +export type { ApiKeyPermissions }; -export interface ApiKeyPermissions { - workflows: { run: boolean; list: boolean; read: boolean }; - runs: { read: boolean; cancel: boolean }; -} +export type AuthRole = 'ADMIN' | 'MEMBER'; export interface AuthContext { userId: string | null; diff --git a/backend/src/database/schema/api-keys.ts b/backend/src/database/schema/api-keys.ts index 20ea772ab..5aa0e7b15 100644 --- a/backend/src/database/schema/api-keys.ts +++ b/backend/src/database/schema/api-keys.ts @@ -20,6 +20,9 @@ export interface ApiKeyPermissions { read: boolean; cancel: boolean; }; + audit: { + read: boolean; + }; } export const apiKeys = pgTable( diff --git a/backend/src/database/schema/audit-logs.ts b/backend/src/database/schema/audit-logs.ts new file mode 100644 index 000000000..8729d26f1 --- /dev/null +++ b/backend/src/database/schema/audit-logs.ts @@ -0,0 +1,58 @@ +import { index, jsonb, pgTable, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; + +export type AuditActorType = 'user' | 'api-key' | 'internal' | 'unknown'; + +export type AuditResourceType = + | 'workflow' + | 'secret' + | 'api_key' + | 'webhook' + | 'artifact' + | 'analytics' + | 'schedule' + | 'mcp_server' + | 'mcp_group' + | 'human_input'; + +export const auditLogsTable = pgTable( + 'audit_logs', + { + id: uuid('id').primaryKey().defaultRandom(), + organizationId: varchar('organization_id', { length: 191 }), + actorId: varchar('actor_id', { length: 191 }), + actorType: varchar('actor_type', { length: 32 }).$type().notNull(), + actorDisplay: varchar('actor_display', { length: 191 }), + action: varchar('action', { length: 64 }).notNull(), + resourceType: varchar('resource_type', { length: 32 }).$type().notNull(), + resourceId: varchar('resource_id', { length: 191 }), + resourceName: varchar('resource_name', { length: 191 }), + metadata: jsonb('metadata').$type | null>().default(null), + ip: varchar('ip', { length: 64 }), + userAgent: text('user_agent'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + orgCreatedAtIdx: index('audit_logs_org_created_at_idx').on( + table.organizationId, + table.createdAt, + ), + resourceIdx: index('audit_logs_org_resource_idx').on( + table.organizationId, + table.resourceType, + table.resourceId, + ), + actionIdx: index('audit_logs_org_action_created_at_idx').on( + table.organizationId, + table.action, + table.createdAt, + ), + actorIdx: index('audit_logs_org_actor_created_at_idx').on( + table.organizationId, + table.actorId, + table.createdAt, + ), + }), +); + +export type AuditLogRecord = typeof auditLogsTable.$inferSelect; +export type AuditLogInsert = typeof auditLogsTable.$inferInsert; diff --git a/backend/src/database/schema/index.ts b/backend/src/database/schema/index.ts index 6d985721e..4fb211469 100644 --- a/backend/src/database/schema/index.ts +++ b/backend/src/database/schema/index.ts @@ -13,6 +13,7 @@ export * from './integrations'; export * from './workflow-schedules'; export * from './human-input-requests'; export * from './webhooks'; +export * from './audit-logs'; export * from './terminal-records'; export * from './agent-trace-events'; diff --git a/backend/src/human-inputs/__tests__/human-inputs.service.test.ts b/backend/src/human-inputs/__tests__/human-inputs.service.test.ts index b6597277d..fdf67943e 100644 --- a/backend/src/human-inputs/__tests__/human-inputs.service.test.ts +++ b/backend/src/human-inputs/__tests__/human-inputs.service.test.ts @@ -10,6 +10,8 @@ import { randomUUID } from 'node:crypto'; describe('HumanInputsService - IDOR Protection', () => { let service: HumanInputsService; let mockDb: any; + let auditRecordCalls: unknown[][]; + let temporalSignalCalls: unknown[]; const ORG_A = 'org-a-' + randomUUID(); const ORG_B = 'org-b-' + randomUUID(); @@ -31,6 +33,9 @@ describe('HumanInputsService - IDOR Protection', () => { }; beforeEach(() => { + auditRecordCalls = []; + temporalSignalCalls = []; + // Mock database with query builder mockDb = { query: { @@ -100,7 +105,18 @@ describe('HumanInputsService - IDOR Protection', () => { this._lastQuery = query; }; - service = new HumanInputsService(mockDb, {} as any); + const temporalService = { + signalWorkflow: async (payload: unknown) => { + temporalSignalCalls.push(payload); + }, + }; + const auditLogService = { + record: (...args: unknown[]) => { + auditRecordCalls.push(args); + }, + }; + + service = new HumanInputsService(mockDb, temporalService as any, auditLogService as any); }); it('should filter list by organization', async () => { @@ -194,4 +210,46 @@ describe('HumanInputsService - IDOR Protection', () => { expect(service.getById.length).toBeGreaterThan(0); expect(service.resolve.length).toBeGreaterThan(0); }); + + it('should keep public-link audit logs scoped to the request organization', async () => { + const publicRequest = { + id: randomUUID(), + organizationId: ORG_B, + title: 'Public Approval in Org B', + status: 'pending', + inputType: 'approval', + resolveToken: 'public-token', + runId: 'run-1', + nodeRef: 'approval-node', + respondedAt: null, + }; + const updated = { + ...publicRequest, + status: 'resolved', + respondedBy: 'public-link', + responseData: { status: 'approved' }, + updatedAt: new Date(), + respondedAt: new Date(), + }; + + mockDb.query.humanInputRequests.findFirst = async () => publicRequest; + mockDb.update = (_table: unknown) => ({ + set: (_updates: unknown) => ({ + where: (_where: unknown) => ({ + returning: async () => [updated], + }), + }), + }); + + const result = await service.resolveByToken('public-token', 'approve', { + comment: 'approved via link', + }); + + expect(result.success).toBe(true); + expect(temporalSignalCalls).toHaveLength(1); + expect(auditRecordCalls).toHaveLength(1); + expect(auditRecordCalls[0]?.[0]).toBeNull(); + expect((auditRecordCalls[0]?.[1] as { action?: string }).action).toBe('human_input.resolve'); + expect(auditRecordCalls[0]?.[3]).toBe(ORG_B); + }); }); diff --git a/backend/src/human-inputs/human-inputs.controller.ts b/backend/src/human-inputs/human-inputs.controller.ts index f8bce9cec..302dc650b 100644 --- a/backend/src/human-inputs/human-inputs.controller.ts +++ b/backend/src/human-inputs/human-inputs.controller.ts @@ -63,7 +63,7 @@ export class HumanInputsController { if (!auth || !auth.organizationId) { throw new UnauthorizedException('Authentication required'); } - return this.service.resolve(id, dto, auth.organizationId); + return this.service.resolve(id, dto, auth.organizationId, auth); } // Public endpoints for resolving via token (no auth guard) diff --git a/backend/src/human-inputs/human-inputs.service.ts b/backend/src/human-inputs/human-inputs.service.ts index 9211a9fe0..6dd72e7c7 100644 --- a/backend/src/human-inputs/human-inputs.service.ts +++ b/backend/src/human-inputs/human-inputs.service.ts @@ -11,6 +11,8 @@ import { PublicResolveResultDto, } from './dto/human-inputs.dto'; import { TemporalService } from '../temporal/temporal.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import type { AuthContext } from '../auth/types'; @Injectable() export class HumanInputsService { @@ -19,6 +21,7 @@ export class HumanInputsService { constructor( @Inject(DRIZZLE_TOKEN) private readonly db: NodePgDatabase, private readonly temporalService: TemporalService, + private readonly auditLogService: AuditLogService, ) {} async list( @@ -73,6 +76,7 @@ export class HumanInputsService { id: string, dto: ResolveHumanInputDto, organizationId?: string, + auth?: AuthContext | null, ): Promise { const request = await this.getById(id, organizationId); @@ -111,6 +115,18 @@ export class HumanInputsService { }, }); + this.auditLogService.record(auth ?? null, { + action: 'human_input.resolve', + resourceType: 'human_input', + resourceId: updated.id, + resourceName: updated.title, + metadata: { + approved: isApproved, + respondedBy: dto.respondedBy ?? 'unknown', + inputType: updated.inputType, + }, + }); + return updated as unknown as HumanInputResponseDto; } @@ -184,6 +200,23 @@ export class HumanInputsService { }, }); + this.auditLogService.record( + null, + { + action: 'human_input.resolve', + resourceType: 'human_input', + resourceId: updated.id, + resourceName: updated.title, + metadata: { + approved: isApproved, + respondedBy: 'public-link', + inputType: updated.inputType, + }, + }, + undefined, + request.organizationId, + ); + return { success: true, message: 'Input received successfully', diff --git a/backend/src/mcp-groups/__tests__/mcp-groups.controller.spec.ts b/backend/src/mcp-groups/__tests__/mcp-groups.controller.spec.ts index 5c5583957..c4b5fd60a 100644 --- a/backend/src/mcp-groups/__tests__/mcp-groups.controller.spec.ts +++ b/backend/src/mcp-groups/__tests__/mcp-groups.controller.spec.ts @@ -67,6 +67,11 @@ describe('McpGroupsController.importTemplate', () => { const body: ImportTemplateRequestDto = { serverCacheTokens: {} }; await controller.importTemplate(AUTH_WITH_ORG, 'my-template', body); - expect(service.importTemplate).toHaveBeenCalledWith('my-template', 'org-123', body); + expect(service.importTemplate).toHaveBeenCalledWith( + 'my-template', + 'org-123', + body, + AUTH_WITH_ORG, + ); }); }); diff --git a/backend/src/mcp-groups/mcp-groups.controller.ts b/backend/src/mcp-groups/mcp-groups.controller.ts index 9fda76881..ae6e46a3a 100644 --- a/backend/src/mcp-groups/mcp-groups.controller.ts +++ b/backend/src/mcp-groups/mcp-groups.controller.ts @@ -85,21 +85,21 @@ export class McpGroupsController { @ApiOperation({ summary: 'Create a new MCP group (admin only)' }) @ApiCreatedResponse({ type: McpGroupResponse }) async createGroup( - @CurrentAuth() _auth: AuthContext | null, + @CurrentAuth() auth: AuthContext | null, @Body() body: CreateMcpGroupDto, ): Promise { - return this.mcpGroupsService.createGroup(body); + return this.mcpGroupsService.createGroup(auth, body); } @Patch(':id') @ApiOperation({ summary: 'Update an MCP group' }) @ApiOkResponse({ type: McpGroupResponse }) async updateGroup( - @CurrentAuth() _auth: AuthContext | null, + @CurrentAuth() auth: AuthContext | null, @Param('id', new ParseUUIDPipe()) id: string, @Body() body: UpdateMcpGroupDto, ): Promise { - return this.mcpGroupsService.updateGroup(id, body); + return this.mcpGroupsService.updateGroup(auth, id, body); } @Delete(':id') @@ -108,10 +108,10 @@ export class McpGroupsController { @ApiOperation({ summary: 'Delete an MCP group (admin only)' }) @ApiNoContentResponse() async deleteGroup( - @CurrentAuth() _auth: AuthContext | null, + @CurrentAuth() auth: AuthContext | null, @Param('id', new ParseUUIDPipe()) id: string, ): Promise { - await this.mcpGroupsService.deleteGroup(id); + await this.mcpGroupsService.deleteGroup(auth, id); } // Group-Server relationship endpoints @@ -185,6 +185,6 @@ export class McpGroupsController { if (!auth?.organizationId) { throw new UnauthorizedException('Organization context is required to import a template'); } - return this.mcpGroupsService.importTemplate(slug, auth.organizationId, body); + return this.mcpGroupsService.importTemplate(slug, auth.organizationId, body, auth); } } diff --git a/backend/src/mcp-groups/mcp-groups.service.ts b/backend/src/mcp-groups/mcp-groups.service.ts index 187ea8b75..4e6e14843 100644 --- a/backend/src/mcp-groups/mcp-groups.service.ts +++ b/backend/src/mcp-groups/mcp-groups.service.ts @@ -11,6 +11,8 @@ import Redis from 'ioredis'; import { McpGroupsRepository, type McpGroupUpdateData } from './mcp-groups.repository'; import { McpGroupsSeedingService } from './mcp-groups-seeding.service'; import { McpServersRepository } from '../mcp-servers/mcp-servers.repository'; +import { AuditLogService } from '../audit/audit-log.service'; +import type { AuthContext } from '../auth/types'; import type { CreateMcpGroupDto, UpdateMcpGroupDto, @@ -38,6 +40,7 @@ export class McpGroupsService implements OnModuleInit { private readonly seedingService: McpGroupsSeedingService, private readonly mcpServersRepository: McpServersRepository, @Optional() @Inject(MCP_SERVERS_REDIS) private readonly redis: Redis | null, + private readonly auditLogService: AuditLogService, ) {} async onModuleInit() { @@ -140,6 +143,7 @@ export class McpGroupsService implements OnModuleInit { slug: string, organizationId: string, input?: ImportTemplateRequestDto, + auth?: AuthContext | null, ): Promise { const result: TemplateSyncResult = await this.seedingService.syncTemplate( slug, @@ -188,13 +192,21 @@ export class McpGroupsService implements OnModuleInit { } } + this.auditLogService.record(auth ?? null, { + action: 'mcp_group.import_template', + resourceType: 'mcp_group', + resourceId: group.id, + resourceName: group.name, + metadata: { slug, action: result.action }, + }); + return { action: result.action, group, }; } - async createGroup(input: CreateMcpGroupDto): Promise { + async createGroup(auth: AuthContext | null, input: CreateMcpGroupDto): Promise { // Validate slug format if (!/^[a-z0-9-]+$/.test(input.slug)) { throw new BadRequestException( @@ -212,10 +224,22 @@ export class McpGroupsService implements OnModuleInit { enabled: input.enabled ?? true, }); + this.auditLogService.record(auth, { + action: 'mcp_group.create', + resourceType: 'mcp_group', + resourceId: group.id, + resourceName: group.name, + metadata: { slug: group.slug }, + }); + return this.mapGroupToResponse(group); } - async updateGroup(id: string, input: UpdateMcpGroupDto): Promise { + async updateGroup( + auth: AuthContext | null, + id: string, + input: UpdateMcpGroupDto, + ): Promise { const updates: McpGroupUpdateData = {}; if (input.name !== undefined) { @@ -252,12 +276,21 @@ export class McpGroupsService implements OnModuleInit { } const group = await this.repository.update(id, updates); + + this.auditLogService.record(auth, { + action: 'mcp_group.update', + resourceType: 'mcp_group', + resourceId: group.id, + resourceName: group.name, + metadata: { slug: group.slug }, + }); + return this.mapGroupToResponse(group); } - async deleteGroup(id: string): Promise { + async deleteGroup(auth: AuthContext | null, id: string): Promise { // Verify group exists and collect servers to clean up - await this.repository.findById(id); + const group = await this.repository.findById(id); const servers = await this.repository.findServersByGroup(id); for (const server of servers) { @@ -269,6 +302,14 @@ export class McpGroupsService implements OnModuleInit { } await this.repository.delete(id); + + this.auditLogService.record(auth, { + action: 'mcp_group.delete', + resourceType: 'mcp_group', + resourceId: group.id, + resourceName: group.name, + metadata: { slug: group.slug, serverCount: servers.length }, + }); } // Group-Server relationship methods diff --git a/backend/src/mcp-servers/mcp-servers.service.ts b/backend/src/mcp-servers/mcp-servers.service.ts index 21b6072dc..31549b9fd 100644 --- a/backend/src/mcp-servers/mcp-servers.service.ts +++ b/backend/src/mcp-servers/mcp-servers.service.ts @@ -5,6 +5,7 @@ import { McpServersEncryptionService } from './mcp-servers.encryption'; import { McpServersRepository, type McpServerUpdateData } from './mcp-servers.repository'; import { TemporalService } from '../temporal/temporal.service'; import type { AuthContext } from '../auth/types'; +import { AuditLogService } from '../audit/audit-log.service'; import { DEFAULT_ORGANIZATION_ID } from '../auth/constants'; import type { CreateMcpServerDto, @@ -30,6 +31,7 @@ export class McpServersService { private readonly secretResolver: SecretResolver, @Optional() @Inject(MCP_SERVERS_REDIS) private readonly redis: Redis | null, @Optional() private readonly temporalService: TemporalService | null, + private readonly auditLogService: AuditLogService, ) {} private resolveOrganizationId(auth: AuthContext | null): string { @@ -216,6 +218,15 @@ export class McpServersService { // Return header keys from input (we know the keys since we just created with them) const headerKeys = input.headers ? Object.keys(input.headers) : null; + + this.auditLogService.record(auth, { + action: 'mcp_server.create', + resourceType: 'mcp_server', + resourceId: server.id, + resourceName: server.name, + metadata: { transportType: server.transportType }, + }); + return this.mapServerToResponse(server, headerKeys); } @@ -340,6 +351,14 @@ export class McpServersService { headerKeys = await this.extractHeaderKeys(server.headers); } + this.auditLogService.record(auth, { + action: 'mcp_server.update', + resourceType: 'mcp_server', + resourceId: server.id, + resourceName: server.name, + metadata: { transportType: server.transportType }, + }); + return this.mapServerToResponse(server, headerKeys); } @@ -351,12 +370,30 @@ export class McpServersService { { enabled: !current.enabled }, { organizationId }, ); + + this.auditLogService.record(auth, { + action: 'mcp_server.toggle', + resourceType: 'mcp_server', + resourceId: server.id, + resourceName: server.name, + metadata: { enabled: server.enabled }, + }); + return this.mapServerToResponse(server); } async deleteServer(auth: AuthContext | null, id: string): Promise { const organizationId = this.assertOrganizationId(auth); + const server = await this.repository.findById(id, { organizationId }); await this.repository.delete(id, { organizationId }); + + this.auditLogService.record(auth, { + action: 'mcp_server.delete', + resourceType: 'mcp_server', + resourceId: server.id, + resourceName: server.name, + metadata: { transportType: server.transportType }, + }); } async getServerWithDecryptedHeaders( diff --git a/backend/src/schedules/__tests__/schedules.service.spec.ts b/backend/src/schedules/__tests__/schedules.service.spec.ts index 58798f08e..ee1342374 100644 --- a/backend/src/schedules/__tests__/schedules.service.spec.ts +++ b/backend/src/schedules/__tests__/schedules.service.spec.ts @@ -232,6 +232,7 @@ describe('SchedulesService', () => { repository as unknown as ScheduleRepository, workflowsService, temporalService, + { record: () => {} } as any, ); ensureWorkflowAdminAccessCalls.length = 0; getCompiledWorkflowContextCalls.length = 0; diff --git a/backend/src/schedules/schedules.service.ts b/backend/src/schedules/schedules.service.ts index 8041c4503..16fd785f3 100644 --- a/backend/src/schedules/schedules.service.ts +++ b/backend/src/schedules/schedules.service.ts @@ -13,6 +13,7 @@ import type { WorkflowScheduleRecord } from '../database/schema'; import { TemporalService, type ScheduleTriggerWorkflowArgs } from '../temporal/temporal.service'; import { CreateScheduleRequestDto, UpdateScheduleRequestDto } from './dto/schedule.dto'; import type { ScheduleRepositoryFilters } from './repository/schedule.repository'; +import { AuditLogService } from '../audit/audit-log.service'; @Injectable() export class SchedulesService { @@ -22,6 +23,7 @@ export class SchedulesService { private readonly repository: ScheduleRepository, private readonly workflowsService: WorkflowsService, private readonly temporalService: TemporalService, + private readonly auditLogService: AuditLogService, ) {} async list(auth: AuthContext | null, filters: ScheduleRepositoryFilters = {}) { @@ -114,6 +116,17 @@ export class SchedulesService { { organizationId: context.organizationId }, ); + this.auditLogService.record(auth, { + action: 'schedule.create', + resourceType: 'schedule', + resourceId: (updated ?? record).id, + resourceName: (updated ?? record).name, + metadata: { + workflowId: (updated ?? record).workflowId, + cronExpression: (updated ?? record).cronExpression, + }, + }); + return this.mapRecord(updated ?? record); } @@ -188,6 +201,14 @@ export class SchedulesService { throw new NotFoundException(`Schedule ${id} not found`); } + this.auditLogService.record(auth, { + action: 'schedule.update', + resourceType: 'schedule', + resourceId: updated.id, + resourceName: updated.name, + metadata: { workflowId: updated.workflowId, cronExpression: updated.cronExpression }, + }); + return this.mapRecord(updated); } @@ -205,6 +226,14 @@ export class SchedulesService { }); } await this.repository.delete(existing.id, { organizationId: existing.organizationId }); + + this.auditLogService.record(auth, { + action: 'schedule.delete', + resourceType: 'schedule', + resourceId: existing.id, + resourceName: existing.name, + metadata: { workflowId: existing.workflowId }, + }); } async pause(auth: AuthContext | null, id: string): Promise { @@ -219,6 +248,13 @@ export class SchedulesService { { status: 'paused' }, { organizationId: existing.organizationId }, ); + this.auditLogService.record(auth, { + action: 'schedule.pause', + resourceType: 'schedule', + resourceId: existing.id, + resourceName: existing.name, + }); + return this.mapRecord(updated ?? existing); } @@ -234,6 +270,14 @@ export class SchedulesService { { status: 'active' }, { organizationId: existing.organizationId }, ); + + this.auditLogService.record(auth, { + action: 'schedule.resume', + resourceType: 'schedule', + resourceId: existing.id, + resourceName: existing.name, + }); + return this.mapRecord(updated ?? existing); } @@ -264,6 +308,14 @@ export class SchedulesService { ); await this.workflowsService.startPreparedRun(prepared); + + this.auditLogService.record(auth, { + action: 'schedule.trigger', + resourceType: 'schedule', + resourceId: existing.id, + resourceName: existing.name, + metadata: { workflowId: existing.workflowId }, + }); } private async findOwnedScheduleOrThrow(id: string, auth: AuthContext | null) { diff --git a/backend/src/secrets/__tests__/secrets.service.spec.ts b/backend/src/secrets/__tests__/secrets.service.spec.ts index d7f0a3879..81f24424e 100644 --- a/backend/src/secrets/__tests__/secrets.service.spec.ts +++ b/backend/src/secrets/__tests__/secrets.service.spec.ts @@ -44,6 +44,9 @@ describe('SecretsService', () => { encrypt: ReturnType; decrypt: ReturnType; }; + let auditLogService: { + record: ReturnType; + }; let service: SecretsService; beforeEach(() => { @@ -63,9 +66,14 @@ describe('SecretsService', () => { decrypt: vi.fn(), }; + auditLogService = { + record: vi.fn(), + }; + service = new SecretsService( repository as unknown as SecretsRepository, encryption as unknown as SecretsEncryptionService, + auditLogService as any, ); }); diff --git a/backend/src/secrets/secrets.service.ts b/backend/src/secrets/secrets.service.ts index 46de84d7d..8759f2397 100644 --- a/backend/src/secrets/secrets.service.ts +++ b/backend/src/secrets/secrets.service.ts @@ -6,6 +6,7 @@ import { SecretsEncryptionService } from './secrets.encryption'; import { SecretsRepository, type SecretSummary, type SecretUpdateData } from './secrets.repository'; import type { AuthContext } from '../auth/types'; import { DEFAULT_ORGANIZATION_ID } from '../auth/constants'; +import { AuditLogService } from '../audit/audit-log.service'; export interface CreateSecretInput { name: string; @@ -37,6 +38,7 @@ export class SecretsService { constructor( private readonly repository: SecretsRepository, private readonly encryption: SecretsEncryptionService, + private readonly auditLogService: AuditLogService, ) {} private resolveOrganizationId(auth: AuthContext | null): string { @@ -70,7 +72,7 @@ export class SecretsService { const organizationId = this.assertOrganizationId(auth); const material = await this.encryption.encrypt(input.value); - return this.repository.createSecret( + const created = await this.repository.createSecret( { name: input.name, description: input.description ?? null, @@ -86,6 +88,15 @@ export class SecretsService { organizationId, }, ); + + this.auditLogService.record(auth, { + action: 'secret.create', + resourceType: 'secret', + resourceId: created.id, + resourceName: created.name, + }); + + return created; } async rotateSecret( @@ -96,7 +107,7 @@ export class SecretsService { const organizationId = this.assertOrganizationId(auth); const material = await this.encryption.encrypt(input.value); - return this.repository.rotateSecret( + const rotated = await this.repository.rotateSecret( secretId, { encryptedValue: material.ciphertext, @@ -108,15 +119,37 @@ export class SecretsService { }, { organizationId }, ); + + this.auditLogService.record(auth, { + action: 'secret.rotate', + resourceType: 'secret', + resourceId: rotated.id, + resourceName: rotated.name, + }); + + return rotated; } - async getSecretValue( + private async getSecretValueInternal( auth: AuthContext | null, secretId: string, version?: number, + resourceName?: string | null, ): Promise { const organizationId = this.assertOrganizationId(auth); const record = await this.repository.findValueBySecretId(secretId, version, { organizationId }); + + this.auditLogService.record(auth, { + action: 'secret.access', + resourceType: 'secret', + resourceId: record.secretId, + resourceName: resourceName ?? null, + metadata: { + requestedVersion: version ?? null, + resolvedVersion: record.version, + }, + }); + const value = await this.encryption.decrypt({ ciphertext: record.encryptedValue, iv: record.iv, @@ -131,6 +164,14 @@ export class SecretsService { }; } + async getSecretValue( + auth: AuthContext | null, + secretId: string, + version?: number, + ): Promise { + return this.getSecretValueInternal(auth, secretId, version, null); + } + async getSecretValueByName( auth: AuthContext | null, secretName: string, @@ -140,7 +181,7 @@ export class SecretsService { const organizationId = this.assertOrganizationId(auth); const secret = await this.repository.findByName(secretName, { organizationId }); // Then get the value using the ID - return this.getSecretValue(auth, secret.id, version); + return this.getSecretValueInternal(auth, secret.id, version, secret.name); } async updateSecret( @@ -179,11 +220,33 @@ export class SecretsService { return this.repository.findById(secretId, { organizationId }); } - return this.repository.updateSecret(secretId, updates, { organizationId }); + const updated = await this.repository.updateSecret(secretId, updates, { organizationId }); + this.auditLogService.record(auth, { + action: 'secret.update', + resourceType: 'secret', + resourceId: updated.id, + resourceName: updated.name, + metadata: { + updatedFields: Object.keys(updates), + }, + }); + return updated; } async deleteSecret(auth: AuthContext | null, secretId: string): Promise { const organizationId = this.assertOrganizationId(auth); + let existing: SecretSummary | null = null; + try { + existing = await this.repository.findById(secretId, { organizationId }); + } catch { + existing = null; + } await this.repository.deleteSecret(secretId, { organizationId }); + this.auditLogService.record(auth, { + action: 'secret.delete', + resourceType: 'secret', + resourceId: secretId, + resourceName: (existing as any)?.name ?? null, + }); } } diff --git a/backend/src/storage/artifacts.service.ts b/backend/src/storage/artifacts.service.ts index 9d5813a8b..ddfe8e2b7 100644 --- a/backend/src/storage/artifacts.service.ts +++ b/backend/src/storage/artifacts.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import type { AuthContext } from '../auth/types'; +import { AuditLogService } from '../audit/audit-log.service'; import { ArtifactsRepository } from './artifacts.repository'; import { FilesService } from './files.service'; import type { ArtifactRecord } from '../database/schema/artifacts.schema'; @@ -24,6 +25,7 @@ export class ArtifactsService { constructor( private readonly repository: ArtifactsRepository, private readonly filesService: FilesService, + private readonly auditLogService: AuditLogService, ) {} async listRunArtifacts( @@ -63,6 +65,17 @@ export class ArtifactsService { async downloadArtifact(auth: AuthContext | null, artifactId: string) { const artifact = await this.getArtifactRecord(auth, artifactId); + this.auditLogService.record(auth, { + action: 'artifact.download', + resourceType: 'artifact', + resourceId: artifact.id, + resourceName: artifact.name, + metadata: { + fileId: artifact.fileId, + workflowId: artifact.workflowId, + runId: artifact.runId ?? null, + }, + }); const download = await this.filesService.downloadFile(auth, artifact.fileId); return { artifact: this.toMetadata(artifact), @@ -77,6 +90,17 @@ export class ArtifactsService { if (!artifact) { throw new NotFoundException(`Artifact ${artifactId} not found for run ${runId}`); } + this.auditLogService.record(auth, { + action: 'artifact.download', + resourceType: 'artifact', + resourceId: artifact.id, + resourceName: artifact.name, + metadata: { + fileId: artifact.fileId, + workflowId: artifact.workflowId, + runId, + }, + }); const download = await this.filesService.downloadFile(auth, artifact.fileId); return { artifact: this.toMetadata(artifact), @@ -102,6 +126,18 @@ export class ArtifactsService { if (!deleted) { throw new NotFoundException(`Artifact ${artifactId} not found`); } + + this.auditLogService.record(auth, { + action: 'artifact.delete', + resourceType: 'artifact', + resourceId: artifactId, + resourceName: artifact.name, + metadata: { + fileId: artifact.fileId, + workflowId: artifact.workflowId, + runId: artifact.runId ?? null, + }, + }); } private toMetadata(record: ArtifactRecord): ArtifactMetadataDto { diff --git a/backend/src/studio-mcp/__tests__/studio-mcp.controller.spec.ts b/backend/src/studio-mcp/__tests__/studio-mcp.controller.spec.ts index e1151d225..db0dd3d2a 100644 --- a/backend/src/studio-mcp/__tests__/studio-mcp.controller.spec.ts +++ b/backend/src/studio-mcp/__tests__/studio-mcp.controller.spec.ts @@ -56,6 +56,7 @@ describe('StudioMcpController', () => { apiKeyPermissions: { workflows: { run: true, list: true, read: true }, runs: { read: true, cancel: true }, + audit: { read: true }, }, }; @@ -68,6 +69,7 @@ describe('StudioMcpController', () => { apiKeyPermissions: { workflows: { run: true, list: true, read: true }, runs: { read: true, cancel: true }, + audit: { read: true }, }, }; diff --git a/backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts b/backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts index 9c1ff1bf2..ad084462d 100644 --- a/backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts +++ b/backend/src/studio-mcp/__tests__/studio-mcp.service.spec.ts @@ -217,6 +217,7 @@ describe('StudioMcpService Unit Tests', () => { apiKeyPermissions: { workflows: { run: false, list: true, read: true }, runs: { read: true, cancel: false }, + audit: { read: false }, }, }; @@ -281,6 +282,7 @@ describe('StudioMcpService Unit Tests', () => { apiKeyPermissions: { workflows: { run: false, list: false, read: false }, runs: { read: false, cancel: false }, + audit: { read: false }, }, }; const server = service.createServer(noPermsAuth); @@ -301,6 +303,7 @@ describe('StudioMcpService Unit Tests', () => { apiKeyPermissions: { workflows: { run: false, list: false, read: false }, runs: { read: false, cancel: false }, + audit: { read: false }, }, }; const server = service.createServer(noPermsAuth); diff --git a/backend/src/webhooks/__tests__/webhooks.service.spec.ts b/backend/src/webhooks/__tests__/webhooks.service.spec.ts index 1c56e36f0..649e536e7 100644 --- a/backend/src/webhooks/__tests__/webhooks.service.spec.ts +++ b/backend/src/webhooks/__tests__/webhooks.service.spec.ts @@ -310,6 +310,10 @@ describe('WebhooksService', () => { getDefaultTaskQueue: () => 'shipsec-default', } as unknown as TemporalService; + const auditLogService = { + record: () => {}, + }; + beforeEach(() => { repository = new InMemoryWebhookRepository(); deliveryRepository = new InMemoryWebhookDeliveryRepository(); @@ -318,6 +322,7 @@ describe('WebhooksService', () => { deliveryRepository as unknown as WebhookDeliveryRepository, workflowsService, temporalService, + auditLogService as any, ); ensureWorkflowAdminAccessCalls.length = 0; getCompiledWorkflowContextCalls.length = 0; diff --git a/backend/src/webhooks/webhooks.service.ts b/backend/src/webhooks/webhooks.service.ts index 493c1df05..2addf5acf 100644 --- a/backend/src/webhooks/webhooks.service.ts +++ b/backend/src/webhooks/webhooks.service.ts @@ -16,6 +16,7 @@ import { import type { AuthContext } from '../auth/types'; import { WorkflowsService } from '../workflows/workflows.service'; import { TemporalService } from '../temporal/temporal.service'; +import { AuditLogService } from '../audit/audit-log.service'; import { WebhookRepository } from './repository/webhook.repository'; import { WebhookDeliveryRepository } from './repository/webhook-delivery.repository'; import type { WebhookConfigurationRecord, WebhookDeliveryRecord } from '../database/schema'; @@ -32,6 +33,7 @@ export class WebhooksService { private readonly deliveryRepository: WebhookDeliveryRepository, private readonly workflowsService: WorkflowsService, private readonly temporalService: TemporalService, + private readonly auditLogService: AuditLogService, ) {} // Management methods (auth required) @@ -95,6 +97,16 @@ export class WebhooksService { }); this.logger.log(`Created webhook ${record.id} for workflow ${dto.workflowId}`); + this.auditLogService.record(auth, { + action: 'webhook.create', + resourceType: 'webhook', + resourceId: record.id, + resourceName: record.name, + metadata: { + workflowId: record.workflowId, + status: record.status, + }, + }); return this.mapConfigurationRecord(record); } @@ -150,6 +162,16 @@ export class WebhooksService { } this.logger.log(`Updated webhook ${id}`); + this.auditLogService.record(auth, { + action: 'webhook.update', + resourceType: 'webhook', + resourceId: id, + resourceName: updated.name, + metadata: { + updatedFields: Object.keys(dto), + status: updated.status, + }, + }); return this.mapConfigurationRecord(updated); } @@ -163,6 +185,15 @@ export class WebhooksService { await this.repository.delete(id, { organizationId: auth?.organizationId }); this.logger.log(`Deleted webhook ${id}`); + this.auditLogService.record(auth, { + action: 'webhook.delete', + resourceType: 'webhook', + resourceId: id, + resourceName: existing.name, + metadata: { + workflowId: existing.workflowId, + }, + }); } async regeneratePath(auth: AuthContext | null, id: string): Promise { @@ -185,6 +216,16 @@ export class WebhooksService { } this.logger.log(`Regenerated path for webhook ${id}: ${newPath}`); + this.auditLogService.record(auth, { + action: 'webhook.regenerate_path', + resourceType: 'webhook', + resourceId: id, + resourceName: updated.name, + metadata: { + oldPathHint: existing.webhookPath?.slice(-4) ?? null, + newPathHint: updated.webhookPath?.slice(-4) ?? null, + }, + }); return { id: updated.id, name: updated.name, @@ -195,6 +236,15 @@ export class WebhooksService { async getUrl(auth: AuthContext | null, id: string): Promise { const webhook = await this.get(auth, id); + this.auditLogService.record(auth, { + action: 'webhook.url_access', + resourceType: 'webhook', + resourceId: webhook.id, + resourceName: webhook.name, + metadata: { + pathHint: webhook.webhookPath?.slice(-4) ?? null, + }, + }); return { id: webhook.id, name: webhook.name, diff --git a/backend/src/workflows/__tests__/run-status-cache.spec.ts b/backend/src/workflows/__tests__/run-status-cache.spec.ts index e48c8507e..52b93cc9b 100644 --- a/backend/src/workflows/__tests__/run-status-cache.spec.ts +++ b/backend/src/workflows/__tests__/run-status-cache.spec.ts @@ -156,6 +156,7 @@ describe('Run status caching', () => { traceRepositoryMock as any, temporalServiceMock, analyticsServiceMock as any, + { record: mock(() => {}) } as any, ); }); diff --git a/backend/src/workflows/__tests__/workflow-ai-agent.spec.ts b/backend/src/workflows/__tests__/workflow-ai-agent.spec.ts index 8004bd835..d3421935f 100644 --- a/backend/src/workflows/__tests__/workflow-ai-agent.spec.ts +++ b/backend/src/workflows/__tests__/workflow-ai-agent.spec.ts @@ -248,6 +248,10 @@ describe('Workflow d177b3c0-644e-40f0-8aa2-7b4f2c13a3af', () => { isEnabled: vi.fn().mockReturnValue(true), }; + const auditLogServiceMock = { + record: vi.fn(), + }; + const service = new WorkflowsService( repositoryMock as WorkflowRepository, workflowRoleRepositoryMock as any, @@ -256,6 +260,7 @@ describe('Workflow d177b3c0-644e-40f0-8aa2-7b4f2c13a3af', () => { traceRepositoryMock as any, {} as any, analyticsServiceMock as any, + auditLogServiceMock as any, ); const definition = await service.commit(workflowId, authContext); diff --git a/backend/src/workflows/__tests__/workflows.controller.spec.ts b/backend/src/workflows/__tests__/workflows.controller.spec.ts index 80effb75c..e8da1485d 100644 --- a/backend/src/workflows/__tests__/workflows.controller.spec.ts +++ b/backend/src/workflows/__tests__/workflows.controller.spec.ts @@ -386,6 +386,7 @@ describe('WorkflowsController', () => { traceRepositoryStub as any, temporalStub as TemporalService, analyticsServiceMock as any, + { record: vi.fn() } as any, ); const traceService = new TraceService({ listByRunId: async () => [], diff --git a/backend/src/workflows/__tests__/workflows.service.spec.ts b/backend/src/workflows/__tests__/workflows.service.spec.ts index eae3eeab0..3ccda0bd2 100644 --- a/backend/src/workflows/__tests__/workflows.service.spec.ts +++ b/backend/src/workflows/__tests__/workflows.service.spec.ts @@ -407,6 +407,7 @@ describe('WorkflowsService', () => { traceRepositoryMock as any, temporalService, analyticsServiceMock as any, + { record: vi.fn() } as any, ); }); @@ -618,6 +619,7 @@ describe('WorkflowsService', () => { traceRepositoryMock as any, failureTemporalService, analyticsServiceMock as any, + { record: vi.fn() } as any, ); const versionRecord = createWorkflowVersionRecord('workflow-id'); diff --git a/backend/src/workflows/workflows.service.ts b/backend/src/workflows/workflows.service.ts index 3f4615958..402c349e1 100644 --- a/backend/src/workflows/workflows.service.ts +++ b/backend/src/workflows/workflows.service.ts @@ -34,6 +34,7 @@ import { WorkflowRunRepository } from './repository/workflow-run.repository'; import { WorkflowVersionRepository } from './repository/workflow-version.repository'; import { TraceRepository } from '../trace/trace.repository'; import { AnalyticsService } from '../analytics/analytics.service'; +import { AuditLogService } from '../audit/audit-log.service'; import { ExecutionStatus, FailureSummary, @@ -154,6 +155,7 @@ export class WorkflowsService { private readonly traceRepository: TraceRepository, private readonly temporalService: TemporalService, private readonly analyticsService: AnalyticsService, + private readonly auditLogService: AuditLogService, ) {} private resolveOrganizationId(auth?: AuthContext | null): string | null { @@ -320,6 +322,17 @@ export class WorkflowsService { this.logger.log( `Created workflow ${response.id} version ${version.version} (nodes=${input.nodes.length}, edges=${input.edges.length})`, ); + this.auditLogService.record(auth ?? null, { + action: 'workflow.create', + resourceType: 'workflow', + resourceId: response.id, + resourceName: response.name, + metadata: { + nodeCount: input.nodes.length, + edgeCount: input.edges.length, + version: version.version, + }, + }); return response; } @@ -351,6 +364,17 @@ export class WorkflowsService { this.logger.log( `Updated workflow ${response.id} to version ${version.version} (nodes=${input.nodes.length}, edges=${input.edges.length})`, ); + this.auditLogService.record(auth ?? null, { + action: 'workflow.update', + resourceType: 'workflow', + resourceId: response.id, + resourceName: response.name, + metadata: { + nodeCount: input.nodes.length, + edgeCount: input.edges.length, + version: version.version, + }, + }); return response; } @@ -368,6 +392,15 @@ export class WorkflowsService { const version = await this.versionRepository.findLatestByWorkflowId(id, { organizationId }); const response = this.buildWorkflowResponse(record, version ?? null); this.logger.log(`Updated workflow ${response.id} metadata (name=${dto.name})`); + this.auditLogService.record(auth ?? null, { + action: 'workflow.update_metadata', + resourceType: 'workflow', + resourceId: response.id, + resourceName: response.name, + metadata: { + name: dto.name, + }, + }); return response; } @@ -536,8 +569,15 @@ export class WorkflowsService { async delete(id: string, auth?: AuthContext | null): Promise { const organizationId = await this.requireWorkflowAdmin(id, auth); + const existing = await this.repository.findById(id, { organizationId }).catch(() => null); await this.repository.delete(id, { organizationId }); this.logger.log(`Deleted workflow ${id}`); + this.auditLogService.record(auth ?? null, { + action: 'workflow.delete', + resourceType: 'workflow', + resourceId: id, + resourceName: (existing as any)?.name ?? null, + }); } async list(auth?: AuthContext | null): Promise { @@ -777,6 +817,17 @@ export class WorkflowsService { this.logger.log( `Compiled workflow ${workflow.id} version ${version.version} with ${definition.actions.length} action(s); entrypoint=${definition.entrypoint.ref}`, ); + this.auditLogService.record(auth ?? null, { + action: 'workflow.commit', + resourceType: 'workflow', + resourceId: workflow.id, + resourceName: workflow.name, + metadata: { + version: version.version, + actionCount: definition.actions.length, + entrypoint: definition.entrypoint.ref, + }, + }); return definition; } @@ -801,6 +852,20 @@ export class WorkflowsService { idempotencyKey: options.idempotencyKey, }); + this.auditLogService.record(auth ?? null, { + action: 'workflow.run', + resourceType: 'workflow', + resourceId: prepared.workflowId, + resourceName: null, + metadata: { + runId: prepared.runId, + workflowVersion: prepared.workflowVersion, + triggerType: options.trigger?.type ?? null, + triggerSourceId: options.trigger?.sourceId ?? null, + triggerLabel: options.trigger?.label ?? null, + }, + }); + return this.startPreparedRun(prepared); } diff --git a/bun.lock b/bun.lock index 93d639842..be8a3cc5a 100644 --- a/bun.lock +++ b/bun.lock @@ -136,6 +136,7 @@ "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dompurify": "^3.2.4", "html-to-image": "1.11.11", "lucide-react": "^0.544.0", @@ -147,6 +148,7 @@ "postcss": "^8.5.6", "posthog-js": "^1.288.0", "react": "^19.2.0", + "react-day-picker": "^9.13.2", "react-dom": "^19.2.0", "react-router-dom": "^7.9.3", "reactflow": "^11.11.4", @@ -488,6 +490,8 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], @@ -1626,6 +1630,8 @@ "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + "dayjs": ["dayjs@1.11.15", "", {}, "sha512-MC+DfnSWiM9APs7fpiurHGCoeIx0Gdl6QZBy+5lu8MbYKN5FZEXqOgrundfibdfhGZ15o9hzmZ2xJjZnbvgKXQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -2612,6 +2618,8 @@ "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react-day-picker": ["react-day-picker@9.13.2", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg=="], + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], diff --git a/docs/audit-logging.mdx b/docs/audit-logging.mdx new file mode 100644 index 000000000..a3129afc6 --- /dev/null +++ b/docs/audit-logging.mdx @@ -0,0 +1,65 @@ +# Audit Logging + +ShipSec Studio emits **audit events** for important platform actions so customers can answer: who did what, when, and in which organization. + +## Event Naming + +Use the pattern: + +- `{resource}.{verb}` + +Examples: + +- `workflow.create` +- `workflow.update` +- `workflow.run` +- `secret.create` +- `secret.rotate` +- `secret.access` +- `api_key.create` +- `webhook.update` +- `artifact.download` +- `analytics.query` + +## Required Fields + +Every audit event should include: + +- `organizationId`: organization scope (falls back to `local-dev` for local/internal tooling) +- `actorId`: user ID or API key ID (when available) +- `actorType`: `user` | `api-key` | `internal` | `unknown` +- `action`: event name (see above) +- `resourceType`: `workflow` | `secret` | `api_key` | `webhook` | `artifact` | `analytics` +- `resourceId`: resource identifier (when applicable) +- `createdAt`: event timestamp + +Optional fields: + +- `resourceName` +- `metadata` (safe, minimal details) +- `ip`, `userAgent` + +## Safety Rules + +- Never log secret values, API key plaintext, or decrypted materials. +- Avoid logging large payloads (workflow graphs, full webhook payloads, raw analytics queries). +- Prefer small, explicit metadata fields like counts, IDs, versions, and booleans. +- Audit logging must be **non-blocking**: failures to write audit logs must not break the API request. + +## Standard Pattern (Backend) + +Prefer emitting audit events in the **service layer** right after a successful action. + +```ts +// Example: after a successful mutation +this.auditLogService.record(auth, { + action: 'secret.rotate', + resourceType: 'secret', + resourceId: secret.id, + resourceName: secret.name, + metadata: { + resolvedVersion: secret.activeVersion, + }, +}); +``` + diff --git a/e2e-tests/core/audit-logs.test.ts b/e2e-tests/core/audit-logs.test.ts new file mode 100644 index 000000000..87f6d7ee2 --- /dev/null +++ b/e2e-tests/core/audit-logs.test.ts @@ -0,0 +1,96 @@ +/** + * E2E Tests - Audit Logs + * + * Validates that core platform actions emit audit events and that audit logs are queryable. + */ + +import { expect, beforeAll } from 'bun:test'; + +import { + API_BASE, + HEADERS, + e2eDescribe, + e2eTest, + checkServicesAvailable, + createWorkflow, + createWebhook, + createOrRotateSecret, +} from '../helpers/e2e-harness'; + +async function fetchAuditLogs(params: Record) { + const qs = new URLSearchParams(params).toString(); + const res = await fetch(`${API_BASE}/audit-logs?${qs}`, { headers: HEADERS }); + if (!res.ok) { + throw new Error(`Failed to list audit logs: ${res.status} ${await res.text()}`); + } + return res.json() as Promise<{ items: any[]; nextCursor: string | null }>; +} + +async function waitForAuditAction(action: string, timeoutMs = 8000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const data = await fetchAuditLogs({ action, limit: '50' }); + if (data.items.length > 0) return data.items; + await new Promise((r) => setTimeout(r, 200)); + } + return []; +} + +beforeAll(async () => { + const available = await checkServicesAvailable(); + if (!available) console.log(' Backend API is not available. Skipping.'); +}); + +e2eDescribe('Audit Logs E2E Tests', () => { + e2eTest('CRUD/access events emit audit logs and are queryable', { timeout: 60000 }, async () => { + const secretName = `e2e-audit-secret-${Date.now()}`; + + // Secret create + await createOrRotateSecret(secretName, 'value-1'); + const created = await waitForAuditAction('secret.create'); + expect(created.length).toBeGreaterThan(0); + + // Secret rotate + await createOrRotateSecret(secretName, 'value-2'); + const rotated = await waitForAuditAction('secret.rotate'); + expect(rotated.length).toBeGreaterThan(0); + + // Webhook create (requires a workflow) + const workflowId = await createWorkflow({ + name: 'Test: Audit Target', + nodes: [ + { + id: 'start', + type: 'core.workflow.entrypoint', + data: { + label: 'Start', + config: { + params: { + runtimeInputs: [{ id: 'x', label: 'X', type: 'text', required: true }], + }, + }, + }, + position: { x: 0, y: 0 }, + }, + ], + edges: [], + }); + + await createWebhook({ + workflowId, + name: 'Audit Webhook', + description: 'For audit log tests', + parsingScript: 'export async function script(input) { return { x: \"ok\" }; }', + expectedInputs: [{ id: 'x', label: 'X', type: 'text', required: true }], + }); + + const webhookCreated = await waitForAuditAction('webhook.create'); + expect(webhookCreated.length).toBeGreaterThan(0); + + // Query endpoint basic shape + const list = await fetchAuditLogs({ limit: '10' }); + expect(Array.isArray(list.items)).toBe(true); + expect(list).toHaveProperty('nextCursor'); + }); +}); + diff --git a/frontend/package.json b/frontend/package.json index f85e6a524..9a2ab0e07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -76,6 +76,7 @@ "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dompurify": "^3.2.4", "html-to-image": "1.11.11", "lucide-react": "^0.544.0", @@ -87,6 +88,7 @@ "postcss": "^8.5.6", "posthog-js": "^1.288.0", "react": "^19.2.0", + "react-day-picker": "^9.13.2", "react-dom": "^19.2.0", "react-router-dom": "^7.9.3", "reactflow": "^11.11.4", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c679e2067..3c34b49db 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,6 +64,9 @@ const RunRedirect = lazy(() => const AnalyticsSettingsPage = lazy(() => import('@/pages/AnalyticsSettingsPage').then((m) => ({ default: m.AnalyticsSettingsPage })), ); +const SettingsPage = lazy(() => + import('@/pages/SettingsPage').then((m) => ({ default: m.SettingsPage })), +); // Lazy-load CommandPalette — it pulls in the entire lucide-react barrel (~350KB) const CommandPalette = lazy(() => @@ -173,6 +176,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/auth/UserButton.tsx b/frontend/src/components/auth/UserButton.tsx index d153831f6..63adc4552 100644 --- a/frontend/src/components/auth/UserButton.tsx +++ b/frontend/src/components/auth/UserButton.tsx @@ -12,6 +12,7 @@ import { import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'; import { Shield, User, LogOut, Settings } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useNavigate } from 'react-router-dom'; interface UserButtonProps { afterSignOutUrl?: string; @@ -30,6 +31,7 @@ export const UserButton: React.FC = ({ }) => { const authProvider = useAuthProvider(); const { user, isAuthenticated, isLoading } = authProvider.context; + const navigate = useNavigate(); // Handle loading state if (isLoading) { @@ -157,7 +159,7 @@ export const UserButton: React.FC = ({ Profile - + navigate('/settings/audit')}> Settings diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx new file mode 100644 index 000000000..f813f4575 --- /dev/null +++ b/frontend/src/components/ui/calendar.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; +import { DayPicker } from 'react-day-picker'; + +import { cn } from '@/lib/utils'; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { + return ( + { + if (orientation === 'left') { + return ; + } + if (orientation === 'right') { + return ; + } + return ; + }, + }} + {...props} + /> + ); +} + +Calendar.displayName = 'Calendar'; + +export { Calendar }; diff --git a/frontend/src/hooks/queries/useAuditLogQueries.ts b/frontend/src/hooks/queries/useAuditLogQueries.ts new file mode 100644 index 000000000..12217a6cb --- /dev/null +++ b/frontend/src/hooks/queries/useAuditLogQueries.ts @@ -0,0 +1,44 @@ +import { skipToken, useInfiniteQuery } from '@tanstack/react-query'; + +import { queryKeys } from '@/lib/queryKeys'; +import { api } from '@/services/api'; + +export interface AuditLogFilters { + resourceType?: string; + resourceId?: string; + action?: string; + actorId?: string; + from?: string; + to?: string; + limit?: number; +} + +export type AuditLogListResponse = Awaited>; +export type AuditLogEntry = AuditLogListResponse['items'][number]; + +export function useAuditLogs(filters: AuditLogFilters, canRead = true) { + const limit = filters.limit ?? 50; + const keyFilters = { + resourceType: filters.resourceType, + resourceId: filters.resourceId, + action: filters.action, + actorId: filters.actorId, + from: filters.from, + to: filters.to, + limit, + }; + + return useInfiniteQuery({ + queryKey: queryKeys.auditLogs.all(keyFilters), + queryFn: canRead + ? ({ pageParam }) => + api.auditLogs.list({ + ...keyFilters, + cursor: typeof pageParam === 'string' ? pageParam : undefined, + }) + : skipToken, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + staleTime: 30_000, + }); +} diff --git a/frontend/src/lib/prefetch-routes.ts b/frontend/src/lib/prefetch-routes.ts index c8fb2a85c..4516eddbc 100644 --- a/frontend/src/lib/prefetch-routes.ts +++ b/frontend/src/lib/prefetch-routes.ts @@ -19,6 +19,7 @@ const routePrefetchMap: Record = { '/api-keys': () => void import('@/pages/ApiKeysManager'), '/mcp-library': () => void import('@/pages/McpLibraryPage'), '/analytics-settings': () => void import('@/pages/AnalyticsSettingsPage'), + '/settings': () => void import('@/pages/SettingsPage'), }; /** Prefetch all sidebar route chunks during browser idle time. */ diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index ad0ce1643..c36c9ca1d 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -37,6 +37,9 @@ export const queryKeys = { apiKeys: { all: () => ['apiKeys', getOrgScope()] as const, }, + auditLogs: { + all: (filters?: Record) => ['auditLogs', getOrgScope(), filters] as const, + }, webhooks: { all: (filters?: Record) => ['webhooks', getOrgScope(), filters] as const, detail: (id: string) => ['webhooks', getOrgScope(), id] as const, diff --git a/frontend/src/pages/ApiKeysManager.tsx b/frontend/src/pages/ApiKeysManager.tsx index 227f7a6fd..9ee5b3074 100644 --- a/frontend/src/pages/ApiKeysManager.tsx +++ b/frontend/src/pages/ApiKeysManager.tsx @@ -49,6 +49,9 @@ const INITIAL_FORM: CreateApiKeyInput = { read: true, // Default to allowing reading runs cancel: false, }, + audit: { + read: false, + }, }, }; @@ -107,7 +110,7 @@ export function ApiKeysManager() { }; const handlePermissionChange = ( - category: 'workflows' | 'runs', + category: 'workflows' | 'runs' | 'audit', action: string, checked: boolean, ) => { @@ -383,7 +386,7 @@ export function ApiKeysManager() {
-
+
+ +
+ +
+ + handlePermissionChange('audit', 'read', checked as boolean) + } + /> + +
+
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 000000000..0c1139d24 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,79 @@ +import { NavLink, Route, Routes, Navigate } from 'react-router-dom'; +import { Shield } from 'lucide-react'; + +import { useAuthStore } from '@/store/authStore'; +import { hasAdminRole } from '@/utils/auth'; +import { AuditLogSettings } from '@/pages/settings/AuditLogSettings'; +import { cn } from '@/lib/utils'; + +export function SettingsPage() { + const roles = useAuthStore((state) => state.roles); + const isAdmin = hasAdminRole(roles); + + const tabs = [ + { + label: 'Audit', + to: '/settings/audit', + adminOnly: true, + }, + ]; + + return ( +
+
+
+
+

Settings

+

+ Organization and workspace configuration +

+
+
+ + {!isAdmin && ( +
+
+ +
+

+ Read-Only Access +

+

+ You need admin privileges to view organization audit logs. +

+
+
+
+ )} + +
+
+ {tabs + .filter((t) => (t.adminOnly ? isAdmin : true)) + .map((tab) => ( + + cn( + 'px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors', + isActive + ? 'border-primary text-foreground' + : 'border-transparent text-muted-foreground hover:text-foreground', + ) + } + > + {tab.label} + + ))} +
+ + + } /> + } /> + +
+
+
+ ); +} diff --git a/frontend/src/pages/settings/AuditLogSettings.tsx b/frontend/src/pages/settings/AuditLogSettings.tsx new file mode 100644 index 000000000..8b5bf52cb --- /dev/null +++ b/frontend/src/pages/settings/AuditLogSettings.tsx @@ -0,0 +1,570 @@ +import { useMemo, useState } from 'react'; +import { format, subHours, subDays, startOfDay, endOfDay, isAfter } from 'date-fns'; +import { CalendarIcon, RefreshCw, X, ListFilter, Clock } from 'lucide-react'; +import { DateRange } from 'react-day-picker'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Calendar } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { useAuditLogs } from '@/hooks/queries/useAuditLogQueries'; +import { useAuthStore } from '@/store/authStore'; +import { cn } from '@/lib/utils'; +import { hasAdminRole } from '@/utils/auth'; + +const RESOURCE_TYPE_OPTIONS = [ + { value: 'workflow', label: 'Workflow' }, + { value: 'secret', label: 'Secret' }, + { value: 'api_key', label: 'API Key' }, + { value: 'webhook', label: 'Webhook' }, + { value: 'artifact', label: 'Artifact' }, + { value: 'analytics', label: 'Analytics' }, + { value: 'schedule', label: 'Schedule' }, + { value: 'mcp_server', label: 'MCP Server' }, + { value: 'mcp_group', label: 'MCP Group' }, + { value: 'human_input', label: 'Human Input' }, +] as const; + +const ACTION_OPTIONS = [ + 'analytics.query', + 'api_key.create', + 'api_key.update', + 'api_key.revoke', + 'api_key.reactivate', + 'api_key.delete', + 'artifact.download', + 'artifact.delete', + 'human_input.resolve', + 'mcp_group.import_template', + 'mcp_group.create', + 'mcp_group.update', + 'mcp_group.delete', + 'mcp_server.create', + 'mcp_server.update', + 'mcp_server.toggle', + 'mcp_server.delete', + 'schedule.create', + 'schedule.update', + 'schedule.delete', + 'schedule.pause', + 'schedule.resume', + 'schedule.trigger', + 'secret.create', + 'secret.rotate', + 'secret.access', + 'secret.update', + 'secret.delete', + 'webhook.create', + 'webhook.update', + 'webhook.delete', + 'webhook.regenerate_path', + 'webhook.url_access', + 'workflow.create', + 'workflow.update', + 'workflow.update_metadata', + 'workflow.commit', + 'workflow.run', + 'workflow.delete', +] as const; + +function formatTimestamp(iso: string) { + return format(new Date(iso), 'MMM d, HH:mm:ss'); +} + +function safeJsonPreview(value: Record | null) { + if (!value || Object.keys(value).length === 0) return '—'; + try { + const str = JSON.stringify(value); + if (str === '{}') return '—'; + return str; + } catch { + return '—'; + } +} + +interface MultiSelectFilterProps { + label: string; + selectedValues: string[]; + options: readonly string[] | readonly { value: string; label: string }[]; + onToggle: (value: string) => void; + onClear: () => void; +} + +function MultiSelectFilter({ + label, + selectedValues, + options, + onToggle, + onClear, +}: MultiSelectFilterProps) { + return ( + + + + + +
+ + {label} + + {selectedValues.length > 0 && ( + + )} +
+
+ {options.map((option) => { + const val = typeof option === 'string' ? option : option.value; + const lab = typeof option === 'string' ? option : option.label; + const isChecked = selectedValues.includes(val); + return ( +
onToggle(val)} + > + onToggle(val)} + className="h-4 w-4" + /> + {lab} +
+ ); + })} +
+
+
+ ); +} + +const DATE_PRESETS = [ + { label: 'Today', getValue: () => ({ from: startOfDay(new Date()), to: endOfDay(new Date()) }) }, + { + label: 'Yesterday', + getValue: () => { + const yesterday = subDays(new Date(), 1); + return { from: startOfDay(yesterday), to: endOfDay(yesterday) }; + }, + }, + { label: 'Last 24 hours', getValue: () => ({ from: subHours(new Date(), 24), to: new Date() }) }, + { label: 'Last 7 days', getValue: () => ({ from: subDays(new Date(), 7), to: new Date() }) }, + { label: 'Last 30 days', getValue: () => ({ from: subDays(new Date(), 30), to: new Date() }) }, +]; + +interface DateTimeRangePickerProps { + from: Date | undefined; + to: Date | undefined; + onSelect: (range: { from?: Date; to?: Date }) => void; +} + +function DateTimeRangePicker({ from, to, onSelect }: DateTimeRangePickerProps) { + const [isOpen, setIsOpen] = useState(false); + + // Internal state for the picker, committed on Apply + const [dateRange, setDateRange] = useState({ from, to }); + const [startTime, setStartTime] = useState(from ? format(from, 'HH:mm') : '00:00'); + const [endTime, setEndTime] = useState(to ? format(to, 'HH:mm') : '23:59'); + + // Reset internal state when prop changes or popover opens + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (open) { + setDateRange({ from, to }); + setStartTime(from ? format(from, 'HH:mm') : '00:00'); + setEndTime(to ? format(to, 'HH:mm') : '23:59'); + } + }; + + const handleApply = () => { + if (!dateRange?.from) { + onSelect({ from: undefined, to: undefined }); + setIsOpen(false); + return; + } + + let newFrom = new Date(dateRange.from); + const [startH, startM] = startTime.split(':').map(Number); + newFrom.setHours(startH || 0, startM || 0, 0, 0); + + let newTo = dateRange.to ? new Date(dateRange.to) : new Date(newFrom); + const [endH, endM] = endTime.split(':').map(Number); + newTo.setHours(endH || 0, endM || 0, 59, 999); + + // Swap if needed + if (isAfter(newFrom, newTo)) { + [newFrom, newTo] = [newTo, newFrom]; + } + + onSelect({ from: newFrom, to: newTo }); + setIsOpen(false); + }; + + const handlePresetSelect = (preset: (typeof DATE_PRESETS)[0]) => { + const range = preset.getValue(); + setDateRange({ from: range.from, to: range.to }); + setStartTime(format(range.from, 'HH:mm')); + setEndTime(format(range.to, 'HH:mm')); + }; + + const displayText = useMemo(() => { + if (!from) return 'Date Range'; + if (!to) return `${format(from, 'MMM d, HH:mm')} - ...`; + return `${format(from, 'MMM d, HH:mm')} - ${format(to, 'MMM d, HH:mm')}`; + }, [from, to]); + + const hasSelection = !!(from || to); + + return ( + + + + + +
+ {/* Presets Sidebar */} +
+
+ Presets +
+ {DATE_PRESETS.map((preset) => ( + + ))} +
+ + {/* Calendar & Time */} +
+ + +
+
+
+ Start Time +
+ setStartTime(e.target.value)} + className="h-8 text-xs font-mono" + /> +
+
+
+ End Time +
+ setEndTime(e.target.value)} + className="h-8 text-xs font-mono" + /> +
+
+ + +
+
+
+
+
+
+ ); +} + +export function AuditLogSettings() { + const roles = useAuthStore((state) => state.roles); + const isAdmin = hasAdminRole(roles); + + const [selectedActions, setSelectedActions] = useState([]); + const [selectedResources, setSelectedResources] = useState([]); + + // Consolidated date range state + const [dateRange, setDateRange] = useState<{ from?: Date; to?: Date }>({}); + + const filters = useMemo( + () => ({ + action: selectedActions.length > 0 ? selectedActions.join(',') : undefined, + resourceType: selectedResources.length > 0 ? selectedResources.join(',') : undefined, + from: dateRange.from ? dateRange.from.toISOString() : undefined, + to: dateRange.to ? dateRange.to.toISOString() : undefined, + limit: 50, + }), + [selectedActions, selectedResources, dateRange], + ); + + const hasActiveFilters = + selectedActions.length > 0 || + selectedResources.length > 0 || + dateRange.from !== undefined || + dateRange.to !== undefined; + + const { + data, + isLoading, + isFetching, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + refetch, + error, + } = useAuditLogs(filters, isAdmin); + const items = useMemo(() => data?.pages.flatMap((page) => page.items) ?? [], [data]); + const loading = isLoading || (isFetching && !isFetchingNextPage); + const errorMessage = error instanceof Error ? error.message : null; + + const clearFilters = () => { + setSelectedActions([]); + setSelectedResources([]); + setDateRange({}); + }; + + const toggleAction = (val: string) => { + setSelectedActions((prev) => + prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val], + ); + }; + + const toggleResource = (val: string) => { + setSelectedResources((prev) => + prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val], + ); + }; + + if (!isAdmin) { + return ( +
+ Audit logs are available to organization admins only. +
+ ); + } + + return ( +
+
+
+

Audit Log

+

Review activity across your organization.

+
+
+ +
+
+ + {/* Filter Bar Above Table */} +
+ setSelectedActions([])} + /> + + setSelectedResources([])} + /> + + + + {hasActiveFilters && ( + + )} +
+ +
+ {errorMessage && ( +
+ Error: {errorMessage} +
+ )} + +
+ + + + Time + Actor + Action + Resource + Details + + + + {items.length === 0 && !isFetching && ( + + +
+

No events found

+

Try adjusting your filters to see more results.

+
+
+
+ )} + {items.map((row) => ( + + + {formatTimestamp(row.createdAt)} + + + + {row.actorType} + + + {row.action} + +
+ {row.resourceType} + + {row.resourceName ?? row.resourceId ?? '—'} + +
+
+ + {safeJsonPreview(row.metadata)} + +
+ ))} + {isFetchingNextPage && ( + + + Loading more events... + + + )} +
+
+
+ +
+
+ {items.length} {items.length === 1 ? 'Event' : 'Events'} Loaded +
+ {hasNextPage && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/schemas/apiKey.ts b/frontend/src/schemas/apiKey.ts index 1b3742f99..80407fd48 100644 --- a/frontend/src/schemas/apiKey.ts +++ b/frontend/src/schemas/apiKey.ts @@ -13,6 +13,9 @@ export const CreateApiKeyInputSchema = z.object({ read: z.boolean().default(false), cancel: z.boolean().default(false), }), + audit: z.object({ + read: z.boolean().default(false), + }), }), expiresAt: z.string().optional(), // ISO date string rateLimit: z.number().int().positive().optional(), @@ -34,6 +37,11 @@ export const UpdateApiKeyInputSchema = z.object({ read: z.boolean().optional(), cancel: z.boolean().optional(), }), + audit: z + .object({ + read: z.boolean().optional(), + }) + .optional(), }) .optional(), isActive: z.boolean().optional(), diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index eeb898562..cc5c49333 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -35,6 +35,7 @@ type ApiKeyResponseDto = components['schemas']['ApiKeyResponseDto']; type CreateApiKeyResponseDto = components['schemas']['CreateApiKeyResponseDto']; type CreateApiKeyDto = components['schemas']['CreateApiKeyDto']; type UpdateApiKeyDto = components['schemas']['UpdateApiKeyDto']; +type ListAuditLogsResponseDto = components['schemas']['ListAuditLogsResponseDto']; export interface TerminalChunkResponse { runId: string; @@ -528,6 +529,38 @@ export const api = { }, }, + auditLogs: { + list: async (query: { + resourceType?: string; + resourceId?: string; + action?: string; + actorId?: string; + from?: string; + to?: string; + limit?: number; + cursor?: string; + }): Promise => { + const headers = await getAuthHeaders(); + const url = new URL(`${API_V1_URL}/audit-logs`); + + if (query.resourceType) url.searchParams.set('resourceType', query.resourceType); + if (query.resourceId) url.searchParams.set('resourceId', query.resourceId); + if (query.action) url.searchParams.set('action', query.action); + if (query.actorId) url.searchParams.set('actorId', query.actorId); + if (query.from) url.searchParams.set('from', query.from); + if (query.to) url.searchParams.set('to', query.to); + if (query.cursor) url.searchParams.set('cursor', query.cursor); + if (query.limit) url.searchParams.set('limit', String(query.limit)); + + const res = await fetch(url.toString(), { headers }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Failed to fetch audit logs: ${res.status} ${text}`); + } + return (await res.json()) as ListAuditLogsResponseDto; + }, + }, + executions: { start: async ( workflowId: string, diff --git a/openapi.json b/openapi.json index 654aa8ea2..9b59c6ad1 100644 --- a/openapi.json +++ b/openapi.json @@ -15,6 +15,57 @@ ] } }, + "/api/v1/auth/validate": { + "get": { + "operationId": "AppController_validateAuth", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "App" + ] + } + }, + "/api/v1/auth/login": { + "post": { + "operationId": "AppController_login", + "parameters": [ + { + "name": "authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "App" + ] + } + }, + "/api/v1/auth/logout": { + "post": { + "operationId": "AppController_logout", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "App" + ] + } + }, "/api/v1/agents/{agentRunId}/parts": { "get": { "operationId": "AgentsController_parts", @@ -295,6 +346,9 @@ "workflowId": { "type": "string" }, + "organizationId": { + "type": "string" + }, "status": { "type": "string", "enum": [ @@ -413,6 +467,9 @@ "workflowId": { "type": "string" }, + "organizationId": { + "type": "string" + }, "status": { "type": "string", "enum": [ @@ -2456,6 +2513,147 @@ ] } }, + "/api/v1/analytics/query": { + "post": { + "operationId": "AnalyticsController_queryAnalytics", + "parameters": [ + { + "name": "X-RateLimit-Remaining", + "in": "header", + "description": "Number of requests remaining in the current time window", + "schema": { + "type": "integer", + "example": 99 + } + }, + { + "name": "X-RateLimit-Limit", + "in": "header", + "description": "Maximum number of requests allowed per minute", + "schema": { + "type": "integer", + "example": 100 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsQueryRequestDto" + } + } + } + }, + "responses": { + "200": { + "description": "Query analytics data for the authenticated organization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsQueryResponseDto" + } + } + } + } + }, + "tags": [ + "analytics" + ] + } + }, + "/api/v1/analytics/settings": { + "get": { + "operationId": "AnalyticsController_getAnalyticsSettings", + "parameters": [], + "responses": { + "200": { + "description": "Get analytics settings for the authenticated organization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsSettingsResponseDto" + } + } + } + } + }, + "tags": [ + "analytics" + ] + }, + "put": { + "operationId": "AnalyticsController_updateAnalyticsSettings", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAnalyticsSettingsDto" + } + } + } + }, + "responses": { + "200": { + "description": "Update analytics settings for the authenticated organization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnalyticsSettingsResponseDto" + } + } + } + } + }, + "tags": [ + "analytics" + ] + } + }, + "/api/v1/analytics/ensure-tenant": { + "post": { + "operationId": "AnalyticsController_ensureTenant", + "parameters": [ + { + "name": "x-internal-token", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Ensure tenant resources exist for organization", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "securityEnabled": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + } + } + } + } + }, + "tags": [ + "analytics" + ] + } + }, "/api/v1/api-keys": { "get": { "operationId": "ApiKeysController_list", @@ -3007,20 +3205,23 @@ "type": "string" } }, - "agentTool": { + "toolProvider": { "type": "object", "nullable": true, "properties": { - "enabled": { - "type": "boolean" - }, - "toolName": { + "kind": { "type": "string", - "nullable": true + "enum": [ + "component", + "mcp-server", + "mcp-group" + ] }, - "toolDescription": { - "type": "string", - "nullable": true + "name": { + "type": "string" + }, + "description": { + "type": "string" } } } @@ -5236,7 +5437,17 @@ "parameters": [], "responses": { "200": { - "description": "" + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GroupTemplateDto" + } + } + } + } } }, "summary": "List available MCP group templates", @@ -5733,40 +5944,16 @@ ] } }, - "/api/v1/internal/mcp/register-remote": { - "post": { - "operationId": "InternalMcpController_registerRemote", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterRemoteMcpInput" - } - } - } - }, - "responses": { - "201": { - "description": "" - } - }, - "tags": [ - "InternalMcp" - ] - } - }, - "/api/v1/internal/mcp/register-local": { + "/api/v1/internal/mcp/register-mcp-server": { "post": { - "operationId": "InternalMcpController_registerLocal", + "operationId": "InternalMcpController_registerMcpServer", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegisterLocalMcpInput" + "$ref": "#/components/schemas/RegisterMcpServerInput" } } } @@ -5959,6 +6146,27 @@ ] } }, + "/api/v1/audit-logs": { + "get": { + "operationId": "AuditLogsController_list", + "parameters": [], + "responses": { + "200": { + "description": "List audit log events for the authenticated organization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListAuditLogsResponseDto" + } + } + } + } + }, + "tags": [ + "audit-logs" + ] + } + }, "/api/v1/testing/webhooks": { "post": { "operationId": "TestingWebhookController_acceptWebhook", @@ -7672,31 +7880,166 @@ "artifacts" ] }, - "ApiKeyResponseDto": { + "AnalyticsQueryRequestDto": { "type": "object", "properties": { - "id": { - "type": "string" + "query": { + "type": "object", + "description": "OpenSearch DSL query object", + "example": { + "match_all": {} + } }, - "name": { - "type": "string" + "size": { + "type": "number", + "description": "Number of results to return", + "example": 10, + "default": 10, + "minimum": 0, + "maximum": 1000 }, - "description": { - "type": "string", - "nullable": true + "from": { + "type": "number", + "description": "Offset for pagination", + "example": 0, + "default": 0, + "minimum": 0, + "maximum": 10000 }, - "keyPrefix": { - "type": "string" + "aggs": { + "type": "object", + "description": "OpenSearch aggregations object", + "example": { + "components": { + "terms": { + "field": "component_id" + } + } + } + } + } + }, + "AnalyticsQueryResponseDto": { + "type": "object", + "properties": { + "total": { + "type": "number", + "description": "Total number of matching documents", + "example": 100 }, - "keyHint": { - "type": "string" + "hits": { + "type": "array", + "description": "Search hits", + "items": { + "type": "object" + } }, - "permissions": { + "aggregations": { "type": "object", - "properties": { - "workflows": { - "type": "object", - "properties": { + "description": "Aggregation results" + } + }, + "required": [ + "total", + "hits" + ] + }, + "AnalyticsSettingsResponseDto": { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "description": "Organization ID", + "example": "org_abc123" + }, + "subscriptionTier": { + "type": "string", + "description": "Subscription tier", + "enum": [ + "free", + "pro", + "enterprise" + ], + "example": "free" + }, + "analyticsRetentionDays": { + "type": "number", + "description": "Data retention period in days", + "example": 30 + }, + "maxRetentionDays": { + "type": "number", + "description": "Maximum retention days allowed for this tier", + "example": 30 + }, + "createdAt": { + "format": "date-time", + "type": "string", + "description": "Timestamp when settings were created", + "example": "2026-01-20T00:00:00.000Z" + }, + "updatedAt": { + "format": "date-time", + "type": "string", + "description": "Timestamp when settings were last updated", + "example": "2026-01-20T00:00:00.000Z" + } + }, + "required": [ + "organizationId", + "subscriptionTier", + "analyticsRetentionDays", + "maxRetentionDays", + "createdAt", + "updatedAt" + ] + }, + "UpdateAnalyticsSettingsDto": { + "type": "object", + "properties": { + "analyticsRetentionDays": { + "type": "number", + "description": "Data retention period in days (must be within tier limits)", + "example": 30, + "minimum": 1, + "maximum": 365 + }, + "subscriptionTier": { + "type": "string", + "description": "Subscription tier (optional - usually set by billing system)", + "enum": [ + "free", + "pro", + "enterprise" + ] + } + } + }, + "ApiKeyResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "keyPrefix": { + "type": "string" + }, + "keyHint": { + "type": "string" + }, + "permissions": { + "type": "object", + "properties": { + "workflows": { + "type": "object", + "properties": { "run": { "type": "boolean" }, @@ -7727,11 +8070,23 @@ "read", "cancel" ] + }, + "audit": { + "type": "object", + "properties": { + "read": { + "type": "boolean" + } + }, + "required": [ + "read" + ] } }, "required": [ "workflows", - "runs" + "runs", + "audit" ] }, "isActive": { @@ -7825,11 +8180,23 @@ "read", "cancel" ] + }, + "audit": { + "type": "object", + "properties": { + "read": { + "type": "boolean" + } + }, + "required": [ + "read" + ] } }, "required": [ "workflows", - "runs" + "runs", + "audit" ] }, "expiresAt": { @@ -7906,11 +8273,23 @@ "read", "cancel" ] + }, + "audit": { + "type": "object", + "properties": { + "read": { + "type": "boolean" + } + }, + "required": [ + "read" + ] } }, "required": [ "workflows", - "runs" + "runs", + "audit" ] }, "isActive": { @@ -8009,11 +8388,23 @@ "read", "cancel" ] + }, + "audit": { + "type": "object", + "properties": { + "read": { + "type": "boolean" + } + }, + "required": [ + "read" + ] } }, "required": [ "workflows", - "runs" + "runs", + "audit" ] }, "isActive": { @@ -9697,7 +10088,9 @@ "propertyNames": { "type": "string" }, - "additionalProperties": {}, + "additionalProperties": { + "type": "string" + }, "nullable": true }, "defaultDockerImage": { @@ -9735,6 +10128,118 @@ "updatedAt" ] }, + "GroupTemplateDto": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "credentialContractName": { + "type": "string", + "minLength": 1 + }, + "credentialMapping": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "defaultDockerImage": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "object", + "properties": { + "major": { + "type": "number" + }, + "minor": { + "type": "number" + }, + "patch": { + "type": "number" + } + }, + "required": [ + "major", + "minor", + "patch" + ] + }, + "servers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "transportType": { + "type": "string", + "enum": [ + "http", + "stdio", + "sse", + "websocket" + ] + }, + "endpoint": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "recommended": { + "type": "boolean" + }, + "defaultSelected": { + "type": "boolean" + } + }, + "required": [ + "name", + "transportType", + "recommended", + "defaultSelected" + ] + } + }, + "templateHash": { + "type": "string" + } + }, + "required": [ + "slug", + "name", + "credentialContractName", + "defaultDockerImage", + "version", + "servers", + "templateHash" + ] + }, "CreateMcpGroupDto": { "type": "object", "properties": { @@ -9759,7 +10264,9 @@ "propertyNames": { "type": "string" }, - "additionalProperties": {}, + "additionalProperties": { + "type": "string" + }, "nullable": true }, "defaultDockerImage": { @@ -9796,7 +10303,9 @@ "propertyNames": { "type": "string" }, - "additionalProperties": {}, + "additionalProperties": { + "type": "string" + }, "nullable": true }, "defaultDockerImage": { @@ -9990,7 +10499,9 @@ "propertyNames": { "type": "string" }, - "additionalProperties": {}, + "additionalProperties": { + "type": "string" + }, "nullable": true }, "defaultDockerImage": { @@ -10038,11 +10549,7 @@ "type": "object", "properties": {} }, - "RegisterRemoteMcpInput": { - "type": "object", - "properties": {} - }, - "RegisterLocalMcpInput": { + "RegisterMcpServerInput": { "type": "object", "properties": {} }, @@ -10395,6 +10902,111 @@ "workflowId", "status" ] + }, + "ListAuditLogsResponseDto": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "organizationId": { + "type": "string", + "nullable": true + }, + "actorId": { + "type": "string", + "nullable": true + }, + "actorType": { + "type": "string", + "enum": [ + "user", + "api-key", + "internal", + "unknown" + ] + }, + "actorDisplay": { + "type": "string", + "nullable": true + }, + "action": { + "type": "string" + }, + "resourceType": { + "type": "string", + "enum": [ + "workflow", + "secret", + "api_key", + "webhook", + "artifact", + "analytics" + ] + }, + "resourceId": { + "type": "string", + "nullable": true + }, + "resourceName": { + "type": "string", + "nullable": true + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {}, + "nullable": true + }, + "ip": { + "type": "string", + "nullable": true + }, + "userAgent": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "id", + "organizationId", + "actorId", + "actorType", + "actorDisplay", + "action", + "resourceType", + "resourceId", + "resourceName", + "metadata", + "ip", + "userAgent", + "createdAt" + ] + } + }, + "nextCursor": { + "type": "string", + "nullable": true + } + }, + "required": [ + "items", + "nextCursor" + ] } } } diff --git a/packages/backend-client/src/client.ts b/packages/backend-client/src/client.ts index eb9be1872..6886c66a8 100644 --- a/packages/backend-client/src/client.ts +++ b/packages/backend-client/src/client.ts @@ -1879,6 +1879,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/audit-logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["AuditLogsController_list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/testing/webhooks": { parameters: { query?: never; @@ -2523,6 +2539,9 @@ export interface components { read: boolean; cancel: boolean; }; + audit: { + read: boolean; + }; }; isActive: boolean; /** Format: date-time */ @@ -2548,6 +2567,9 @@ export interface components { read: boolean; cancel: boolean; }; + audit: { + read: boolean; + }; }; /** Format: date-time */ expiresAt?: string; @@ -2570,6 +2592,9 @@ export interface components { read: boolean; cancel: boolean; }; + audit: { + read: boolean; + }; }; isActive: boolean; /** Format: date-time */ @@ -2597,6 +2622,9 @@ export interface components { read: boolean; cancel: boolean; }; + audit: { + read: boolean; + }; }; isActive?: boolean; rateLimit?: number | null; @@ -3383,6 +3411,30 @@ export interface components { error?: string; errorCode?: string; }; + ListAuditLogsResponseDto: { + items: { + /** Format: uuid */ + id: string; + organizationId: string | null; + actorId: string | null; + /** @enum {string} */ + actorType: "user" | "api-key" | "internal" | "unknown"; + actorDisplay: string | null; + action: string; + /** @enum {string} */ + resourceType: "workflow" | "secret" | "api_key" | "webhook" | "artifact" | "analytics"; + resourceId: string | null; + resourceName: string | null; + metadata: { + [key: string]: unknown; + } | null; + ip: string | null; + userAgent: string | null; + /** Format: date-time */ + createdAt: string; + }[]; + nextCursor: string | null; + }; }; responses: never; parameters: never; @@ -7087,6 +7139,26 @@ export interface operations { }; }; }; + AuditLogsController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List audit log events for the authenticated organization */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListAuditLogsResponseDto"]; + }; + }; + }; + }; TestingWebhookController_listRecords: { parameters: { query?: never;