feat(security): classify API errors and enforce bounded requests
This commit is contained in:
parent
eb976d2c8c
commit
cad3f4ec29
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
|
||||||
|
import { randomUUID, createHash } from 'node:crypto';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { classifyError } from './classify-error';
|
||||||
|
import { ERRORS } from './error-catalog';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class ApiExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger('ApiException');
|
||||||
|
catch(error: unknown, host: ArgumentsHost): void {
|
||||||
|
const http = host.switchToHttp();
|
||||||
|
const request = http.getRequest<Request>();
|
||||||
|
const response = http.getResponse<Response>();
|
||||||
|
const failure = classifyError(error);
|
||||||
|
const requestId =
|
||||||
|
(response.locals.requestId as string | undefined) ?? randomUUID();
|
||||||
|
const record = {
|
||||||
|
event: failure.code,
|
||||||
|
message: ERRORS[failure.code][2],
|
||||||
|
diagnostic: failure.diagnostic,
|
||||||
|
requestId,
|
||||||
|
method: request.method,
|
||||||
|
route: request.route?.path ?? 'unmatched',
|
||||||
|
// Fingerprints distinguish unexpected faults without logging raw errors, queries or secrets.
|
||||||
|
faultId:
|
||||||
|
failure.code === 'INTERNAL_FAILURE' && error instanceof Error
|
||||||
|
? createHash('sha256')
|
||||||
|
.update(error.stack ?? error.name)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
if (failure.getStatus() >= 500) this.logger.error(JSON.stringify(record));
|
||||||
|
else this.logger.warn(JSON.stringify(record));
|
||||||
|
if (response.headersSent) return;
|
||||||
|
response.setHeader('X-Request-Id', requestId);
|
||||||
|
response.setHeader('Cache-Control', 'no-store');
|
||||||
|
if (failure.getStatus() === 429)
|
||||||
|
response.setHeader(
|
||||||
|
'Retry-After',
|
||||||
|
String(failure.retryAfterSeconds ?? 60),
|
||||||
|
);
|
||||||
|
response.status(failure.getStatus()).json({
|
||||||
|
statusCode: failure.getStatus(),
|
||||||
|
code: failure.code,
|
||||||
|
message: failure.message,
|
||||||
|
requestId,
|
||||||
|
...(failure.fields ? { fields: failure.fields } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import { ERRORS, type ErrorCode } from './error-catalog';
|
||||||
|
|
||||||
|
export class AppError extends HttpException {
|
||||||
|
constructor(
|
||||||
|
readonly code: ErrorCode,
|
||||||
|
readonly diagnostic?: string,
|
||||||
|
readonly fields?: string[],
|
||||||
|
readonly retryAfterSeconds?: number,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
{ code, message: ERRORS[code][1], ...(fields ? { fields } : {}) },
|
||||||
|
ERRORS[code][0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '../../generated/prisma/client';
|
||||||
|
import { AppError } from './app-error';
|
||||||
|
import type { ErrorCode } from './error-catalog';
|
||||||
|
|
||||||
|
export function classifyError(error: unknown): AppError {
|
||||||
|
if (error instanceof AppError) return error;
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
const codes: Record<string, ErrorCode> = {
|
||||||
|
P2002: 'RECORD_CONFLICT',
|
||||||
|
P2003: 'REFERENCE_INVALID',
|
||||||
|
P2004: 'DATA_CONSTRAINT',
|
||||||
|
P2025: 'RECORD_NOT_FOUND',
|
||||||
|
P2024: 'DATABASE_BUSY',
|
||||||
|
P2028: 'DATABASE_BUSY',
|
||||||
|
P2034: 'TRANSACTION_CONFLICT',
|
||||||
|
};
|
||||||
|
return new AppError(codes[error.code] ?? 'INTERNAL_FAILURE', error.code);
|
||||||
|
}
|
||||||
|
if (error instanceof Prisma.PrismaClientInitializationError)
|
||||||
|
return new AppError('DATABASE_BUSY');
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
const codes: Record<number, ErrorCode> = {
|
||||||
|
400: 'REQUEST_MALFORMED',
|
||||||
|
401: 'AUTH_REQUIRED',
|
||||||
|
403: 'ACCESS_DENIED',
|
||||||
|
404: 'ROUTE_NOT_FOUND',
|
||||||
|
413: 'REQUEST_TOO_LARGE',
|
||||||
|
429: 'RATE_LIMITED',
|
||||||
|
503: 'DATABASE_BUSY',
|
||||||
|
};
|
||||||
|
return new AppError(codes[error.getStatus()] ?? 'INTERNAL_FAILURE');
|
||||||
|
}
|
||||||
|
// Express parser errors are not Nest HttpExceptions.
|
||||||
|
if (typeof error === 'object' && error !== null && 'type' in error) {
|
||||||
|
if (error.type === 'entity.too.large')
|
||||||
|
return new AppError('REQUEST_TOO_LARGE');
|
||||||
|
if (error.type === 'entity.parse.failed')
|
||||||
|
return new AppError('REQUEST_MALFORMED');
|
||||||
|
}
|
||||||
|
return new AppError('INTERNAL_FAILURE');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
export const COMMERCE_ERRORS = {
|
||||||
|
PRODUCT_NOT_FOUND: [404, 'Product not found', 'Scoped product lookup failed'],
|
||||||
|
PRODUCT_NOT_PUBLISHABLE: [
|
||||||
|
409,
|
||||||
|
'Add an active priced variant before publishing',
|
||||||
|
'Product publish eligibility failed',
|
||||||
|
],
|
||||||
|
PRODUCT_ARCHIVED: [
|
||||||
|
409,
|
||||||
|
'Archived products cannot be edited',
|
||||||
|
'Archived product mutation rejected',
|
||||||
|
],
|
||||||
|
VARIANT_NOT_FOUND: [
|
||||||
|
404,
|
||||||
|
'Product variant not found',
|
||||||
|
'Scoped product variant lookup failed',
|
||||||
|
],
|
||||||
|
VARIANT_LIMIT: [
|
||||||
|
409,
|
||||||
|
'Product variant limit reached',
|
||||||
|
'Variant resource quota exceeded',
|
||||||
|
],
|
||||||
|
VARIANT_REQUIRED: [
|
||||||
|
409,
|
||||||
|
'A published product needs an active variant',
|
||||||
|
'Last sellable variant deactivation rejected',
|
||||||
|
],
|
||||||
|
GROUP_NOT_FOUND: [
|
||||||
|
404,
|
||||||
|
'Catalogue group not found',
|
||||||
|
'Scoped catalogue group lookup failed',
|
||||||
|
],
|
||||||
|
ADDRESS_NOT_FOUND: [
|
||||||
|
404,
|
||||||
|
'Address not found',
|
||||||
|
'Private address lookup failed',
|
||||||
|
],
|
||||||
|
ADDRESS_LIMIT: [
|
||||||
|
409,
|
||||||
|
'Address limit reached',
|
||||||
|
'Account address quota exceeded',
|
||||||
|
],
|
||||||
|
WAREHOUSE_NOT_FOUND: [
|
||||||
|
404,
|
||||||
|
'Warehouse not found',
|
||||||
|
'Scoped warehouse lookup failed',
|
||||||
|
],
|
||||||
|
STOCK_NOT_FOUND: [404, 'Stock item not found', 'Scoped stock lookup failed'],
|
||||||
|
STOCK_INSUFFICIENT: [
|
||||||
|
409,
|
||||||
|
'Insufficient available stock',
|
||||||
|
'Inventory availability check rejected operation',
|
||||||
|
],
|
||||||
|
STOCK_CAPACITY: [
|
||||||
|
409,
|
||||||
|
'Stock balance limit would be exceeded',
|
||||||
|
'Inventory integer bound rejected operation',
|
||||||
|
],
|
||||||
|
IDEMPOTENCY_CONFLICT: [
|
||||||
|
409,
|
||||||
|
'Idempotency key was used for a different request',
|
||||||
|
'Idempotency payload mismatch',
|
||||||
|
],
|
||||||
|
RESERVATION_NOT_FOUND: [
|
||||||
|
404,
|
||||||
|
'Stock reservation not found',
|
||||||
|
'Scoped reservation lookup failed',
|
||||||
|
],
|
||||||
|
RESERVATION_EXPIRED: [
|
||||||
|
409,
|
||||||
|
'Stock reservation has expired',
|
||||||
|
'Expired reservation commit rejected',
|
||||||
|
],
|
||||||
|
RESERVATION_CLOSED: [
|
||||||
|
409,
|
||||||
|
'Stock reservation is already closed',
|
||||||
|
'Invalid reservation state transition',
|
||||||
|
],
|
||||||
|
PRODUCT_UNAVAILABLE: [
|
||||||
|
409,
|
||||||
|
'Product variant is not available for reservation',
|
||||||
|
'Non-sellable variant reservation rejected',
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { PLATFORM_ERRORS } from './platform-errors';
|
||||||
|
import { COMMERCE_ERRORS } from './commerce-errors';
|
||||||
|
export const ERRORS = { ...PLATFORM_ERRORS, ...COMMERCE_ERRORS } as const;
|
||||||
|
export type ErrorCode = keyof typeof ERRORS;
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
export const PLATFORM_ERRORS = {
|
||||||
|
REQUEST_INVALID: [400, 'Invalid request', 'Request schema validation failed'],
|
||||||
|
REQUEST_MALFORMED: [
|
||||||
|
400,
|
||||||
|
'Malformed request syntax',
|
||||||
|
'HTTP request parser rejected input',
|
||||||
|
],
|
||||||
|
REQUEST_TOO_LARGE: [
|
||||||
|
413,
|
||||||
|
'Request body exceeds the allowed size',
|
||||||
|
'HTTP payload limit exceeded',
|
||||||
|
],
|
||||||
|
ROUTE_NOT_FOUND: [404, 'API route not found', 'Unmatched HTTP route'],
|
||||||
|
AUTH_REQUIRED: [
|
||||||
|
401,
|
||||||
|
'Authentication is required',
|
||||||
|
'Bearer credential missing or malformed',
|
||||||
|
],
|
||||||
|
AUTH_INVALID_CREDENTIALS: [
|
||||||
|
401,
|
||||||
|
'Invalid credentials',
|
||||||
|
'Login credential verification failed',
|
||||||
|
],
|
||||||
|
SESSION_INVALID: [
|
||||||
|
401,
|
||||||
|
'Session is invalid or expired',
|
||||||
|
'Session authentication rejected',
|
||||||
|
],
|
||||||
|
ACCESS_DENIED: [
|
||||||
|
403,
|
||||||
|
'You do not have permission for this action',
|
||||||
|
'Permission check rejected operation',
|
||||||
|
],
|
||||||
|
SCOPE_DENIED: [
|
||||||
|
403,
|
||||||
|
'Account scope is not authorized',
|
||||||
|
'Transaction principal or organization mismatch',
|
||||||
|
],
|
||||||
|
RECORD_CONFLICT: [
|
||||||
|
409,
|
||||||
|
'A record with these details already exists',
|
||||||
|
'Database uniqueness conflict',
|
||||||
|
],
|
||||||
|
REFERENCE_INVALID: [
|
||||||
|
409,
|
||||||
|
'A related record is unavailable',
|
||||||
|
'Database foreign-key constraint rejected operation',
|
||||||
|
],
|
||||||
|
DATA_CONSTRAINT: [
|
||||||
|
409,
|
||||||
|
'Operation violates a data integrity rule',
|
||||||
|
'Database check constraint rejected operation',
|
||||||
|
],
|
||||||
|
RECORD_NOT_FOUND: [
|
||||||
|
404,
|
||||||
|
'Requested record is unavailable',
|
||||||
|
'Database record lookup failed',
|
||||||
|
],
|
||||||
|
DATABASE_BUSY: [
|
||||||
|
503,
|
||||||
|
'Database is temporarily busy; retry later',
|
||||||
|
'Database timeout or connection unavailable',
|
||||||
|
],
|
||||||
|
TRANSACTION_CONFLICT: [
|
||||||
|
409,
|
||||||
|
'Concurrent update detected; retry this operation',
|
||||||
|
'Database transaction conflict',
|
||||||
|
],
|
||||||
|
INTERNAL_FAILURE: [
|
||||||
|
500,
|
||||||
|
'An unexpected error occurred',
|
||||||
|
'Unhandled application failure',
|
||||||
|
],
|
||||||
|
USER_NOT_FOUND: [404, 'User not found', 'Scoped user lookup failed'],
|
||||||
|
ROLE_NOT_FOUND: [404, 'Role not found', 'Scoped role lookup failed'],
|
||||||
|
ROLE_GRANT_DENIED: [
|
||||||
|
403,
|
||||||
|
'Cannot grant permissions you do not hold',
|
||||||
|
'Role grant would exceed actor permissions',
|
||||||
|
],
|
||||||
|
ROLE_IMMUTABLE: [
|
||||||
|
403,
|
||||||
|
'System role is immutable',
|
||||||
|
'Attempt to modify protected system role',
|
||||||
|
],
|
||||||
|
ROLE_ASSIGNMENT_DENIED: [
|
||||||
|
403,
|
||||||
|
'Cannot change these role assignments',
|
||||||
|
'Protected or self role assignment rejected',
|
||||||
|
],
|
||||||
|
ROLE_SYSTEM_DENIED: [
|
||||||
|
403,
|
||||||
|
'System role cannot be assigned',
|
||||||
|
'Attempt to assign owner role',
|
||||||
|
],
|
||||||
|
USER_STATUS_DENIED: [
|
||||||
|
403,
|
||||||
|
'Cannot change this account status',
|
||||||
|
'Protected or self approval change rejected',
|
||||||
|
],
|
||||||
|
OWNER_EXISTS: [
|
||||||
|
409,
|
||||||
|
'Owner already exists',
|
||||||
|
'Installation bootstrap repeated',
|
||||||
|
],
|
||||||
|
RECOVERY_INVALID: [
|
||||||
|
400,
|
||||||
|
'Invalid or expired recovery token',
|
||||||
|
'Recovery token consumption rejected',
|
||||||
|
],
|
||||||
|
RECOVERY_UNAVAILABLE: [
|
||||||
|
503,
|
||||||
|
'Password recovery is not configured',
|
||||||
|
'SMTP recovery configuration missing',
|
||||||
|
],
|
||||||
|
RATE_LIMITED: [429, 'Too many requests', 'Durable request quota exceeded'],
|
||||||
|
DATABASE_NOT_READY: [
|
||||||
|
503,
|
||||||
|
'Service is not ready',
|
||||||
|
'Database readiness probe failed',
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
export const text = (max: number, min = 1) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(min)
|
||||||
|
.max(max)
|
||||||
|
.regex(/^[^<>\u0000-\u001F\u007F]*$/, 'Plain text only');
|
||||||
|
export const ids = z
|
||||||
|
.array(z.uuid())
|
||||||
|
.max(20)
|
||||||
|
.refine((values) => new Set(values).size === values.length);
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
import { BadRequestException, PipeTransform } from '@nestjs/common';
|
import type { PipeTransform } from '@nestjs/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { AppError } from './errors/app-error';
|
||||||
|
|
||||||
export class SchemaPipe<T> implements PipeTransform<unknown, T> {
|
export class SchemaPipe<T> implements PipeTransform<unknown, T> {
|
||||||
constructor(private readonly schema: z.ZodType<T>) {}
|
constructor(private readonly schema: z.ZodType<T>) {}
|
||||||
transform(value: unknown): T {
|
transform(value: unknown): T {
|
||||||
const result = this.schema.safeParse(value);
|
const result = this.schema.safeParse(value);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new BadRequestException({
|
throw new AppError('REQUEST_INVALID', 'SCHEMA_REJECTED', [
|
||||||
message: 'Invalid request',
|
...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
|
||||||
fields: [
|
]);
|
||||||
...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const schema = z
|
||||||
.enum(['development', 'test', 'production'])
|
.enum(['development', 'test', 'production'])
|
||||||
.default('development'),
|
.default('development'),
|
||||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||||
|
DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(50).default(10),
|
||||||
DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)),
|
DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)),
|
||||||
CORS_ORIGINS: z
|
CORS_ORIGINS: z
|
||||||
.string()
|
.string()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import type { INestApplication } from '@nestjs/common';
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import type { Environment } from './config/environment';
|
import type { Environment } from './config/environment';
|
||||||
|
import { ApiExceptionFilter } from './common/errors/api-exception.filter';
|
||||||
|
|
||||||
export function configureApp(
|
export function configureApp(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
|
|
@ -8,6 +12,19 @@ export function configureApp(
|
||||||
): void {
|
): void {
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
|
app.use((_request: Request, response: Response, next: NextFunction) => {
|
||||||
|
response.locals.requestId = randomUUID();
|
||||||
|
response.setHeader('X-Request-Id', response.locals.requestId);
|
||||||
|
response.setHeader('Cache-Control', 'no-store');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
(app as NestExpressApplication).useBodyParser('json', { limit: '32kb' });
|
||||||
|
(app as NestExpressApplication).useBodyParser('urlencoded', {
|
||||||
|
limit: '32kb',
|
||||||
|
extended: false,
|
||||||
|
parameterLimit: 100,
|
||||||
|
});
|
||||||
|
app.useGlobalFilters(new ApiExceptionFilter());
|
||||||
app.enableCors({ origin: environment.CORS_ORIGINS, credentials: true });
|
app.enableCors({ origin: environment.CORS_ORIGINS, credentials: true });
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export class DatabaseService
|
||||||
connectionString: environment.DATABASE_URL,
|
connectionString: environment.DATABASE_URL,
|
||||||
connectionTimeoutMillis: 3000,
|
connectionTimeoutMillis: 3000,
|
||||||
query_timeout: 3000,
|
query_timeout: 3000,
|
||||||
max: 10,
|
max: environment.DATABASE_POOL_SIZE,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -13,7 +14,7 @@ export class HealthService {
|
||||||
try {
|
try {
|
||||||
await this.database.ping();
|
await this.database.ping();
|
||||||
} catch {
|
} catch {
|
||||||
throw new ServiceUnavailableException('Service is not ready');
|
throw new AppError('DATABASE_NOT_READY');
|
||||||
}
|
}
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import {
|
import { RateLimitService } from './rate-limit.service';
|
||||||
CanActivate,
|
import { AppError } from '../common/errors/app-error';
|
||||||
ExecutionContext,
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { SessionStore } from './session.store';
|
import { SessionStore } from './session.store';
|
||||||
import { PUBLIC_ROUTE, REQUIRED_PERMISSION } from './access.decorator';
|
import { PUBLIC_ROUTE, REQUIRED_PERMISSION } from './access.decorator';
|
||||||
|
|
@ -15,6 +11,7 @@ export class AccessGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly reflector: Reflector,
|
private readonly reflector: Reflector,
|
||||||
private readonly sessions: SessionStore,
|
private readonly sessions: SessionStore,
|
||||||
|
private readonly limits: RateLimitService,
|
||||||
) {}
|
) {}
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const targets = [context.getHandler(), context.getClass()];
|
const targets = [context.getHandler(), context.getClass()];
|
||||||
|
|
@ -24,14 +21,20 @@ export class AccessGuard implements CanActivate {
|
||||||
const match = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(
|
const match = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(
|
||||||
request.headers.authorization ?? '',
|
request.headers.authorization ?? '',
|
||||||
);
|
);
|
||||||
if (!match) throw new UnauthorizedException();
|
if (!match) throw new AppError('AUTH_REQUIRED');
|
||||||
|
await this.limits.consume(`protected-ip:${request.ip}`, 300, 60);
|
||||||
request.principal = await this.sessions.authenticate(match[1]);
|
request.principal = await this.sessions.authenticate(match[1]);
|
||||||
|
await this.limits.consume(
|
||||||
|
`protected-user:${request.principal.userId}`,
|
||||||
|
180,
|
||||||
|
60,
|
||||||
|
);
|
||||||
const permission = this.reflector.getAllAndOverride<string>(
|
const permission = this.reflector.getAllAndOverride<string>(
|
||||||
REQUIRED_PERMISSION,
|
REQUIRED_PERMISSION,
|
||||||
targets,
|
targets,
|
||||||
);
|
);
|
||||||
if (permission && !request.principal.permissions.includes(permission))
|
if (permission && !request.principal.permissions.includes(permission))
|
||||||
throw new ForbiddenException();
|
throw new AppError('ACCESS_DENIED');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import {
|
import { Injectable } from '@nestjs/common';
|
||||||
ConflictException,
|
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { Prisma } from '../generated/prisma/client';
|
import { Prisma } from '../generated/prisma/client';
|
||||||
import { readPrincipal } from './session.store';
|
import { readPrincipal } from './session.store';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
import type { Principal } from './identity.types';
|
import type { Principal } from './identity.types';
|
||||||
import type { Permission } from './permissions';
|
import type { Permission } from './permissions';
|
||||||
|
|
||||||
|
|
@ -14,20 +11,24 @@ export class AccessStore {
|
||||||
constructor(private readonly db: DatabaseService) {}
|
constructor(private readonly db: DatabaseService) {}
|
||||||
async mutate<T>(
|
async mutate<T>(
|
||||||
actor: Principal,
|
actor: Principal,
|
||||||
permission: Permission,
|
permission: Permission | null,
|
||||||
work: (tx: Prisma.TransactionClient, current: Principal) => Promise<T>,
|
work: (tx: Prisma.TransactionClient, current: Principal) => Promise<T>,
|
||||||
|
lockOrganization = true,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
try {
|
try {
|
||||||
return await this.db.$transaction(async (tx) => {
|
return await this.db.$transaction(async (tx) => {
|
||||||
// Serialize administration within an organization and recheck permissions after locking.
|
if (lockOrganization) {
|
||||||
await tx.$queryRaw`SELECT id FROM organizations WHERE id = ${actor.organizationId}::uuid FOR UPDATE`;
|
await tx.$queryRaw`SELECT id FROM organizations WHERE id = ${actor.organizationId}::uuid FOR UPDATE`;
|
||||||
|
}
|
||||||
const current = await readPrincipal(tx, actor.sessionId);
|
const current = await readPrincipal(tx, actor.sessionId);
|
||||||
if (
|
if (
|
||||||
current.organizationId !== actor.organizationId ||
|
current.organizationId !== actor.organizationId ||
|
||||||
!current.permissions.includes(permission)
|
current.userId !== actor.userId
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException();
|
throw new AppError('SCOPE_DENIED');
|
||||||
}
|
}
|
||||||
|
if (permission && !current.permissions.includes(permission))
|
||||||
|
throw new AppError('ACCESS_DENIED');
|
||||||
return work(tx, current);
|
return work(tx, current);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -35,9 +36,7 @@ export class AccessStore {
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
error.code === 'P2002'
|
error.code === 'P2002'
|
||||||
) {
|
) {
|
||||||
throw new ConflictException(
|
throw new AppError('RECORD_CONFLICT');
|
||||||
'A record with these details already exists',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { ENVIRONMENT } from '../config/environment.module';
|
import { ENVIRONMENT } from '../config/environment.module';
|
||||||
import type { Environment } from '../config/environment';
|
import type { Environment } from '../config/environment';
|
||||||
|
import { AppError } from '../common/errors/app-error';
|
||||||
import { AuthStore } from './auth.store';
|
import { AuthStore } from './auth.store';
|
||||||
import { PasswordService } from './password.service';
|
import { PasswordService } from './password.service';
|
||||||
import { RateLimitService } from './rate-limit.service';
|
import { RateLimitService } from './rate-limit.service';
|
||||||
|
|
@ -22,12 +23,18 @@ export class AuthService {
|
||||||
900,
|
900,
|
||||||
);
|
);
|
||||||
const user = await this.store.findUser(input.organizationId, input.email);
|
const user = await this.store.findUser(input.organizationId, input.email);
|
||||||
const valid = user
|
let valid = false;
|
||||||
? await this.passwords.verify(input.password, user.passwordHash)
|
if (user)
|
||||||
: (await this.passwords.dummyVerify(input.password), false);
|
valid = await this.passwords.verify(input.password, user.passwordHash);
|
||||||
|
else await this.passwords.dummyVerify(input.password);
|
||||||
if (!valid || !user || user.status !== 'ACTIVE') {
|
if (!valid || !user || user.status !== 'ACTIVE') {
|
||||||
if (user) await this.store.failedLogin(user.organizationId, user.id);
|
if (user) await this.store.failedLogin(user.organizationId, user.id);
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
const reason = !user
|
||||||
|
? 'ACCOUNT_UNKNOWN'
|
||||||
|
: !valid
|
||||||
|
? 'PASSWORD_MISMATCH'
|
||||||
|
: 'ACCOUNT_INACTIVE';
|
||||||
|
throw new AppError('AUTH_INVALID_CREDENTIALS', reason);
|
||||||
}
|
}
|
||||||
const { token, tokenHash } = issueToken();
|
const { token, tokenHash } = issueToken();
|
||||||
const expiresAt = new Date(
|
const expiresAt = new Date(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { recordAudit } from './audit';
|
import { recordAudit } from './audit';
|
||||||
import type { Principal } from './identity.types';
|
import type { Principal } from './identity.types';
|
||||||
|
|
@ -21,7 +22,7 @@ export class AuthStore {
|
||||||
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId}::uuid FOR UPDATE`;
|
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId}::uuid FOR UPDATE`;
|
||||||
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||||
if (user.status !== 'ACTIVE' || user.passwordHash !== expectedHash)
|
if (user.status !== 'ACTIVE' || user.passwordHash !== expectedHash)
|
||||||
throw new UnauthorizedException();
|
throw new AppError('SESSION_INVALID', 'LOGIN_SNAPSHOT_CHANGED');
|
||||||
await tx.session.create({ data: { userId, tokenHash, expiresAt } });
|
await tx.session.create({ data: { userId, tokenHash, expiresAt } });
|
||||||
await recordAudit(
|
await recordAudit(
|
||||||
tx,
|
tx,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { ConflictException, Injectable } from '@nestjs/common';
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { PasswordService } from './password.service';
|
import { PasswordService } from './password.service';
|
||||||
import { PERMISSIONS } from './permissions';
|
import { PERMISSIONS } from './permissions';
|
||||||
|
|
@ -17,7 +18,7 @@ export class BootstrapService {
|
||||||
// One-time installation bootstrap, serialized across processes.
|
// One-time installation bootstrap, serialized across processes.
|
||||||
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74192001)::text`;
|
await tx.$queryRaw`SELECT pg_advisory_xact_lock(74192001)::text`;
|
||||||
if (await tx.user.count({ where: { isOwner: true } }))
|
if (await tx.user.count({ where: { isOwner: true } }))
|
||||||
throw new ConflictException('Owner already exists');
|
throw new AppError('OWNER_EXISTS');
|
||||||
const organization = await tx.organization.create({
|
const organization = await tx.organization.create({
|
||||||
data: { name: organizationName },
|
data: { name: organizationName },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,6 @@ import { UsersController } from './users.controller';
|
||||||
UserStore,
|
UserStore,
|
||||||
{ provide: APP_GUARD, useClass: AccessGuard },
|
{ provide: APP_GUARD, useClass: AccessGuard },
|
||||||
],
|
],
|
||||||
exports: [BootstrapService],
|
exports: [BootstrapService, AccessStore, RateLimitService],
|
||||||
})
|
})
|
||||||
export class IdentityModule {}
|
export class IdentityModule {}
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,13 @@ export const PERMISSIONS = [
|
||||||
'roles.read',
|
'roles.read',
|
||||||
'roles.manage',
|
'roles.manage',
|
||||||
'audit.read',
|
'audit.read',
|
||||||
|
'catalog.read',
|
||||||
|
'catalog.manage',
|
||||||
|
'catalog.publish',
|
||||||
|
'inventory.read',
|
||||||
|
'inventory.manage',
|
||||||
|
'inventory.adjust',
|
||||||
|
'inventory.reserve',
|
||||||
|
'inventory.commit',
|
||||||
] as const;
|
] as const;
|
||||||
export type Permission = (typeof PERMISSIONS)[number];
|
export type Permission = (typeof PERMISSIONS)[number];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { hashToken } from './tokens';
|
import { hashToken } from './tokens';
|
||||||
|
|
||||||
|
|
@ -7,19 +8,23 @@ export class RateLimitService {
|
||||||
constructor(private readonly db: DatabaseService) {}
|
constructor(private readonly db: DatabaseService) {}
|
||||||
async consume(key: string, limit: number, seconds: number): Promise<void> {
|
async consume(key: string, limit: number, seconds: number): Promise<void> {
|
||||||
const digest = hashToken(key);
|
const digest = hashToken(key);
|
||||||
const [row] = await this.db.$queryRaw<Array<{ hits: number }>>`
|
const [row] = await this.db.$queryRaw<
|
||||||
|
Array<{ hits: number; expiresAt: Date }>
|
||||||
|
>`
|
||||||
INSERT INTO rate_limits (key, hits, expires_at)
|
INSERT INTO rate_limits (key, hits, expires_at)
|
||||||
VALUES (${digest}, 1, NOW() + ${seconds} * INTERVAL '1 second')
|
VALUES (${digest}, 1, NOW() + ${seconds} * INTERVAL '1 second')
|
||||||
ON CONFLICT (key) DO UPDATE SET
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
hits = CASE WHEN rate_limits.expires_at <= NOW() THEN 1 ELSE rate_limits.hits + 1 END,
|
hits = CASE WHEN rate_limits.expires_at <= NOW() THEN 1 ELSE rate_limits.hits + 1 END,
|
||||||
expires_at = CASE WHEN rate_limits.expires_at <= NOW()
|
expires_at = CASE WHEN rate_limits.expires_at <= NOW()
|
||||||
THEN NOW() + ${seconds} * INTERVAL '1 second' ELSE rate_limits.expires_at END
|
THEN NOW() + ${seconds} * INTERVAL '1 second' ELSE rate_limits.expires_at END
|
||||||
RETURNING hits
|
RETURNING hits, expires_at AS "expiresAt"
|
||||||
`;
|
`;
|
||||||
if (row.hits > limit)
|
if (row.hits > limit)
|
||||||
throw new HttpException(
|
throw new AppError(
|
||||||
'Too many requests',
|
'RATE_LIMITED',
|
||||||
HttpStatus.TOO_MANY_REQUESTS,
|
key.split(':')[0],
|
||||||
|
undefined,
|
||||||
|
Math.max(1, Math.ceil((row.expiresAt.getTime() - Date.now()) / 1000)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import {
|
import { AppError } from '../common/errors/app-error';
|
||||||
Inject,
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { createTransport } from 'nodemailer';
|
import { createTransport } from 'nodemailer';
|
||||||
import { ENVIRONMENT } from '../config/environment.module';
|
import { ENVIRONMENT } from '../config/environment.module';
|
||||||
import type { Environment } from '../config/environment';
|
import type { Environment } from '../config/environment';
|
||||||
|
|
@ -11,10 +8,7 @@ import type { Environment } from '../config/environment';
|
||||||
export class RecoveryMailer {
|
export class RecoveryMailer {
|
||||||
constructor(@Inject(ENVIRONMENT) private readonly env: Environment) {}
|
constructor(@Inject(ENVIRONMENT) private readonly env: Environment) {}
|
||||||
assertConfigured(): void {
|
assertConfigured(): void {
|
||||||
if (!this.env.SMTP_HOST)
|
if (!this.env.SMTP_HOST) throw new AppError('RECOVERY_UNAVAILABLE');
|
||||||
throw new ServiceUnavailableException(
|
|
||||||
'Password recovery is not configured',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
async send(email: string, token: string): Promise<void> {
|
async send(email: string, token: string): Promise<void> {
|
||||||
this.assertConfigured();
|
this.assertConfigured();
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,12 @@ export class RecoveryService {
|
||||||
} catch {
|
} catch {
|
||||||
await this.store.discard(tokenHash);
|
await this.store.discard(tokenHash);
|
||||||
// SMTP errors may contain credentials and addresses; do not log the raw error.
|
// SMTP errors may contain credentials and addresses; do not log the raw error.
|
||||||
this.logger.error('Password recovery delivery failed');
|
this.logger.error(
|
||||||
|
JSON.stringify({
|
||||||
|
event: 'RECOVERY_DELIVERY_FAILED',
|
||||||
|
message: 'Password recovery delivery failed',
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async reset(token: string, password: string): Promise<void> {
|
async reset(token: string, password: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { recordAudit } from './audit';
|
import { recordAudit } from './audit';
|
||||||
|
|
||||||
|
|
@ -32,8 +33,7 @@ export class RecoveryStore {
|
||||||
async reset(tokenHash: string, passwordHash: string) {
|
async reset(tokenHash: string, passwordHash: string) {
|
||||||
await this.db.$transaction(async (tx) => {
|
await this.db.$transaction(async (tx) => {
|
||||||
const token = await tx.recoveryToken.findUnique({ where: { tokenHash } });
|
const token = await tx.recoveryToken.findUnique({ where: { tokenHash } });
|
||||||
if (!token)
|
if (!token) throw new AppError('RECOVERY_INVALID');
|
||||||
throw new BadRequestException('Invalid or expired recovery token');
|
|
||||||
await tx.$queryRaw`SELECT id FROM users WHERE id = ${token.userId}::uuid FOR UPDATE`;
|
await tx.$queryRaw`SELECT id FROM users WHERE id = ${token.userId}::uuid FOR UPDATE`;
|
||||||
const user = await tx.user.findUniqueOrThrow({
|
const user = await tx.user.findUniqueOrThrow({
|
||||||
where: { id: token.userId },
|
where: { id: token.userId },
|
||||||
|
|
@ -42,7 +42,7 @@ export class RecoveryStore {
|
||||||
where: { id: token.id, expiresAt: { gt: new Date() } },
|
where: { id: token.id, expiresAt: { gt: new Date() } },
|
||||||
});
|
});
|
||||||
if (consumed.count !== 1 || user.status !== 'ACTIVE') {
|
if (consumed.count !== 1 || user.status !== 'ACTIVE') {
|
||||||
throw new BadRequestException('Invalid or expired recovery token');
|
throw new AppError('RECOVERY_INVALID');
|
||||||
}
|
}
|
||||||
await tx.user.update({ where: { id: user.id }, data: { passwordHash } });
|
await tx.user.update({ where: { id: user.id }, data: { passwordHash } });
|
||||||
await tx.recoveryToken.deleteMany({ where: { userId: user.id } });
|
await tx.recoveryToken.deleteMany({ where: { userId: user.id } });
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import {
|
import { AppError } from '../common/errors/app-error';
|
||||||
ForbiddenException,
|
import { Injectable } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { AccessStore } from './access.store';
|
import { AccessStore } from './access.store';
|
||||||
import { recordAudit } from './audit';
|
import { recordAudit } from './audit';
|
||||||
|
|
@ -13,7 +10,7 @@ function ensureGrantable(actor: Principal, permissions: string[]) {
|
||||||
if (
|
if (
|
||||||
permissions.some((permission) => !actor.permissions.includes(permission))
|
permissions.some((permission) => !actor.permissions.includes(permission))
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Cannot grant permissions you do not hold');
|
throw new AppError('ROLE_GRANT_DENIED');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -37,9 +34,8 @@ export class RoleStore {
|
||||||
const role = await tx.role.findFirst({
|
const role = await tx.role.findFirst({
|
||||||
where: { id, organizationId: actor.organizationId },
|
where: { id, organizationId: actor.organizationId },
|
||||||
});
|
});
|
||||||
if (!role) throw new NotFoundException();
|
if (!role) throw new AppError('ROLE_NOT_FOUND');
|
||||||
if (role.isSystem)
|
if (role.isSystem) throw new AppError('ROLE_IMMUTABLE');
|
||||||
throw new ForbiddenException('System role is immutable');
|
|
||||||
ensureGrantable(current, role.permissions);
|
ensureGrantable(current, role.permissions);
|
||||||
}
|
}
|
||||||
const role = id
|
const role = id
|
||||||
|
|
@ -66,9 +62,9 @@ export class RoleStore {
|
||||||
where: { id: userId, organizationId: actor.organizationId },
|
where: { id: userId, organizationId: actor.organizationId },
|
||||||
include: { roles: { include: { role: true } } },
|
include: { roles: { include: { role: true } } },
|
||||||
});
|
});
|
||||||
if (!user) throw new NotFoundException();
|
if (!user) throw new AppError('USER_NOT_FOUND');
|
||||||
if (user.isOwner || user.id === current.userId)
|
if (user.isOwner || user.id === current.userId)
|
||||||
throw new ForbiddenException('Cannot change these role assignments');
|
throw new AppError('ROLE_ASSIGNMENT_DENIED');
|
||||||
ensureGrantable(
|
ensureGrantable(
|
||||||
current,
|
current,
|
||||||
user.roles.flatMap((assignment) => assignment.role.permissions),
|
user.roles.flatMap((assignment) => assignment.role.permissions),
|
||||||
|
|
@ -76,9 +72,10 @@ export class RoleStore {
|
||||||
const roles = await tx.role.findMany({
|
const roles = await tx.role.findMany({
|
||||||
where: { id: { in: roleIds }, organizationId: actor.organizationId },
|
where: { id: { in: roleIds }, organizationId: actor.organizationId },
|
||||||
});
|
});
|
||||||
if (roles.length !== roleIds.length) throw new NotFoundException();
|
if (roles.length !== roleIds.length)
|
||||||
|
throw new AppError('ROLE_NOT_FOUND');
|
||||||
if (roles.some((role) => role.isSystem))
|
if (roles.some((role) => role.isSystem))
|
||||||
throw new ForbiddenException('System role cannot be assigned');
|
throw new AppError('ROLE_SYSTEM_DENIED');
|
||||||
ensureGrantable(
|
ensureGrantable(
|
||||||
current,
|
current,
|
||||||
roles.flatMap((role) => role.permissions),
|
roles.flatMap((role) => role.permissions),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { AppError } from '../common/errors/app-error';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import type { Prisma } from '../generated/prisma/client';
|
import type { Prisma } from '../generated/prisma/client';
|
||||||
import { hashToken } from './tokens';
|
import { hashToken } from './tokens';
|
||||||
|
|
@ -18,7 +19,7 @@ export async function readPrincipal(
|
||||||
session.expiresAt <= new Date() ||
|
session.expiresAt <= new Date() ||
|
||||||
session.user.status !== 'ACTIVE'
|
session.user.status !== 'ACTIVE'
|
||||||
) {
|
) {
|
||||||
throw new UnauthorizedException();
|
throw new AppError('SESSION_INVALID');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
|
|
@ -36,7 +37,7 @@ export class SessionStore {
|
||||||
const session = await this.db.session.findUnique({
|
const session = await this.db.session.findUnique({
|
||||||
where: { tokenHash: hashToken(token) },
|
where: { tokenHash: hashToken(token) },
|
||||||
});
|
});
|
||||||
if (!session) throw new UnauthorizedException();
|
if (!session) throw new AppError('SESSION_INVALID');
|
||||||
return readPrincipal(this.db, session.id);
|
return readPrincipal(this.db, session.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import {
|
import { AppError } from '../common/errors/app-error';
|
||||||
ForbiddenException,
|
import { Injectable } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import { AccessStore } from './access.store';
|
import { AccessStore } from './access.store';
|
||||||
import { PasswordService } from './password.service';
|
import { PasswordService } from './password.service';
|
||||||
|
|
@ -66,9 +63,9 @@ export class UserStore {
|
||||||
const user = await tx.user.findFirst({
|
const user = await tx.user.findFirst({
|
||||||
where: { id, organizationId: actor.organizationId },
|
where: { id, organizationId: actor.organizationId },
|
||||||
});
|
});
|
||||||
if (!user) throw new NotFoundException();
|
if (!user) throw new AppError('USER_NOT_FOUND');
|
||||||
if (user.isOwner || user.id === actor.userId)
|
if (user.isOwner || user.id === actor.userId)
|
||||||
throw new ForbiddenException('Cannot change this account status');
|
throw new AppError('USER_STATUS_DENIED');
|
||||||
const updated = await tx.user.update({
|
const updated = await tx.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status },
|
data: { status },
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ describe('administration use-case policy', () => {
|
||||||
it('hides users outside the organization scope', async () => {
|
it('hides users outside the organization scope', async () => {
|
||||||
tx.user.findFirst.mockResolvedValue(null);
|
tx.user.findFirst.mockResolvedValue(null);
|
||||||
await expect(users.setStatus(actor, 'foreign', 'ACTIVE')).rejects.toThrow(
|
await expect(users.setStatus(actor, 'foreign', 'ACTIVE')).rejects.toThrow(
|
||||||
'Not Found',
|
/not found/i,
|
||||||
);
|
);
|
||||||
expect(tx.user.update).not.toHaveBeenCalled();
|
expect(tx.user.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -105,7 +105,7 @@ describe('administration use-case policy', () => {
|
||||||
tx.role.findFirst.mockResolvedValue(null);
|
tx.role.findFirst.mockResolvedValue(null);
|
||||||
await expect(
|
await expect(
|
||||||
roles.save(actor, { name: 'Changed', permissions: [] }, 'foreign'),
|
roles.save(actor, { name: 'Changed', permissions: [] }, 'foreign'),
|
||||||
).rejects.toThrow('Not Found');
|
).rejects.toThrow(/not found/i);
|
||||||
});
|
});
|
||||||
it('does not remove a target user permissions the actor cannot grant', async () => {
|
it('does not remove a target user permissions the actor cannot grant', async () => {
|
||||||
tx.user.findFirst.mockResolvedValue({
|
tx.user.findFirst.mockResolvedValue({
|
||||||
|
|
@ -121,7 +121,7 @@ describe('administration use-case policy', () => {
|
||||||
it('hides missing assignment targets', async () => {
|
it('hides missing assignment targets', async () => {
|
||||||
tx.user.findFirst.mockResolvedValue(null);
|
tx.user.findFirst.mockResolvedValue(null);
|
||||||
await expect(roles.assign(actor, 'foreign', [])).rejects.toThrow(
|
await expect(roles.assign(actor, 'foreign', [])).rejects.toThrow(
|
||||||
'Not Found',
|
/not found/i,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { ArgumentsHost, HttpException, Logger } from '@nestjs/common';
|
||||||
|
import { ApiExceptionFilter } from '../src/common/errors/api-exception.filter';
|
||||||
|
import { AppError } from '../src/common/errors/app-error';
|
||||||
|
import { ERRORS } from '../src/common/errors/error-catalog';
|
||||||
|
import { classifyError } from '../src/common/errors/classify-error';
|
||||||
|
import { Prisma } from '../src/generated/prisma/client';
|
||||||
|
|
||||||
|
describe('safe error responses and logs', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||||
|
});
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
function dispatch(error: unknown, headersSent = false) {
|
||||||
|
const response = {
|
||||||
|
locals: { requestId: 'server-generated-id' },
|
||||||
|
headersSent,
|
||||||
|
setHeader: jest.fn(),
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
json: jest.fn(),
|
||||||
|
};
|
||||||
|
const host = {
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({
|
||||||
|
method: 'POST',
|
||||||
|
route: { path: '/products/:id' },
|
||||||
|
body: { password: 'SECRET' },
|
||||||
|
headers: { authorization: 'Bearer SECRET' },
|
||||||
|
originalUrl: '/products?token=SECRET',
|
||||||
|
}),
|
||||||
|
getResponse: () => response,
|
||||||
|
}),
|
||||||
|
} as unknown as ArgumentsHost;
|
||||||
|
new ApiExceptionFilter().catch(error, host);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
it('returns stable codes and correlation IDs without leaking raw faults', () => {
|
||||||
|
const response = dispatch(new Error('SQL password=SECRET token=SECRET'));
|
||||||
|
expect(response.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'INTERNAL_FAILURE',
|
||||||
|
requestId: 'server-generated-id',
|
||||||
|
statusCode: 500,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const output = JSON.stringify([
|
||||||
|
response.json.mock.calls,
|
||||||
|
jest.mocked(Logger.prototype.error).mock.calls,
|
||||||
|
]);
|
||||||
|
expect(output).not.toContain('SECRET');
|
||||||
|
expect(output).not.toContain('password=');
|
||||||
|
expect(output).toContain('faultId');
|
||||||
|
});
|
||||||
|
it('keeps validation field names and unique business error messages', () => {
|
||||||
|
const response = dispatch(
|
||||||
|
new AppError('REQUEST_INVALID', 'SCHEMA_REJECTED', ['price']),
|
||||||
|
);
|
||||||
|
expect(response.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ fields: ['price'], code: 'REQUEST_INVALID' }),
|
||||||
|
);
|
||||||
|
const messages = Object.values(ERRORS).map((value) => value[1]);
|
||||||
|
expect(new Set(messages).size).toBe(messages.length);
|
||||||
|
});
|
||||||
|
it('does not try to write a second response', () => {
|
||||||
|
expect(
|
||||||
|
dispatch(new AppError('STOCK_INSUFFICIENT'), true).json,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('adds retry guidance for throttling', () => {
|
||||||
|
expect(
|
||||||
|
dispatch(new AppError('RATE_LIMITED')).setHeader,
|
||||||
|
).toHaveBeenCalledWith('Retry-After', '60');
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
['P2002', 'RECORD_CONFLICT'],
|
||||||
|
['P2003', 'REFERENCE_INVALID'],
|
||||||
|
['P2004', 'DATA_CONSTRAINT'],
|
||||||
|
['P2025', 'RECORD_NOT_FOUND'],
|
||||||
|
['P2028', 'DATABASE_BUSY'],
|
||||||
|
['P2034', 'TRANSACTION_CONFLICT'],
|
||||||
|
['P9999', 'INTERNAL_FAILURE'],
|
||||||
|
])('classifies database error %s as %s', (databaseCode, code) => {
|
||||||
|
const error = new Prisma.PrismaClientKnownRequestError('SECRET', {
|
||||||
|
code: databaseCode,
|
||||||
|
clientVersion: 'test',
|
||||||
|
});
|
||||||
|
expect(classifyError(error).code).toBe(code);
|
||||||
|
});
|
||||||
|
it('classifies parser, transport and connection failures', () => {
|
||||||
|
expect(classifyError({ type: 'entity.too.large' }).code).toBe(
|
||||||
|
'REQUEST_TOO_LARGE',
|
||||||
|
);
|
||||||
|
expect(classifyError({ type: 'entity.parse.failed' }).code).toBe(
|
||||||
|
'REQUEST_MALFORMED',
|
||||||
|
);
|
||||||
|
expect(classifyError(new HttpException('SECRET', 404)).code).toBe(
|
||||||
|
'ROUTE_NOT_FOUND',
|
||||||
|
);
|
||||||
|
expect(classifyError(new HttpException('SECRET', 502)).code).toBe(
|
||||||
|
'INTERNAL_FAILURE',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
classifyError(
|
||||||
|
new Prisma.PrismaClientInitializationError('SECRET', 'test'),
|
||||||
|
).code,
|
||||||
|
).toBe('DATABASE_BUSY');
|
||||||
|
expect(classifyError(null).code).toBe('INTERNAL_FAILURE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,6 +16,7 @@ export async function identityApp() {
|
||||||
const env = parseEnvironment({
|
const env = parseEnvironment({
|
||||||
DATABASE_URL: database.connectionUrl,
|
DATABASE_URL: database.connectionUrl,
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
|
DATABASE_POOL_SIZE: process.env.TEST_DATABASE_URL ? '10' : '1',
|
||||||
});
|
});
|
||||||
const mailer = {
|
const mailer = {
|
||||||
assertConfigured: jest.fn(),
|
assertConfigured: jest.fn(),
|
||||||
|
|
@ -28,6 +29,7 @@ export async function identityApp() {
|
||||||
.useValue(mailer)
|
.useValue(mailer)
|
||||||
.compile();
|
.compile();
|
||||||
const app = module.createNestApplication();
|
const app = module.createNestApplication();
|
||||||
|
app.useLogger(false);
|
||||||
configureApp(app, env);
|
configureApp(app, env);
|
||||||
await app.init();
|
await app.init();
|
||||||
const db = app.get(DatabaseService);
|
const db = app.get(DatabaseService);
|
||||||
|
|
@ -51,6 +53,7 @@ export async function identityApp() {
|
||||||
app,
|
app,
|
||||||
db,
|
db,
|
||||||
|
|
||||||
|
executeSql: database.execute,
|
||||||
owner,
|
owner,
|
||||||
token,
|
token,
|
||||||
api,
|
api,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,8 @@ describe('authentication with migrated PostgreSQL engine', () => {
|
||||||
const missing = await ctx.login('missing@example.com');
|
const missing = await ctx.login('missing@example.com');
|
||||||
const wrong = await ctx.login('owner@example.com', 'wrong password');
|
const wrong = await ctx.login('owner@example.com', 'wrong password');
|
||||||
expect(missing.status).toBe(401);
|
expect(missing.status).toBe(401);
|
||||||
expect(wrong.body).toEqual(missing.body);
|
expect(wrong.body.code).toBe(missing.body.code);
|
||||||
|
expect(wrong.body.message).toBe(missing.body.message);
|
||||||
});
|
});
|
||||||
it('validates payloads without echoing secrets and rejects mass assignment', async () => {
|
it('validates payloads without echoing secrets and rejects mass assignment', async () => {
|
||||||
const response = await ctx
|
const response = await ctx
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { identityApp, type IdentityApp } from './helpers/identity-app';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
describe('HTTP security boundaries', () => {
|
||||||
|
let ctx: IdentityApp;
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await identityApp();
|
||||||
|
}, 60000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await ctx?.close();
|
||||||
|
});
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
it('bounds bodies and rejects malformed JSON with distinct codes', async () => {
|
||||||
|
const large = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/products')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({ name: 'A', slug: 'a', description: 'x'.repeat(40000) })
|
||||||
|
.expect(413);
|
||||||
|
expect(large.body.code).toBe('REQUEST_TOO_LARGE');
|
||||||
|
const invalid = await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/products')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send('{"invalid":')
|
||||||
|
.expect(400);
|
||||||
|
expect(invalid.body.code).toBe('REQUEST_MALFORMED');
|
||||||
|
});
|
||||||
|
it('generates its own request IDs and ignores cookie credentials', async () => {
|
||||||
|
const response = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/addresses')
|
||||||
|
.set('X-Request-Id', 'attacker-controlled')
|
||||||
|
.set('Cookie', 'accessToken=' + ctx.token)
|
||||||
|
.expect(401);
|
||||||
|
expect(response.body.requestId).toMatch(/^[a-f0-9-]{36}$/);
|
||||||
|
expect(response.headers['x-request-id']).toBe(response.body.requestId);
|
||||||
|
expect(response.headers['cache-control']).toBe('no-store');
|
||||||
|
});
|
||||||
|
it('treats SQL-looking input as data and prevents field injection', async () => {
|
||||||
|
const name = "Robert'); DROP TABLE users;--";
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.post('/api/v1/products')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.send({ name, slug: randomUUID() })
|
||||||
|
.expect(201);
|
||||||
|
expect(await ctx.db.user.count()).toBeGreaterThan(0);
|
||||||
|
await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/products?limit=999999')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
it('logs an unexpected database failure without exposing sensitive text', async () => {
|
||||||
|
const logger = jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||||
|
jest
|
||||||
|
.spyOn(ctx.db.product, 'findMany')
|
||||||
|
.mockRejectedValueOnce(new Error('SELECT secret password=SECRET'));
|
||||||
|
const response = await ctx
|
||||||
|
.api()
|
||||||
|
.get('/api/v1/products')
|
||||||
|
.auth(ctx.token, { type: 'bearer' })
|
||||||
|
.expect(500);
|
||||||
|
expect(response.body.code).toBe('INTERNAL_FAILURE');
|
||||||
|
expect(JSON.stringify([response.body, logger.mock.calls])).not.toContain(
|
||||||
|
'SECRET',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue