514 lines
13 KiB
TypeScript
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)}`;
|
|
}
|
|
}
|