Files
stack/apps/api/src/credentials/credentials.service.ts
2026-02-07 17:33:32 -06:00

514 lines
13 KiB
TypeScript

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,
QueryCredentialAuditDto,
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 credential audit logs with filters and pagination
* Returns all credential-related activities for the workspace
* RLS ensures users only see their own workspace activities
*/
async getAuditLog(
workspaceId: string,
query: QueryCredentialAuditDto
): Promise<{
data: {
id: string;
action: ActivityAction;
entityId: string;
createdAt: Date;
details: Record<string, unknown>;
user: {
id: string;
name: string | null;
email: string;
};
}[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
// Build where clause
const where: Prisma.ActivityLogWhereInput = {
workspaceId,
entityType: EntityType.CREDENTIAL,
};
// Filter by specific credential if provided
if (query.credentialId) {
where.entityId = query.credentialId;
}
// Filter by action if provided
if (query.action) {
where.action = query.action;
}
// Filter by date range if provided
if (query.startDate || query.endDate) {
where.createdAt = {};
if (query.startDate) {
where.createdAt.gte = query.startDate;
}
if (query.endDate) {
where.createdAt.lte = query.endDate;
}
}
// Execute queries in parallel
const [data, total] = await Promise.all([
this.prisma.activityLog.findMany({
where,
select: {
id: true,
action: true,
entityId: true,
createdAt: true,
details: true,
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
skip,
take: limit,
}),
this.prisma.activityLog.count({ where }),
]);
return {
data: data as {
id: string;
action: ActivityAction;
entityId: string;
createdAt: Date;
details: Record<string, unknown>;
user: {
id: string;
name: string | null;
email: string;
};
}[],
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* 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)}`;
}
}