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 { 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: [

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