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 <[email protected]>
This commit is contained in:
2026-02-07 16:50:02 -06:00
parent aa2ee5aea3
commit 46d0a06ef5
14 changed files with 1566 additions and 0 deletions
@@ -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>;
}
@@ -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
View 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";
@@ -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;
}
@@ -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;
}
@@ -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;
}