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
98 changes: 50 additions & 48 deletions app/backend/src/api-keys/api-keys.types.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,50 @@
export const API_KEY_SCOPES = [
'links:read',
'links:write',
'transactions:read',
'usernames:read',
'refunds:write',
'support:read',
'support:write',
'admin', // Admin scope for job queue management and other admin operations
] as const;

export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];

export interface ApiKeyRecord {
id: string;
name: string;
key_hash: string;
key_hash_old: string | null;
key_prefix: string;
scopes: ApiKeyScope[];
owner_id: string | null;
organization_id: string | null;
is_active: boolean;
request_count: number;
monthly_quota: number;
last_used_at: string | null;
rotated_at: string | null;
last_reset_at: string;
created_at: string;
updated_at: string;
}

export interface ApiKeyPublic {
id: string;
name: string;
key_prefix: string;
scopes: ApiKeyScope[];
is_active: boolean;
request_count: number;
monthly_quota: number;
last_used_at: string | null;
created_at: string;
}

/** Returned once at creation / rotation — contains the raw key. */
export interface ApiKeyCreated extends ApiKeyPublic {
key: string;
}
export const API_KEY_SCOPES = [
'links:read',
'links:write',
'transactions:read',
'usernames:read',
'refunds:write',
'support:read',
'support:write',
'admin', // Admin scope for job queue management and other admin operations
'branch_preview:owner',
'branch_preview:reviewer',
] as const;

export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];

export interface ApiKeyRecord {
id: string;
name: string;
key_hash: string;
key_hash_old: string | null;
key_prefix: string;
scopes: ApiKeyScope[];
owner_id: string | null;
organization_id: string | null;
is_active: boolean;
request_count: number;
monthly_quota: number;
last_used_at: string | null;
rotated_at: string | null;
last_reset_at: string;
created_at: string;
updated_at: string;
}

export interface ApiKeyPublic {
id: string;
name: string;
key_prefix: string;
scopes: ApiKeyScope[];
is_active: boolean;
request_count: number;
monthly_quota: number;
last_used_at: string | null;
created_at: string;
}

/** Returned once at creation / rotation — contains the raw key. */
export interface ApiKeyCreated extends ApiKeyPublic {
key: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { SetMetadata } from '@nestjs/common';
import { ApiKeyScope } from '../../api-keys/api-keys.types';

export const REQUIRED_ANY_SCOPE_KEY = 'requiredAnyScope';

export const RequireAnyScope = (...scopes: ApiKeyScope[]) =>
SetMetadata(REQUIRED_ANY_SCOPE_KEY, scopes);
27 changes: 23 additions & 4 deletions app/backend/src/auth/guards/api-key.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ApiKeysService } from "../../api-keys/api-keys.service";
import { ApiKeyScope } from "../../api-keys/api-keys.types";
import { throttlerConfig } from "../../config/rate-limit.config";
import { REQUIRED_SCOPES_KEY } from "../decorators/require-scopes.decorator";
import { REQUIRED_ANY_SCOPE_KEY } from "../decorators/require-any-scope.decorator";

@Injectable()
export class ApiKeyGuard implements CanActivate {
Expand Down Expand Up @@ -55,13 +56,31 @@ export class ApiKeyGuard implements CanActivate {
this.reflector.getAllAndOverride<ApiKeyScope[]>(REQUIRED_SCOPES_KEY, [
context.getHandler(),
context.getClass(),
]) ?? [];
]);

for (const scope of requiredScopes) {
if (!hasScope(scope)) {
if (requiredScopes) {
for (const scope of requiredScopes) {
if (!hasScope(scope)) {
throw new ForbiddenException({
error: "INSUFFICIENT_SCOPE",
message: `API key missing required scope: ${scope}`,
});
}
}
}

const requiredAnyScope =
this.reflector.getAllAndOverride<ApiKeyScope[]>(REQUIRED_ANY_SCOPE_KEY, [
context.getHandler(),
context.getClass(),
]);

if (requiredAnyScope && requiredAnyScope.length > 0) {
const hasAny = requiredAnyScope.some(scope => hasScope(scope));
if (!hasAny) {
throw new ForbiddenException({
error: "INSUFFICIENT_SCOPE",
message: `API key missing required scope: ${scope}`,
message: `API key missing one of required scopes: ${requiredAnyScope.join(', ')}`,
});
}
}
Expand Down
29 changes: 17 additions & 12 deletions app/backend/src/branch-preview/branch-preview.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ import { Request } from 'express';

interface AuthenticatedRequest extends Request {
user?: { id: string };
apiKey?: { id: string; scopes: string[] };
correlationId?: string;
}

import { ApiKeyGuard } from '../auth/guards/api-key.guard';
import { RequireScopes } from '../auth/decorators/require-scopes.decorator';
import { RequireAnyScope } from '../auth/decorators/require-any-scope.decorator';
import { RateLimitGroupTag } from '../auth/decorators/rate-limit-group.decorator';
import { BranchPreviewService } from './branch-preview.service';
import { BranchPreviewResponseDto } from './branch-preview.model';
Expand Down Expand Up @@ -66,7 +68,7 @@ export class BranchPreviewController {

// Admin endpoints
@Post('admin/branch-previews')
@RequireScopes('admin')
@RequireAnyScope('admin', 'branch_preview:owner', 'branch_preview:reviewer')
@RateLimitGroupTag('authenticated')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
Expand All @@ -77,13 +79,13 @@ export class BranchPreviewController {
@Body() dto: CreateBranchPreviewRequestDto,
@Req() req: AuthenticatedRequest,
) {
const actorId = req.user?.id || 'unknown';
const actorId = req.user?.id || req.apiKey?.id || 'unknown';
const requestId = req.correlationId;
return this.branchPreviewService.createPreview(dto, actorId, requestId);
}

@Put('admin/branch-previews/:id')
@RequireScopes('admin')
@RequireAnyScope('admin', 'branch_preview:owner', 'branch_preview:reviewer')
@RateLimitGroupTag('authenticated')
@ApiOperation({
summary: 'Update an existing branch preview mapping',
Expand All @@ -94,13 +96,14 @@ export class BranchPreviewController {
@Body() dto: UpdateBranchPreviewRequestDto,
@Req() req: AuthenticatedRequest,
) {
const actorId = req.user?.id || 'unknown';
const actorId = req.user?.id || req.apiKey?.id || 'unknown';
const scopes = req.apiKey?.scopes || [];
const requestId = req.correlationId;
return this.branchPreviewService.updatePreview(id, dto, actorId, requestId);
return this.branchPreviewService.updatePreview(id, dto, actorId, scopes, requestId);
}

@Delete('admin/branch-previews/:id')
@RequireScopes('admin')
@RequireAnyScope('admin', 'branch_preview:owner', 'branch_preview:reviewer')
@RateLimitGroupTag('authenticated')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
Expand All @@ -111,13 +114,14 @@ export class BranchPreviewController {
@Param('id') id: string,
@Req() req: AuthenticatedRequest,
) {
const actorId = req.user?.id || 'unknown';
const actorId = req.user?.id || req.apiKey?.id || 'unknown';
const scopes = req.apiKey?.scopes || [];
const requestId = req.correlationId;
return this.branchPreviewService.deletePreview(id, actorId, requestId);
return this.branchPreviewService.deletePreview(id, actorId, scopes, requestId);
}

@Get('admin/branch-previews')
@RequireScopes('admin')
@RequireAnyScope('admin', 'branch_preview:owner', 'branch_preview:reviewer')
@RateLimitGroupTag('authenticated')
@ApiOperation({
summary: 'List all branch preview mappings',
Expand All @@ -131,7 +135,7 @@ export class BranchPreviewController {
}

@Post('admin/branch-previews/:branchName/invalidate-cache')
@RequireScopes('admin')
@RequireAnyScope('admin', 'branch_preview:owner', 'branch_preview:reviewer')
@RateLimitGroupTag('authenticated')
@HttpCode(HttpStatus.OK)
@ApiOperation({
Expand All @@ -142,9 +146,10 @@ export class BranchPreviewController {
@Param('branchName') branchName: string,
@Req() req: AuthenticatedRequest,
) {
const actorId = req.user?.id || 'unknown';
const actorId = req.user?.id || req.apiKey?.id || 'unknown';
const scopes = req.apiKey?.scopes || [];
const requestId = req.correlationId;
const success = await this.branchPreviewService.invalidateCache(branchName, actorId, requestId);
const success = await this.branchPreviewService.invalidateCache(branchName, actorId, scopes, requestId);
return { success };
}

Expand Down
3 changes: 3 additions & 0 deletions app/backend/src/branch-preview/branch-preview.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface BranchPreviewEnvironment {
expiresAt?: Date;
autoExpiredAt?: Date;
autoExpiryReason?: string;
ownerId?: string;
}

export interface CreateBranchPreviewDto {
Expand All @@ -27,6 +28,7 @@ export interface CreateBranchPreviewDto {
ttlMs?: number;
isShared?: boolean;
expiryExempt?: boolean;
ownerId?: string;
}

export interface UpdateBranchPreviewDto {
Expand All @@ -38,6 +40,7 @@ export interface UpdateBranchPreviewDto {
ttlMs?: number;
isShared?: boolean;
expiryExempt?: boolean;
ownerId?: string;
}

export class BranchPreviewResponseDto {
Expand Down
25 changes: 25 additions & 0 deletions app/backend/src/branch-preview/branch-preview.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class BranchPreviewRepository {
expiryExempt: dto.expiryExempt ?? false,
lastActivityAt: now,
expiresAt: expiresAt || undefined,
ownerId: dto.ownerId,
};

const { data, error } = await client
Expand All @@ -48,6 +49,7 @@ export class BranchPreviewRepository {
created_at: now.toISOString(),
updated_at: now.toISOString(),
expires_at: expiresAt?.toISOString(),
owner_id: preview.ownerId,
})
.select()
.single();
Expand Down Expand Up @@ -82,6 +84,27 @@ export class BranchPreviewRepository {
return this.mapDbToModel(data);
}

/**
* Find a branch preview by ID
*/
async findById(id: string): Promise<BranchPreviewEnvironment | null> {
const client = this.supabaseService.getClient();
const { data, error } = await client
.from(this.TABLE_NAME)
.select('*')
.eq('id', id)
.single();

if (error) {
if (error.code !== 'PGRST116') {
this.logger.error(`Error finding branch preview by id: ${error.message}`, error);
}
return null;
}

return this.mapDbToModel(data);
}

/**
* Find all active branch previews
*/
Expand Down Expand Up @@ -120,6 +143,7 @@ export class BranchPreviewRepository {
if (dto.isActive !== undefined) updateData.is_active = dto.isActive;
if (dto.isShared !== undefined) updateData.is_shared = dto.isShared;
if (dto.expiryExempt !== undefined) updateData.expiry_exempt = dto.expiryExempt;
if (dto.ownerId !== undefined) updateData.owner_id = dto.ownerId;
if (dto.ttlMs) {
updateData.expires_at = new Date(now.getTime() + dto.ttlMs).toISOString();
}
Expand Down Expand Up @@ -276,6 +300,7 @@ export class BranchPreviewRepository {
? new Date(dbRecord.auto_expired_at as string)
: undefined,
autoExpiryReason: dbRecord.auto_expiry_reason as string | undefined,
ownerId: dbRecord.owner_id as string | undefined,
};
}
}
Loading
Loading