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