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:
@@ -36,6 +36,7 @@ import { JobEventsModule } from "./job-events/job-events.module";
|
||||
import { JobStepsModule } from "./job-steps/job-steps.module";
|
||||
import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module";
|
||||
import { FederationModule } from "./federation/federation.module";
|
||||
import { CredentialsModule } from "./credentials/credentials.module";
|
||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||
|
||||
@Module({
|
||||
@@ -92,6 +93,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
||||
JobStepsModule,
|
||||
CoordinatorIntegrationModule,
|
||||
FederationModule,
|
||||
CredentialsModule,
|
||||
],
|
||||
controllers: [AppController, CsrfController],
|
||||
providers: [
|
||||
|
||||
223
apps/api/src/credentials/credentials.controller.spec.ts
Normal file
223
apps/api/src/credentials/credentials.controller.spec.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { CredentialsController } from "./credentials.controller";
|
||||
import { CredentialsService } from "./credentials.service";
|
||||
import { CredentialType, CredentialScope } from "@prisma/client";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
|
||||
describe("CredentialsController", () => {
|
||||
let controller: CredentialsController;
|
||||
let service: CredentialsService;
|
||||
|
||||
const mockCredentialsService = {
|
||||
create: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
getValue: vi.fn(),
|
||||
update: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAuthGuard = {
|
||||
canActivate: vi.fn((context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
request.user = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440002",
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
};
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
const mockWorkspaceGuard = {
|
||||
canActivate: vi.fn(() => true),
|
||||
};
|
||||
|
||||
const mockPermissionGuard = {
|
||||
canActivate: vi.fn(() => true),
|
||||
};
|
||||
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const mockCredentialId = "550e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
const mockCredential = {
|
||||
id: mockCredentialId,
|
||||
userId: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "GitHub Token",
|
||||
provider: "github",
|
||||
type: CredentialType.API_KEY,
|
||||
scope: CredentialScope.USER,
|
||||
maskedValue: "****3456",
|
||||
description: "My GitHub API key",
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
metadata: {},
|
||||
isActive: true,
|
||||
rotatedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [CredentialsController],
|
||||
providers: [
|
||||
{
|
||||
provide: CredentialsService,
|
||||
useValue: mockCredentialsService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(mockAuthGuard)
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue(mockWorkspaceGuard)
|
||||
.overrideGuard(PermissionGuard)
|
||||
.useValue(mockPermissionGuard)
|
||||
.compile();
|
||||
|
||||
controller = module.get<CredentialsController>(CredentialsController);
|
||||
service = module.get<CredentialsService>(CredentialsService);
|
||||
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a credential", async () => {
|
||||
const createDto = {
|
||||
name: "GitHub Token",
|
||||
provider: "github",
|
||||
type: CredentialType.API_KEY,
|
||||
value: "ghp_1234567890abcdef",
|
||||
description: "My GitHub API key",
|
||||
};
|
||||
|
||||
mockCredentialsService.create.mockResolvedValue(mockCredential);
|
||||
|
||||
const result = await controller.create(createDto, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(result).toEqual(mockCredential);
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, createDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return paginated credentials", async () => {
|
||||
const query = { page: 1, limit: 10 };
|
||||
const paginatedResult = {
|
||||
data: [mockCredential],
|
||||
meta: {
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
mockCredentialsService.findAll.mockResolvedValue(paginatedResult);
|
||||
|
||||
const result = await controller.findAll(query, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(result).toEqual(paginatedResult);
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, mockUserId, query);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a single credential", async () => {
|
||||
mockCredentialsService.findOne.mockResolvedValue(mockCredential);
|
||||
|
||||
const result = await controller.findOne(mockCredentialId, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(result).toEqual(mockCredential);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockCredentialId, mockWorkspaceId, mockUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getValue", () => {
|
||||
it("should return decrypted credential value", async () => {
|
||||
const valueResponse = { value: "ghp_1234567890abcdef" };
|
||||
mockCredentialsService.getValue.mockResolvedValue(valueResponse);
|
||||
|
||||
const result = await controller.getValue(mockCredentialId, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(result).toEqual(valueResponse);
|
||||
expect(service.getValue).toHaveBeenCalledWith(mockCredentialId, mockWorkspaceId, mockUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should update credential metadata", async () => {
|
||||
const updateDto = { description: "Updated description" };
|
||||
const updatedCredential = { ...mockCredential, ...updateDto };
|
||||
|
||||
mockCredentialsService.update.mockResolvedValue(updatedCredential);
|
||||
|
||||
const result = await controller.update(
|
||||
mockCredentialId,
|
||||
updateDto,
|
||||
mockWorkspaceId,
|
||||
mockUser
|
||||
);
|
||||
|
||||
expect(result).toEqual(updatedCredential);
|
||||
expect(service.update).toHaveBeenCalledWith(
|
||||
mockCredentialId,
|
||||
mockWorkspaceId,
|
||||
mockUserId,
|
||||
updateDto
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotate", () => {
|
||||
it("should rotate credential value", async () => {
|
||||
const rotateDto = { newValue: "ghp_new_token_12345" };
|
||||
const rotatedCredential = { ...mockCredential, rotatedAt: new Date() };
|
||||
|
||||
mockCredentialsService.rotate.mockResolvedValue(rotatedCredential);
|
||||
|
||||
const result = await controller.rotate(
|
||||
mockCredentialId,
|
||||
rotateDto,
|
||||
mockWorkspaceId,
|
||||
mockUser
|
||||
);
|
||||
|
||||
expect(result).toEqual(rotatedCredential);
|
||||
expect(service.rotate).toHaveBeenCalledWith(
|
||||
mockCredentialId,
|
||||
mockWorkspaceId,
|
||||
mockUserId,
|
||||
rotateDto.newValue
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should soft-delete a credential", async () => {
|
||||
mockCredentialsService.remove.mockResolvedValue(undefined);
|
||||
|
||||
await controller.remove(mockCredentialId, mockWorkspaceId, mockUser);
|
||||
|
||||
expect(service.remove).toHaveBeenCalledWith(mockCredentialId, mockWorkspaceId, mockUserId);
|
||||
});
|
||||
});
|
||||
});
|
||||
158
apps/api/src/credentials/credentials.controller.ts
Normal file
158
apps/api/src/credentials/credentials.controller.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { CredentialsService } from "./credentials.service";
|
||||
import {
|
||||
CreateCredentialDto,
|
||||
UpdateCredentialDto,
|
||||
RotateCredentialDto,
|
||||
QueryCredentialDto,
|
||||
} from "./dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import type { AuthenticatedUser } from "../common/types/user.types";
|
||||
|
||||
/**
|
||||
* Controller for user credential endpoints
|
||||
* All endpoints require authentication and workspace context
|
||||
*
|
||||
* Guards are applied in order:
|
||||
* 1. AuthGuard - Verifies user authentication
|
||||
* 2. WorkspaceGuard - Validates workspace access and sets RLS context
|
||||
* 3. PermissionGuard - Checks role-based permissions
|
||||
*
|
||||
* Security:
|
||||
* - List/Get endpoints NEVER return plaintext values (only maskedValue)
|
||||
* - getValue endpoint is intentionally separate and rate-limited
|
||||
* - All operations are audit-logged via ActivityService
|
||||
*/
|
||||
@Controller("credentials")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class CredentialsController {
|
||||
constructor(private readonly credentialsService: CredentialsService) {}
|
||||
|
||||
/**
|
||||
* POST /api/credentials
|
||||
* Create a new credential
|
||||
* Requires: MEMBER role or higher
|
||||
*/
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create(
|
||||
@Body() createDto: CreateCredentialDto,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
) {
|
||||
return this.credentialsService.create(workspaceId, user.id, createDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/credentials
|
||||
* Get paginated credentials with optional filters
|
||||
* NEVER returns plaintext values - only maskedValue
|
||||
* Requires: Any workspace member (including GUEST)
|
||||
*/
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findAll(
|
||||
@Query() query: QueryCredentialDto,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
) {
|
||||
return this.credentialsService.findAll(workspaceId, user.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/credentials/:id
|
||||
* Get a single credential by ID
|
||||
* NEVER returns plaintext value - only maskedValue
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get(":id")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findOne(
|
||||
@Param("id") id: string,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
) {
|
||||
return this.credentialsService.findOne(id, workspaceId, user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/credentials/:id/value
|
||||
* Decrypt and return credential value
|
||||
* CRITICAL: This is the ONLY endpoint that returns plaintext
|
||||
* Rate limited to 10 requests per minute per user
|
||||
* Logs access to activity log
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get(":id/value")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
@Throttle({ strict: { limit: 10, ttl: 60000 } })
|
||||
async getValue(
|
||||
@Param("id") id: string,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
) {
|
||||
return this.credentialsService.getValue(id, workspaceId, user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/credentials/:id
|
||||
* Update credential metadata (NOT the value itself)
|
||||
* Use /rotate endpoint to change the credential value
|
||||
* Requires: MEMBER role or higher
|
||||
*/
|
||||
@Patch(":id")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async update(
|
||||
@Param("id") id: string,
|
||||
@Body() updateDto: UpdateCredentialDto,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
) {
|
||||
return this.credentialsService.update(id, workspaceId, user.id, updateDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/credentials/:id/rotate
|
||||
* Replace credential value with new encrypted value
|
||||
* Requires: MEMBER role or higher
|
||||
*/
|
||||
@Post(":id/rotate")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async rotate(
|
||||
@Param("id") id: string,
|
||||
@Body() rotateDto: RotateCredentialDto,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
) {
|
||||
return this.credentialsService.rotate(id, workspaceId, user.id, rotateDto.newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/credentials/:id
|
||||
* Soft-delete credential (set isActive = false)
|
||||
* Requires: MEMBER role or higher
|
||||
*/
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async remove(
|
||||
@Param("id") id: string,
|
||||
@Workspace() workspaceId: string,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
) {
|
||||
return this.credentialsService.remove(id, workspaceId, user.id);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/credentials/credentials.module.ts
Normal file
14
apps/api/src/credentials/credentials.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CredentialsController } from "./credentials.controller";
|
||||
import { CredentialsService } from "./credentials.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { ActivityModule } from "../activity/activity.module";
|
||||
import { VaultModule } from "../vault/vault.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ActivityModule, VaultModule],
|
||||
controllers: [CredentialsController],
|
||||
providers: [CredentialsService],
|
||||
exports: [CredentialsService],
|
||||
})
|
||||
export class CredentialsModule {}
|
||||
482
apps/api/src/credentials/credentials.service.spec.ts
Normal file
482
apps/api/src/credentials/credentials.service.spec.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { CredentialsService } from "./credentials.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { ActivityService } from "../activity/activity.service";
|
||||
import { VaultService } from "../vault/vault.service";
|
||||
import { CredentialType, CredentialScope } from "@prisma/client";
|
||||
import { ActivityAction, EntityType } from "@prisma/client";
|
||||
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
import { TransitKey } from "../vault/vault.constants";
|
||||
|
||||
describe("CredentialsService", () => {
|
||||
let service: CredentialsService;
|
||||
let prisma: PrismaService;
|
||||
let activityService: ActivityService;
|
||||
let vaultService: VaultService;
|
||||
|
||||
const mockPrismaService = {
|
||||
userCredential: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockActivityService = {
|
||||
logActivity: vi.fn(),
|
||||
};
|
||||
|
||||
const mockVaultService = {
|
||||
encrypt: vi.fn(),
|
||||
decrypt: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const mockCredentialId = "550e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
const mockCredential = {
|
||||
id: mockCredentialId,
|
||||
userId: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "GitHub Token",
|
||||
provider: "github",
|
||||
type: CredentialType.API_KEY,
|
||||
scope: CredentialScope.USER,
|
||||
encryptedValue: "vault:v1:encrypted-data-here",
|
||||
maskedValue: "****3456",
|
||||
description: "My GitHub API key",
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
metadata: {},
|
||||
isActive: true,
|
||||
rotatedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CredentialsService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
{
|
||||
provide: ActivityService,
|
||||
useValue: mockActivityService,
|
||||
},
|
||||
{
|
||||
provide: VaultService,
|
||||
useValue: mockVaultService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CredentialsService>(CredentialsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
activityService = module.get<ActivityService>(ActivityService);
|
||||
vaultService = module.get<VaultService>(VaultService);
|
||||
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a credential with encrypted value and log activity", async () => {
|
||||
const createDto = {
|
||||
name: "GitHub Token",
|
||||
provider: "github",
|
||||
type: CredentialType.API_KEY,
|
||||
value: "ghp_1234567890abcdef",
|
||||
description: "My GitHub API key",
|
||||
};
|
||||
|
||||
mockVaultService.encrypt.mockResolvedValue("vault:v1:encrypted-data-here");
|
||||
mockPrismaService.userCredential.create.mockResolvedValue(mockCredential);
|
||||
mockActivityService.logActivity.mockResolvedValue({});
|
||||
|
||||
const result = await service.create(mockWorkspaceId, mockUserId, createDto);
|
||||
|
||||
expect(result).toEqual(mockCredential);
|
||||
expect(vaultService.encrypt).toHaveBeenCalledWith(createDto.value, TransitKey.CREDENTIALS);
|
||||
expect(prisma.userCredential.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: createDto.name,
|
||||
provider: createDto.provider,
|
||||
type: createDto.type,
|
||||
scope: CredentialScope.USER,
|
||||
encryptedValue: "vault:v1:encrypted-data-here",
|
||||
maskedValue: "****cdef",
|
||||
description: createDto.description,
|
||||
expiresAt: null,
|
||||
metadata: {},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(activityService.logActivity).toHaveBeenCalledWith({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
action: ActivityAction.CREDENTIAL_CREATED,
|
||||
entityType: EntityType.CREDENTIAL,
|
||||
entityId: mockCredential.id,
|
||||
details: {
|
||||
name: createDto.name,
|
||||
provider: createDto.provider,
|
||||
type: createDto.type,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create credential with custom scope", async () => {
|
||||
const createDto = {
|
||||
name: "Workspace API Key",
|
||||
provider: "custom",
|
||||
type: CredentialType.API_KEY,
|
||||
scope: CredentialScope.WORKSPACE,
|
||||
value: "ws_key_123",
|
||||
};
|
||||
|
||||
mockVaultService.encrypt.mockResolvedValue("vault:v1:encrypted");
|
||||
mockPrismaService.userCredential.create.mockResolvedValue({
|
||||
...mockCredential,
|
||||
scope: CredentialScope.WORKSPACE,
|
||||
});
|
||||
|
||||
await service.create(mockWorkspaceId, mockUserId, createDto);
|
||||
|
||||
expect(prisma.userCredential.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
scope: CredentialScope.WORKSPACE,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate maskedValue from last 4 characters", async () => {
|
||||
const createDto = {
|
||||
name: "Short",
|
||||
provider: "test",
|
||||
type: CredentialType.SECRET,
|
||||
value: "abc",
|
||||
};
|
||||
|
||||
mockVaultService.encrypt.mockResolvedValue("vault:v1:encrypted");
|
||||
mockPrismaService.userCredential.create.mockResolvedValue(mockCredential);
|
||||
|
||||
await service.create(mockWorkspaceId, mockUserId, createDto);
|
||||
|
||||
expect(prisma.userCredential.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
maskedValue: "****abc",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return paginated credentials without encryptedValue", async () => {
|
||||
const query = { page: 1, limit: 10 };
|
||||
const credentials = [mockCredential];
|
||||
|
||||
mockPrismaService.userCredential.findMany.mockResolvedValue(credentials);
|
||||
mockPrismaService.userCredential.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.findAll(mockWorkspaceId, mockUserId, query);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: credentials,
|
||||
meta: {
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
},
|
||||
});
|
||||
expect(prisma.userCredential.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
select: {
|
||||
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,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by type", async () => {
|
||||
const query = { type: CredentialType.API_KEY };
|
||||
|
||||
mockPrismaService.userCredential.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.userCredential.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(mockWorkspaceId, mockUserId, query);
|
||||
|
||||
expect(prisma.userCredential.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
type: { in: [CredentialType.API_KEY] },
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter by isActive", async () => {
|
||||
const query = { isActive: true };
|
||||
|
||||
mockPrismaService.userCredential.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.userCredential.count.mockResolvedValue(0);
|
||||
|
||||
await service.findAll(mockWorkspaceId, mockUserId, query);
|
||||
|
||||
expect(prisma.userCredential.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
isActive: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a single credential without encryptedValue", async () => {
|
||||
mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential);
|
||||
|
||||
const result = await service.findOne(mockCredentialId, mockWorkspaceId, mockUserId);
|
||||
|
||||
expect(result).toEqual(mockCredential);
|
||||
expect(prisma.userCredential.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockCredentialId,
|
||||
userId: mockUserId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
select: expect.objectContaining({
|
||||
id: true,
|
||||
maskedValue: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when credential not found", async () => {
|
||||
mockPrismaService.userCredential.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getValue", () => {
|
||||
it("should decrypt and return credential value, update lastUsedAt, and log access", async () => {
|
||||
mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential);
|
||||
mockVaultService.decrypt.mockResolvedValue("ghp_1234567890abcdef");
|
||||
mockPrismaService.userCredential.update.mockResolvedValue(mockCredential);
|
||||
mockActivityService.logActivity.mockResolvedValue({});
|
||||
|
||||
const result = await service.getValue(mockCredentialId, mockWorkspaceId, mockUserId);
|
||||
|
||||
expect(result).toEqual({ value: "ghp_1234567890abcdef" });
|
||||
expect(vaultService.decrypt).toHaveBeenCalledWith(
|
||||
mockCredential.encryptedValue,
|
||||
TransitKey.CREDENTIALS
|
||||
);
|
||||
expect(prisma.userCredential.update).toHaveBeenCalledWith({
|
||||
where: { id: mockCredentialId },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
expect(activityService.logActivity).toHaveBeenCalledWith({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
action: ActivityAction.CREDENTIAL_ACCESSED,
|
||||
entityType: EntityType.CREDENTIAL,
|
||||
entityId: mockCredentialId,
|
||||
details: {
|
||||
name: mockCredential.name,
|
||||
provider: mockCredential.provider,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when credential not found", async () => {
|
||||
mockPrismaService.userCredential.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getValue(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when credential is not active", async () => {
|
||||
mockPrismaService.userCredential.findUnique.mockResolvedValue({
|
||||
...mockCredential,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
await expect(service.getValue(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("should update credential metadata and log activity", async () => {
|
||||
const updateDto = {
|
||||
description: "Updated description",
|
||||
};
|
||||
|
||||
mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential);
|
||||
mockPrismaService.userCredential.update.mockResolvedValue({
|
||||
...mockCredential,
|
||||
...updateDto,
|
||||
});
|
||||
mockActivityService.logActivity.mockResolvedValue({});
|
||||
|
||||
const result = await service.update(mockCredentialId, mockWorkspaceId, mockUserId, updateDto);
|
||||
|
||||
expect(result.description).toBe(updateDto.description);
|
||||
expect(prisma.userCredential.update).toHaveBeenCalledWith({
|
||||
where: { id: mockCredentialId },
|
||||
data: updateDto,
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(activityService.logActivity).toHaveBeenCalledWith({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: EntityType.CREDENTIAL,
|
||||
entityId: mockCredentialId,
|
||||
details: {
|
||||
description: updateDto.description,
|
||||
expiresAt: undefined,
|
||||
isActive: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when credential not found", async () => {
|
||||
mockPrismaService.userCredential.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update(mockCredentialId, mockWorkspaceId, mockUserId, { description: "test" })
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotate", () => {
|
||||
it("should rotate credential value and log activity", async () => {
|
||||
const newValue = "ghp_new_token_12345";
|
||||
|
||||
mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential);
|
||||
mockVaultService.encrypt.mockResolvedValue("vault:v1:new-encrypted");
|
||||
mockPrismaService.userCredential.update.mockResolvedValue({
|
||||
...mockCredential,
|
||||
encryptedValue: "vault:v1:new-encrypted",
|
||||
maskedValue: "****2345",
|
||||
rotatedAt: new Date(),
|
||||
});
|
||||
mockActivityService.logActivity.mockResolvedValue({});
|
||||
|
||||
const result = await service.rotate(mockCredentialId, mockWorkspaceId, mockUserId, newValue);
|
||||
|
||||
expect(vaultService.encrypt).toHaveBeenCalledWith(newValue, TransitKey.CREDENTIALS);
|
||||
expect(prisma.userCredential.update).toHaveBeenCalledWith({
|
||||
where: { id: mockCredentialId },
|
||||
data: {
|
||||
encryptedValue: "vault:v1:new-encrypted",
|
||||
maskedValue: "****2345",
|
||||
rotatedAt: expect.any(Date),
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
expect(activityService.logActivity).toHaveBeenCalledWith({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
action: ActivityAction.CREDENTIAL_ROTATED,
|
||||
entityType: EntityType.CREDENTIAL,
|
||||
entityId: mockCredentialId,
|
||||
details: {
|
||||
name: mockCredential.name,
|
||||
provider: mockCredential.provider,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when credential not found", async () => {
|
||||
mockPrismaService.userCredential.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.rotate(mockCredentialId, mockWorkspaceId, mockUserId, "new_value")
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should soft-delete credential and log activity", async () => {
|
||||
mockPrismaService.userCredential.findUnique.mockResolvedValue(mockCredential);
|
||||
mockPrismaService.userCredential.update.mockResolvedValue({
|
||||
...mockCredential,
|
||||
isActive: false,
|
||||
});
|
||||
mockActivityService.logActivity.mockResolvedValue({});
|
||||
|
||||
await service.remove(mockCredentialId, mockWorkspaceId, mockUserId);
|
||||
|
||||
expect(prisma.userCredential.update).toHaveBeenCalledWith({
|
||||
where: { id: mockCredentialId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
expect(activityService.logActivity).toHaveBeenCalledWith({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
action: ActivityAction.CREDENTIAL_REVOKED,
|
||||
entityType: EntityType.CREDENTIAL,
|
||||
entityId: mockCredentialId,
|
||||
details: {
|
||||
name: mockCredential.name,
|
||||
provider: mockCredential.provider,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when credential not found", async () => {
|
||||
mockPrismaService.userCredential.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockCredentialId, mockWorkspaceId, mockUserId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
49
apps/api/src/credentials/dto/create-credential.dto.ts
Normal file
49
apps/api/src/credentials/dto/create-credential.dto.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { CredentialType, CredentialScope } from "@prisma/client";
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsDateString,
|
||||
IsObject,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for creating a new user credential
|
||||
*/
|
||||
export class CreateCredentialDto {
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsString({ message: "provider must be a string" })
|
||||
@MinLength(1, { message: "provider must not be empty" })
|
||||
@MaxLength(100, { message: "provider must not exceed 100 characters" })
|
||||
provider!: string;
|
||||
|
||||
@IsEnum(CredentialType, { message: "type must be a valid CredentialType" })
|
||||
type!: CredentialType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CredentialScope, { message: "scope must be a valid CredentialScope" })
|
||||
scope?: CredentialScope;
|
||||
|
||||
@IsString({ message: "value must be a string" })
|
||||
@MinLength(1, { message: "value must not be empty" })
|
||||
value!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(1000, { message: "description must not exceed 1000 characters" })
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: "expiresAt must be a valid ISO 8601 date string" })
|
||||
expiresAt?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: "metadata must be an object" })
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
44
apps/api/src/credentials/dto/credential-response.dto.ts
Normal file
44
apps/api/src/credentials/dto/credential-response.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { CredentialType, CredentialScope, Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* DTO for credential responses
|
||||
* NEVER includes encryptedValue - only maskedValue is exposed
|
||||
*/
|
||||
export interface CredentialResponseDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
workspaceId: string | null;
|
||||
name: string;
|
||||
provider: string;
|
||||
type: CredentialType;
|
||||
scope: CredentialScope;
|
||||
maskedValue: string | null;
|
||||
description: string | null;
|
||||
expiresAt: Date | null;
|
||||
lastUsedAt: Date | null;
|
||||
metadata: Prisma.JsonValue;
|
||||
isActive: boolean;
|
||||
rotatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for paginated credential list responses
|
||||
*/
|
||||
export interface PaginatedCredentialsDto {
|
||||
data: CredentialResponseDto[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for decrypted credential value response
|
||||
*/
|
||||
export interface CredentialValueResponseDto {
|
||||
value: string;
|
||||
}
|
||||
5
apps/api/src/credentials/dto/index.ts
Normal file
5
apps/api/src/credentials/dto/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./create-credential.dto";
|
||||
export * from "./update-credential.dto";
|
||||
export * from "./rotate-credential.dto";
|
||||
export * from "./query-credential.dto";
|
||||
export * from "./credential-response.dto";
|
||||
49
apps/api/src/credentials/dto/query-credential.dto.ts
Normal file
49
apps/api/src/credentials/dto/query-credential.dto.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { CredentialType, CredentialScope } from "@prisma/client";
|
||||
import { IsEnum, IsOptional, IsInt, Min, Max, IsString, IsBoolean, IsUUID } from "class-validator";
|
||||
import { Type, Transform } from "class-transformer";
|
||||
|
||||
/**
|
||||
* DTO for querying credentials with filters and pagination
|
||||
*/
|
||||
export class QueryCredentialDto {
|
||||
@IsOptional()
|
||||
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
|
||||
workspaceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CredentialType, { each: true, message: "type must be a valid CredentialType" })
|
||||
@Transform(({ value }) =>
|
||||
value === undefined ? undefined : Array.isArray(value) ? value : [value]
|
||||
)
|
||||
type?: CredentialType | CredentialType[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CredentialScope, { message: "scope must be a valid CredentialScope" })
|
||||
scope?: CredentialScope;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "provider must be a string" })
|
||||
provider?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "search must be a string" })
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
@Transform(({ value }) => value === "true" || value === true)
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "page must be an integer" })
|
||||
@Min(1, { message: "page must be at least 1" })
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "limit must be an integer" })
|
||||
@Min(1, { message: "limit must be at least 1" })
|
||||
@Max(100, { message: "limit must not exceed 100" })
|
||||
limit?: number;
|
||||
}
|
||||
10
apps/api/src/credentials/dto/rotate-credential.dto.ts
Normal file
10
apps/api/src/credentials/dto/rotate-credential.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for rotating a credential's value
|
||||
*/
|
||||
export class RotateCredentialDto {
|
||||
@IsString({ message: "newValue must be a string" })
|
||||
@MinLength(1, { message: "newValue must not be empty" })
|
||||
newValue!: string;
|
||||
}
|
||||
31
apps/api/src/credentials/dto/update-credential.dto.ts
Normal file
31
apps/api/src/credentials/dto/update-credential.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsDateString,
|
||||
IsObject,
|
||||
IsBoolean,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for updating a credential's metadata
|
||||
* Note: Cannot update the credential value itself - use rotate endpoint instead
|
||||
*/
|
||||
export class UpdateCredentialDto {
|
||||
@IsOptional()
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(1000, { message: "description must not exceed 1000 characters" })
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: "expiresAt must be a valid ISO 8601 date string" })
|
||||
expiresAt?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: "metadata must be an object" })
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
isActive?: boolean;
|
||||
}
|
||||
4
apps/api/src/credentials/index.ts
Normal file
4
apps/api/src/credentials/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./credentials.module";
|
||||
export * from "./credentials.service";
|
||||
export * from "./credentials.controller";
|
||||
export * from "./dto";
|
||||
Reference in New Issue
Block a user