Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions backend/drizzle/0020_create-audit-logs.sql
Original file line number Diff line number Diff line change
@@ -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);

6 changes: 6 additions & 0 deletions backend/scripts/generate-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
15 changes: 15 additions & 0 deletions backend/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string>('INTERNAL_SERVICE_TOKEN') || '';
}
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions backend/src/api-keys/api-keys.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_';

Expand All @@ -24,6 +25,7 @@ export class ApiKeysService {
constructor(
@Inject(DRIZZLE_TOKEN)
private readonly db: NodePgDatabase<typeof schema>,
private readonly auditLogService: AuditLogService,
) {}

async create(auth: AuthContext, dto: CreateApiKeyDto) {
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -117,13 +147,27 @@ 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)));

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<ApiKey | null> {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/api-keys/dto/api-key.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -52,6 +53,7 @@ const coreModules = [
McpGroupsModule,
McpModule,
StudioMcpModule,
AuditModule,
];

const testingModules = process.env.NODE_ENV === 'production' ? [] : [TestingSupportModule];
Expand Down
95 changes: 95 additions & 0 deletions backend/src/audit/__tests__/audit-log.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
87 changes: 87 additions & 0 deletions backend/src/audit/audit-log.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, desc, eq, gte, lt, lte, or, sql, 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;
resourceId?: string;
action?: 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<AuditLogInsert, 'id' | 'createdAt'>): Promise<void> {
await this.db.insert(auditLogsTable).values(values);
}

async list(filters: ListAuditLogFilters): Promise<AuditLogRecord[]> {
const conditions: SQL<unknown>[] = [];

// Always scope by organization.
conditions.push(eq(auditLogsTable.organizationId, filters.organizationId));

if (filters.resourceType) {
conditions.push(eq(auditLogsTable.resourceType, filters.resourceType as any));
}
if (filters.resourceId) {
conditions.push(eq(auditLogsTable.resourceId, filters.resourceId));
}
if (filters.action) {
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<unknown> = 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);
}
}
Loading
Loading