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:
- Per-(tenant, user) rate limiting on declared sensitive endpoints — driven by a
@RateLimitannotation, backed by Bucket4j + Redis. - Per-tenant storage quota for binary attachments (MinIO-backed documents) — enforced at the storage-service boundary against the tenant's plan cap.
- Subscription gate — block writes (and optionally reads) for tenants whose billing status is
SUSPENDED/DELINQUENT; allow read-only access duringGRACE_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.upload — capacity=10, refillTokens=10, refillPeriodSeconds=60
- DocumentController.download — capacity=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:
- Skips when no tenant context is present (public endpoints / actuator).
- Loads tenant status from cache (Redis, TTL: 30s; Caffeine fallback TTL: 10s — matches the pattern from CP-007's
ModuleGatingService). - Applies the policy:
ACTIVE,TRIAL→ allow.GRACE_PERIOD→ allowGET/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.").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.").CLOSED→ 403 +TENANT_CLOSED.- Always returns the standard
ApiResponseenvelope (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
ApiResponseenvelope (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 forGRACE_PERIOD, red forSUSPENDED/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). ShowsXX% 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, §ApiResponseenvelopedocs/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 bannersdocs/standards/offline-sync-guidelines.md— §Handling 429 retry, §Handling 402 hard failuredocs/standards/multi-datasource-patterns.md— §Reading from platform DB vs tenant DB
Testing Requirements¶
Unit Tests¶
RateLimitInterceptor: token bucket exhausts aftercapacitycalls → next call returns 429.RateLimitInterceptor: refill afterrefillPeriodSeconds→ 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/documents11 times in <1 minute → first 10 succeed, 11th returns 429. - Upload that would exceed plan cap → returns 422;
platform_documentrow 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¶
- [ ]
@RateLimitannotation +RateLimitInterceptor+ Bucket4j-Redis wiring implemented. - [ ] At least
DocumentController.uploadandDocumentController.downloadannotated with@RateLimit(AB-014 follow-up). - [ ]
TenantStorageQuotaServiceimplemented;DocumentStorageService.store()callsassertCanStorebefore MinIOputObject. - [ ]
SubscriptionGateFilterimplemented; 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
TenantStatusChangedandDocumentUploaded/DocumentDeletedevents. - [ ] 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
ApiResponseenvelope.
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.