diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 3324518..a217a33 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -36,6 +36,7 @@ import { JobEventsModule } from "./job-events/job-events.module"; import { JobStepsModule } from "./job-steps/job-steps.module"; import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module"; import { FederationModule } from "./federation/federation.module"; +import { CredentialsModule } from "./credentials/credentials.module"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; @Module({ @@ -92,6 +93,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce JobStepsModule, CoordinatorIntegrationModule, FederationModule, + CredentialsModule, ], controllers: [AppController, CsrfController], providers: [ diff --git a/apps/api/src/credentials/credentials.controller.spec.ts b/apps/api/src/credentials/credentials.controller.spec.ts new file mode 100644 index 0000000..a9b0f12 --- /dev/null +++ b/apps/api/src/credentials/credentials.controller.spec.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { CredentialsController } from "./credentials.controller"; +import { CredentialsService } from "./credentials.service"; +import { CredentialType, CredentialScope } from "@prisma/client"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard } from "../common/guards/workspace.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import { ExecutionContext } from "@nestjs/common"; + +describe("CredentialsController", () => { + let controller: CredentialsController; + let service: CredentialsService; + + const mockCredentialsService = { + create: vi.fn(), + findAll: vi.fn(), + findOne: vi.fn(), + getValue: vi.fn(), + update: vi.fn(), + rotate: vi.fn(), + remove: vi.fn(), + }; + + const mockAuthGuard = { + canActivate: vi.fn((context: ExecutionContext) => { + const request = context.switchToHttp().getRequest(); + request.user = { + id: "550e8400-e29b-41d4-a716-446655440002", + workspaceId: "550e8400-e29b-41d4-a716-446655440001", + }; + return true; + }), + }; + + const mockWorkspaceGuard = { + canActivate: vi.fn(() => true), + }; + + const mockPermissionGuard = { + canActivate: vi.fn(() => true), + }; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; + const mockUserId = "550e8400-e29b-41d4-a716-446655440002"; + const mockCredentialId = "550e8400-e29b-41d4-a716-446655440003"; + + const mockUser = { + id: mockUserId, + email: "test@example.com", + name: "Test User", + }; + + const mockCredential = { + id: mockCredentialId, + userId: mockUserId, + workspaceId: mockWorkspaceId, + name: "GitHub Token", + provider: "github", + type: CredentialType.API_KEY, + scope: CredentialScope.USER, + maskedValue: "****3456", + description: "My GitHub API key", + expiresAt: null, + lastUsedAt: null, + metadata: {}, + isActive: true, + rotatedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CredentialsController], + providers: [ + { + provide: CredentialsService, + useValue: mockCredentialsService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue(mockAuthGuard) + .overrideGuard(WorkspaceGuard) + .useValue(mockWorkspaceGuard) + .overrideGuard(PermissionGuard) + .useValue(mockPermissionGuard) + .compile(); + + controller = module.get(CredentialsController); + service = module.get(CredentialsService); + + // Clear all mocks before each test + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); + + describe("create", () => { + it("should create a credential", async () => { + const createDto = { + name: "GitHub Token", + provider: "github", + type: CredentialType.API_KEY, + value: "ghp_1234567890abcdef", + description: "My GitHub API key", + }; + + mockCredentialsService.create.mockResolvedValue(mockCredential); + + const result = await controller.create(createDto, mockWorkspaceId, mockUser); + + expect(result).toEqual(mockCredential); + expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, createDto); + }); + }); + + describe("findAll", () => { + it("should return paginated credentials", async () => { + const query = { page: 1, limit: 10 }; + const paginatedResult = { + data: [mockCredential], + meta: { + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + }; + + mockCredentialsService.findAll.mockResolvedValue(paginatedResult); + + const result = await controller.findAll(query, mockWorkspaceId, mockUser); + + expect(result).toEqual(paginatedResult); + expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, query); + }); + }); + + describe("findOne", () => { + it("should return a single credential", async () => { + mockCredentialsService.findOne.mockResolvedValue(mockCredential); + + const result = await controller.findOne(mockCredentialId, mockWorkspaceId, mockUser); + + expect(result).toEqual(mockCredential); + expect(service.findOne).toHaveBeenCalledWith(mockCredentialId, mockWorkspaceId, mockUserId); + }); + }); + + describe("getValue", () => { + it("should return decrypted credential value", async () => { + const valueResponse = { value: "ghp_1234567890abcdef" }; + mockCredentialsService.getValue.mockResolvedValue(valueResponse); + + const result = await controller.getValue(mockCredentialId, mockWorkspaceId, mockUser); + + expect(result).toEqual(valueResponse); + expect(service.getValue).toHaveBeenCalledWith(mockCredentialId, mockWorkspaceId, mockUserId); + }); + }); + + describe("update", () => { + it("should update credential metadata", async () => { + const updateDto = { description: "Updated description" }; + const updatedCredential = { ...mockCredential, ...updateDto }; + + mockCredentialsService.update.mockResolvedValue(updatedCredential); + + const result = await controller.update( + mockCredentialId, + updateDto, + mockWorkspaceId, + mockUser + ); + + expect(result).toEqual(updatedCredential); + expect(service.update).toHaveBeenCalledWith( + mockCredentialId, + mockWorkspaceId, + mockUserId, + updateDto + ); + }); + }); + + describe("rotate", () => { + it("should rotate credential value", async () => { + const rotateDto = { newValue: "ghp_new_token_12345" }; + const rotatedCredential = { ...mockCredential, rotatedAt: new Date() }; + + mockCredentialsService.rotate.mockResolvedValue(rotatedCredential); + + const result = await controller.rotate( + mockCredentialId, + rotateDto, + mockWorkspaceId, + mockUser + ); + + expect(result).toEqual(rotatedCredential); + expect(service.rotate).toHaveBeenCalledWith( + mockCredentialId, + mockWorkspaceId, + mockUserId, + rotateDto.newValue + ); + }); + }); + + describe("remove", () => { + it("should soft-delete a credential", async () => { + mockCredentialsService.remove.mockResolvedValue(undefined); + + await controller.remove(mockCredentialId, mockWorkspaceId, mockUser); + + expect(service.remove).toHaveBeenCalledWith(mockCredentialId, mockWorkspaceId, mockUserId); + }); + }); +}); diff --git a/apps/api/src/credentials/credentials.controller.ts b/apps/api/src/credentials/credentials.controller.ts new file mode 100644 index 0000000..2a35a1b --- /dev/null +++ b/apps/api/src/credentials/credentials.controller.ts @@ -0,0 +1,158 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, +} from "@nestjs/common"; +import { Throttle } from "@nestjs/throttler"; +import { CredentialsService } from "./credentials.service"; +import { + CreateCredentialDto, + UpdateCredentialDto, + RotateCredentialDto, + QueryCredentialDto, +} from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Workspace, Permission, RequirePermission } from "../common/decorators"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import type { AuthenticatedUser } from "../common/types/user.types"; + +/** + * Controller for user credential endpoints + * All endpoints require authentication and workspace context + * + * Guards are applied in order: + * 1. AuthGuard - Verifies user authentication + * 2. WorkspaceGuard - Validates workspace access and sets RLS context + * 3. PermissionGuard - Checks role-based permissions + * + * Security: + * - List/Get endpoints NEVER return plaintext values (only maskedValue) + * - getValue endpoint is intentionally separate and rate-limited + * - All operations are audit-logged via ActivityService + */ +@Controller("credentials") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) +export class CredentialsController { + constructor(private readonly credentialsService: CredentialsService) {} + + /** + * POST /api/credentials + * Create a new credential + * Requires: MEMBER role or higher + */ + @Post() + @RequirePermission(Permission.WORKSPACE_MEMBER) + async create( + @Body() createDto: CreateCredentialDto, + @Workspace() workspaceId: string, + @CurrentUser() user: AuthenticatedUser + ) { + return this.credentialsService.create(workspaceId, user.id, createDto); + } + + /** + * GET /api/credentials + * Get paginated credentials with optional filters + * NEVER returns plaintext values - only maskedValue + * Requires: Any workspace member (including GUEST) + */ + @Get() + @RequirePermission(Permission.WORKSPACE_ANY) + async findAll( + @Query() query: QueryCredentialDto, + @Workspace() workspaceId: string, + @CurrentUser() user: AuthenticatedUser + ) { + return this.credentialsService.findAll(workspaceId, user.id, query); + } + + /** + * GET /api/credentials/:id + * Get a single credential by ID + * NEVER returns plaintext value - only maskedValue + * Requires: Any workspace member + */ + @Get(":id") + @RequirePermission(Permission.WORKSPACE_ANY) + async findOne( + @Param("id") id: string, + @Workspace() workspaceId: string, + @CurrentUser() user: AuthenticatedUser + ) { + return this.credentialsService.findOne(id, workspaceId, user.id); + } + + /** + * GET /api/credentials/:id/value + * Decrypt and return credential value + * CRITICAL: This is the ONLY endpoint that returns plaintext + * Rate limited to 10 requests per minute per user + * Logs access to activity log + * Requires: Any workspace member + */ + @Get(":id/value") + @RequirePermission(Permission.WORKSPACE_ANY) + @Throttle({ strict: { limit: 10, ttl: 60000 } }) + async getValue( + @Param("id") id: string, + @Workspace() workspaceId: string, + @CurrentUser() user: AuthenticatedUser + ) { + return this.credentialsService.getValue(id, workspaceId, user.id); + } + + /** + * PATCH /api/credentials/:id + * Update credential metadata (NOT the value itself) + * Use /rotate endpoint to change the credential value + * Requires: MEMBER role or higher + */ + @Patch(":id") + @RequirePermission(Permission.WORKSPACE_MEMBER) + async update( + @Param("id") id: string, + @Body() updateDto: UpdateCredentialDto, + @Workspace() workspaceId: string, + @CurrentUser() user: AuthenticatedUser + ) { + return this.credentialsService.update(id, workspaceId, user.id, updateDto); + } + + /** + * POST /api/credentials/:id/rotate + * Replace credential value with new encrypted value + * Requires: MEMBER role or higher + */ + @Post(":id/rotate") + @RequirePermission(Permission.WORKSPACE_MEMBER) + async rotate( + @Param("id") id: string, + @Body() rotateDto: RotateCredentialDto, + @Workspace() workspaceId: string, + @CurrentUser() user: AuthenticatedUser + ) { + return this.credentialsService.rotate(id, workspaceId, user.id, rotateDto.newValue); + } + + /** + * DELETE /api/credentials/:id + * Soft-delete credential (set isActive = false) + * Requires: MEMBER role or higher + */ + @Delete(":id") + @RequirePermission(Permission.WORKSPACE_MEMBER) + async remove( + @Param("id") id: string, + @Workspace() workspaceId: string, + @CurrentUser() user: AuthenticatedUser + ) { + return this.credentialsService.remove(id, workspaceId, user.id); + } +} diff --git a/apps/api/src/credentials/credentials.module.ts b/apps/api/src/credentials/credentials.module.ts new file mode 100644 index 0000000..8e8b811 --- /dev/null +++ b/apps/api/src/credentials/credentials.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { CredentialsController } from "./credentials.controller"; +import { CredentialsService } from "./credentials.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { ActivityModule } from "../activity/activity.module"; +import { VaultModule } from "../vault/vault.module"; + +@Module({ + imports: [PrismaModule, ActivityModule, VaultModule], + controllers: [CredentialsController], + providers: [CredentialsService], + exports: [CredentialsService], +}) +export class CredentialsModule {} diff --git a/apps/api/src/credentials/credentials.service.spec.ts b/apps/api/src/credentials/credentials.service.spec.ts new file mode 100644 index 0000000..5cc3f9c --- /dev/null +++ b/apps/api/src/credentials/credentials.service.spec.ts @@ -0,0 +1,482 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { CredentialsService } from "./credentials.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { ActivityService } from "../activity/activity.service"; +import { VaultService } from "../vault/vault.service"; +import { CredentialType, CredentialScope } from "@prisma/client"; +import { ActivityAction, EntityType } from "@prisma/client"; +import { NotFoundException, BadRequestException } from "@nestjs/common"; +import { TransitKey } from "../vault/vault.constants"; + +describe("CredentialsService", () => { + let service: CredentialsService; + let prisma: PrismaService; + let activityService: ActivityService; + let vaultService: VaultService; + + const mockPrismaService = { + userCredential: { + create: vi.fn(), + findMany: vi.fn(), + count: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }; + + const mockActivityService = { + logActivity: vi.fn(), + }; + + const mockVaultService = { + encrypt: vi.fn(), + decrypt: vi.fn(), + }; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; + const mockUserId = "550e8400-e29b-41d4-a716-446655440002"; + const mockCredentialId = "550e8400-e29b-41d4-a716-446655440003"; + + const mockCredential = { + id: mockCredentialId, + userId: mockUserId, + workspaceId: mockWorkspaceId, + name: "GitHub Token", + provider: "github", + type: CredentialType.API_KEY, + scope: CredentialScope.USER, + encryptedValue: "vault:v1:encrypted-data-here", + maskedValue: "****3456", + description: "My GitHub API key", + expiresAt: null, + lastUsedAt: null, + metadata: {}, + isActive: true, + rotatedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CredentialsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + { + provide: ActivityService, + useValue: mockActivityService, + }, + { + provide: VaultService, + useValue: mockVaultService, + }, + ], + }).compile(); + + service = module.get(CredentialsService); + prisma = module.get(PrismaService); + activityService = module.get(ActivityService); + vaultService = module.get(VaultService); + + // Clear all mocks before each test + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("create", () => { + it("should create a credential with encrypted value and log activity", async () => { + const createDto = { + name: "GitHub Token", + provider: "github", + type: CredentialType.API_KEY, + value: "ghp_1234567890abcdef", + description: "My GitHub API key", + }; + + mockVaultService.encrypt.mockResolvedValue("vault:v1:encrypted-data-here"); + mockPrismaService.userCredential.create.mockResolvedValue(mockCredential); + mockActivityService.logActivity.mockResolvedValue({}); + + const result = await service.create(mockWorkspaceId, mockUserId, createDto); + + expect(result).toEqual(mockCredential); + expect(vaultService.encrypt).toHaveBeenCalledWith(createDto.value, TransitKey.CREDENTIALS); + expect(prisma.userCredential.create).toHaveBeenCalledWith({ + data: { + userId: mockUserId, + workspaceId: mockWorkspaceId, + name: createDto.name, + provider: createDto.provider, + type: createDto.type, + scope: CredentialScope.USER, + encryptedValue: "vault:v1:encrypted-data-here", + maskedValue: "****cdef", + description: createDto.description, + expiresAt: null, + metadata: {}, + }, + select: expect.any(Object), + }); + expect(activityService.logActivity).toHaveBeenCalledWith({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + action: ActivityAction.CREDENTIAL_CREATED, + entityType: EntityType.CREDENTIAL, + entityId: mockCredential.id, + details: { + name: createDto.name, + provider: createDto.provider, + type: createDto.type, + }, + }); + }); + + it("should create credential with custom scope", async () => { + const createDto = { + name: "Workspace API Key", + provider: "custom", + type: CredentialType.API_KEY, + scope: CredentialScope.WORKSPACE, + value: "ws_key_123", + }; + + mockVaultService.encrypt.mockResolvedValue("vault:v1:encrypted"); + mockPrismaService.userCredential.create.mockResolvedValue({ + ...mockCredential, + scope: CredentialScope.WORKSPACE, + }); + + await service.create(mockWorkspaceId, mockUserId, createDto); + + expect(prisma.userCredential.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + scope: CredentialScope.WORKSPACE, + }), + }) + ); + }); + + it("should generate maskedValue from last 4 characters", async () => { + const createDto = { + name: "Short", + provider: "test", + type: CredentialType.SECRET, + value: "abc", + }; + + mockVaultService.encrypt.mockResolvedValue("vault:v1:encrypted"); + mockPrismaService.userCredential.create.mockResolvedValue(mockCredential); + + await service.create(mockWorkspaceId, mockUserId, createDto); + + expect(prisma.userCredential.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + maskedValue: "****abc", + }), + }) + ); + }); + }); + + describe("findAll", () => { + it("should return paginated credentials without encryptedValue", async () => { + const query = { page: 1, limit: 10 }; + const credentials = [mockCredential]; + + mockPrismaService.userCredential.findMany.mockResolvedValue(credentials); + mockPrismaService.userCredential.count.mockResolvedValue(1); + + const result = await service.findAll(mockWorkspaceId, mockUserId, query); + + expect(result).toEqual({ + data: credentials, + meta: { + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + }); + expect(prisma.userCredential.findMany).toHaveBeenCalledWith({ + where: { + userId: mockUserId, + workspaceId: mockWorkspaceId, + }, + select: { + id: true, + userId: true, + workspaceId: true, + name: true, + provider: true, + type: true, + scope: true, + maskedValue: true, + description: true, + expiresAt: true, + lastUsedAt: true, + metadata: true, + isActive: true, + rotatedAt: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { createdAt: "desc" }, + skip: 0, + take: 10, + }); + }); + + it("should filter by type", async () => { + const query = { type: CredentialType.API_KEY }; + + mockPrismaService.userCredential.findMany.mockResolvedValue([]); + mockPrismaService.userCredential.count.mockResolvedValue(0); + + await service.findAll(mockWorkspaceId, mockUserId, query); + + expect(prisma.userCredential.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + type: { in: [CredentialType.API_KEY] }, + }), + }) + ); + }); + + it("should filter by isActive", async () => { + const query = { isActive: true }; + + mockPrismaService.userCredential.findMany.mockResolvedValue([]); + mockPrismaService.userCredential.count.mockResolvedValue(0); + + await service.findAll(mockWorkspaceId, mockUserId, query); + + expect(prisma.userCredential.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + isActive: true, + }), + }) + ); + }); + }); + + describe("findOne", () => { + it("should return a single credential without encryptedValue", async () => { + mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential); + + const result = await service.findOne(mockCredentialId, mockWorkspaceId, mockUserId); + + expect(result).toEqual(mockCredential); + expect(prisma.userCredential.findUnique).toHaveBeenCalledWith({ + where: { + id: mockCredentialId, + userId: mockUserId, + workspaceId: mockWorkspaceId, + }, + select: expect.objectContaining({ + id: true, + maskedValue: true, + }), + }); + }); + + it("should throw NotFoundException when credential not found", async () => { + mockPrismaService.userCredential.findUnique.mockResolvedValue(null); + + await expect(service.findOne(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe("getValue", () => { + it("should decrypt and return credential value, update lastUsedAt, and log access", async () => { + mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential); + mockVaultService.decrypt.mockResolvedValue("ghp_1234567890abcdef"); + mockPrismaService.userCredential.update.mockResolvedValue(mockCredential); + mockActivityService.logActivity.mockResolvedValue({}); + + const result = await service.getValue(mockCredentialId, mockWorkspaceId, mockUserId); + + expect(result).toEqual({ value: "ghp_1234567890abcdef" }); + expect(vaultService.decrypt).toHaveBeenCalledWith( + mockCredential.encryptedValue, + TransitKey.CREDENTIALS + ); + expect(prisma.userCredential.update).toHaveBeenCalledWith({ + where: { id: mockCredentialId }, + data: { lastUsedAt: expect.any(Date) }, + }); + expect(activityService.logActivity).toHaveBeenCalledWith({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + action: ActivityAction.CREDENTIAL_ACCESSED, + entityType: EntityType.CREDENTIAL, + entityId: mockCredentialId, + details: { + name: mockCredential.name, + provider: mockCredential.provider, + }, + }); + }); + + it("should throw NotFoundException when credential not found", async () => { + mockPrismaService.userCredential.findUnique.mockResolvedValue(null); + + await expect(service.getValue(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow( + NotFoundException + ); + }); + + it("should throw BadRequestException when credential is not active", async () => { + mockPrismaService.userCredential.findUnique.mockResolvedValue({ + ...mockCredential, + isActive: false, + }); + + await expect(service.getValue(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow( + BadRequestException + ); + }); + }); + + describe("update", () => { + it("should update credential metadata and log activity", async () => { + const updateDto = { + description: "Updated description", + }; + + mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential); + mockPrismaService.userCredential.update.mockResolvedValue({ + ...mockCredential, + ...updateDto, + }); + mockActivityService.logActivity.mockResolvedValue({}); + + const result = await service.update(mockCredentialId, mockWorkspaceId, mockUserId, updateDto); + + expect(result.description).toBe(updateDto.description); + expect(prisma.userCredential.update).toHaveBeenCalledWith({ + where: { id: mockCredentialId }, + data: updateDto, + select: expect.any(Object), + }); + expect(activityService.logActivity).toHaveBeenCalledWith({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + action: ActivityAction.UPDATED, + entityType: EntityType.CREDENTIAL, + entityId: mockCredentialId, + details: { + description: updateDto.description, + expiresAt: undefined, + isActive: undefined, + }, + }); + }); + + it("should throw NotFoundException when credential not found", async () => { + mockPrismaService.userCredential.findUnique.mockResolvedValue(null); + + await expect( + service.update(mockCredentialId, mockWorkspaceId, mockUserId, { description: "test" }) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("rotate", () => { + it("should rotate credential value and log activity", async () => { + const newValue = "ghp_new_token_12345"; + + mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential); + mockVaultService.encrypt.mockResolvedValue("vault:v1:new-encrypted"); + mockPrismaService.userCredential.update.mockResolvedValue({ + ...mockCredential, + encryptedValue: "vault:v1:new-encrypted", + maskedValue: "****2345", + rotatedAt: new Date(), + }); + mockActivityService.logActivity.mockResolvedValue({}); + + const result = await service.rotate(mockCredentialId, mockWorkspaceId, mockUserId, newValue); + + expect(vaultService.encrypt).toHaveBeenCalledWith(newValue, TransitKey.CREDENTIALS); + expect(prisma.userCredential.update).toHaveBeenCalledWith({ + where: { id: mockCredentialId }, + data: { + encryptedValue: "vault:v1:new-encrypted", + maskedValue: "****2345", + rotatedAt: expect.any(Date), + }, + select: expect.any(Object), + }); + expect(activityService.logActivity).toHaveBeenCalledWith({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + action: ActivityAction.CREDENTIAL_ROTATED, + entityType: EntityType.CREDENTIAL, + entityId: mockCredentialId, + details: { + name: mockCredential.name, + provider: mockCredential.provider, + }, + }); + }); + + it("should throw NotFoundException when credential not found", async () => { + mockPrismaService.userCredential.findUnique.mockResolvedValue(null); + + await expect( + service.rotate(mockCredentialId, mockWorkspaceId, mockUserId, "new_value") + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("remove", () => { + it("should soft-delete credential and log activity", async () => { + mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential); + mockPrismaService.userCredential.update.mockResolvedValue({ + ...mockCredential, + isActive: false, + }); + mockActivityService.logActivity.mockResolvedValue({}); + + await service.remove(mockCredentialId, mockWorkspaceId, mockUserId); + + expect(prisma.userCredential.update).toHaveBeenCalledWith({ + where: { id: mockCredentialId }, + data: { isActive: false }, + }); + expect(activityService.logActivity).toHaveBeenCalledWith({ + workspaceId: mockWorkspaceId, + userId: mockUserId, + action: ActivityAction.CREDENTIAL_REVOKED, + entityType: EntityType.CREDENTIAL, + entityId: mockCredentialId, + details: { + name: mockCredential.name, + provider: mockCredential.provider, + }, + }); + }); + + it("should throw NotFoundException when credential not found", async () => { + mockPrismaService.userCredential.findUnique.mockResolvedValue(null); + + await expect(service.remove(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow( + NotFoundException + ); + }); + }); +}); diff --git a/apps/api/src/credentials/credentials.service.ts b/apps/api/src/credentials/credentials.service.ts new file mode 100644 index 0000000..193185d --- /dev/null +++ b/apps/api/src/credentials/credentials.service.ts @@ -0,0 +1,404 @@ +import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { ActivityService } from "../activity/activity.service"; +import { VaultService } from "../vault/vault.service"; +import { getRlsClient } from "../prisma/rls-context.provider"; +import { TransitKey } from "../vault/vault.constants"; +import { ActivityAction, EntityType, Prisma } from "@prisma/client"; +import type { + CreateCredentialDto, + UpdateCredentialDto, + QueryCredentialDto, + CredentialResponseDto, + PaginatedCredentialsDto, + CredentialValueResponseDto, +} from "./dto"; +import { CredentialScope } from "@prisma/client"; + +/** + * Service for managing user credentials with encryption and RLS + */ +@Injectable() +export class CredentialsService { + constructor( + private readonly prisma: PrismaService, + private readonly activityService: ActivityService, + private readonly vaultService: VaultService + ) {} + + /** + * Create a new credential + * Encrypts value before storage and generates maskedValue + */ + async create( + workspaceId: string, + userId: string, + dto: CreateCredentialDto + ): Promise { + const client = getRlsClient() ?? this.prisma; + + // Encrypt the credential value + const encryptedValue = await this.vaultService.encrypt(dto.value, TransitKey.CREDENTIALS); + + // Generate masked value (last 4 characters) + const maskedValue = this.generateMaskedValue(dto.value); + + // Create the credential + const credential = await client.userCredential.create({ + data: { + userId, + workspaceId, + name: dto.name, + provider: dto.provider, + type: dto.type, + scope: dto.scope ?? CredentialScope.USER, + encryptedValue, + maskedValue, + description: dto.description ?? null, + expiresAt: dto.expiresAt ?? null, + metadata: (dto.metadata ?? {}) as Prisma.InputJsonValue, + }, + select: this.getSelectFields(), + }); + + // Log activity (fire-and-forget) + await this.activityService.logActivity({ + workspaceId, + userId, + action: ActivityAction.CREDENTIAL_CREATED, + entityType: EntityType.CREDENTIAL, + entityId: credential.id, + details: { + name: credential.name, + provider: credential.provider, + type: credential.type, + }, + }); + + return credential as CredentialResponseDto; + } + + /** + * Get paginated credentials with filters + * NEVER returns encryptedValue - only maskedValue + */ + async findAll( + workspaceId: string, + userId: string, + query: QueryCredentialDto + ): Promise { + const client = getRlsClient() ?? this.prisma; + + const page = query.page ?? 1; + const limit = query.limit ?? 50; + const skip = (page - 1) * limit; + + // Build where clause + const where: Prisma.UserCredentialWhereInput = { + userId, + workspaceId, + }; + + if (query.type !== undefined) { + where.type = { + in: Array.isArray(query.type) ? query.type : [query.type], + }; + } + + if (query.scope !== undefined) { + where.scope = query.scope; + } + + if (query.provider !== undefined) { + where.provider = query.provider; + } + + if (query.isActive !== undefined) { + where.isActive = query.isActive; + } + + if (query.search !== undefined) { + where.OR = [ + { name: { contains: query.search, mode: "insensitive" } }, + { description: { contains: query.search, mode: "insensitive" } }, + { provider: { contains: query.search, mode: "insensitive" } }, + ]; + } + + // Execute queries in parallel + const [data, total] = await Promise.all([ + client.userCredential.findMany({ + where, + select: this.getSelectFields(), + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + client.userCredential.count({ where }), + ]); + + return { + data: data as CredentialResponseDto[], + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Get a single credential by ID + * NEVER returns encryptedValue - only maskedValue + */ + async findOne(id: string, workspaceId: string, userId: string): Promise { + const client = getRlsClient() ?? this.prisma; + + const credential = await client.userCredential.findUnique({ + where: { + id, + userId, + workspaceId, + }, + select: this.getSelectFields(), + }); + + if (!credential) { + throw new NotFoundException(`Credential with ID ${id} not found`); + } + + return credential as CredentialResponseDto; + } + + /** + * Get decrypted credential value + * Intentionally separate endpoint for security + * Updates lastUsedAt and logs access + */ + async getValue( + id: string, + workspaceId: string, + userId: string + ): Promise { + const client = getRlsClient() ?? this.prisma; + + // Fetch credential with encryptedValue + const credential = await client.userCredential.findUnique({ + where: { + id, + userId, + workspaceId, + }, + select: { + id: true, + name: true, + provider: true, + encryptedValue: true, + isActive: true, + workspaceId: true, + }, + }); + + if (!credential) { + throw new NotFoundException(`Credential with ID ${id} not found`); + } + + if (!credential.isActive) { + throw new BadRequestException("Cannot decrypt inactive credential"); + } + + // Decrypt the value + const value = await this.vaultService.decrypt( + credential.encryptedValue, + TransitKey.CREDENTIALS + ); + + // Update lastUsedAt + await client.userCredential.update({ + where: { id }, + data: { lastUsedAt: new Date() }, + }); + + // Log access (fire-and-forget) + await this.activityService.logActivity({ + workspaceId, + userId, + action: ActivityAction.CREDENTIAL_ACCESSED, + entityType: EntityType.CREDENTIAL, + entityId: id, + details: { + name: credential.name, + provider: credential.provider, + }, + }); + + return { value }; + } + + /** + * Update credential metadata + * Cannot update the value itself - use rotate endpoint + */ + async update( + id: string, + workspaceId: string, + userId: string, + dto: UpdateCredentialDto + ): Promise { + const client = getRlsClient() ?? this.prisma; + + // Verify credential exists + await this.findOne(id, workspaceId, userId); + + // Build update data object with only defined fields + const updateData: Prisma.UserCredentialUpdateInput = {}; + if (dto.description !== undefined) { + updateData.description = dto.description; + } + if (dto.expiresAt !== undefined) { + updateData.expiresAt = dto.expiresAt; + } + if (dto.metadata !== undefined) { + updateData.metadata = dto.metadata as Prisma.InputJsonValue; + } + if (dto.isActive !== undefined) { + updateData.isActive = dto.isActive; + } + + // Update credential + const credential = await client.userCredential.update({ + where: { id }, + data: updateData, + select: this.getSelectFields(), + }); + + // Log activity (fire-and-forget) + await this.activityService.logActivity({ + workspaceId, + userId, + action: ActivityAction.UPDATED, + entityType: EntityType.CREDENTIAL, + entityId: id, + details: { + description: dto.description, + expiresAt: dto.expiresAt?.toISOString(), + isActive: dto.isActive, + } as Prisma.JsonValue, + }); + + return credential as CredentialResponseDto; + } + + /** + * Rotate credential value + * Encrypts new value and updates maskedValue + */ + async rotate( + id: string, + workspaceId: string, + userId: string, + newValue: string + ): Promise { + const client = getRlsClient() ?? this.prisma; + + // Verify credential exists + const existing = await this.findOne(id, workspaceId, userId); + + // Encrypt new value + const encryptedValue = await this.vaultService.encrypt(newValue, TransitKey.CREDENTIALS); + + // Generate new masked value + const maskedValue = this.generateMaskedValue(newValue); + + // Update credential + const credential = await client.userCredential.update({ + where: { id }, + data: { + encryptedValue, + maskedValue, + rotatedAt: new Date(), + }, + select: this.getSelectFields(), + }); + + // Log activity (fire-and-forget) + await this.activityService.logActivity({ + workspaceId, + userId, + action: ActivityAction.CREDENTIAL_ROTATED, + entityType: EntityType.CREDENTIAL, + entityId: id, + details: { + name: existing.name, + provider: existing.provider, + }, + }); + + return credential as CredentialResponseDto; + } + + /** + * Soft-delete credential (set isActive = false) + */ + async remove(id: string, workspaceId: string, userId: string): Promise { + const client = getRlsClient() ?? this.prisma; + + // Verify credential exists + const existing = await this.findOne(id, workspaceId, userId); + + // Soft delete + await client.userCredential.update({ + where: { id }, + data: { isActive: false }, + }); + + // Log activity (fire-and-forget) + await this.activityService.logActivity({ + workspaceId, + userId, + action: ActivityAction.CREDENTIAL_REVOKED, + entityType: EntityType.CREDENTIAL, + entityId: id, + details: { + name: existing.name, + provider: existing.provider, + }, + }); + } + + /** + * Get select fields for credential responses + * NEVER includes encryptedValue + */ + private getSelectFields(): Prisma.UserCredentialSelect { + return { + id: true, + userId: true, + workspaceId: true, + name: true, + provider: true, + type: true, + scope: true, + maskedValue: true, + description: true, + expiresAt: true, + lastUsedAt: true, + metadata: true, + isActive: true, + rotatedAt: true, + createdAt: true, + updatedAt: true, + }; + } + + /** + * Generate masked value showing only last 4 characters + */ + private generateMaskedValue(value: string): string { + if (value.length <= 4) { + return `****${value}`; + } + return `****${value.slice(-4)}`; + } +} diff --git a/apps/api/src/credentials/dto/create-credential.dto.ts b/apps/api/src/credentials/dto/create-credential.dto.ts new file mode 100644 index 0000000..fae7107 --- /dev/null +++ b/apps/api/src/credentials/dto/create-credential.dto.ts @@ -0,0 +1,49 @@ +import { CredentialType, CredentialScope } from "@prisma/client"; +import { + IsString, + IsEnum, + IsOptional, + IsDateString, + IsObject, + MinLength, + MaxLength, +} from "class-validator"; + +/** + * DTO for creating a new user credential + */ +export class CreateCredentialDto { + @IsString({ message: "name must be a string" }) + @MinLength(1, { message: "name must not be empty" }) + @MaxLength(255, { message: "name must not exceed 255 characters" }) + name!: string; + + @IsString({ message: "provider must be a string" }) + @MinLength(1, { message: "provider must not be empty" }) + @MaxLength(100, { message: "provider must not exceed 100 characters" }) + provider!: string; + + @IsEnum(CredentialType, { message: "type must be a valid CredentialType" }) + type!: CredentialType; + + @IsOptional() + @IsEnum(CredentialScope, { message: "scope must be a valid CredentialScope" }) + scope?: CredentialScope; + + @IsString({ message: "value must be a string" }) + @MinLength(1, { message: "value must not be empty" }) + value!: string; + + @IsOptional() + @IsString({ message: "description must be a string" }) + @MaxLength(1000, { message: "description must not exceed 1000 characters" }) + description?: string; + + @IsOptional() + @IsDateString({}, { message: "expiresAt must be a valid ISO 8601 date string" }) + expiresAt?: Date; + + @IsOptional() + @IsObject({ message: "metadata must be an object" }) + metadata?: Record; +} diff --git a/apps/api/src/credentials/dto/credential-response.dto.ts b/apps/api/src/credentials/dto/credential-response.dto.ts new file mode 100644 index 0000000..29271ca --- /dev/null +++ b/apps/api/src/credentials/dto/credential-response.dto.ts @@ -0,0 +1,44 @@ +import type { CredentialType, CredentialScope, Prisma } from "@prisma/client"; + +/** + * DTO for credential responses + * NEVER includes encryptedValue - only maskedValue is exposed + */ +export interface CredentialResponseDto { + id: string; + userId: string; + workspaceId: string | null; + name: string; + provider: string; + type: CredentialType; + scope: CredentialScope; + maskedValue: string | null; + description: string | null; + expiresAt: Date | null; + lastUsedAt: Date | null; + metadata: Prisma.JsonValue; + isActive: boolean; + rotatedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +/** + * DTO for paginated credential list responses + */ +export interface PaginatedCredentialsDto { + data: CredentialResponseDto[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +/** + * DTO for decrypted credential value response + */ +export interface CredentialValueResponseDto { + value: string; +} diff --git a/apps/api/src/credentials/dto/index.ts b/apps/api/src/credentials/dto/index.ts new file mode 100644 index 0000000..f88cb96 --- /dev/null +++ b/apps/api/src/credentials/dto/index.ts @@ -0,0 +1,5 @@ +export * from "./create-credential.dto"; +export * from "./update-credential.dto"; +export * from "./rotate-credential.dto"; +export * from "./query-credential.dto"; +export * from "./credential-response.dto"; diff --git a/apps/api/src/credentials/dto/query-credential.dto.ts b/apps/api/src/credentials/dto/query-credential.dto.ts new file mode 100644 index 0000000..536c573 --- /dev/null +++ b/apps/api/src/credentials/dto/query-credential.dto.ts @@ -0,0 +1,49 @@ +import { CredentialType, CredentialScope } from "@prisma/client"; +import { IsEnum, IsOptional, IsInt, Min, Max, IsString, IsBoolean, IsUUID } from "class-validator"; +import { Type, Transform } from "class-transformer"; + +/** + * DTO for querying credentials with filters and pagination + */ +export class QueryCredentialDto { + @IsOptional() + @IsUUID("4", { message: "workspaceId must be a valid UUID" }) + workspaceId?: string; + + @IsOptional() + @IsEnum(CredentialType, { each: true, message: "type must be a valid CredentialType" }) + @Transform(({ value }) => + value === undefined ? undefined : Array.isArray(value) ? value : [value] + ) + type?: CredentialType | CredentialType[]; + + @IsOptional() + @IsEnum(CredentialScope, { message: "scope must be a valid CredentialScope" }) + scope?: CredentialScope; + + @IsOptional() + @IsString({ message: "provider must be a string" }) + provider?: string; + + @IsOptional() + @IsString({ message: "search must be a string" }) + search?: string; + + @IsOptional() + @IsBoolean({ message: "isActive must be a boolean" }) + @Transform(({ value }) => value === "true" || value === true) + isActive?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; +} diff --git a/apps/api/src/credentials/dto/rotate-credential.dto.ts b/apps/api/src/credentials/dto/rotate-credential.dto.ts new file mode 100644 index 0000000..ed04d40 --- /dev/null +++ b/apps/api/src/credentials/dto/rotate-credential.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MinLength } from "class-validator"; + +/** + * DTO for rotating a credential's value + */ +export class RotateCredentialDto { + @IsString({ message: "newValue must be a string" }) + @MinLength(1, { message: "newValue must not be empty" }) + newValue!: string; +} diff --git a/apps/api/src/credentials/dto/update-credential.dto.ts b/apps/api/src/credentials/dto/update-credential.dto.ts new file mode 100644 index 0000000..c02607e --- /dev/null +++ b/apps/api/src/credentials/dto/update-credential.dto.ts @@ -0,0 +1,31 @@ +import { + IsString, + IsOptional, + IsDateString, + IsObject, + IsBoolean, + MaxLength, +} from "class-validator"; + +/** + * DTO for updating a credential's metadata + * Note: Cannot update the credential value itself - use rotate endpoint instead + */ +export class UpdateCredentialDto { + @IsOptional() + @IsString({ message: "description must be a string" }) + @MaxLength(1000, { message: "description must not exceed 1000 characters" }) + description?: string; + + @IsOptional() + @IsDateString({}, { message: "expiresAt must be a valid ISO 8601 date string" }) + expiresAt?: Date; + + @IsOptional() + @IsObject({ message: "metadata must be an object" }) + metadata?: Record; + + @IsOptional() + @IsBoolean({ message: "isActive must be a boolean" }) + isActive?: boolean; +} diff --git a/apps/api/src/credentials/index.ts b/apps/api/src/credentials/index.ts new file mode 100644 index 0000000..086c98e --- /dev/null +++ b/apps/api/src/credentials/index.ts @@ -0,0 +1,4 @@ +export * from "./credentials.module"; +export * from "./credentials.service"; +export * from "./credentials.controller"; +export * from "./dto"; diff --git a/docs/scratchpads/356-credential-crud-api.md b/docs/scratchpads/356-credential-crud-api.md new file mode 100644 index 0000000..bc460da --- /dev/null +++ b/docs/scratchpads/356-credential-crud-api.md @@ -0,0 +1,91 @@ +# Issue #356: Build credential CRUD API endpoints + +## Objective + +Implement CRUD API endpoints for managing user credentials with encryption, RLS, and audit logging. + +## Approach + +Following TDD approach: + +1. Create DTOs with validation +2. Write service tests first (RED) +3. Implement service with VaultService integration (GREEN) +4. Write controller tests (RED) +5. Implement controller with guards (GREEN) +6. Create module and wire dependencies +7. Verify all tests pass with 85%+ coverage + +## Key Patterns Followed + +- RLS: Use `getRlsClient() ?? this.prisma` pattern +- Encryption: Use VaultService with TransitKey.CREDENTIALS +- Activity logging: Fire-and-forget pattern like existing activity helpers +- DTOs: class-validator decorators for input validation +- Guards: AuthGuard + WorkspaceGuard + PermissionGuard +- Rate limiting: @Throttle decorator on getValue endpoint (10/minute) + +## Files Created + +- [x] apps/api/src/credentials/dto/create-credential.dto.ts +- [x] apps/api/src/credentials/dto/update-credential.dto.ts +- [x] apps/api/src/credentials/dto/query-credential.dto.ts +- [x] apps/api/src/credentials/dto/credential-response.dto.ts +- [x] apps/api/src/credentials/dto/rotate-credential.dto.ts +- [x] apps/api/src/credentials/dto/index.ts +- [x] apps/api/src/credentials/credentials.service.spec.ts (18 tests - all passing) +- [x] apps/api/src/credentials/credentials.service.ts +- [x] apps/api/src/credentials/credentials.controller.spec.ts (8 tests - all passing) +- [x] apps/api/src/credentials/credentials.controller.ts +- [x] apps/api/src/credentials/credentials.module.ts +- [x] apps/api/src/credentials/index.ts + +## Files Modified + +- [x] apps/api/src/app.module.ts - imported CredentialsModule + +## Admin Credentials Controller + +**Decision: Not implemented in this phase** + +- Issue #356 listed admin endpoints as optional ("Admin Secret Endpoints") +- The UserCredential model supports SYSTEM scope for admin secrets +- Admin endpoints can be added in a future issue when needed +- Current implementation covers all user credential endpoints + +## Testing Results + +- Service tests: 18/18 passing +- Controller tests: 8/8 passing +- Total: 26/26 tests passing +- Coverage: **95.71%** (exceeds 85% requirement) + - Service: 95.16% + - Controller: 100% +- TypeScript: All checks pass + +## Endpoints Implemented + +- POST /api/credentials - Create credential +- GET /api/credentials - List credentials (masked values only) +- GET /api/credentials/:id - Get single credential (masked value only) +- GET /api/credentials/:id/value - Decrypt and return plaintext (rate limited 10/min) +- PATCH /api/credentials/:id - Update metadata +- POST /api/credentials/:id/rotate - Rotate credential value +- DELETE /api/credentials/:id - Soft delete + +## Security Features + +- All credential values encrypted with VaultService (TransitKey.CREDENTIALS) +- List/Get endpoints NEVER return plaintext (only maskedValue) +- getValue endpoint rate limited to 10 requests/minute per user +- All operations audit-logged with CREDENTIAL\_\* ActivityAction enums +- RLS enforces per-user isolation +- Input validation via class-validator DTOs + +## Notes + +- CREDENTIAL\_\* ActivityAction enums already existed in schema (from issue #355) +- Used existing activity logging pattern (fire-and-forget) +- Followed existing controller patterns for guards and decorators +- maskedValue shows last 4 characters with \*\*\*\* prefix +- TypeScript exactOptionalPropertyTypes required careful handling in update method