Skip to content

Commit b6f2b36

Browse files
authored
Merge pull request #167 from ShipSecAI/betterclever/api-keys-webhooks
feat: Implement API Keys & Webhook Invocation Support
2 parents 088abec + 0162daf commit b6f2b36

33 files changed

Lines changed: 4047 additions & 222 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
CREATE TABLE "api_keys" (
2+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3+
"name" varchar(191) NOT NULL,
4+
"description" text,
5+
"key_hash" text NOT NULL,
6+
"key_prefix" varchar(20) NOT NULL,
7+
"key_hint" varchar(8) NOT NULL,
8+
"permissions" jsonb NOT NULL,
9+
"scopes" jsonb DEFAULT '[]'::jsonb,
10+
"organization_id" varchar(191) NOT NULL,
11+
"created_by" varchar(191) NOT NULL,
12+
"is_active" boolean DEFAULT true NOT NULL,
13+
"expires_at" timestamp with time zone,
14+
"last_used_at" timestamp with time zone,
15+
"usage_count" integer DEFAULT 0 NOT NULL,
16+
"rate_limit" integer,
17+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
18+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
19+
CONSTRAINT "api_keys_key_hash_unique" UNIQUE("key_hash")
20+
);
21+
--> statement-breakpoint
22+
CREATE INDEX "api_keys_org_idx" ON "api_keys" USING btree ("organization_id");--> statement-breakpoint
23+
CREATE INDEX "api_keys_active_idx" ON "api_keys" USING btree ("is_active","organization_id");--> statement-breakpoint
24+
CREATE INDEX "api_keys_created_by_idx" ON "api_keys" USING btree ("created_by");--> statement-breakpoint
25+
CREATE INDEX "api_keys_hash_idx" ON "api_keys" USING btree ("key_hash");

backend/package.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,14 @@
1616
"delete:runs": "bun scripts/delete-all-workflow-runs.ts"
1717
},
1818
"dependencies": {
19+
"@clerk/backend": "^2.9.4",
20+
"@clerk/types": "^4.81.0",
1921
"@grpc/grpc-js": "^1.14.0",
20-
"ai": "^6.0.0-beta.68",
2122
"@nestjs/common": "^10.4.0",
2223
"@nestjs/config": "^3.2.0",
2324
"@nestjs/core": "^10.4.0",
2425
"@nestjs/platform-express": "^10.4.0",
2526
"@nestjs/swagger": "^11.2.0",
26-
"@clerk/backend": "^2.9.4",
27-
"@clerk/types": "^4.81.0",
2827
"@shipsec/component-sdk": "workspace:*",
2928
"@shipsec/shared": "workspace:*",
3029
"@shipsec/studio-worker": "workspace:*",
@@ -33,10 +32,13 @@
3332
"@temporalio/workflow": "^1.11.3",
3433
"@types/express": "^5.0.3",
3534
"@types/minio": "^7.1.1",
35+
"ai": "^6.0.0-beta.68",
36+
"bcryptjs": "^3.0.3",
3637
"class-transformer": "^0.5.1",
3738
"class-validator": "^0.14.1",
3839
"dotenv": "^17.2.3",
3940
"drizzle-orm": "^0.44.6",
41+
"ioredis": "^5.4.1",
4042
"kafkajs": "^2.2.4",
4143
"long": "^5.2.4",
4244
"minio": "^8.0.6",
@@ -46,11 +48,11 @@
4648
"posthog-node": "^5.17.2",
4749
"reflect-metadata": "^0.2.2",
4850
"swagger-ui-express": "^5.0.1",
49-
"zod": "^4.1.12",
50-
"ioredis": "^5.4.1"
51+
"zod": "^4.1.12"
5152
},
5253
"devDependencies": {
5354
"@nestjs/testing": "^10.4.0",
55+
"@types/bcryptjs": "^3.0.0",
5456
"@types/express-serve-static-core": "^4.19.6",
5557
"@types/multer": "^2.0.0",
5658
"@types/node": "^20.16.11",
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {
2+
Body,
3+
Controller,
4+
Delete,
5+
Get,
6+
Param,
7+
Patch,
8+
Post,
9+
Query,
10+
UseGuards,
11+
} from '@nestjs/common';
12+
import { ZodValidationPipe } from 'nestjs-zod';
13+
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
14+
15+
import { CurrentAuth } from '../auth/auth-context.decorator';
16+
import type { AuthContext } from '../auth/types';
17+
import { ApiKeysService } from './api-keys.service';
18+
import {
19+
ApiKeyResponseDto,
20+
ApiKeyResponseSchema,
21+
CreateApiKeyDto,
22+
CreateApiKeySchema,
23+
CreateApiKeyResponseDto,
24+
DeleteApiKeyResponseDto,
25+
ListApiKeysQueryDto,
26+
ListApiKeysQuerySchema,
27+
UpdateApiKeyDto,
28+
UpdateApiKeySchema,
29+
} from './dto/api-key.dto';
30+
import { AuthGuard } from '../auth/auth.guard';
31+
import { RolesGuard } from '../auth/roles.guard';
32+
import { Roles } from '../auth/roles.decorator';
33+
34+
@ApiTags('api-keys')
35+
@Controller('api-keys')
36+
@UseGuards(AuthGuard, RolesGuard)
37+
export class ApiKeysController {
38+
constructor(private readonly apiKeysService: ApiKeysService) {}
39+
40+
@Get()
41+
@ApiOkResponse({ type: ApiKeyResponseDto, isArray: true })
42+
async list(
43+
@CurrentAuth() auth: AuthContext,
44+
@Query(new ZodValidationPipe(ListApiKeysQuerySchema)) query: ListApiKeysQueryDto,
45+
) {
46+
const keys = await this.apiKeysService.list(auth, query);
47+
return keys.map((key) => ApiKeyResponseDto.create(key));
48+
}
49+
50+
@Post()
51+
@Roles('ADMIN')
52+
@ApiCreatedResponse({ type: CreateApiKeyResponseDto })
53+
async create(
54+
@CurrentAuth() auth: AuthContext,
55+
@Body(new ZodValidationPipe(CreateApiKeySchema)) dto: CreateApiKeyDto,
56+
) {
57+
const { apiKey, plainKey } = await this.apiKeysService.create(auth, dto);
58+
// Return the response DTO plus the plain key (one-time only)
59+
return {
60+
...ApiKeyResponseDto.create(apiKey),
61+
plainKey,
62+
};
63+
}
64+
65+
@Get(':id')
66+
@ApiOkResponse({ type: ApiKeyResponseDto })
67+
async get(@CurrentAuth() auth: AuthContext, @Param('id') id: string) {
68+
const apiKey = await this.apiKeysService.get(auth, id);
69+
return ApiKeyResponseDto.create(apiKey);
70+
}
71+
72+
@Patch(':id')
73+
@Roles('ADMIN')
74+
@ApiOkResponse({ type: ApiKeyResponseDto })
75+
async update(
76+
@CurrentAuth() auth: AuthContext,
77+
@Param('id') id: string,
78+
@Body(new ZodValidationPipe(UpdateApiKeySchema)) dto: UpdateApiKeyDto,
79+
) {
80+
const apiKey = await this.apiKeysService.update(auth, id, dto);
81+
return ApiKeyResponseDto.create(apiKey);
82+
}
83+
84+
@Post(':id/revoke')
85+
@Roles('ADMIN')
86+
@ApiOkResponse({ type: ApiKeyResponseDto })
87+
async revoke(@CurrentAuth() auth: AuthContext, @Param('id') id: string) {
88+
const apiKey = await this.apiKeysService.update(auth, id, { isActive: false });
89+
return ApiKeyResponseDto.create(apiKey);
90+
}
91+
92+
@Delete(':id')
93+
@Roles('ADMIN')
94+
@ApiOkResponse({ type: DeleteApiKeyResponseDto })
95+
async delete(@CurrentAuth() auth: AuthContext, @Param('id') id: string) {
96+
await this.apiKeysService.delete(auth, id);
97+
return { success: true };
98+
}
99+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module, forwardRef } from '@nestjs/common';
2+
import { ApiKeysService } from './api-keys.service';
3+
import { ApiKeysController } from './api-keys.controller';
4+
import { DatabaseModule } from '../database/database.module';
5+
import { AuthModule } from '../auth/auth.module';
6+
7+
@Module({
8+
imports: [DatabaseModule, forwardRef(() => AuthModule)],
9+
providers: [ApiKeysService],
10+
controllers: [ApiKeysController],
11+
exports: [ApiKeysService],
12+
})
13+
export class ApiKeysModule {}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import {
2+
Inject,
3+
Injectable,
4+
InternalServerErrorException,
5+
NotFoundException,
6+
Logger,
7+
} from '@nestjs/common';
8+
import { DRIZZLE_TOKEN } from '../database/database.module';
9+
import { type NodePgDatabase } from 'drizzle-orm/node-postgres';
10+
import * as schema from '../database/schema';
11+
import { apiKeys, type ApiKey, type ApiKeyPermissions } from '../database/schema/api-keys';
12+
import { eq, and, desc, sql } from 'drizzle-orm';
13+
import * as crypto from 'crypto';
14+
import * as bcrypt from 'bcryptjs';
15+
import type { CreateApiKeyDto, ListApiKeysQueryDto, UpdateApiKeyDto } from './dto/api-key.dto';
16+
import type { AuthContext } from '../auth/types';
17+
18+
const KEY_PREFIX = 'sk_live_';
19+
20+
@Injectable()
21+
export class ApiKeysService {
22+
private readonly logger = new Logger(ApiKeysService.name);
23+
24+
constructor(
25+
@Inject(DRIZZLE_TOKEN)
26+
private readonly db: NodePgDatabase<typeof schema>,
27+
) {}
28+
29+
async create(auth: AuthContext, dto: CreateApiKeyDto) {
30+
if (!auth.organizationId) {
31+
throw new InternalServerErrorException('Organization ID missing in context');
32+
}
33+
34+
const { key: plainKey, id: keyId } = this.generateKeyWithId();
35+
const keyHash = await bcrypt.hash(plainKey, 10);
36+
37+
const [apiKey] = await this.db
38+
.insert(apiKeys)
39+
.values({
40+
name: dto.name,
41+
description: dto.description,
42+
keyHash,
43+
keyPrefix: KEY_PREFIX,
44+
keyHint: keyId,
45+
permissions: dto.permissions,
46+
organizationId: dto.organizationId ?? auth.organizationId,
47+
createdBy: auth.userId || 'system',
48+
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
49+
rateLimit: dto.rateLimit,
50+
isActive: true,
51+
})
52+
.returning();
53+
54+
return { apiKey, plainKey };
55+
}
56+
57+
async list(auth: AuthContext, query: ListApiKeysQueryDto) {
58+
if (!auth.organizationId) {
59+
return [];
60+
}
61+
62+
const conditions = [eq(apiKeys.organizationId, auth.organizationId)];
63+
64+
if (query.isActive !== undefined) {
65+
conditions.push(eq(apiKeys.isActive, query.isActive));
66+
}
67+
68+
return this.db
69+
.select()
70+
.from(apiKeys)
71+
.where(and(...conditions))
72+
.orderBy(desc(apiKeys.createdAt))
73+
.limit(query.limit)
74+
.offset(query.offset);
75+
}
76+
77+
async get(auth: AuthContext, id: string) {
78+
if (!auth.organizationId) {
79+
throw new NotFoundException('API key not found');
80+
}
81+
82+
const [apiKey] = await this.db
83+
.select()
84+
.from(apiKeys)
85+
.where(and(eq(apiKeys.id, id), eq(apiKeys.organizationId, auth.organizationId)));
86+
87+
if (!apiKey) {
88+
throw new NotFoundException('API key not found');
89+
}
90+
91+
return apiKey;
92+
}
93+
94+
async update(auth: AuthContext, id: string, dto: UpdateApiKeyDto) {
95+
if (!auth.organizationId) {
96+
throw new NotFoundException('API key not found');
97+
}
98+
99+
const [apiKey] = await this.db
100+
.update(apiKeys)
101+
.set({
102+
...dto,
103+
updatedAt: new Date(),
104+
})
105+
.where(and(eq(apiKeys.id, id), eq(apiKeys.organizationId, auth.organizationId)))
106+
.returning();
107+
108+
if (!apiKey) {
109+
throw new NotFoundException('API key not found');
110+
}
111+
112+
return apiKey;
113+
}
114+
115+
async delete(auth: AuthContext, id: string) {
116+
if (!auth.organizationId) {
117+
throw new NotFoundException('API key not found');
118+
}
119+
120+
const result = await this.db
121+
.delete(apiKeys)
122+
.where(and(eq(apiKeys.id, id), eq(apiKeys.organizationId, auth.organizationId)));
123+
124+
if (result.rowCount === 0) {
125+
throw new NotFoundException('API key not found');
126+
}
127+
}
128+
129+
async validateKey(plainKey: string): Promise<ApiKey | null> {
130+
// Basic format check
131+
if (!plainKey.startsWith(KEY_PREFIX)) {
132+
return null;
133+
}
134+
135+
const parts = plainKey.split('_');
136+
// Expected format: sk_live_<8-char-id>_<secret>
137+
if (parts.length !== 4) return null;
138+
139+
const [sk, env, id, secret] = parts;
140+
if (sk !== 'sk' || env !== 'live') return null;
141+
142+
// Look up by keyHint (which stores the ID part of the key)
143+
// This allows us to find the specific key record without scanning all keys
144+
const candidates = await this.db
145+
.select()
146+
.from(apiKeys)
147+
.where(and(eq(apiKeys.keyHint, id), eq(apiKeys.isActive, true)));
148+
149+
for (const key of candidates) {
150+
const match = await bcrypt.compare(plainKey, key.keyHash);
151+
if (match) {
152+
// Check expiration
153+
if (key.expiresAt && key.expiresAt < new Date()) {
154+
return null;
155+
}
156+
157+
// Update stats (async, don't await)
158+
this.updateUsage(key.id);
159+
160+
return key;
161+
}
162+
}
163+
164+
return null;
165+
}
166+
167+
private async updateUsage(id: string) {
168+
try {
169+
await this.db
170+
.update(apiKeys)
171+
.set({
172+
lastUsedAt: new Date(),
173+
usageCount: sql`${apiKeys.usageCount} + 1`,
174+
})
175+
.where(eq(apiKeys.id, id));
176+
} catch (e) {
177+
this.logger.error(`Failed to update usage for key ${id}`, e);
178+
}
179+
}
180+
181+
// Adjusted generation to match the lookup strategy
182+
private generateKeyWithId(): { key: string; id: string } {
183+
const id = crypto.randomBytes(6).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 8);
184+
const secret = crypto.randomBytes(24).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 32);
185+
const key = `${KEY_PREFIX}${id}_${secret}`;
186+
return { key, id };
187+
}
188+
189+
190+
}

0 commit comments

Comments
 (0)