feat(sprint-3): Phase 1 — staff permissions + token revocation

- StaffPermission enum (8 granular permissions)
- StaffAccount JPA entity with permissions collection
- RevokedToken entity for JWT blacklisting
- Flyway V3 migration (staff_accounts, staff_account_permissions, revoked_tokens)
- StaffAccountRepository + RevokedTokenRepository
- TokenRevocationService with Caffeine cache (60s TTL, 10k max)
- StaffPermissionChecker SpEL bean (@staffPermissions.has)
- PreventionOfficerChecker SpEL bean (@preventionOfficer.check)
- JwtService: added jti claim + generateStaffAccessToken + extractJti/extractPermissions
- JwtAuthFilter: token blacklist check via TokenRevocationService
- SecurityConfig: STAFF role added to endpoint matchers
- Controllers updated with @PreAuthorize for fine-grained access
- TokenCleanupScheduler (daily 03:00 cleanup of expired revoked tokens)
- Caffeine dependency added to cannamanage-service
- Unit tests: StaffPermissionCheckerTest (7), TokenRevocationServiceTest (9)
This commit is contained in:
Patrick Plate
2026-06-11 16:45:21 +02:00
parent a1ddec37da
commit 55d8434f35
24 changed files with 2333 additions and 23 deletions
@@ -0,0 +1,26 @@
package de.cannamanage.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Scheduled task to clean up expired revoked tokens.
* Runs daily at 03:00 to remove tokens whose expiration has passed
* (they can no longer be used anyway, so the revocation record is stale).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TokenCleanupScheduler {
private final TokenRevocationService tokenRevocationService;
@Scheduled(cron = "0 0 3 * * *")
public void cleanupExpiredTokens() {
log.info("Starting expired token cleanup...");
int deleted = tokenRevocationService.cleanupExpiredTokens();
log.info("Expired token cleanup complete: {} tokens removed", deleted);
}
}
@@ -0,0 +1,99 @@
package de.cannamanage.service;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import de.cannamanage.domain.entity.RevokedToken;
import de.cannamanage.service.repository.RevokedTokenRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* Service for JWT token revocation with Caffeine cache for fast lookups.
* Cache: 60s TTL, max 10,000 entries.
* Flow: isRevoked() checks cache first, then falls back to DB.
*/
@Slf4j
@Service
public class TokenRevocationService {
private final RevokedTokenRepository revokedTokenRepository;
/**
* Cache stores JTI → Boolean (true = revoked).
* TTL 60s means a revoked token could still be accepted for up to 60s
* on other nodes (acceptable tradeoff for single-node MVP).
*/
private final Cache<String, Boolean> revokedCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.build();
public TokenRevocationService(RevokedTokenRepository revokedTokenRepository) {
this.revokedTokenRepository = revokedTokenRepository;
}
/**
* Checks if a token (by JTI) is revoked.
* Checks local cache first, then DB as fallback.
*/
public boolean isRevoked(String jti) {
if (jti == null || jti.isBlank()) {
return false;
}
// Check cache first
Boolean cached = revokedCache.getIfPresent(jti);
if (cached != null) {
return cached;
}
// Fallback to DB
boolean revoked = revokedTokenRepository.existsByJti(jti);
if (revoked) {
revokedCache.put(jti, true);
}
return revoked;
}
/**
* Revokes a single token by JTI.
*/
@Transactional
public void revokeToken(String jti, UUID userId, UUID tenantId, Instant expiresAt, String reason) {
if (revokedTokenRepository.existsByJti(jti)) {
log.debug("Token {} already revoked, skipping", jti);
return;
}
RevokedToken revokedToken = new RevokedToken();
revokedToken.setJti(jti);
revokedToken.setUserId(userId);
revokedToken.setTenantId(tenantId);
revokedToken.setRevokedAt(Instant.now());
revokedToken.setExpiresAt(expiresAt);
revokedToken.setReason(reason);
revokedTokenRepository.save(revokedToken);
revokedCache.put(jti, true);
log.info("Revoked token {} for user {} (reason: {})", jti, userId, reason);
}
/**
* Removes expired revoked tokens from the database.
* Called by TokenCleanupScheduler nightly.
*/
@Transactional
public int cleanupExpiredTokens() {
int deleted = revokedTokenRepository.deleteExpiredTokens(Instant.now());
if (deleted > 0) {
log.info("Cleaned up {} expired revoked tokens", deleted);
revokedCache.invalidateAll();
}
return deleted;
}
}
@@ -0,0 +1,25 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.RevokedToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.UUID;
@Repository
public interface RevokedTokenRepository extends JpaRepository<RevokedToken, UUID> {
boolean existsByJti(String jti);
@Modifying
@Query("DELETE FROM RevokedToken r WHERE r.expiresAt < :cutoff")
int deleteExpiredTokens(@Param("cutoff") Instant cutoff);
@Modifying
@Query("DELETE FROM RevokedToken r WHERE r.userId = :userId")
int deleteByUserId(@Param("userId") UUID userId);
}
@@ -0,0 +1,23 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.StaffAccount;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface StaffAccountRepository extends JpaRepository<StaffAccount, UUID> {
Optional<StaffAccount> findByUserId(UUID userId);
List<StaffAccount> findByTenantIdAndActiveTrue(UUID tenantId);
List<StaffAccount> findByTenantIdAndPreventionOfficerTrue(UUID tenantId);
long countByTenantIdAndPreventionOfficerTrueAndActiveTrue(UUID tenantId);
boolean existsByUserId(UUID userId);
}
@@ -0,0 +1,123 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.RevokedToken;
import de.cannamanage.service.repository.RevokedTokenRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TokenRevocationServiceTest {
@Mock
private RevokedTokenRepository revokedTokenRepository;
@InjectMocks
private TokenRevocationService service;
private String testJti;
private UUID testUserId;
private UUID testTenantId;
@BeforeEach
void setUp() {
testJti = UUID.randomUUID().toString();
testUserId = UUID.randomUUID();
testTenantId = UUID.randomUUID();
}
@Test
void isRevoked_notRevoked_returnsFalse() {
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(false);
assertThat(service.isRevoked(testJti)).isFalse();
}
@Test
void isRevoked_revoked_returnsTrue() {
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
assertThat(service.isRevoked(testJti)).isTrue();
}
@Test
void isRevoked_nullJti_returnsFalse() {
assertThat(service.isRevoked(null)).isFalse();
}
@Test
void isRevoked_blankJti_returnsFalse() {
assertThat(service.isRevoked(" ")).isFalse();
}
@Test
void isRevoked_usesCache_onSecondCall() {
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
// First call goes to DB
assertThat(service.isRevoked(testJti)).isTrue();
// Second call should use cache
assertThat(service.isRevoked(testJti)).isTrue();
// DB should only be called once (cache handles second call)
verify(revokedTokenRepository, times(1)).existsByJti(testJti);
}
@Test
void revokeToken_savesRevocation() {
Instant expiresAt = Instant.now().plusSeconds(3600);
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(false);
service.revokeToken(testJti, testUserId, testTenantId, expiresAt, "logout");
ArgumentCaptor<RevokedToken> captor = ArgumentCaptor.forClass(RevokedToken.class);
verify(revokedTokenRepository).save(captor.capture());
RevokedToken saved = captor.getValue();
assertThat(saved.getJti()).isEqualTo(testJti);
assertThat(saved.getUserId()).isEqualTo(testUserId);
assertThat(saved.getTenantId()).isEqualTo(testTenantId);
assertThat(saved.getExpiresAt()).isEqualTo(expiresAt);
assertThat(saved.getReason()).isEqualTo("logout");
assertThat(saved.getRevokedAt()).isNotNull();
}
@Test
void revokeToken_alreadyRevoked_doesNotSaveAgain() {
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
service.revokeToken(testJti, testUserId, testTenantId, Instant.now().plusSeconds(3600), "duplicate");
verify(revokedTokenRepository, never()).save(any());
}
@Test
void cleanupExpiredTokens_deletesExpired() {
when(revokedTokenRepository.deleteExpiredTokens(any(Instant.class))).thenReturn(5);
int deleted = service.cleanupExpiredTokens();
assertThat(deleted).isEqualTo(5);
verify(revokedTokenRepository).deleteExpiredTokens(any(Instant.class));
}
@Test
void cleanupExpiredTokens_nothingToDelete_returnsZero() {
when(revokedTokenRepository.deleteExpiredTokens(any(Instant.class))).thenReturn(0);
int deleted = service.cleanupExpiredTokens();
assertThat(deleted).isEqualTo(0);
}
}