Skip to content
Merged
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
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ProfileModule } from './modules/profile/profile.module';
import { PrismaModule } from './database/prisma.module';
import { IncidentsModule } from './modules/incidents/incidents.module';
import { InvitationsModule } from './modules/invitations/invitations.module';
import { ApiKeysModule } from './modules/api-keys/api-keys.module';

@Module({
imports: [
Expand All @@ -38,6 +39,7 @@ import { InvitationsModule } from './modules/invitations/invitations.module';
IncidentsModule,
ProtocolHealthModule,
InvitationsModule,
ApiKeysModule,
],
controllers: [AppController],
})
Expand Down
45 changes: 29 additions & 16 deletions apps/backend/src/modules/api-keys/api-keys.controller.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,54 @@
import { Controller, Get, Post, Delete, Body, Param, HttpCode } from '@nestjs/common';
import { Controller, Get, Post, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthenticatedUser } from '../../common/interfaces/authenticated-user.interface';
import { ApiKeysService } from './api-keys.service';
import { CreateApiKeyDto, ApiKeyResponseDto, ApiKeyUsageDto } from './dto/api-key.dto';

// NOTE: In production, add @UseGuards(AuthGuard) and extract userId from request
// For now, userId is passed as a path parameter for demo purposes

@ApiTags('api-keys')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('api-keys')
export class ApiKeysController {
constructor(private readonly apiKeysService: ApiKeysService) {}

@Post(':userId')
@Post()
@ApiOperation({ summary: 'Create a new API key for the authenticated user' })
@ApiResponse({ status: 201, type: ApiKeyResponseDto })
async createApiKey(
@Param('userId') userId: string,
@CurrentUser() user: AuthenticatedUser,
@Body() dto: CreateApiKeyDto,
): Promise<ApiKeyResponseDto & { key: string }> {
return this.apiKeysService.createApiKey(userId, dto);
return this.apiKeysService.createApiKey(user.userId, dto);
}

@Get(':userId')
async getApiKeys(@Param('userId') userId: string): Promise<ApiKeyResponseDto[]> {
return this.apiKeysService.getApiKeys(userId);
@Get()
@ApiOperation({ summary: "List the authenticated user's active API keys" })
@ApiResponse({ status: 200, type: [ApiKeyResponseDto] })
async getApiKeys(@CurrentUser() user: AuthenticatedUser): Promise<ApiKeyResponseDto[]> {
return this.apiKeysService.getApiKeys(user.userId);
}

@Delete(':userId/:keyId')
@Delete(':keyId')
@HttpCode(204)
@ApiOperation({ summary: "Revoke one of the authenticated user's API keys" })
async revokeApiKey(
@Param('userId') userId: string,
@CurrentUser() user: AuthenticatedUser,
@Param('keyId') keyId: string,
): Promise<void> {
return this.apiKeysService.revokeApiKey(userId, keyId);
return this.apiKeysService.revokeApiKey(user.userId, keyId);
}

@Get(':userId/:keyId/usage')
@Get(':keyId/usage')
@ApiOperation({
summary: "Get usage metadata for one of the authenticated user's API keys",
})
@ApiResponse({ status: 200, type: ApiKeyUsageDto })
async getApiKeyUsage(
@Param('userId') userId: string,
@CurrentUser() user: AuthenticatedUser,
@Param('keyId') keyId: string,
): Promise<ApiKeyUsageDto> {
return this.apiKeysService.getApiKeyUsage(userId, keyId);
return this.apiKeysService.getApiKeyUsage(user.userId, keyId);
}
}
6 changes: 6 additions & 0 deletions apps/backend/src/modules/api-keys/api-keys.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';

@Module({
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET,
}),
],
controllers: [ApiKeysController],
providers: [ApiKeysService],
exports: [ApiKeysService],
Expand Down
34 changes: 18 additions & 16 deletions apps/backend/src/modules/api-keys/api-keys.service.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomBytes, createHash } from 'crypto';
import { PrismaService } from '../../database/prisma.service';
import { CreateApiKeyDto, ApiKeyResponseDto, ApiKeyUsageDto } from './dto/api-key.dto';

@Injectable()
export class ApiKeysService {
private prisma: PrismaClient;

constructor() {
this.prisma = new PrismaClient();
}
constructor(private readonly prisma: PrismaService) {}

private generateKey(): string {
return `sk_${randomBytes(32).toString('hex')}`;
Expand Down Expand Up @@ -72,16 +68,18 @@ export class ApiKeysService {
}

async revokeApiKey(userId: string, keyId: string): Promise<void> {
const apiKey = await this.prisma.apiKey.findUnique({
where: { id: keyId },
// Scope the lookup to the caller's own keys so a key ID belonging to
// another user reports as not-found rather than leaking its existence.
const apiKey = await this.prisma.apiKey.findFirst({
where: { id: keyId, userId },
});

if (!apiKey) {
throw new NotFoundException(`API key not found`);
throw new NotFoundException('API key not found');
}

if (apiKey.userId !== userId) {
throw new ConflictException(`Unauthorized access to API key`);
if (apiKey.revokedAt) {
return;
}

await this.prisma.apiKey.update({
Expand All @@ -90,6 +88,10 @@ export class ApiKeysService {
});
}

/**
* Validate a presented key and record usage. Returns the owning userId, or
* null if the key is unknown or has been revoked.
*/
async validateAndUpdateUsage(key: string): Promise<string | null> {
const keyHash = this.hashKey(key);

Expand All @@ -110,12 +112,12 @@ export class ApiKeysService {
}

async getApiKeyUsage(userId: string, keyId: string): Promise<ApiKeyUsageDto> {
const apiKey = await this.prisma.apiKey.findUnique({
where: { id: keyId },
const apiKey = await this.prisma.apiKey.findFirst({
where: { id: keyId, userId },
});

if (!apiKey || apiKey.userId !== userId) {
throw new NotFoundException(`API key not found`);
if (!apiKey) {
throw new NotFoundException('API key not found');
}

// Placeholder for actual usage tracking
Expand Down
38 changes: 36 additions & 2 deletions apps/backend/src/modules/api-keys/dto/api-key.dto.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,56 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MaxLength, MinLength } from 'class-validator';

export class CreateApiKeyDto {
@ApiProperty({ description: 'Human-readable label for the key', example: 'CI deploy key' })
@IsString()
@MinLength(1)
@MaxLength(100)
name!: string;
}

export class ApiKeyResponseDto {
@ApiProperty()
id!: string;

@ApiProperty()
name!: string;
key?: string; // Only returned on creation
keyPreview!: string; // Last 4 chars

@ApiProperty({
required: false,
description: 'The plaintext key — only ever present in the create response.',
})
key?: string;

@ApiProperty({ description: 'Last 4 characters of the key, for identification in listings.' })
keyPreview!: string;

@ApiProperty()
createdAt!: Date;

@ApiProperty({ nullable: true })
lastUsedAt!: Date | null;

@ApiProperty({ nullable: true })
revokedAt!: Date | null;
}

export class ApiKeyUsageDto {
@ApiProperty()
id!: string;

@ApiProperty()
name!: string;

@ApiProperty()
keyPreview!: string;

@ApiProperty()
createdAt!: Date;

@ApiProperty({ nullable: true })
lastUsedAt!: Date | null;

@ApiProperty()
requestCount!: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ApiKeysController } from '../api-keys.controller';
import { ApiKeysService } from '../api-keys.service';
import { AuthenticatedUser } from '../../../common/interfaces/authenticated-user.interface';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { Role } from '../../../common/enums/role.enum';

describe('ApiKeysController', () => {
let controller: ApiKeysController;
let service: jest.Mocked<ApiKeysService>;

const currentUser: AuthenticatedUser = {
userId: 'user-1',
email: 'user1@example.com',
roles: [Role.ANALYST],
};

beforeEach(async () => {
const mockService: Partial<jest.Mocked<ApiKeysService>> = {
createApiKey: jest.fn(),
getApiKeys: jest.fn(),
revokeApiKey: jest.fn(),
getApiKeyUsage: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [ApiKeysController],
providers: [{ provide: ApiKeysService, useValue: mockService }],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();

controller = module.get(ApiKeysController);
service = module.get(ApiKeysService);
});

it('creates a key scoped to the authenticated user, ignoring any client-supplied id', async () => {
await controller.createApiKey(currentUser, { name: 'CI key' });

expect(service.createApiKey).toHaveBeenCalledWith('user-1', { name: 'CI key' });
});

it("lists only the authenticated user's keys", async () => {
await controller.getApiKeys(currentUser);

expect(service.getApiKeys).toHaveBeenCalledWith('user-1');
});

it('revokes a key scoped to the authenticated user', async () => {
await controller.revokeApiKey(currentUser, 'key-1');

expect(service.revokeApiKey).toHaveBeenCalledWith('user-1', 'key-1');
});

it('reads usage scoped to the authenticated user', async () => {
await controller.getApiKeyUsage(currentUser, 'key-1');

expect(service.getApiKeyUsage).toHaveBeenCalledWith('user-1', 'key-1');
});
});
Loading
Loading