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

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