feat(#356): Build credential CRUD API endpoints

Implement comprehensive CRUD API for managing user credentials with encryption,
RLS, and audit logging following TDD methodology.

Features:
- POST /api/credentials - Create encrypted credential
- GET /api/credentials - List credentials (masked values only)
- GET /api/credentials/:id - Get single credential (masked)
- GET /api/credentials/:id/value - Decrypt 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:
- All values encrypted via 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
- RLS enforces per-user isolation via getRlsClient() pattern
- Input validation via class-validator DTOs

Testing:
- 26/26 unit tests passing
- 95.71% code coverage (exceeds 85% requirement)
  - Service: 95.16%
  - Controller: 100%
- TypeScript checks pass

Files created:
- apps/api/src/credentials/credentials.service.ts
- apps/api/src/credentials/credentials.service.spec.ts
- apps/api/src/credentials/credentials.controller.ts
- apps/api/src/credentials/credentials.controller.spec.ts
- apps/api/src/credentials/credentials.module.ts
- apps/api/src/credentials/dto/*.dto.ts (5 DTOs)

Files modified:
- apps/api/src/app.module.ts - imported CredentialsModule

Note: Admin credentials endpoints deferred to future issue. Current
implementation covers all user credential endpoints.

Refs #346
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 16:50:02 -06:00
parent aa2ee5aea3
commit 46d0a06ef5
14 changed files with 1566 additions and 0 deletions

View File

@@ -36,6 +36,7 @@ import { JobEventsModule } from "./job-events/job-events.module";
import { JobStepsModule } from "./job-steps/job-steps.module"; import { JobStepsModule } from "./job-steps/job-steps.module";
import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module"; import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module";
import { FederationModule } from "./federation/federation.module"; import { FederationModule } from "./federation/federation.module";
import { CredentialsModule } from "./credentials/credentials.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({ @Module({
@@ -92,6 +93,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
JobStepsModule, JobStepsModule,
CoordinatorIntegrationModule, CoordinatorIntegrationModule,
FederationModule, FederationModule,
CredentialsModule,
], ],
controllers: [AppController, CsrfController], controllers: [AppController, CsrfController],
providers: [ providers: [

View File

@@ -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>(CredentialsController);
service = module.get<CredentialsService>(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);
});
});
});

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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>(CredentialsService);
prisma = module.get<PrismaService>(PrismaService);
activityService = module.get<ActivityService>(ActivityService);
vaultService = module.get<VaultService>(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
);
});
});
});

View File

@@ -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<CredentialResponseDto> {
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<PaginatedCredentialsDto> {
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<CredentialResponseDto> {
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<CredentialValueResponseDto> {
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<CredentialResponseDto> {
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<CredentialResponseDto> {
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<void> {
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)}`;
}
}

View File

@@ -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<string, unknown>;
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<string, unknown>;
@IsOptional()
@IsBoolean({ message: "isActive must be a boolean" })
isActive?: boolean;
}

View File

@@ -0,0 +1,4 @@
export * from "./credentials.module";
export * from "./credentials.service";
export * from "./credentials.controller";
export * from "./dto";

View File

@@ -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