diff --git a/app/backend/src/api-keys/api-keys.types.ts b/app/backend/src/api-keys/api-keys.types.ts index af3ab6860..bae3d10bf 100644 --- a/app/backend/src/api-keys/api-keys.types.ts +++ b/app/backend/src/api-keys/api-keys.types.ts @@ -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; +} diff --git a/app/backend/src/auth/decorators/require-any-scope.decorator.ts b/app/backend/src/auth/decorators/require-any-scope.decorator.ts new file mode 100644 index 000000000..39dc88e73 --- /dev/null +++ b/app/backend/src/auth/decorators/require-any-scope.decorator.ts @@ -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); diff --git a/app/backend/src/auth/guards/api-key.guard.ts b/app/backend/src/auth/guards/api-key.guard.ts index 42b2dbee5..eead5aa63 100644 --- a/app/backend/src/auth/guards/api-key.guard.ts +++ b/app/backend/src/auth/guards/api-key.guard.ts @@ -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 { @@ -55,13 +56,31 @@ export class ApiKeyGuard implements CanActivate { this.reflector.getAllAndOverride(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(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(', ')}`, }); } } diff --git a/app/backend/src/branch-preview/branch-preview.controller.ts b/app/backend/src/branch-preview/branch-preview.controller.ts index ab4654d08..a78031cc5 100644 --- a/app/backend/src/branch-preview/branch-preview.controller.ts +++ b/app/backend/src/branch-preview/branch-preview.controller.ts @@ -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'; @@ -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({ @@ -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', @@ -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({ @@ -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', @@ -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({ @@ -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 }; } diff --git a/app/backend/src/branch-preview/branch-preview.model.ts b/app/backend/src/branch-preview/branch-preview.model.ts index 64f0a2a40..6134c55e0 100644 --- a/app/backend/src/branch-preview/branch-preview.model.ts +++ b/app/backend/src/branch-preview/branch-preview.model.ts @@ -16,6 +16,7 @@ export interface BranchPreviewEnvironment { expiresAt?: Date; autoExpiredAt?: Date; autoExpiryReason?: string; + ownerId?: string; } export interface CreateBranchPreviewDto { @@ -27,6 +28,7 @@ export interface CreateBranchPreviewDto { ttlMs?: number; isShared?: boolean; expiryExempt?: boolean; + ownerId?: string; } export interface UpdateBranchPreviewDto { @@ -38,6 +40,7 @@ export interface UpdateBranchPreviewDto { ttlMs?: number; isShared?: boolean; expiryExempt?: boolean; + ownerId?: string; } export class BranchPreviewResponseDto { diff --git a/app/backend/src/branch-preview/branch-preview.repository.ts b/app/backend/src/branch-preview/branch-preview.repository.ts index 08244a1f5..292198608 100644 --- a/app/backend/src/branch-preview/branch-preview.repository.ts +++ b/app/backend/src/branch-preview/branch-preview.repository.ts @@ -30,6 +30,7 @@ export class BranchPreviewRepository { expiryExempt: dto.expiryExempt ?? false, lastActivityAt: now, expiresAt: expiresAt || undefined, + ownerId: dto.ownerId, }; const { data, error } = await client @@ -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(); @@ -82,6 +84,27 @@ export class BranchPreviewRepository { return this.mapDbToModel(data); } + /** + * Find a branch preview by ID + */ + async findById(id: string): Promise { + 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 */ @@ -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(); } @@ -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, }; } } \ No newline at end of file diff --git a/app/backend/src/branch-preview/branch-preview.service.ts b/app/backend/src/branch-preview/branch-preview.service.ts index c9e999024..706129168 100644 --- a/app/backend/src/branch-preview/branch-preview.service.ts +++ b/app/backend/src/branch-preview/branch-preview.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, ForbiddenException } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import { BranchPreviewCache } from './branch-preview.cache'; import { BranchPreviewRepository } from './branch-preview.repository'; @@ -79,6 +79,7 @@ export class BranchPreviewService { { branchName: preview.branchName, apiUrl: preview.apiUrl, + ownerId: preview.ownerId, }, requestId, ); @@ -93,8 +94,23 @@ export class BranchPreviewService { id: string, dto: UpdateBranchPreviewDto, actorId: string, + scopes: string[], requestId?: string, ): Promise { + const existing = await this.repository.findById(id); + if (!existing) { + throw new Error(`Branch preview ${id} not found`); + } + + const isAdmin = scopes.includes('admin'); + const isReviewer = scopes.includes('branch_preview:reviewer'); + const isOwner = existing.ownerId === actorId || scopes.includes('branch_preview:owner'); + + if (!isAdmin && !isReviewer && !isOwner) { + this.logger.warn(`Unauthorized update attempt on preview ${id} by actor ${actorId}`); + throw new ForbiddenException('You do not have permission to modify this preview environment'); + } + const updated = await this.repository.update(id, dto); // Invalidate cache to force refresh @@ -121,8 +137,23 @@ export class BranchPreviewService { async deletePreview( id: string, actorId: string, + scopes: string[], requestId?: string, ): Promise { + const existing = await this.repository.findById(id); + if (!existing) { + return; // Already deleted or not found + } + + const isAdmin = scopes.includes('admin'); + const isReviewer = scopes.includes('branch_preview:reviewer'); + const isOwner = existing.ownerId === actorId || scopes.includes('branch_preview:owner'); + + if (!isAdmin && !isReviewer && !isOwner) { + this.logger.warn(`Unauthorized delete attempt on preview ${id} by actor ${actorId}`); + throw new ForbiddenException('You do not have permission to delete this preview environment'); + } + // We could add a findById method to the repository for better accuracy, // but for simplicity we'll just clear the entire cache if we can't get the branch name await this.repository.delete(id); @@ -151,8 +182,21 @@ export class BranchPreviewService { async invalidateCache( branchName: string, actorId: string, + scopes: string[], requestId?: string, ): Promise { + const existing = await this.repository.findByBranchName(branchName); + if (existing) { + const isAdmin = scopes.includes('admin'); + const isReviewer = scopes.includes('branch_preview:reviewer'); + const isOwner = existing.ownerId === actorId || scopes.includes('branch_preview:owner'); + + if (!isAdmin && !isReviewer && !isOwner) { + this.logger.warn(`Unauthorized cache invalidation attempt on preview ${branchName} by actor ${actorId}`); + throw new ForbiddenException('You do not have permission to invalidate cache for this preview environment'); + } + } + const deleted = this.cache.delete(branchName); await this.auditService.log( diff --git a/app/backend/src/branch-preview/branch-preview.service.unit.spec.ts b/app/backend/src/branch-preview/branch-preview.service.unit.spec.ts index 722aeebf9..f5ed47108 100644 --- a/app/backend/src/branch-preview/branch-preview.service.unit.spec.ts +++ b/app/backend/src/branch-preview/branch-preview.service.unit.spec.ts @@ -25,6 +25,7 @@ describe("BranchPreviewService", () => { delete: jest.fn(), findAll: jest.fn(), findExpired: jest.fn(), + findById: jest.fn(), touchLastActivity: jest.fn(), }; @@ -145,4 +146,53 @@ describe("BranchPreviewService", () => { expect(result.isFallback).toBe(true); }); + + describe("permissions and authorization", () => { + it("allows admin to update preview", async () => { + const mockPreview = { id: "test-id", branchName: "b", ownerId: "user-1", apiUrl: "foo", frontendUrl: "foo", network: "testnet" as const, contractRegistryVersion: "latest", isActive: true, isShared: false, expiryExempt: false, createdAt: new Date(), updatedAt: new Date() }; + repository.findById.mockResolvedValue(mockPreview); + repository.update.mockResolvedValue({ ...mockPreview, apiUrl: "bar" }); + + const result = await service.updatePreview("test-id", { apiUrl: "bar" }, "admin-user", ["admin"]); + expect(result.apiUrl).toBe("bar"); + }); + + it("allows reviewer to delete preview", async () => { + const mockPreview = { id: "test-id", branchName: "b", ownerId: "user-1", apiUrl: "foo", frontendUrl: "foo", network: "testnet" as const, contractRegistryVersion: "latest", isActive: true, isShared: false, expiryExempt: false, createdAt: new Date(), updatedAt: new Date() }; + repository.findById.mockResolvedValue(mockPreview); + repository.delete.mockResolvedValue(undefined); + + await expect(service.deletePreview("test-id", "reviewer-user", ["branch_preview:reviewer"])).resolves.not.toThrow(); + }); + + it("allows owner to update their preview", async () => { + const mockPreview = { id: "test-id", branchName: "b", ownerId: "user-owner", apiUrl: "foo", frontendUrl: "foo", network: "testnet" as const, contractRegistryVersion: "latest", isActive: true, isShared: false, expiryExempt: false, createdAt: new Date(), updatedAt: new Date() }; + repository.findById.mockResolvedValue(mockPreview); + repository.update.mockResolvedValue({ ...mockPreview, apiUrl: "bar" }); + + const result = await service.updatePreview("test-id", { apiUrl: "bar" }, "user-owner", ["some:other:scope"]); + expect(result.apiUrl).toBe("bar"); + }); + + it("throws ForbiddenException when unauthorized user attempts to update", async () => { + const mockPreview = { id: "test-id", branchName: "b", ownerId: "user-owner", apiUrl: "foo", frontendUrl: "foo", network: "testnet" as const, contractRegistryVersion: "latest", isActive: true, isShared: false, expiryExempt: false, createdAt: new Date(), updatedAt: new Date() }; + repository.findById.mockResolvedValue(mockPreview); + + await expect(service.updatePreview("test-id", { apiUrl: "bar" }, "unauthorized-user", ["some:scope"])).rejects.toThrow("You do not have permission to modify this preview environment"); + }); + + it("throws ForbiddenException when unauthorized user attempts to delete", async () => { + const mockPreview = { id: "test-id", branchName: "b", ownerId: "user-owner", apiUrl: "foo", frontendUrl: "foo", network: "testnet" as const, contractRegistryVersion: "latest", isActive: true, isShared: false, expiryExempt: false, createdAt: new Date(), updatedAt: new Date() }; + repository.findById.mockResolvedValue(mockPreview); + + await expect(service.deletePreview("test-id", "unauthorized-user", ["some:scope"])).rejects.toThrow("You do not have permission to delete this preview environment"); + }); + + it("throws ForbiddenException when unauthorized user attempts to invalidate cache", async () => { + const mockPreview = { id: "test-id", branchName: "test-branch", ownerId: "user-owner", apiUrl: "foo", frontendUrl: "foo", network: "testnet" as const, contractRegistryVersion: "latest", isActive: true, isShared: false, expiryExempt: false, createdAt: new Date(), updatedAt: new Date() }; + repository.findByBranchName.mockResolvedValue(mockPreview); + + await expect(service.invalidateCache("test-branch", "unauthorized-user", ["some:scope"])).rejects.toThrow("You do not have permission to invalidate cache for this preview environment"); + }); + }); }); diff --git a/app/backend/src/branch-preview/dto/admin-branch-preview.dto.ts b/app/backend/src/branch-preview/dto/admin-branch-preview.dto.ts index 0e156cdcb..432230456 100644 --- a/app/backend/src/branch-preview/dto/admin-branch-preview.dto.ts +++ b/app/backend/src/branch-preview/dto/admin-branch-preview.dto.ts @@ -43,6 +43,14 @@ export class CreateBranchPreviewRequestDto { @IsOptional() @IsBoolean() expiryExempt?: boolean; + + @ApiProperty({ + description: 'Owner of the branch preview environment', + required: false, + }) + @IsOptional() + @IsString() + ownerId?: string; } export class UpdateBranchPreviewRequestDto {