Skip to content

Commit 2ced9a8

Browse files
authored
Merge branch 'main' into krishna9358/template-library
Signed-off-by: Krishna Mohan <krishanmohank974@gmail.com>
2 parents cb3e339 + b66be74 commit 2ced9a8

79 files changed

Lines changed: 4590 additions & 321 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ just instance show # Print active instance number
2727
just instance use 5 # Set active instance for this workspace
2828
```
2929

30+
**Instance env files**:
31+
32+
```bash
33+
just instance-init 5 # Initialize .instances/instance-5/*.env
34+
just instance-env init 5 # Create from app/.env or app/.env.example
35+
just instance-env update 5 # Re-apply instance-scoped vars
36+
just instance-env copy 5 6 # Copy env setup from instance 5 -> 6
37+
just instance-env show 6 # Show file status and computed values
38+
```
39+
3040
**URLs**:
3141

3242
- Frontend: `http://localhost:${5173 + instance*100}`
@@ -43,14 +53,15 @@ Local development runs as **multiple app instances** (PM2) on top of **one share
4353
- Per-instance apps: `shipsec-{frontend,backend,worker}-N`.
4454
- Isolation is via per-instance DB + Temporal namespace/task queue + Kafka topic suffixing + instance-scoped Kafka consumer groups/client IDs (not per-instance infra containers).
4555
- The workspace can have an **active instance** (stored in `.shipsec-instance`, gitignored).
56+
- Instance env files are stored at `.instances/instance-N/{backend,worker,frontend}.env` and can be managed with `just instance-env ...`.
4657

4758
**Agent rule:** before running any dev commands, ensure you’re targeting the intended instance.
4859

4960
- Always check: `just instance show`
5061
- If the task is ambiguous (logs, curl, E2E, “run locally”, etc.), ask the user which instance to use.
5162
- If the user says “use instance N”, prefer either:
5263
- `just instance use N` then run `just dev` / `bun run test:e2e`, or
53-
- explicit instance invocation (`just dev N ...`) for one-off commands.
64+
- explicit env override (`SHIPSEC_INSTANCE=N just dev ...`) for one-off commands.
5465

5566
**Ports / URLs**
5667

@@ -61,7 +72,7 @@ Local development runs as **multiple app instances** (PM2) on top of **one share
6172
**E2E tests**
6273

6374
- E2E targets the backend for `SHIPSEC_INSTANCE` (or the active instance).
64-
- When asked to run E2E, confirm the instance and ensure that instance is running: `just dev N start`.
75+
- When asked to run E2E, confirm the instance and ensure that instance is running: `SHIPSEC_INSTANCE=N just dev start` (or `just instance use N` then `just dev start`).
6576

6677
**Keep docs in sync**
6778

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,12 @@ Run multiple isolated dev instances on one machine for parallel feature work:
137137
# Instance 0 (default)
138138
just dev
139139

140-
# Instance 1 — offset ports (frontend :5273, backend :3311)
141-
SHIPSEC_INSTANCE=1 just dev
140+
# Switch active workspace instance
141+
just instance use 1
142+
just dev
143+
144+
# Manage per-instance env files
145+
just instance-env init 1
142146
```
143147

144148
Each instance gets its own frontend port, backend port, database, and Temporal namespace while sharing a single Docker infra stack. See [Multi-Instance Development Guide](docs/MULTI-INSTANCE-DEV.md) for full details.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
CREATE TABLE IF NOT EXISTS "audit_logs" (
2+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3+
"organization_id" varchar(191),
4+
"actor_id" varchar(191),
5+
"actor_type" varchar(32) NOT NULL,
6+
"actor_display" varchar(191),
7+
"action" varchar(64) NOT NULL,
8+
"resource_type" varchar(32) NOT NULL,
9+
"resource_id" varchar(191),
10+
"resource_name" varchar(191),
11+
"metadata" jsonb,
12+
"ip" varchar(64),
13+
"user_agent" text,
14+
"created_at" timestamptz NOT NULL DEFAULT now()
15+
);
16+
17+
CREATE INDEX IF NOT EXISTS "audit_logs_org_created_at_idx" ON "audit_logs" ("organization_id", "created_at" DESC);
18+
CREATE INDEX IF NOT EXISTS "audit_logs_org_resource_idx" ON "audit_logs" ("organization_id", "resource_type", "resource_id");
19+
CREATE INDEX IF NOT EXISTS "audit_logs_org_action_created_at_idx" ON "audit_logs" ("organization_id", "action", "created_at" DESC);
20+
CREATE INDEX IF NOT EXISTS "audit_logs_org_actor_created_at_idx" ON "audit_logs" ("organization_id", "actor_id", "created_at" DESC);
21+

backend/scripts/generate-openapi.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ async function generateOpenApi() {
1111
// Skip ingest services that require external connections during OpenAPI generation
1212
process.env.SKIP_INGEST_SERVICES = 'true';
1313
process.env.SHIPSEC_SKIP_MIGRATION_CHECK = 'true';
14+
// Ensure encryption services can bootstrap during schema generation.
15+
// This key is only used to construct the Nest application for OpenAPI output.
16+
process.env.SECRET_STORE_MASTER_KEY =
17+
process.env.SECRET_STORE_MASTER_KEY ?? 'shipsec-openapi-master-key-32bxx';
18+
process.env.INTEGRATION_STORE_MASTER_KEY =
19+
process.env.INTEGRATION_STORE_MASTER_KEY ?? 'shipsec-openapi-master-key-32bxx';
1420

1521
const { AppModule } = await import('../src/app.module');
1622

backend/src/analytics/analytics.controller.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
UpdateAnalyticsSettingsDto,
2323
TIER_LIMITS,
2424
} from './dto/analytics-settings.dto';
25+
import { AuditLogService } from '../audit/audit-log.service';
2526
import { CurrentAuth } from '../auth/auth-context.decorator';
2627
import { Public } from '../auth/public.decorator';
2728
import type { AuthContext } from '../auth/types';
@@ -43,6 +44,7 @@ export class AnalyticsController {
4344
private readonly organizationSettingsService: OrganizationSettingsService,
4445
private readonly openSearchTenantService: OpenSearchTenantService,
4546
private readonly configService: ConfigService,
47+
private readonly auditLogService: AuditLogService,
4648
) {
4749
this.internalServiceToken = this.configService.get<string>('INTERNAL_SERVICE_TOKEN') || '';
4850
}
@@ -106,6 +108,19 @@ export class AnalyticsController {
106108
throw new BadRequestException(`Invalid from: maximum is ${MAX_QUERY_FROM}`);
107109
}
108110

111+
this.auditLogService.record(auth, {
112+
action: 'analytics.query',
113+
resourceType: 'analytics',
114+
resourceId: null,
115+
resourceName: null,
116+
metadata: {
117+
size,
118+
from,
119+
hasQuery: Boolean(queryDto.query),
120+
hasAggs: Boolean(queryDto.aggs),
121+
},
122+
});
123+
109124
// Call the service to execute the query
110125
return this.securityAnalyticsService.query(auth.organizationId, {
111126
query: queryDto.query,

backend/src/api-keys/api-keys.service.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as crypto from 'crypto';
1414
import * as bcrypt from 'bcryptjs';
1515
import type { CreateApiKeyDto, ListApiKeysQueryDto, UpdateApiKeyDto } from './dto/api-key.dto';
1616
import type { AuthContext } from '../auth/types';
17+
import { AuditLogService } from '../audit/audit-log.service';
1718

1819
const KEY_PREFIX = 'sk_live_';
1920

@@ -24,6 +25,7 @@ export class ApiKeysService {
2425
constructor(
2526
@Inject(DRIZZLE_TOKEN)
2627
private readonly db: NodePgDatabase<typeof schema>,
28+
private readonly auditLogService: AuditLogService,
2729
) {}
2830

2931
async create(auth: AuthContext, dto: CreateApiKeyDto) {
@@ -51,6 +53,17 @@ export class ApiKeysService {
5153
})
5254
.returning();
5355

56+
this.auditLogService.record(auth, {
57+
action: 'api_key.create',
58+
resourceType: 'api_key',
59+
resourceId: apiKey.id,
60+
resourceName: apiKey.name,
61+
metadata: {
62+
isActive: apiKey.isActive,
63+
expiresAt: apiKey.expiresAt?.toISOString() ?? null,
64+
},
65+
});
66+
5467
return { apiKey, plainKey };
5568
}
5669

@@ -109,6 +122,23 @@ export class ApiKeysService {
109122
throw new NotFoundException('API key not found');
110123
}
111124

125+
const action =
126+
dto.isActive === false
127+
? 'api_key.revoke'
128+
: dto.isActive === true
129+
? 'api_key.reactivate'
130+
: 'api_key.update';
131+
this.auditLogService.record(auth, {
132+
action,
133+
resourceType: 'api_key',
134+
resourceId: apiKey.id,
135+
resourceName: apiKey.name,
136+
metadata: {
137+
updatedFields: Object.keys(dto),
138+
isActive: apiKey.isActive,
139+
},
140+
});
141+
112142
return apiKey;
113143
}
114144

@@ -117,13 +147,27 @@ export class ApiKeysService {
117147
throw new NotFoundException('API key not found');
118148
}
119149

150+
const existing = await this.db
151+
.select()
152+
.from(apiKeys)
153+
.where(and(eq(apiKeys.id, id), eq(apiKeys.organizationId, auth.organizationId)))
154+
.limit(1)
155+
.then((rows) => rows[0] ?? null);
156+
120157
const result = await this.db
121158
.delete(apiKeys)
122159
.where(and(eq(apiKeys.id, id), eq(apiKeys.organizationId, auth.organizationId)));
123160

124161
if (result.rowCount === 0) {
125162
throw new NotFoundException('API key not found');
126163
}
164+
165+
this.auditLogService.record(auth, {
166+
action: 'api_key.delete',
167+
resourceType: 'api_key',
168+
resourceId: id,
169+
resourceName: existing?.name ?? null,
170+
});
127171
}
128172

129173
async validateKey(plainKey: string): Promise<ApiKey | null> {

backend/src/api-keys/dto/api-key.dto.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,17 @@ export const ApiKeyPermissionsSchema = z.object({
88
run: z.boolean(),
99
list: z.boolean(),
1010
read: z.boolean(),
11+
create: z.boolean().optional(),
12+
update: z.boolean().optional(),
13+
delete: z.boolean().optional(),
1114
}),
1215
runs: z.object({
1316
read: z.boolean(),
1417
cancel: z.boolean(),
1518
}),
19+
audit: z.object({
20+
read: z.boolean(),
21+
}),
1622
});
1723

1824
export const CreateApiKeySchema = z.object({

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { SchedulesModule } from './schedules/schedules.module';
2727
import { AnalyticsModule } from './analytics/analytics.module';
2828
import { McpModule } from './mcp/mcp.module';
2929
import { StudioMcpModule } from './studio-mcp/studio-mcp.module';
30+
import { AuditModule } from './audit/audit.module';
3031

3132
import { ApiKeysModule } from './api-keys/api-keys.module';
3233
import { WebhooksModule } from './webhooks/webhooks.module';
@@ -54,6 +55,7 @@ const coreModules = [
5455
McpModule,
5556
StudioMcpModule,
5657
TemplatesModule,
58+
AuditModule,
5759
];
5860

5961
const testingModules = process.env.NODE_ENV === 'production' ? [] : [TestingSupportModule];
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect } from 'bun:test';
2+
3+
import type { AuthContext } from '../../auth/types';
4+
import { AuditLogService } from '../audit-log.service';
5+
import type { AuditLogRepository } from '../audit-log.repository';
6+
7+
function makeAuth(overrides: Partial<AuthContext> = {}): AuthContext {
8+
return {
9+
userId: 'user-1',
10+
organizationId: 'org-1',
11+
roles: ['MEMBER'],
12+
isAuthenticated: true,
13+
provider: 'local',
14+
...overrides,
15+
};
16+
}
17+
18+
describe('AuditLogService', () => {
19+
it('allows org admins to read audit logs', () => {
20+
const repo: AuditLogRepository = {
21+
insert: async () => {},
22+
list: async () => [],
23+
} as any;
24+
const service = new AuditLogService(repo);
25+
expect(service.canRead(makeAuth({ roles: ['ADMIN'] }))).toBe(true);
26+
});
27+
28+
it('allows API keys with audit.read=true to read audit logs', () => {
29+
const repo: AuditLogRepository = {
30+
insert: async () => {},
31+
list: async () => [],
32+
} as any;
33+
const service = new AuditLogService(repo);
34+
expect(
35+
service.canRead(
36+
makeAuth({
37+
provider: 'api-key',
38+
roles: ['MEMBER'],
39+
apiKeyPermissions: {
40+
workflows: { run: false, list: false, read: false },
41+
runs: { read: false, cancel: false },
42+
audit: { read: true },
43+
},
44+
}),
45+
),
46+
).toBe(true);
47+
});
48+
49+
it('denies API keys without audit.read', () => {
50+
const repo: AuditLogRepository = {
51+
insert: async () => {},
52+
list: async () => [],
53+
} as any;
54+
const service = new AuditLogService(repo);
55+
expect(
56+
service.canRead(
57+
makeAuth({
58+
provider: 'api-key',
59+
roles: ['MEMBER'],
60+
apiKeyPermissions: {
61+
workflows: { run: true, list: true, read: true },
62+
runs: { read: true, cancel: true },
63+
audit: { read: false },
64+
},
65+
}),
66+
),
67+
).toBe(false);
68+
});
69+
70+
it('record() never throws even if repository insert fails', async () => {
71+
let called = false;
72+
const repo: AuditLogRepository = {
73+
insert: async () => {
74+
called = true;
75+
throw new Error('db down');
76+
},
77+
list: async () => [],
78+
} as any;
79+
const service = new AuditLogService(repo);
80+
81+
expect(() =>
82+
service.record(makeAuth({ roles: ['ADMIN'] }), {
83+
action: 'secret.access',
84+
resourceType: 'secret',
85+
resourceId: 'secret-1',
86+
resourceName: 'foo',
87+
metadata: { requestedVersion: 1 },
88+
}),
89+
).not.toThrow();
90+
91+
// Flush microtasks (record uses queueMicrotask).
92+
await Promise.resolve();
93+
expect(called).toBe(true);
94+
});
95+
});

0 commit comments

Comments
 (0)