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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user