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:
404
apps/api/src/credentials/credentials.service.ts
Normal file
404
apps/api/src/credentials/credentials.service.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user