Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public Map<String, Object> get() {
root.put("tenant", tenantView());
root.put("identity", identityView());
root.put("audit", auditView());
root.put("menu", menuView());
root.put("cache", cacheView());
root.put("raw", rawDump());
return root;
}
Expand Down Expand Up @@ -90,11 +90,17 @@ private Map<String, Object> auditView() {
return audit;
}

private Map<String, Object> menuView() {
Map<String, Object> menu = new LinkedHashMap<>();
menu.put("cacheTtlSeconds",
durationToSeconds(env.getProperty(PREFIX + "menu.cache-ttl"), 300L));
return menu;
/**
* The pluggable cache (ADR 0002). The menu cache now rides this shared
* CacheManager rather than its own per-cache TTL, so the menu section was
* replaced by this one.
*/
private Map<String, Object> cacheView() {
Map<String, Object> cache = new LinkedHashMap<>();
cache.put("type", env.getProperty(PREFIX + "cache.type", "in-memory"));
cache.put("ttlSeconds", durationToSeconds(env.getProperty(PREFIX + "cache.ttl"), 600L));
cache.put("keyPrefix", env.getProperty(PREFIX + "cache.key-prefix", "devslab:"));
return cache;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,10 @@ public static class Menu {

private boolean enabled = true;

/**
* How long a {@link kr.devslab.kit.menu.MenuProvider} keeps a
* per-user menu tree before re-querying the database. Set to
* {@code PT0S} (or any zero / negative duration) to disable the
* cache entirely — useful in tests where menu / permission
* mutations should be visible immediately.
*/
private java.time.Duration cacheTtl = java.time.Duration.ofMinutes(5);
// The menu cache no longer has its own TTL knob: the per-user menu tree
// now rides the shared CacheManager (ADR 0002 §5), so its lifetime is
// governed by devslab.kit.cache.ttl (Redis) / the cache backend. The
// former devslab.kit.menu.cache-ttl property was removed in that change.

public boolean isEnabled() {
return enabled;
Expand All @@ -201,14 +197,6 @@ public boolean isEnabled() {
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

public java.time.Duration getCacheTtl() {
return cacheTtl;
}

public void setCacheTtl(java.time.Duration cacheTtl) {
this.cacheTtl = cacheTtl;
}
}

public static class Audit {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package kr.devslab.kit.autoconfigure;

import jakarta.persistence.EntityManager;
import java.time.Clock;
import java.time.Duration;
import kr.devslab.kit.access.PermissionChecker;
import kr.devslab.kit.menu.MenuProvider;
import kr.devslab.kit.menu.core.repository.JpaPlatformMenuRepository;
Expand All @@ -16,6 +14,7 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;

@AutoConfiguration(afterName = {
Expand Down Expand Up @@ -46,11 +45,13 @@ public PermissionBasedMenuFilter permissionBasedMenuFilter(PermissionChecker per
}

/**
* Wraps the database-backed {@link DefaultMenuProvider} in a
* {@link CachingMenuProvider} when {@code devslab.kit.menu.cache-ttl} is
* positive (default 5m). A zero / negative TTL skips the cache decorator
* entirely, which is the right behaviour for tests that mutate menus or
* permissions and expect the next read to see the change immediately.
* The database-backed {@link DefaultMenuProvider}, wrapped in a
* {@link CachingMenuProvider} riding the shared {@link CacheManager} when one
* is present (ADR 0002 §5). The cache backend — in-memory, Redis, or no-op —
* follows {@code devslab.kit.cache.type}; TTL and distribution are the cache
* manager's concern, not this provider's. When no {@code CacheManager} bean
* exists (caching disabled entirely), the bare database-backed provider is
* returned, so menus always work.
*/
@Bean
@ConditionalOnMissingBean
Expand All @@ -59,14 +60,13 @@ public MenuProvider menuProvider(
JpaPlatformMenuRepository repository,
MenuTreeBuilder menuTreeBuilder,
PermissionBasedMenuFilter filter,
DevslabKitProperties properties,
Clock clock
org.springframework.beans.factory.ObjectProvider<CacheManager> cacheManager
) {
MenuProvider base = new DefaultMenuProvider(repository, menuTreeBuilder, filter);
Duration ttl = properties.getMenu().getCacheTtl();
if (ttl == null || ttl.isZero() || ttl.isNegative()) {
CacheManager cm = cacheManager.getIfAvailable();
if (cm == null) {
return base;
}
return new CachingMenuProvider(base, ttl, clock);
return new CachingMenuProvider(base, cm);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ public Optional<CurrentUser> parse(String token) {
Claims claims = Jwts.parser()
.verifyWith(signingKey)
.requireIssuer(issuer)
// Validate expiration/not-before against the SAME injected clock
// that issue() uses. JJWT's parser otherwise defaults to the real
// system clock, which (a) makes the injected Clock untestable and
// (b) is asymmetric with issue(). io.jsonwebtoken.Clock is a
// {@code Date now()} functional interface, so adapt java.time.Clock.
.clock(() -> Date.from(Instant.now(clock)))
.build()
.parseSignedClaims(token)
.getPayload();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,41 @@ void roundTripsMustChangePasswordFalse() {

assertThat(parsed.mustChangePassword()).isFalse();
}

/**
* Regression lock for the parser-clock bug: {@code parse()} must validate
* expiry against the <em>injected</em> clock, not the real system clock.
*
* <p>The clock is fixed far in the past, so the token's 8h window closed
* years ago in wall-clock terms. If {@code parse()} (re)introduces the
* default system clock, it sees the token as long expired and returns
* empty — and this assertion fails. It passes only while the injected clock
* governs expiry. Deterministic regardless of when CI runs.
*/
@Test
void parseHonorsInjectedClock_acceptsTokenThatRealClockWouldReject() {
Clock past = Clock.fixed(Instant.parse("2020-01-01T00:00:00Z"), ZoneOffset.UTC);
JjwtAuthTokenService pastService =
new JjwtAuthTokenService(SECRET, Duration.ofHours(8), "devslab-kit-test", past);

AuthToken token = pastService.issue(user(false));

assertThat(pastService.parse(token.value())).isPresent();
}

/** And the symmetric case: a token past its TTL per the injected clock is rejected. */
@Test
void parseRejectsTokenExpiredPerInjectedClock() {
Clock t0 = Clock.fixed(Instant.parse("2026-05-31T00:00:00Z"), ZoneOffset.UTC);
JjwtAuthTokenService issuerService =
new JjwtAuthTokenService(SECRET, Duration.ofHours(1), "devslab-kit-test", t0);
AuthToken token = issuerService.issue(user(false));

// A reader whose clock sits 2h later — past the 1h TTL — must reject it.
Clock later = Clock.fixed(Instant.parse("2026-05-31T02:00:00Z"), ZoneOffset.UTC);
JjwtAuthTokenService readerService =
new JjwtAuthTokenService(SECRET, Duration.ofHours(1), "devslab-kit-test", later);

assertThat(readerService.parse(token.value())).isEmpty();
}
}
5 changes: 5 additions & 0 deletions devslab-kit-menu-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ description = "devslab-kit :: menu default implementation"

dependencies {
api(project(":devslab-kit-menu-api"))
// CacheNames (kit cache-name constants) + Spring's cache abstraction so the
// menu cache rides the shared CacheManager (ADR 0002 §5) instead of its own
// map — distributed automatically when the consumer sets cache.type=redis.
api(project(":devslab-kit-cache-api"))
api("org.springframework:spring-context")

api("org.springframework.boot:spring-boot-starter-data-jpa")

Expand Down
Original file line number Diff line number Diff line change
@@ -1,72 +1,70 @@
package kr.devslab.kit.menu.core.service;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import kr.devslab.kit.cache.CacheNames;
import kr.devslab.kit.identity.CurrentUser;
import kr.devslab.kit.menu.MenuProvider;
import kr.devslab.kit.menu.MenuTree;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;

/**
* {@link MenuProvider} decorator that caches the per-user menu tree for a
* configurable TTL.
* {@link MenuProvider} decorator that caches the per-user menu tree on the
* kit's shared {@link CacheManager} (ADR 0002 §5).
*
* <p>Caching keyed by {@code CurrentUser.id()} — the menu tree the delegate
* returns is already permission-filtered, so the result varies per user
* even within the same tenant. Per-user keys keep the cache correct without
* having to invalidate on tenant-wide menu edits (the TTL handles that).
* <p>Previously this kept its own {@code ConcurrentHashMap}, which went stale
* across replicas. Riding the shared cache manager means the menu cache inherits
* whatever backend the consumer chose with {@code devslab.kit.cache.type}: a
* {@code ConcurrentMapCacheManager} for {@code in-memory} (single node, same as
* before), a real distributed cache for {@code redis} (correct across replicas,
* with the kit's JSON serialization), or a {@code NoOpCacheManager} for
* {@code none} (every read hits the database — the right behaviour for tests
* that mutate menus and expect to see the change immediately, replacing the old
* "zero TTL disables the decorator" special case).
*
* <p>Implementation deliberately small: a {@link ConcurrentHashMap} with a
* per-entry expiry timestamp, no background eviction thread, no LRU bound.
* Entries expire lazily on read; stale entries linger until a read evicts
* them. For the usage we have today (a few dozen admins per tenant, menus
* loaded once per session) this is plenty. Swap in Caffeine or a Spring
* Cache if the access pattern outgrows it.
* <p>Keyed by {@code CurrentUser.id()} — the delegate's tree is already
* permission-filtered, so it varies per user even within a tenant. TTL is owned
* by the cache backend ({@code devslab.kit.cache.ttl} for Redis), not this class.
*
* <p>Manual invalidation is exposed for admin tooling that mutates menus
* and wants the next read to skip the cache.
* <p>Manual eviction is exposed for admin tooling that mutates menus and wants
* the next read recomputed.
*/
public class CachingMenuProvider implements MenuProvider {

private final MenuProvider delegate;
private final Duration ttl;
private final Clock clock;
private final Map<UUID, CacheEntry> cache = new ConcurrentHashMap<>();
private final CacheManager cacheManager;

public CachingMenuProvider(MenuProvider delegate, Duration ttl, Clock clock) {
public CachingMenuProvider(MenuProvider delegate, CacheManager cacheManager) {
this.delegate = delegate;
this.ttl = ttl;
this.clock = clock;
this.cacheManager = cacheManager;
}

@Override
public MenuTree menusFor(CurrentUser user) {
UUID key = user.id().value();
Instant now = Instant.now(clock);
CacheEntry hit = cache.get(key);
if (hit != null && hit.expiresAt().isAfter(now)) {
return hit.tree();
Cache cache = cacheManager.getCache(CacheNames.MENU);
if (cache == null) {
// Manager declined to provide the cache (e.g. a fixed-name manager
// that doesn't know MENU) — fall back to the live delegate rather
// than fail. Correctness over caching.
return delegate.menusFor(user);
}
// Recompute via the delegate. ConcurrentHashMap.put has racing-thunderr
// semantics — two callers may both miss and both invoke the delegate
// briefly; both writes are idempotent. Worth it to avoid holding a
// lock across the DB round-trip.
MenuTree fresh = delegate.menusFor(user);
cache.put(key, new CacheEntry(fresh, now.plus(ttl)));
return fresh;
// get(key, valueLoader): atomic cache-or-compute. On a miss the loader
// runs the DB-backed delegate and the result is stored under the user id.
return cache.get(user.id().value(), () -> delegate.menusFor(user));
}

public void invalidate(UUID userId) {
cache.remove(userId);
/** Evict one user's cached menu tree (e.g. after editing their visible menus). */
public void invalidate(java.util.UUID userId) {
Cache cache = cacheManager.getCache(CacheNames.MENU);
if (cache != null) {
cache.evict(userId);
}
}

/** Evict every cached menu tree (e.g. after a tenant-wide menu change). */
public void invalidateAll() {
cache.clear();
}

private record CacheEntry(MenuTree tree, Instant expiresAt) {
Cache cache = cacheManager.getCache(CacheNames.MENU);
if (cache != null) {
cache.clear();
}
}
}
9 changes: 8 additions & 1 deletion devslab-kit-sample-app/src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ devslab:

menu:
enabled: true
cache-ttl: ${DEVSLAB_MENU_CACHE_TTL:PT5M}

# Pluggable cache (ADR 0002). Default in-memory (single-node). Flip type to
# `redis` to use the Redis that docker-compose already starts — the per-user
# menu tree and any of your own @Cacheable methods then cache as JSON across
# replicas, with the kit owning serialization (no Serializable, no config).
cache:
type: ${DEVSLAB_CACHE_TYPE:in-memory}
ttl: ${DEVSLAB_CACHE_TTL:PT10M}

# First-admin bootstrap (ADR 0001). The starter's own runner provisions a
# default tenant + PLATFORM_ADMIN role + admin.* permissions + admin user on
Expand Down
Loading