Aller au contenu

Story CP-007bis: Module Gating — Cache Invalidation & TTL Security Ceiling

Module: control-plane Status: 🟡 DRAFT — awaiting human PO review and conversion to a numbered story Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 7 (delta on CP-007) Depends on: CP-007 (creates TenantModule, ModuleGatingService, ModuleGatingInterceptor, Redis + Caffeine caches), CP-002 (publishes TenantStatusChangedEvent) Estimated complexity: S Drafted by: Architecture session 2026-05-02 (docs/qr-006-access-control-clarification)


Why this story exists (gap analysis vs CP-007 / CP-020)

CP-007 establishes the module gating infrastructure with caching:

"Cached in Redis (TTL: 5 min) with Caffeine fallback (TTL: 1 min)."

CP-020 adds suspended-tenant write denial. Neither story specifies how the cache is invalidated when a tenant's status or a module's activation status changes. The default 5-minute TTL is a security risk for the suspension path:

  • Tenant becomes SUSPENDED at T₀ (stops paying, admin suspends, or grace period expires).
  • TenantStatusChangedEvent(SUSPENDED) is published.
  • Until T₀ + 5 min, ModuleGatingService.isModuleActive(tenantId, "QRCONTR") continues to return cached ACTIVE from Redis.
  • The non-paying tenant retains illicit write access for up to 5 minutes.

The same gap exists for ModuleActivated / ModuleDeactivated: a deactivated module remains "active" in cache, and a newly-activated module remains "inactive" in cache. The deactivation case is the security-relevant one.

This story closes the gap by adding event-driven cache flushing and lowering the TTL ceiling as a defensive measure.


Objective

Ensure that any change to a tenant's operability (status) or module subscription is reflected in the module gating cache within ≤1 second of the corresponding application event being published. Add a defensive TTL ceiling so even if the listener fails to fire, the staleness window is bounded.


Backend Scope

New Component: ModuleGatingCacheInvalidator

A @Component in the tenant Modulith module (same package as ModuleGatingService).

Responsibilities: - Listens to TenantStatusChangedEvent → flushes ALL Redis + Caffeine entries for that tenantId (key pattern module:{tenantId}:*). - Listens to ModuleActivated and ModuleDeactivated → flushes the single cache entry module:{tenantId}:{moduleCode} in both Redis and Caffeine. - Listener annotation: @ApplicationModuleListener (Spring Modulith — guaranteed delivery via EVENT_PUBLICATION table per ADR-21). - Idempotent: re-running on the same event has no adverse effect. - Logs at INFO level for every flush (tenantId, eventType, affectedKeys count) to support audit/post-mortem.

Modification: ModuleGatingService

TTL ceiling reduction: - Redis TTL: lower from 5 minutes → 2 minutes (defensive ceiling — listener should flush in ms; TTL is the safety net). - Caffeine fallback TTL: keep at 1 minute (already short enough).

Rationale: even if the event publication table fails or the listener errors, the staleness window is bounded to 2 minutes for security-critical state transitions, not 5.

Test Support

  • Add ModuleGatingCacheTestSupport in test-support module — exposes flushAll() and assertCacheEmpty(tenantId) for integration tests.

Frontend Scope

N/A — Backend-only slice.


Acceptance Criteria

AC-CP007bis-001: TenantStatusChangedEvent flushes module gating cache
Given tenant T has TenantModule(QRCONTR, ACTIVE) cached in Redis and Caffeine
And tenant T's status transitions from ACTIVE to SUSPENDED
When TenantStatusChangedEvent(tenantId=T, oldStatus=ACTIVE, newStatus=SUSPENDED) is published
Then within 1 second, the cache entries module:T:* are removed from Redis
And the cache entries module:T:* are removed from Caffeine
And the next call to isModuleActive(T, any) re-reads from the database

AC-CP007bis-002: ModuleDeactivated flushes single module cache entry
Given tenant T has TenantModule(EXPMGT, ACTIVE) cached
When ModuleDeactivated(tenantId=T, moduleCode=EXPMGT) is published
Then within 1 second, module:T:EXPMGT is removed from Redis and Caffeine
And module:T:QRCONTR (other modules) remains untouched

AC-CP007bis-003: ModuleActivated flushes single module cache entry
Given tenant T does not have TenantModule(PAYROL, ACTIVE) — cache holds INACTIVE result
When ModuleActivated(tenantId=T, moduleCode=PAYROL) is published
Then within 1 second, module:T:PAYROL is removed from Redis and Caffeine
And the next call to isModuleActive(T, PAYROL) reads ACTIVE from DB

AC-CP007bis-004: TTL ceiling enforced
Given a fresh write to the module gating cache for module:T:QRCONTR=ACTIVE
When 2 minutes 1 second elapse with no events
Then the Redis entry has expired
And the next call to isModuleActive(T, QRCONTR) re-reads from the database

AC-CP007bis-005: Cache invalidator is idempotent
Given module:T:QRCONTR has already been flushed
When TenantStatusChangedEvent for tenant T is published a second time
Then the listener completes successfully without errors
And the cache remains empty (no resurrected entries)

Multi-Tenant Considerations

  • Cache keys are tenant-scoped (module:{tenantId}:*) — flushing one tenant never affects another.
  • For Profile C / D (DB-per-tenant, BYO-DB): cache invalidation pattern is identical because the cache is in the platform process memory + Redis, not in tenant DBs.
  • Open question (forwarded to OQ-20 in PROGRESS.md): for Profiles C/D, should the connection pool also be marked read-only on SUSPENDED? Out of scope for this story — covered separately when those profiles activate.

Standards & Conventions

  • docs/standards/java-spring-guidelines.md — §6 Spring Boot 4 / Spring Modulith (use @ApplicationModuleListener)
  • docs/architecture/platform-vision-arch.md — §12.5 (Tenant Isolation Security Layers), §15 (Caching architecture)
  • ADR-21 — Audit trail integration via Application Events (same delivery guarantee mechanism applies here)

Testing Requirements

Unit Tests

  • ModuleGatingCacheInvalidator: receives TenantStatusChangedEvent → calls Redis + Caffeine flush methods with correct key pattern
  • ModuleGatingCacheInvalidator: receives ModuleDeactivated → flushes single entry
  • ModuleGatingCacheInvalidator: idempotent on repeated events

Integration Tests

  • End-to-end: write cache → publish TenantStatusChangedEvent → verify cache empty within 1 second (use Awaitility)
  • End-to-end: write cache → wait 2 min 1 sec → verify TTL expiry
  • Tenant isolation: flush tenant A cache, verify tenant B cache untouched
  • Failure injection: simulate Redis unavailable during flush → fallback to Caffeine flush succeeds, error logged

What QA Will Validate

  • Manual: suspend a tenant in admin console; within 2 seconds, verify the tenant's QRCONTR endpoints return 403 TENANT_SUSPENDED (not stale 200 OK).

Out of Scope

  • Pool-level read-only enforcement for Profile C/D — see OQ-20 in PROGRESS.md.
  • Cache invalidation for non-gating caches (permissions, country config) — owned by other ADRs.
  • Distributed cache coherence beyond Redis pub/sub — single Redis instance is MVP-sufficient.

Definition of Done

  • [ ] ModuleGatingCacheInvalidator implemented with @ApplicationModuleListener for the three event types
  • [ ] Redis TTL ceiling reduced from 5 min → 2 min in ModuleGatingService
  • [ ] Caffeine fallback TTL unchanged (1 min)
  • [ ] Unit tests written and passing
  • [ ] Integration tests written and passing (using Awaitility for the 1-second assertion window)
  • [ ] Multi-tenant isolation verified (tenant A flush ≠ tenant B flush)
  • [ ] No Java raw types
  • [ ] Audit log entry on every flush
  • [ ] Standards guidelines updated if new pattern emerges
  • [ ] PROGRESS.md OQ-19 closed and decision documented

Notes for the Human PO

This file is a draft plan, not an active story in the index.md story list. Convert it to a numbered story when ready (suggested ID: CP-031 if following the next-free-number rule, or rename to keep the bis convention if you prefer attaching it explicitly to CP-007). When converting:

  1. Rename file to CP-031.md (or keep as CP-007bis.md — your call on the naming pattern).
  2. Add to docs/stories/control-plane/index.md with dependencies CP-007, CP-002.
  3. Update Implementation Order section.
  4. Close OQ-19 in docs/architecture/PROGRESS.md once the story is implemented and merged.