Skip to content

Commit 7337a29

Browse files
authored
Merge pull request #1162 from Osifowora/Storage-Upload-Quotas
feat(backend): add per-user storage upload quotas + per-user upload rate limiting
2 parents e322784 + d4e49d4 commit 7337a29

25 files changed

Lines changed: 1985 additions & 43 deletions

backend/src/app.module.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { IdempotencyInterceptor } from './common/interceptors/idempotency.interc
1212
import { MetricsInterceptor } from './common/interceptors/metrics.interceptor';
1313
import { AdminConfirmationInterceptor } from './common/interceptors/admin-confirmation.interceptor';
1414
import { AdminConfirmationFilter } from './common/filters/admin-confirmation.filter';
15+
import { StorageQuotaExceptionFilter } from './common/filters/storage-quota-exception.filter';
1516
import { TieredThrottlerGuard } from './common/guards/tiered-throttler.guard';
1617
import { AdminConfirmationGuard } from './common/guards/admin-confirmation.guard';
1718
import { CommonModule } from './common/common.module';
@@ -64,6 +65,7 @@ import { SandboxModule } from './modules/sandbox/sandbox.module';
6465
import { FeedbackModule } from './modules/feedback/feedback.module';
6566
import { StatisticsModule } from './modules/statistics/statistics.module';
6667
import { FeatureFlagsModule } from './modules/feature-flags/feature-flags.module';
68+
import { StorageQuotaModule } from './modules/storage-quota/storage-quota.module';
6769

6870
const envValidationSchema = Joi.object({
6971
NODE_ENV: Joi.string().valid('development', 'production', 'test').required(),
@@ -342,6 +344,7 @@ const envValidationSchema = Joi.object({
342344
SandboxModule,
343345
FeedbackModule,
344346
CommonModule,
347+
StorageQuotaModule,
345348
ThrottlerModule.forRoot([
346349
{
347350
name: 'default',
@@ -385,6 +388,14 @@ const envValidationSchema = Joi.object({
385388
ttl: 60_000, // 1 minute
386389
limit: 10,
387390
},
391+
{
392+
// Upload endpoints (avatar / KYC documents / dispute evidence /
393+
// feedback screenshots). Per-tier cap is enforced by TieredThrottlerGuard;
394+
// this top-level limit exists as a safety floor for anonymous traffic.
395+
name: 'upload',
396+
ttl: 60_000, // 1 minute
397+
limit: 5,
398+
},
388399
{
389400
// Admin high-risk endpoints require confirmation and tight throttling.
390401
// Intentionally restrictive: 2 requests per 5 minutes per admin.
@@ -410,6 +421,10 @@ const envValidationSchema = Joi.object({
410421
provide: APP_FILTER,
411422
useClass: AdminConfirmationFilter,
412423
},
424+
{
425+
provide: APP_FILTER,
426+
useClass: StorageQuotaExceptionFilter,
427+
},
413428
{
414429
provide: APP_INTERCEPTOR,
415430
useClass: RequestLoggingInterceptor,
@@ -443,7 +458,11 @@ const envValidationSchema = Joi.object({
443458
export class AppModule implements NestModule {
444459
configure(consumer: MiddlewareConsumer) {
445460
consumer
446-
.apply(CorrelationIdMiddleware, CompressionMetricsMiddleware, TenantContextMiddleware)
461+
.apply(
462+
CorrelationIdMiddleware,
463+
CompressionMetricsMiddleware,
464+
TenantContextMiddleware,
465+
)
447466
.forRoutes('*');
448467
}
449468
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
ArgumentsHost,
3+
Catch,
4+
ExceptionFilter,
5+
HttpException,
6+
HttpStatus,
7+
Logger,
8+
} from '@nestjs/common';
9+
import { Response, Request } from 'express';
10+
import { StorageQuotaExceededException } from '../../modules/storage-quota/storage-quota.types';
11+
12+
/**
13+
* Maps {@link StorageQuotaExceededException} to an HTTP response.
14+
*
15+
* Status code policy:
16+
* - 'storage' → 402 Payment Required (semantically: quota is
17+
* capacity-bound, not request-bound).
18+
* - 'concurrency' → 429 Too Many Requests.
19+
* - 'frequency' → 429 Too Many Requests.
20+
*
21+
* The downstream rate-limiter (`TieredThrottlerGuard`) already produces
22+
* 429s for the upload throttler bucket, so when this filter fires the
23+
* exception has already escaped that layer and represents a deeper
24+
* capacity / concurrency check.
25+
*
26+
* Kept globally registered in CommonModule; any other HTTP exception
27+
* falls through to the standard Nest filter chain.
28+
*/
29+
@Catch(StorageQuotaExceededException)
30+
export class StorageQuotaExceptionFilter implements ExceptionFilter {
31+
private readonly logger = new Logger(StorageQuotaExceptionFilter.name);
32+
33+
catch(exception: StorageQuotaExceededException, host: ArgumentsHost): void {
34+
const ctx = host.switchToHttp();
35+
const response = ctx.getResponse<Response>();
36+
const request = ctx.getRequest<Request>();
37+
38+
const status =
39+
exception.kind === 'storage'
40+
? HttpStatus.PAYMENT_REQUIRED
41+
: HttpStatus.TOO_MANY_REQUESTS;
42+
43+
const body = {
44+
success: false,
45+
statusCode: status,
46+
errorCode: exception.code,
47+
quotaKind: exception.kind,
48+
message: exception.message,
49+
meta: exception.meta,
50+
endpoint: `${request?.method ?? '?'} ${request?.originalUrl ?? request?.url ?? '/'}`,
51+
timestamp: new Date().toISOString(),
52+
};
53+
54+
this.logger.warn(
55+
`[storage-quota] ${exception.kind} ${request?.method ?? '?'} ${request?.originalUrl ?? request?.url ?? '/'}${exception.message}`,
56+
);
57+
58+
response.status(status).json(body);
59+
}
60+
}

backend/src/common/guards/tiered-throttler.guard.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const TIER_LIMITS: Record<
3939
rpc: { limit: 5, ttl: 60000 },
4040
export: { limit: 1, ttl: 15 * 60 * 1000 },
4141
vote: { limit: 5, ttl: 60_000 },
42+
upload: { limit: 10, ttl: 60_000 },
4243
'admin-high-risk': { limit: 0, ttl: 5 * 60 * 1000 }, // Not allowed
4344
},
4445
[UserTier.VERIFIED]: {
@@ -49,6 +50,7 @@ const TIER_LIMITS: Record<
4950
rpc: { limit: 15, ttl: 60000 },
5051
export: { limit: 3, ttl: 15 * 60 * 1000 },
5152
vote: { limit: 10, ttl: 60_000 },
53+
upload: { limit: 30, ttl: 60_000 },
5254
'admin-high-risk': { limit: 0, ttl: 5 * 60 * 1000 }, // Not allowed
5355
},
5456
[UserTier.PREMIUM]: {
@@ -59,6 +61,7 @@ const TIER_LIMITS: Record<
5961
rpc: { limit: 30, ttl: 60000 },
6062
export: { limit: 4, ttl: 15 * 60 * 1000 },
6163
vote: { limit: 20, ttl: 60_000 },
64+
upload: { limit: 60, ttl: 60_000 },
6265
'admin-high-risk': { limit: 0, ttl: 5 * 60 * 1000 }, // Not allowed
6366
},
6467
[UserTier.ENTERPRISE]: {
@@ -69,6 +72,7 @@ const TIER_LIMITS: Record<
6972
rpc: { limit: 100, ttl: 60000 },
7073
export: { limit: 8, ttl: 15 * 60 * 1000 },
7174
vote: { limit: 30, ttl: 60_000 },
75+
upload: { limit: 120, ttl: 60_000 },
7276
'admin-high-risk': { limit: 0, ttl: 5 * 60 * 1000 }, // Not allowed
7377
},
7478
[UserTier.ADMIN]: {
@@ -79,6 +83,7 @@ const TIER_LIMITS: Record<
7983
rpc: { limit: 100, ttl: 60000 },
8084
export: { limit: 6, ttl: 15 * 60 * 1000 },
8185
vote: { limit: 30, ttl: 60_000 },
86+
upload: { limit: 240, ttl: 60_000 },
8287
'admin-high-risk': { limit: 2, ttl: 5 * 60 * 1000 }, // 2 per 5 minutes
8388
},
8489
};
@@ -201,10 +206,7 @@ export class TieredThrottlerGuard extends ThrottlerGuard {
201206

202207
response.setHeader('Retry-After', retryAfter);
203208
response.setHeader('X-RateLimit-Remaining', 0);
204-
response.setHeader(
205-
'X-RateLimit-Reset',
206-
resetAt,
207-
);
209+
response.setHeader('X-RateLimit-Reset', resetAt);
208210

209211
throw new HttpException(
210212
{

backend/src/config/configuration.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,17 @@ export default () => ({
241241
localDir: process.env.STORAGE_LOCAL_DIR || './uploads',
242242
virusScanningEnabled: process.env.UPLOAD_VIRUS_SCANNING === 'true',
243243
},
244+
storageQuota: {
245+
enabled: process.env.STORAGE_QUOTA_ENABLED !== 'false',
246+
reservationTtlHours: parseInt(
247+
process.env.STORAGE_QUOTA_RESERVATION_TTL_HOURS || '4',
248+
10,
249+
),
250+
cleanupIntervalMinutes: parseInt(
251+
process.env.STORAGE_QUOTA_CLEANUP_INTERVAL_MINUTES || '15',
252+
10,
253+
),
254+
},
244255
adminNotifications: {
245256
maxPerMinute: parseInt(process.env.ADMIN_NOTIF_MAX_PER_MINUTE || '60', 10),
246257
maxPerHour: parseInt(process.env.ADMIN_NOTIF_MAX_PER_HOUR || '500', 10),
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class CreateStorageQuotasTables1800460000000 implements MigrationInterface {
4+
name = 'CreateStorageQuotasTables1800460000000';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
8+
9+
await queryRunner.query(`
10+
CREATE TABLE IF NOT EXISTS "storage_quotas" (
11+
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
12+
"userId" uuid NOT NULL,
13+
"tenantId" varchar(64) NOT NULL DEFAULT '',
14+
"maxTotalBytes" bigint NOT NULL DEFAULT 0,
15+
"usedBytes" bigint NOT NULL DEFAULT 0,
16+
"reservedBytes" bigint NOT NULL DEFAULT 0,
17+
"maxActiveUploads" integer NOT NULL DEFAULT 0,
18+
"activeUploads" integer NOT NULL DEFAULT 0,
19+
"maxUploadsPerHour" integer NOT NULL DEFAULT 0,
20+
"uploadsThisHour" integer NOT NULL DEFAULT 0,
21+
"uploadWindowStartedAt" timestamptz NULL,
22+
"tier" varchar(32) NOT NULL DEFAULT 'free',
23+
"createdAt" timestamptz NOT NULL DEFAULT now(),
24+
"updatedAt" timestamptz NOT NULL DEFAULT now(),
25+
"version" integer NOT NULL DEFAULT 0,
26+
CONSTRAINT "uq_storage_quotas_user_tenant" UNIQUE ("userId", "tenantId"),
27+
CONSTRAINT "pk_storage_quotas" PRIMARY KEY ("id")
28+
)
29+
`);
30+
31+
await queryRunner.query(
32+
`CREATE INDEX IF NOT EXISTS "idx_storage_quotas_user" ON "storage_quotas" ("userId")`,
33+
);
34+
35+
await queryRunner.query(`
36+
CREATE TABLE IF NOT EXISTS "storage_quota_ledger" (
37+
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
38+
"reservationId" varchar(64) NOT NULL,
39+
"userId" uuid NOT NULL,
40+
"tenantId" varchar(64) NOT NULL DEFAULT '',
41+
"uploadKind" varchar(32) NOT NULL DEFAULT 'generic',
42+
"uploadId" varchar(128) NULL,
43+
"byteDelta" bigint NOT NULL,
44+
"status" varchar(16) NOT NULL DEFAULT 'pending',
45+
"expiresAt" timestamptz NULL,
46+
"finalBytes" bigint NULL,
47+
"reason" varchar(256) NULL,
48+
"createdAt" timestamptz NOT NULL DEFAULT now(),
49+
CONSTRAINT "uq_storage_quota_ledger_reservation" UNIQUE ("reservationId"),
50+
CONSTRAINT "pk_storage_quota_ledger" PRIMARY KEY ("id")
51+
)
52+
`);
53+
54+
await queryRunner.query(
55+
`CREATE INDEX IF NOT EXISTS "idx_storage_quota_ledger_user_created" ON "storage_quota_ledger" ("userId", "createdAt")`,
56+
);
57+
await queryRunner.query(
58+
`CREATE INDEX IF NOT EXISTS "idx_storage_quota_ledger_tenant_created" ON "storage_quota_ledger" ("tenantId", "createdAt")`,
59+
);
60+
await queryRunner.query(
61+
`CREATE INDEX IF NOT EXISTS "idx_storage_quota_ledger_status_expires" ON "storage_quota_ledger" ("status", "expiresAt")`,
62+
);
63+
await queryRunner.query(
64+
`CREATE INDEX IF NOT EXISTS "idx_storage_quota_ledger_upload" ON "storage_quota_ledger" ("uploadKind", "uploadId")`,
65+
);
66+
}
67+
68+
public async down(queryRunner: QueryRunner): Promise<void> {
69+
await queryRunner.query(`DROP TABLE IF EXISTS "storage_quota_ledger"`);
70+
await queryRunner.query(`DROP TABLE IF EXISTS "storage_quotas"`);
71+
}
72+
}

backend/src/modules/disputes/disputes.service.ts

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ import {
3131
} from '../../common/entities/audit-log.entity';
3232
import { StorageService } from '../storage/storage.service';
3333
import { JobQueueService } from '../job-queue/job-queue.service';
34+
import {
35+
StorageQuotaService,
36+
QuotaReservation,
37+
} from '../storage-quota/storage-quota.service';
38+
import { QuotaUploadKind } from '../storage-quota/entities/storage-quota-ledger.entity';
3439

3540
const ALLOWED_TRANSITIONS: Record<DisputeStatus, DisputeStatus[]> = {
3641
[DisputeStatus.OPEN]: [
@@ -87,6 +92,7 @@ export class DisputesService {
8792
private readonly auditLogService: AuditLogService,
8893
private readonly storageService: StorageService,
8994
private readonly jobQueueService: JobQueueService,
95+
private readonly quotaService: StorageQuotaService,
9096
) {}
9197

9298
async createDispute(
@@ -392,12 +398,43 @@ export class DisputesService {
392398
dto: UploadEvidenceDto,
393399
): Promise<DisputeEvidence> {
394400
// Verify dispute exists
395-
await this.findOne(disputeId);
401+
const dispute = await this.findOne(disputeId);
402+
403+
// Reserve quota BEFORE writing to storage. Attribution goes to the
404+
// disputer only — `dto.uploadedBy` is a freeform staff label (e.g.
405+
// "Hospital Admin") and must NOT be used as the quota userId, since
406+
// it is not a UUID and would collide with other staff uploads for
407+
// the same dispute on the (userId, tenantId) UNIQUE index.
408+
const attributedUserId = dispute.disputedBy;
409+
if (!attributedUserId) {
410+
throw new BadRequestException(
411+
'Cannot attribute upload quota: dispute has no attributed disputer',
412+
);
413+
}
414+
await this.quotaService.ensureQuotaRow(attributedUserId, 'verified');
415+
416+
const reservation: QuotaReservation = await this.quotaService.reserve(
417+
attributedUserId,
418+
file.size,
419+
{
420+
uploadKind: QuotaUploadKind.DISPUTE_EVIDENCE,
421+
reason: 'dispute-evidence',
422+
},
423+
);
396424

397-
// Persist file to storage
398-
const storagePath = await this.storageService.saveFile(file);
425+
// Write file. If this fails we MUST release the reservation.
426+
let storagePath: string;
427+
try {
428+
storagePath = await this.storageService.saveFile(file);
429+
} catch (err) {
430+
await this.quotaService.release(reservation.reservationId, {
431+
reason: 'storage-write-failed',
432+
});
433+
throw err;
434+
}
399435

400-
// Create evidence record with PENDING status
436+
// Create evidence record with PENDING status and the reservation token
437+
// so the dispute-evidence processor can reconcile on completion.
401438
const evidence = this.evidenceRepository.create({
402439
disputeId,
403440
originalFilename: file.originalname,
@@ -407,18 +444,27 @@ export class DisputesService {
407444
uploadedBy: dto.uploadedBy,
408445
processingStatus: EvidenceProcessingStatus.PENDING,
409446
jobId: null,
447+
quotaReservationId: reservation.reservationId,
410448
});
411449
const savedEvidence = await this.evidenceRepository.save(evidence);
412450

413451
// Enqueue background processing job
414-
const job = await this.jobQueueService.addEvidenceProcessingJob({
415-
evidenceId: savedEvidence.id,
416-
disputeId,
417-
storagePath,
418-
mimeType: file.mimetype,
419-
originalFilename: file.originalname,
420-
uploadedBy: dto.uploadedBy,
421-
});
452+
let job;
453+
try {
454+
job = await this.jobQueueService.addEvidenceProcessingJob({
455+
evidenceId: savedEvidence.id,
456+
disputeId,
457+
storagePath,
458+
mimeType: file.mimetype,
459+
originalFilename: file.originalname,
460+
uploadedBy: dto.uploadedBy,
461+
});
462+
} catch (err) {
463+
await this.quotaService.release(reservation.reservationId, {
464+
reason: 'job-enqueue-failed',
465+
});
466+
throw err;
467+
}
422468

423469
// Store job ID on the evidence record for status polling
424470
await this.evidenceRepository.update(savedEvidence.id, {

backend/src/modules/disputes/entities/dispute-evidence.entity.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,17 @@ export class DisputeEvidence {
7575
@Column({ type: 'jsonb', nullable: true })
7676
processingMetadata: Record<string, any> | null;
7777

78+
/**
79+
* Token returned by {@link StorageQuotaService.reserve} when the evidence
80+
* upload was accepted. The dispute-evidence processor calls `commit()`
81+
* on success or `release()` on failure so the per-user quota ledger
82+
* reconciles correctly even when the upload is short-circuited.
83+
*/
84+
@ApiProperty({ nullable: true })
85+
@Index()
86+
@Column({ type: 'varchar', length: 64, nullable: true })
87+
quotaReservationId: string | null;
88+
7889
@ApiProperty()
7990
@CreateDateColumn()
8091
createdAt: Date;

0 commit comments

Comments
 (0)