Aller au contenu

Story CP-031: Platform Throttling — Rate Limiting + Storage Quota + Subscription Gate

Module: control-plane Slice: New cross-cutting slice (emerged from AB-014 review — PR #155) Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 31 Depends on: CP-001 (TenantContext), CP-002 (Tenant CRUD + Lifecycle), CP-007 (Module Gating — for the gating-interceptor pattern this story extends) Can develop concurrently with: any non-CP work Merge order: After CP-007 (reuses its interceptor stack), before any future quota- or billing-status-sensitive feature Estimated complexity: L PRD User Stories: N/A — Technical / Platform Story


Objective

Introduce three platform-wide enforcement mechanisms — currently missing — that protect the system against abusive usage, plan-cap overruns, and unpaid-tenant traffic. All three were surfaced by the AB-014 backend review (PR #155, Round 1) as cross-cutting concerns that must not live inside each individual service method:

  1. Per-(tenant, user) rate limiting on declared sensitive endpoints — driven by a @RateLimit annotation, backed by Bucket4j + Redis.
  2. Per-tenant storage quota for binary attachments (MinIO-backed documents) — enforced at the storage-service boundary against the tenant's plan cap.
  3. Subscription gate — block writes (and optionally reads) for tenants whose billing status is SUSPENDED / DELINQUENT; allow read-only access during GRACE_PERIOD.

Without this story, AB-014 ships with isAuthenticated()-only upload (no rate cap, no quota) and no payment-status enforcement on any data-mutating endpoint.


Backend Scope

Sub-slice A — Rate Limiting Infrastructure

New annotation (infrastructure/ratelimit/RateLimit.java):

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
  String key();              // logical key, e.g. "document.upload"
  int capacity();            // bucket size
  int refillTokens();        // tokens added per refill period
  int refillPeriodSeconds(); // refill cadence
}

New interceptor (infrastructure/ratelimit/RateLimitInterceptor.java) — HandlerInterceptor that reads the annotation off the HandlerMethod, builds a Redis-backed Bucket4j bucket keyed ratelimit:<key>:<tenantId>:<userId>, and returns 429 with the standard ApiResponse envelope (error code RATE_LIMIT_EXCEEDED, French message) when the bucket is empty.

Redis-backed proxy manager — wire Bucket4jRedisProxyManager as a Spring bean, reusing the existing Redis connection from spring-boot-starter-data-redis.

Registration — add to the existing WebMvcConfigurer chain after TenantContextFilter (interceptor runs after filter — tenant is already in ScopedValue by then).

Endpoint annotations — apply on day one to: - DocumentController.uploadcapacity=10, refillTokens=10, refillPeriodSeconds=60 - DocumentController.downloadcapacity=30, refillTokens=30, refillPeriodSeconds=60 - Any other endpoint flagged by the platform team (extensible by annotation, no code change needed).

Sub-slice B — Per-Tenant Storage Quota

Tenant plan extension — add storage_cap_bytes BIGINT NOT NULL DEFAULT 5368709120 (5 GB) to the tenant_plan table (or wherever plan tiers live post-CP-009). Reference data: PRO=5GB, BUSINESS=20GB, ENTERPRISE=100GB. Values configurable, not hardcoded.

New service (platform/quota/TenantStorageQuotaService.java):

public long getUsedBytes(UUID tenantId);   // SUM(size_bytes) FROM platform_document WHERE tenant_id = :t
public long getCapBytes(UUID tenantId);    // From tenant's plan
public void assertCanStore(UUID tenantId, long incomingSizeBytes);  // throws TenantStorageQuotaExceededException

The SUM query result is cached in Redis (TTL: 60s) to avoid hammering the DB on every upload. Cache invalidated by DocumentUploaded / DocumentDeleted events.

Wiring — call assertCanStore(tenantId, sizeBytes) at the top of DocumentStorageService.store(), before the MinIO putObject. Return 422 with error code TENANT_STORAGE_QUOTA_EXCEEDED (French: "Quota de stockage du tenant atteint — contactez votre administrateur").

Sub-slice C — Subscription / Payment Gate

Tenant status reading — extend the existing TenantStatus enum (from CP-002) if needed to cover: ACTIVE, TRIAL, GRACE_PERIOD, SUSPENDED, DELINQUENT, CLOSED. Status is already on the tenant table.

New filter (platform/billing/SubscriptionGateFilter.java) — OncePerRequestFilter, ordered after TenantContextFilter (so TenantContextHolder.current().tenantId() is set) and before RateLimitInterceptor. The filter:

  1. Skips when no tenant context is present (public endpoints / actuator).
  2. Loads tenant status from cache (Redis, TTL: 30s; Caffeine fallback TTL: 10s — matches the pattern from CP-007's ModuleGatingService).
  3. Applies the policy:
  4. ACTIVE, TRIAL → allow.
  5. GRACE_PERIOD → allow GET / HEAD / OPTIONS; block other methods with 402 + TENANT_GRACE_PERIOD_WRITE_BLOCKED (French: "Période de grâce — paiement en attente. Lecture seule autorisée.").
  6. SUSPENDED, DELINQUENT → block all methods except a small allow-list (/api/v1/cp/billing/**, /api/v1/auth/**, /actuator/**) with 402 + TENANT_SUSPENDED (French: "Compte suspendu — régularisez votre paiement pour continuer.").
  7. CLOSED → 403 + TENANT_CLOSED.
  8. Always returns the standard ApiResponse envelope (per CLAUDE.md mistake §5).

Cache invalidation — listen to TenantStatusChanged events (publish from CP-002's lifecycle service if not already) and evict the affected tenant's status cache entry.

Multi-Tenant Considerations

  • Rate limit keys are tenant-scoped, so noisy tenants cannot starve quiet ones.
  • Storage quota is per-tenant by design.
  • Subscription gate operates entirely on the resolved tenant context — no cross-tenant lookup.
  • Works across all 4 DB profiles: tenant status / plan are read from the platform DB (not the tenant DB), so BYO-DB tenants see the same gate.

API Endpoints

Method Path Request Response Auth
GET /api/v1/cp/tenant/storage-usage { usedBytes, capBytes, percentUsed } RH_MANAGER, DG
GET /api/v1/cp/tenant/subscription-status { status, until, message } any authenticated

(Both are read-only helpers that the frontend uses to display banners — quota near-full warning, grace-period banner, etc.)

Validation Rules

  • All three sub-slices return error codes in the standard ApiResponse envelope (CLAUDE.md §5).
  • HTTP status: 429 for rate limit, 422 for quota, 402 for subscription gate (where the semantics fit), 403 for CLOSED.
  • French messages on every error code.

Frontend Scope

OFFLINE SYNC REMINDER (Papillon): The subscription-gate 402 response is a hard failure that the offline queue MUST not silently retry forever — it should surface a persistent banner and drop the offline mutation into a "blocked" tray for the user to review. Rate-limit 429s, by contrast, should retry with exponential backoff (cap 5 attempts).

Pages & Routes

No new routes. Adds banners + small UI components to existing pages.

Components

  • <SubscriptionStatusBanner /> — sticky banner shown on every page when status ≠ ACTIVE/TRIAL. Colors: amber for GRACE_PERIOD, red for SUSPENDED/DELINQUENT. Tap → routes to billing page (CP-009 / CP-025).
  • <StorageQuotaCard /> — on the document-upload zone (used by AB-014 sick certs and future modules). Shows XX% used, turns amber at 80%, red at 95%. Hidden when status is fine.
  • HTTP interceptor: catch 429 → toast "Trop de requêtes — réessayez dans un instant" + exponential-backoff retry queue (max 5 attempts, base 1s, factor 2). Catch 402 → route to billing page or show modal explaining the situation.

UI States (ALL REQUIRED)

State Behavior French Copy
Loading N/A (status is fetched at session start, cached client-side)
Empty N/A
Error (429) Toast + retry queue "Trop de requêtes — réessayez dans un instant"
Error (402 grace) Persistent amber banner "Période de grâce — paiement en attente. Régularisez sous 7 jours."
Error (402 suspended) Persistent red banner, app in read-only "Compte suspendu — régularisez votre paiement pour continuer."
Offline If quota is breached offline, queued upload moves to "blocked" tray on next sync "Quota dépassé — supprimez d'anciens documents ou augmentez votre plan."
Success Banner disappears once status returns to ACTIVE

Responsive Behavior

Mobile (360px — Papillon primary): - SubscriptionStatusBanner is sticky at the top, height 48px, tap-target full-width. - StorageQuotaCard sits inline with the upload zone, full-width.

Desktop (1024px+ — Enterprise primary): - SubscriptionStatusBanner spans the full top of the layout. - StorageQuotaCard appears in the right sidebar of upload modals.

French Micro-Copy

  • billing.banner.grace: "Période de grâce — paiement en attente. Régularisez sous 7 jours."
  • billing.banner.suspended: "Compte suspendu — régularisez votre paiement pour continuer."
  • billing.banner.delinquent: "Compte en recouvrement — contactez votre administrateur."
  • quota.near_full: "Stockage presque plein — {percent} % utilisés"
  • quota.exceeded: "Quota de stockage atteint — supprimez d'anciens documents ou augmentez votre plan."
  • rate_limit.retry: "Trop de requêtes — réessayez dans un instant"

Acceptance Criteria

AC-CP031-01: Rate limit enforced on document upload
Given a user has uploaded 10 documents in the last minute
When they attempt to upload an 11th document
Then the server returns 429 with code RATE_LIMIT_EXCEEDED
And the response uses the standard ApiResponse envelope
And the rate-limit bucket is keyed by (tenantId, userId)

AC-CP031-02: Storage quota blocks oversize upload
Given a tenant on the PRO plan has 4.99 GB used
When a user uploads a 20 MB document
Then the server returns 422 with code TENANT_STORAGE_QUOTA_EXCEEDED
And no row is inserted into platform_document
And no object is written to MinIO

AC-CP031-03: Subscription gate blocks writes for SUSPENDED tenant
Given a tenant whose status is SUSPENDED
When any user from that tenant performs a POST/PUT/PATCH/DELETE
Then the server returns 402 with code TENANT_SUSPENDED
And the response uses the standard ApiResponse envelope
And the allow-list endpoints (/api/v1/cp/billing/**) still work

AC-CP031-04: Grace period allows reads, blocks writes
Given a tenant whose status is GRACE_PERIOD
When any user performs a GET → it succeeds
When any user performs a POST/PUT/PATCH/DELETE
Then the server returns 402 with code TENANT_GRACE_PERIOD_WRITE_BLOCKED

AC-CP031-05: Cache invalidation on status change
Given a tenant whose status was just changed from SUSPENDED to ACTIVE
When a user from that tenant performs any request
Then the request succeeds within ≤ 30s of the status change
(cache TTL is 30s in Redis, but TenantStatusChanged event evicts immediately)

OHADA & Regulatory Rules

N/A — pure platform infrastructure. No OHADA touchpoint beyond using the existing ApiResponse envelope and French error messages (CLAUDE.md rule 3).


Standards & Conventions

  • docs/standards/java-spring-guidelines.md — §HandlerInterceptor patterns, §Filter ordering, §Exception handling, §ApiResponse envelope
  • docs/standards/database-guidelines.md — §Tenant-scoped queries, §Caching patterns (Redis + Caffeine fallback)
  • docs/standards/api-guidelines.md — §Error envelope, §HTTP status codes (429, 422, 402)
  • docs/standards/react-typescript-guidelines.md — §HTTP interceptors, §Sticky banners
  • docs/standards/offline-sync-guidelines.md — §Handling 429 retry, §Handling 402 hard failure
  • docs/standards/multi-datasource-patterns.md — §Reading from platform DB vs tenant DB

Testing Requirements

Unit Tests

  • RateLimitInterceptor: token bucket exhausts after capacity calls → next call returns 429.
  • RateLimitInterceptor: refill after refillPeriodSeconds → calls allowed again.
  • RateLimitInterceptor: different (tenantId, userId) keys are independent.
  • TenantStorageQuotaService.assertCanStore: sum + incoming > cap → throws.
  • SubscriptionGateFilter: each status × method matrix (ACTIVE/GET, ACTIVE/POST, GRACE_PERIOD/GET, GRACE_PERIOD/POST, SUSPENDED/GET, SUSPENDED/POST, CLOSED/any).
  • SubscriptionGateFilter: allow-list paths bypass the gate even when SUSPENDED.

Integration Tests

  • POST /api/v1/platform/documents 11 times in <1 minute → first 10 succeed, 11th returns 429.
  • Upload that would exceed plan cap → returns 422; platform_document row count unchanged; MinIO bucket unchanged.
  • Set tenant status to SUSPENDED → any POST to a gated endpoint returns 402; /api/v1/cp/billing/** still 200.
  • Publish TenantStatusChanged(ACTIVE) event → next request from that tenant within 1s succeeds (cache evicted).
  • Multi-tenant: rate-limit consumption in tenant A does not affect tenant B.

What QA Will Validate

  • Storage quota near-full banner appears at 80%, turns red at 95%.
  • Subscription banner appears when admin manually sets a tenant to GRACE_PERIOD via the admin console (CP-012).
  • 429 → user sees toast + automatic retry succeeds when bucket refills.
  • 402 SUSPENDED → app enters read-only mode; mutation buttons disabled with tooltip.

Out of Scope

  • The subscription billing engine itself (CP-009 / CP-025 / CP-026) — this story only enforces the gate; it does not compute invoices or transitions.
  • Per-IP rate limiting at the gateway / Coolify — that's a deployment concern, not application code.
  • ClamAV / virus-scanning pipeline for uploaded documents — separate debt item, tracked elsewhere.
  • Per-endpoint rate-limit metric exposure (Prometheus) — nice to have, but out of MVP scope. Bucket4j exposes hooks if we want them later.

Definition of Done

  • [ ] @RateLimit annotation + RateLimitInterceptor + Bucket4j-Redis wiring implemented.
  • [ ] At least DocumentController.upload and DocumentController.download annotated with @RateLimit (AB-014 follow-up).
  • [ ] TenantStorageQuotaService implemented; DocumentStorageService.store() calls assertCanStore before MinIO putObject.
  • [ ] SubscriptionGateFilter implemented; filter order documented; allow-list endpoints work even when tenant is SUSPENDED.
  • [ ] Tenant status / plan caching uses Redis (TTL: 30s for status, 60s for storage usage) with Caffeine fallback.
  • [ ] Cache eviction wired via TenantStatusChanged and DocumentUploaded / DocumentDeleted events.
  • [ ] Frontend: SubscriptionStatusBanner + StorageQuotaCard implemented for Papillon (mobile-first).
  • [ ] Frontend: HTTP interceptor handles 429 (retry) and 402 (banner + read-only mode).
  • [ ] French micro-copy matches §French Micro-Copy section above.
  • [ ] All acceptance criteria pass manually.
  • [ ] Unit tests + integration tests cover the matrix listed above.
  • [ ] Multi-tenant isolation verified — rate-limit / quota / status are independent per tenant.
  • [ ] Audit trail entries for tenant-status-change-triggered cache evictions (DG / RH visible in CP-016 audit UI).
  • [ ] No TypeScript any; no Java raw types.
  • [ ] API responses use standard ApiResponse envelope.

Origin

Surfaced by the AB-014 backend review (PR #155, Round 1, 2026-05-13) as [IMPORTANT][PLATFORM] follow-ups deliberately kept out of AB-014's scope to avoid bloating that story. See docs/reviews/absence-management/AB-014-review.md §Platform-level findings.