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
@@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -25,6 +26,7 @@ public class ComplianceController {
@GetMapping("/quota/{memberId}") @GetMapping("/quota/{memberId}")
@Operation(summary = "Get member quota status", @Operation(summary = "Get member quota status",
description = "Returns current monthly remaining quota for a member per CanG §19") description = "Returns current monthly remaining quota for a member per CanG §19")
@PreAuthorize("hasRole('ADMIN') or hasRole('MEMBER') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_QUOTA)")
public ResponseEntity<QuotaResponse> getQuotaStatus(@PathVariable UUID memberId) { public ResponseEntity<QuotaResponse> getQuotaStatus(@PathVariable UUID memberId) {
QuotaStatus status = complianceService.getQuotaStatus(memberId); QuotaStatus status = complianceService.getQuotaStatus(memberId);
@@ -11,6 +11,7 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -29,6 +30,7 @@ public class DistributionController {
@GetMapping @GetMapping
@Operation(summary = "List all distributions", description = "Returns all distribution records for the current tenant") @Operation(summary = "List all distributions", description = "Returns all distribution records for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
public ResponseEntity<List<DistributionResponse>> listDistributions() { public ResponseEntity<List<DistributionResponse>> listDistributions() {
List<DistributionResponse> distributions = distributionRepository.findAll().stream() List<DistributionResponse> distributions = distributionRepository.findAll().stream()
.map(this::toResponse) .map(this::toResponse)
@@ -39,6 +41,7 @@ public class DistributionController {
@PostMapping @PostMapping
@Operation(summary = "Record a distribution", @Operation(summary = "Record a distribution",
description = "Records a cannabis distribution after compliance checks pass (CanG §19)") description = "Records a cannabis distribution after compliance checks pass (CanG §19)")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
public ResponseEntity<DistributionResponse> createDistribution( public ResponseEntity<DistributionResponse> createDistribution(
@Valid @RequestBody CreateDistributionRequest request, @Valid @RequestBody CreateDistributionRequest request,
Authentication authentication) { Authentication authentication) {
@@ -13,6 +13,7 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -31,6 +32,7 @@ public class MemberController {
@GetMapping @GetMapping
@Operation(summary = "List all members", description = "Returns all members for the current tenant") @Operation(summary = "List all members", description = "Returns all members for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_LIST)")
public ResponseEntity<List<MemberResponse>> listMembers() { public ResponseEntity<List<MemberResponse>> listMembers() {
List<MemberResponse> members = memberRepository.findAll().stream() List<MemberResponse> members = memberRepository.findAll().stream()
.map(this::toResponse) .map(this::toResponse)
@@ -40,6 +42,7 @@ public class MemberController {
@GetMapping("/{id}") @GetMapping("/{id}")
@Operation(summary = "Get member by ID") @Operation(summary = "Get member by ID")
@PreAuthorize("hasRole('ADMIN') or hasRole('MEMBER') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_LIST)")
public ResponseEntity<MemberResponse> getMember(@PathVariable UUID id) { public ResponseEntity<MemberResponse> getMember(@PathVariable UUID id) {
Member member = memberRepository.findById(id) Member member = memberRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
@@ -48,6 +51,7 @@ public class MemberController {
@PostMapping @PostMapping
@Operation(summary = "Create a new member") @Operation(summary = "Create a new member")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)")
public ResponseEntity<MemberResponse> createMember(@Valid @RequestBody CreateMemberRequest request) { public ResponseEntity<MemberResponse> createMember(@Valid @RequestBody CreateMemberRequest request) {
Member member = new Member(); Member member = new Member();
member.setFirstName(request.firstName()); member.setFirstName(request.firstName());
@@ -65,6 +69,7 @@ public class MemberController {
@PutMapping("/{id}") @PutMapping("/{id}")
@Operation(summary = "Update a member") @Operation(summary = "Update a member")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)")
public ResponseEntity<MemberResponse> updateMember(@PathVariable UUID id, public ResponseEntity<MemberResponse> updateMember(@PathVariable UUID id,
@Valid @RequestBody UpdateMemberRequest request) { @Valid @RequestBody UpdateMemberRequest request) {
Member member = memberRepository.findById(id) Member member = memberRepository.findById(id)
@@ -99,7 +104,7 @@ public class MemberController {
m.getMembershipNumber(), m.getMembershipNumber(),
m.getStatus(), m.getStatus(),
m.isUnder21(), m.isUnder21(),
m.isPreventionOfficer() false // preventionOfficer flag comes from StaffAccount, not Member
); );
} }
} }
@@ -11,6 +11,7 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -26,6 +27,7 @@ public class StockController {
@GetMapping @GetMapping
@Operation(summary = "List all batches", description = "Returns all batches for the current tenant") @Operation(summary = "List all batches", description = "Returns all batches for the current tenant")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
public ResponseEntity<List<BatchResponse>> listBatches() { public ResponseEntity<List<BatchResponse>> listBatches() {
List<BatchResponse> batches = batchRepository.findAll().stream() List<BatchResponse> batches = batchRepository.findAll().stream()
.map(this::toResponse) .map(this::toResponse)
@@ -35,6 +37,7 @@ public class StockController {
@GetMapping("/{id}") @GetMapping("/{id}")
@Operation(summary = "Get batch by ID") @Operation(summary = "Get batch by ID")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
public ResponseEntity<BatchResponse> getBatch(@PathVariable UUID id) { public ResponseEntity<BatchResponse> getBatch(@PathVariable UUID id) {
Batch batch = batchRepository.findById(id) Batch batch = batchRepository.findById(id)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException( .orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
@@ -44,6 +47,7 @@ public class StockController {
@PostMapping @PostMapping
@Operation(summary = "Create a new batch", description = "Registers a new cannabis batch in inventory") @Operation(summary = "Create a new batch", description = "Registers a new cannabis batch in inventory")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
public ResponseEntity<BatchResponse> createBatch(@Valid @RequestBody CreateBatchRequest request) { public ResponseEntity<BatchResponse> createBatch(@Valid @RequestBody CreateBatchRequest request) {
Batch batch = new Batch(); Batch batch = new Batch();
batch.setStrainId(request.strainId()); batch.setStrainId(request.strainId());
@@ -1,6 +1,7 @@
package de.cannamanage.api.security; package de.cannamanage.api.security;
import de.cannamanage.domain.entity.TenantContext; import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.service.TokenRevocationService;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -21,7 +22,7 @@ import java.util.UUID;
/** /**
* JWT authentication filter. * JWT authentication filter.
* Extracts Bearer token from Authorization header, validates it, * Extracts Bearer token from Authorization header, validates it,
* sets SecurityContext and TenantContext for downstream processing. * checks token blacklist (revocation), sets SecurityContext and TenantContext.
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -29,6 +30,7 @@ import java.util.UUID;
public class JwtAuthFilter extends OncePerRequestFilter { public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService; private final JwtService jwtService;
private final TokenRevocationService tokenRevocationService;
@Override @Override
protected void doFilterInternal(HttpServletRequest request, protected void doFilterInternal(HttpServletRequest request,
@@ -48,6 +50,14 @@ public class JwtAuthFilter extends OncePerRequestFilter {
return; return;
} }
// Check token blacklist (revocation) — skip for portal paths per plan review warning #5
String jti = jwtService.extractJti(token);
if (jti != null && tokenRevocationService.isRevoked(jti)) {
log.debug("Token {} is revoked, rejecting request", jti);
filterChain.doFilter(request, response);
return;
}
UUID userId = jwtService.extractUserId(token); UUID userId = jwtService.extractUserId(token);
UUID tenantId = jwtService.extractTenantId(token); UUID tenantId = jwtService.extractTenantId(token);
String role = jwtService.extractRole(token); String role = jwtService.extractRole(token);
@@ -76,6 +86,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
protected boolean shouldNotFilter(HttpServletRequest request) { protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath(); String path = request.getServletPath();
return path.startsWith("/api/v1/auth/") return path.startsWith("/api/v1/auth/")
|| path.startsWith("/portal/")
|| path.startsWith("/swagger-ui") || path.startsWith("/swagger-ui")
|| path.startsWith("/v3/api-docs"); || path.startsWith("/v3/api-docs");
} }
@@ -9,14 +9,12 @@ import org.springframework.stereotype.Service;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import java.time.Instant; import java.time.Instant;
import java.util.Date; import java.util.*;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function; import java.util.function.Function;
/** /**
* JWT token generation and validation service. * JWT token generation and validation service.
* Access tokens: 1 hour expiry. * Access tokens: 1 hour expiry, includes jti + permissions for STAFF.
* Refresh tokens: 30 days expiry. * Refresh tokens: 30 days expiry.
*/ */
@Service @Service
@@ -31,19 +29,40 @@ public class JwtService {
@Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}") @Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
private long refreshTokenExpiry; // seconds (30 days) private long refreshTokenExpiry; // seconds (30 days)
/**
* Generate access token for ADMIN/MEMBER roles (no permissions claim needed).
*/
public String generateAccessToken(UUID userId, UUID tenantId, String role, String email) { public String generateAccessToken(UUID userId, UUID tenantId, String role, String email) {
return buildToken(Map.of( Map<String, Object> claims = new HashMap<>();
"tenant_id", tenantId.toString(), claims.put("tenant_id", tenantId.toString());
"role", role, claims.put("role", role);
"email", email claims.put("email", email);
), userId.toString(), accessTokenExpiry); claims.put("jti", UUID.randomUUID().toString());
return buildToken(claims, userId.toString(), accessTokenExpiry);
}
/**
* Generate access token for STAFF role — includes permissions list.
*/
public String generateStaffAccessToken(UUID userId, UUID tenantId, String email, List<String> permissions) {
Map<String, Object> claims = new HashMap<>();
claims.put("tenant_id", tenantId.toString());
claims.put("role", "STAFF");
claims.put("email", email);
claims.put("jti", UUID.randomUUID().toString());
claims.put("permissions", permissions);
return buildToken(claims, userId.toString(), accessTokenExpiry);
} }
public String generateRefreshToken(UUID userId, UUID tenantId) { public String generateRefreshToken(UUID userId, UUID tenantId) {
return buildToken(Map.of( Map<String, Object> claims = new HashMap<>();
"tenant_id", tenantId.toString(), claims.put("tenant_id", tenantId.toString());
"type", "refresh" claims.put("type", "refresh");
), userId.toString(), refreshTokenExpiry); claims.put("jti", UUID.randomUUID().toString());
return buildToken(claims, userId.toString(), refreshTokenExpiry);
} }
public String extractSubject(String token) { public String extractSubject(String token) {
@@ -66,6 +85,36 @@ public class JwtService {
return extractClaim(token, claims -> claims.get("email", String.class)); return extractClaim(token, claims -> claims.get("email", String.class));
} }
/**
* Extract the JTI (JWT ID) claim — used for token revocation.
*/
public String extractJti(String token) {
return extractClaim(token, claims -> claims.get("jti", String.class));
}
/**
* Extract permissions list from STAFF token.
* Returns empty list if not present (non-STAFF tokens).
*/
@SuppressWarnings("unchecked")
public List<String> extractPermissions(String token) {
return extractClaim(token, claims -> {
Object perms = claims.get("permissions");
if (perms instanceof List<?>) {
return (List<String>) perms;
}
return Collections.emptyList();
});
}
/**
* Extract token expiration as Instant — used for revocation record.
*/
public Instant extractExpirationInstant(String token) {
Date exp = extractClaim(token, Claims::getExpiration);
return exp.toInstant();
}
public boolean isTokenValid(String token) { public boolean isTokenValid(String token) {
try { try {
extractAllClaims(token); extractAllClaims(token);
@@ -0,0 +1,56 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.service.repository.StaffAccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* SpEL-accessible bean for checking prevention officer status.
* Usage in @PreAuthorize:
* @PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
*/
@Slf4j
@Component("preventionOfficer")
@RequiredArgsConstructor
public class PreventionOfficerChecker {
private final StaffAccountRepository staffAccountRepository;
/**
* Checks if the authenticated user is a designated prevention officer.
* ADMIN always passes. STAFF must have is_prevention_officer = true.
*/
public boolean check(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
// ADMIN always passes
boolean isAdmin = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_ADMIN"));
if (isAdmin) {
return true;
}
// STAFF must be a prevention officer
boolean isStaff = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_STAFF"));
if (!isStaff) {
return false;
}
UUID userId = (UUID) authentication.getPrincipal();
return staffAccountRepository.findByUserId(userId)
.filter(StaffAccount::isActive)
.map(StaffAccount::isPreventionOfficer)
.orElse(false);
}
}
@@ -14,9 +14,8 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/** /**
* Security configuration — Sprint 2: API-only with JWT. * Security configuration — Sprint 3: API + Staff portal with JWT.
* Roles: ADMIN (full access) + MEMBER (self-service endpoints only). * Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service).
* STAFF role reserved for Sprint 3.
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@@ -28,7 +27,7 @@ public class SecurityConfig {
/** /**
* API security — stateless JWT authentication. * API security — stateless JWT authentication.
* All /api/v1/** endpoints require authentication except /api/v1/auth/**. * URL-level role checks provide first layer; @PreAuthorize provides fine-grained.
*/ */
@Bean @Bean
@Order(1) @Order(1)
@@ -41,10 +40,10 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "MEMBER") .requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "MEMBER") .requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/stock/**").hasRole("ADMIN") .requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "MEMBER") .requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN") .requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
.anyRequest().authenticated()) .anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
@@ -0,0 +1,57 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.repository.StaffAccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* SpEL-accessible bean for fine-grained staff permission checks.
* Usage in @PreAuthorize:
* @PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
*/
@Slf4j
@Component("staffPermissions")
@RequiredArgsConstructor
public class StaffPermissionChecker {
private final StaffAccountRepository staffAccountRepository;
/**
* Checks if the authenticated user has the required permission.
* ADMIN role always passes. STAFF checks granted_permissions on their StaffAccount.
*/
public boolean has(Authentication authentication, StaffPermission required) {
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
// ADMIN always has all permissions
boolean isAdmin = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_ADMIN"));
if (isAdmin) {
return true;
}
// STAFF must have the specific permission granted
boolean isStaff = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_STAFF"));
if (!isStaff) {
return false;
}
UUID userId = (UUID) authentication.getPrincipal();
return staffAccountRepository.findByUserId(userId)
.filter(StaffAccount::isActive)
.map(staff -> staff.hasPermission(required))
.orElse(false);
}
}
@@ -0,0 +1,45 @@
-- Sprint 3: Staff Portal foundation
-- Staff accounts, permissions, revoked tokens, prevention officer support
-- Staff accounts table (links users with STAFF role to their permissions)
CREATE TABLE staff_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES clubs(id),
user_id UUID NOT NULL UNIQUE REFERENCES users(id),
display_name VARCHAR(150) NOT NULL,
is_prevention_officer BOOLEAN NOT NULL DEFAULT FALSE,
active BOOLEAN NOT NULL DEFAULT TRUE,
invited_at TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Staff account permissions (element collection table)
CREATE TABLE staff_account_permissions (
staff_account_id UUID NOT NULL REFERENCES staff_accounts(id) ON DELETE CASCADE,
permission VARCHAR(50) NOT NULL,
PRIMARY KEY (staff_account_id, permission)
);
-- Revoked tokens table for JWT blacklisting
CREATE TABLE revoked_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
jti VARCHAR(36) NOT NULL UNIQUE,
user_id UUID NOT NULL,
tenant_id UUID NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
reason VARCHAR(100)
);
-- Indexes for revoked tokens
CREATE INDEX idx_revoked_tokens_jti ON revoked_tokens(jti);
CREATE INDEX idx_revoked_tokens_user_id ON revoked_tokens(user_id);
CREATE INDEX idx_revoked_tokens_expires_at ON revoked_tokens(expires_at);
-- Index for staff accounts
CREATE INDEX idx_staff_accounts_tenant_id ON staff_accounts(tenant_id);
CREATE INDEX idx_staff_accounts_user_id ON staff_accounts(user_id);
-- Add max_prevention_officers to clubs table (default 2 per plan)
ALTER TABLE clubs ADD COLUMN max_prevention_officers INTEGER NOT NULL DEFAULT 2;
@@ -0,0 +1,123 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.repository.StaffAccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StaffPermissionCheckerTest {
@Mock
private StaffAccountRepository staffAccountRepository;
@InjectMocks
private StaffPermissionChecker checker;
private UUID staffUserId;
private StaffAccount staffAccount;
@BeforeEach
void setUp() {
staffUserId = UUID.randomUUID();
staffAccount = new StaffAccount();
staffAccount.setUserId(staffUserId);
staffAccount.setDisplayName("Test Staff");
staffAccount.setActive(true);
staffAccount.setGrantedPermissions(Set.of(
StaffPermission.RECORD_DISTRIBUTION,
StaffPermission.VIEW_MEMBER_LIST
));
}
@Test
void adminAlwaysHasPermission() {
Authentication auth = new UsernamePasswordAuthenticationToken(
UUID.randomUUID(), null,
List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
);
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isTrue();
assertThat(checker.has(auth, StaffPermission.MANAGE_GROW_CALENDAR)).isTrue();
}
@Test
void staffWithGrantedPermission_returnsTrue() {
Authentication auth = new UsernamePasswordAuthenticationToken(
staffUserId, null,
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
);
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.of(staffAccount));
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isTrue();
}
@Test
void staffWithoutGrantedPermission_returnsFalse() {
Authentication auth = new UsernamePasswordAuthenticationToken(
staffUserId, null,
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
);
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.of(staffAccount));
assertThat(checker.has(auth, StaffPermission.MANAGE_GROW_CALENDAR)).isFalse();
}
@Test
void inactiveStaff_returnsFalse() {
staffAccount.setActive(false);
Authentication auth = new UsernamePasswordAuthenticationToken(
staffUserId, null,
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
);
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.of(staffAccount));
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isFalse();
}
@Test
void memberRole_returnsFalse() {
Authentication auth = new UsernamePasswordAuthenticationToken(
UUID.randomUUID(), null,
List.of(new SimpleGrantedAuthority("ROLE_MEMBER"))
);
assertThat(checker.has(auth, StaffPermission.VIEW_MEMBER_LIST)).isFalse();
}
@Test
void nullAuthentication_returnsFalse() {
assertThat(checker.has(null, StaffPermission.RECORD_DISTRIBUTION)).isFalse();
}
@Test
void staffWithNoAccount_returnsFalse() {
Authentication auth = new UsernamePasswordAuthenticationToken(
staffUserId, null,
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
);
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.empty());
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isFalse();
}
}
@@ -0,0 +1,66 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Stores revoked JWT tokens for token blacklist checking.
* Tokens are identified by their JTI (JWT ID) claim.
* Cleanup scheduler removes expired entries nightly.
*/
@Entity
@Table(name = "revoked_tokens", indexes = {
@Index(name = "idx_revoked_tokens_jti", columnList = "jti", unique = true),
@Index(name = "idx_revoked_tokens_user_id", columnList = "user_id"),
@Index(name = "idx_revoked_tokens_expires_at", columnList = "expires_at")
})
public class RevokedToken {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Column(name = "jti", nullable = false, unique = true, length = 36)
private String jti;
@Column(name = "user_id", nullable = false)
private UUID userId;
@Column(name = "tenant_id", nullable = false)
private UUID tenantId;
@Column(name = "revoked_at", nullable = false)
private Instant revokedAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "reason", length = 100)
private String reason;
// --- Getters & Setters ---
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public String getJti() { return jti; }
public void setJti(String jti) { this.jti = jti; }
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public UUID getTenantId() { return tenantId; }
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
public Instant getRevokedAt() { return revokedAt; }
public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; }
public Instant getExpiresAt() { return expiresAt; }
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
}
@@ -0,0 +1,71 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.StaffPermission;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
/**
* Staff account with fine-grained permissions.
* Links a user (STAFF role) to their granted permissions stored as JSONB.
* One StaffAccount per user; permissions are a subset of StaffPermission enum values.
*/
@Entity
@Table(name = "staff_accounts")
public class StaffAccount extends AbstractTenantEntity {
@Column(name = "user_id", nullable = false, unique = true)
private UUID userId;
@Column(name = "display_name", nullable = false, length = 150)
private String displayName;
@ElementCollection(targetClass = StaffPermission.class, fetch = FetchType.EAGER)
@CollectionTable(name = "staff_account_permissions",
joinColumns = @JoinColumn(name = "staff_account_id"))
@Enumerated(EnumType.STRING)
@Column(name = "permission", nullable = false, length = 50)
private Set<StaffPermission> grantedPermissions = new HashSet<>();
@Column(name = "is_prevention_officer", nullable = false)
private boolean preventionOfficer = false;
@Column(name = "active", nullable = false)
private boolean active = true;
@Column(name = "invited_at")
private Instant invitedAt;
@Column(name = "activated_at")
private Instant activatedAt;
// --- Getters & Setters ---
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public Set<StaffPermission> getGrantedPermissions() { return grantedPermissions; }
public void setGrantedPermissions(Set<StaffPermission> grantedPermissions) { this.grantedPermissions = grantedPermissions; }
public boolean isPreventionOfficer() { return preventionOfficer; }
public void setPreventionOfficer(boolean preventionOfficer) { this.preventionOfficer = preventionOfficer; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public Instant getInvitedAt() { return invitedAt; }
public void setInvitedAt(Instant invitedAt) { this.invitedAt = invitedAt; }
public Instant getActivatedAt() { return activatedAt; }
public void setActivatedAt(Instant activatedAt) { this.activatedAt = activatedAt; }
public boolean hasPermission(StaffPermission permission) {
return grantedPermissions.contains(permission);
}
}
@@ -0,0 +1,17 @@
package de.cannamanage.domain.enums;
/**
* Fine-grained permissions for STAFF role users.
* Admins implicitly have all permissions.
* Staff members are granted a subset via their StaffAccount.
*/
public enum StaffPermission {
RECORD_DISTRIBUTION,
VIEW_MEMBER_LIST,
VIEW_MEMBER_QUOTA,
ADD_MEMBER,
VIEW_STOCK,
RECORD_STOCK_IN,
VIEW_COMPLIANCE_REPORT,
MANAGE_GROW_CALENDAR
}
+10
View File
@@ -47,6 +47,16 @@
<artifactId>assertj-core</artifactId> <artifactId>assertj-core</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- Caffeine caching for token revocation -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- Spring Context for @Scheduled -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -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);
}
}
@@ -0,0 +1,316 @@
# Plan Review: CannaManage Sprint 3
**Date:** 2026-06-11
**Reviewer:** Roo (Plan Reviewer)
**Documents:** Sprint 3 Plan v2 (APPROVED by Planner)
**Verdict:** ✅ APPROVED
---
## Summary
The Sprint 3 plan is comprehensive, technically sound, and well-ordered. All deferred Sprint 2 features (staff, portal, reports, prevention officer, club settings) are covered. Architecture decisions are consistent with the existing codebase. The plan correctly builds on Sprint 2's shared-schema + TenantFilterAspect pattern rather than the architecture doc's theoretical schema-per-tenant model. 7 non-blocking findings identified — all are suggestions for improvement, none require plan revision.
---
## Review Checklist
### 1. Does the plan cover ALL deferred Sprint 2 items?
| Deferred Item | Plan Coverage | Status |
|---------------|--------------|--------|
| STAFF role | Phase 1 (StaffPermission enum, StaffAccount entity, SpEL checker) | ✅ |
| Member portal | Phase 5 (session-based SecurityFilterChain, PortalController) | ✅ |
| Reports | Phase 4 (ReportController, iText 7 PDF, Commons CSV) | ✅ |
| Prevention Officer | Phase 6 (configurable limit, assignment endpoint, under-21 gate) | ✅ |
| Club settings | Phase 2 (ClubController GET/PUT /clubs/me, stats) | ✅ |
| Integration tests | Phase 7 (Testcontainers PostgreSQL, 6 test classes) | ✅ |
**Result: ✅ PASS** — All deferred items fully addressed.
---
### 2. Architecture consistency with Sprint 2 patterns
| Pattern | Sprint 2 Implementation | Sprint 3 Plan | Consistent? |
|---------|------------------------|---------------|-------------|
| Tenant isolation | `TenantFilterAspect` (shared-schema, Hibernate @Filter) | Continues shared-schema; `staff_accounts` has `tenant_id` | ✅ |
| JWT auth | `JwtService` + `JwtAuthFilter` (stateless, JJWT) | Extends with `jti` + `permissions` claims | ✅ |
| SecurityConfig | `@Order(1)` API + `@Order(2)` Public | Inserts Portal `@Order(2)`, shifts Public to `@Order(3)` | ✅ |
| Role model | `UserRole` enum: `ROLE_ADMIN`, `ROLE_STAFF`, `ROLE_MEMBER` | Uses same enum; STAFF already present in code | ✅ |
| Entity base | `AbstractTenantEntity` with `tenant_id` | New entities (StaffAccount, InviteToken) follow same pattern | ✅ |
| Repositories | Spring Data JPA in `cannamanage-service/.../repository/` | New repos placed in same package | ✅ |
**Result: ✅ PASS** — Fully consistent with established patterns.
---
### 3. Staff permission model well-defined?
| Aspect | Assessment |
|--------|-----------|
| Enum values | 8 permissions covering all current features + 1 future (`MANAGE_GROW_CALENDAR`) | ✅ |
| Storage | JSONB on `staff_accounts` — correct for PostgreSQL, supports flexible permission sets | ✅ |
| Enforcement | SpEL `@PreAuthorize` + custom `StaffPermissionChecker` bean | ✅ |
| JWT embedding | Permissions in JWT for stateless checks; blacklist fallback for revoked tokens | ✅ |
| ADMIN bypass | `StaffPermissionChecker.has()` returns `true` for ADMIN role first | ✅ |
| Templates | 3 role templates (Ausgabe, Lager, Vorstand) — matches architecture doc | ✅ |
**Result: ✅ PASS** — Well-designed, DSGVO-compliant least-privilege model.
---
### 4. Member portal auth design clean?
| Aspect | Assessment |
|--------|-----------|
| Dual SecurityFilterChain | `@Order(2)` for `/portal/**` — correct isolation from API chain | ✅ |
| Session-based | `SessionCreationPolicy.IF_REQUIRED` + 30min timeout | ✅ |
| CSRF | Enabled via `CookieCsrfTokenRepository.withHttpOnlyFalse()` — React SPA can read cookie | ✅ |
| Read-only | All portal endpoints are GET — minimal attack surface | ✅ |
| Data isolation | Member can only see own data (enforced by `memberId` from session principal) | ✅ |
| `SameSite=Strict` | Correct for CSRF prevention | ✅ |
**Result: ✅ PASS** — Clean separation of concerns.
---
### 5. Dependencies pinned to specific versions?
| Library | Plan Version | Pinned? |
|---------|-------------|---------|
| iText 7 | `8.0.x` | ⚠️ Range |
| Apache Commons CSV | `1.11+` | ⚠️ Range |
| Caffeine | `3.1+` | ⚠️ Range |
| Spring Boot Starter Mail | Boot-managed | ✅ (BOM) |
| Testcontainers | `1.19+` | ⚠️ Range |
**Result: ⚠️ WARNING (non-blocking)** — Versions use ranges instead of exact pins. This is acceptable for a plan document (exact versions determined at implementation time by checking latest stable), but the implementor should pin exact versions in the POM.
---
### 6. Flyway V3 migration complete?
| Schema Change | Covered in V3? |
|---------------|---------------|
| `staff_accounts` table | ✅ Full DDL with JSONB, indexes, unique constraint |
| `revoked_tokens` table | ✅ With jti index + expires index |
| `invite_tokens` table | ✅ With token index, FK to users |
| `users.prevention_officer` column | ✅ `ALTER TABLE ADD COLUMN` |
| Club extension columns (9 columns) | ✅ All `ALTER TABLE ADD COLUMN IF NOT EXISTS` |
**Result: ✅ PASS** — All new tables and columns defined in a single idempotent migration.
---
### 7. Test plan comprehensive?
| Phase | Unit Tests | Integration Tests | Total |
|-------|-----------|-------------------|-------|
| P1 (Staff + Revocation) | T-01 to T-06 | — | 6 |
| P2 (Club) | T-07 to T-10 | — | 4 |
| P3 (Staff CRUD + Invite) | T-11 to T-14 | — | 4 |
| P4 (Reports) | T-15 to T-18 | — | 4 |
| P5 (Portal) | — | T-19, T-20 | 2 |
| P6 (Prevention Officer) | T-21, T-22 | — | 2 |
| P7 (Integration) | — | T-23 to T-26 | 4 |
| **Total** | **18** | **8** | **26** |
Coverage check:
- Every phase has at least one test: ✅
- Edge cases (expired token, invalid regex, permission denial): ✅
- E2E flows (invite → set-password → login → permission check): ✅
- Tenant isolation: ✅
- Token revocation lifecycle: ✅
**Result: ✅ PASS** — Comprehensive coverage matching all implementation phases.
---
### 8. Gaps between API spec and implementation plan?
| API Spec Endpoint | Sprint 3 Plan | Gap? |
|-------------------|---------------|------|
| `GET /clubs/me` (§6.1) | Phase 2 — `ClubController` | ✅ Covered |
| `PUT /clubs/me` (§6.2) | Phase 2 — `ClubController` | ✅ Covered |
| `GET /clubs/me/stats` (§6.3) | Phase 2 — `ClubStatsResponse` DTO | ✅ Covered |
| `POST /auth/logout` (§5.3) | Not explicitly addressed | ⚠️ See finding #3 |
| `GET /reports/monthly` (§10) | Phase 4 — `ReportController` | ✅ Covered |
| `GET /reports/members` (§10) | Phase 4 — member list export | ✅ Covered |
| `GET /reports/recall/{batchId}` (§10) | Phase 4 — recall report | ✅ Covered |
| Staff endpoints (not in spec) | Phase 3 — new endpoints | ️ Plan extends spec |
| Portal endpoints (not in spec) | Phase 5 — new `/portal/**` | ️ Plan extends spec |
| `POST /auth/set-password` (not in spec) | Phase 3 step 3.9 | ️ Plan extends spec |
**Result: ✅ PASS** — One minor gap (logout integration), otherwise plan both implements spec and correctly extends it for Sprint 3 scope.
---
### 9. Token revocation/blacklist approach sound?
| Aspect | Assessment |
|--------|-----------|
| DB-backed (no Redis) | Correct for MVP scale — simple, durable, no infrastructure dependency | ✅ |
| Caffeine cache (60s TTL, 10K max) | Appropriate tradeoff — worst case 60s window after revocation | ✅ |
| `jti` claim in JWT | Required for per-token revocation — currently missing, plan adds it | ✅ |
| `revokeAllForUser()` | Called on permission change + staff deactivation — correct triggers | ✅ |
| Cleanup scheduler (daily 3 AM) | Removes expired tokens to prevent table bloat | ✅ |
| Index on `jti` | Fast lookup for blacklist check | ✅ |
| Index on `expires_at` | Fast cleanup queries | ✅ |
**Result: ✅ PASS** — Sound, pragmatic approach for a club-scale application.
---
### 10. Risks not addressed?
| Potential Risk | Addressed? | Notes |
|----------------|-----------|-------|
| iText AGPL license | ✅ | Risk table mentions it, mitigation: switch to OpenPDF before go-live |
| Boot 4 `@EntityScan` issue | ✅ | Phase 7 step 7.1 explicitly addresses it |
| JSONB + Hibernate 6 | ✅ | `@JdbcTypeCode(SqlTypes.JSON)` mentioned |
| SMTP delivery | ✅ | Mailpit for dev, transactional email service for prod |
| Cache staleness | ✅ | Accepted as non-critical for club app |
| Portal CSRF + SPA | ✅ | `CookieCsrfTokenRepository.withHttpOnlyFalse()` |
| `JwtAuthFilter` portal path exclusion | ⚠️ | See finding #5 |
| Email domain regex DoS | ⚠️ | See finding #6 |
**Result: ✅ PASS** — All major risks addressed. Two minor items noted below.
---
## Findings
### ⚠️ Warnings (non-blocking — implement-time improvements)
#### 1. Dependency versions not pinned
**Plan §9** uses version ranges (`8.0.x`, `1.11+`, `3.1+`).
**Recommendation:** At implementation time, pin exact versions in the POM:
- iText 7: `8.0.5`
- Commons CSV: `1.12.0`
- Caffeine: `3.1.8`
- Testcontainers: `1.20.1`
---
#### 2. `revoked_tokens` table has no `tenant_id` column
The token blacklist is global across all tenants. This works correctly (tokens are unique by `jti` regardless of tenant), but it means:
- `revokeAllForUser(userId)` queries ALL tenants' revoked tokens
- No ability to purge one tenant's revoked tokens independently
**Recommendation:** Consider adding `tenant_id` for operational convenience. Not blocking — the current design is functionally correct.
---
#### 3. `POST /auth/logout` not integrated with token revocation
API spec §5.3 defines a logout endpoint that invalidates the refresh token. Sprint 3's `TokenRevocationService` adds access token revocation. The plan doesn't explicitly describe how these interact.
**Recommendation:** On `POST /auth/logout`:
1. Revoke refresh token (existing behavior)
2. Also add current access token's `jti` to `revoked_tokens` (new behavior)
This ensures immediate invalidation rather than waiting for natural token expiry. Implementor should handle this in Phase 1.
---
#### 4. Portal `formLogin` with JSON API may need `AuthenticationSuccessHandler`
The plan describes portal as "JSON API" (Decision D2: no Thymeleaf) but configures `formLogin()` with `defaultSuccessUrl`. Standard `formLogin` returns HTTP redirects (302), not JSON responses.
**Recommendation:** Implement a custom `AuthenticationSuccessHandler` that returns `200 OK` with session info as JSON, and a custom `AuthenticationFailureHandler` returning `401` JSON error. This aligns with the SPA architecture.
---
#### 5. `JwtAuthFilter.shouldNotFilter()` needs `/portal/**` exclusion
Current `shouldNotFilter()` skips `/api/v1/auth/`, `/swagger-ui`, `/v3/api-docs`. The plan adds a portal with session-based auth, but the JWT filter will still process `/portal/**` requests (finding no Bearer header → passes through).
**Recommendation:** Add `/portal/` to `shouldNotFilter()` for clarity and to avoid unnecessary filter processing:
```java
return path.startsWith("/api/v1/auth/")
|| path.startsWith("/portal/") // ← add this
|| path.startsWith("/swagger-ui")
|| path.startsWith("/v3/api-docs");
```
Not strictly blocking (filter already passes through when no Bearer header present), but cleaner.
---
#### 6. Regex pattern validation — potential ReDoS
`validateEmailDomain()` uses `email.matches(club.getAllowedEmailPattern())` with admin-supplied regex. Malicious or poorly written regex could cause catastrophic backtracking (ReDoS).
**Recommendation:** Add a timeout or use `Pattern.compile()` with a simple validation check:
```java
try {
Pattern.compile(pattern); // validates syntax
// Additionally: reject patterns with known dangerous constructs
// Or use a timeout: java.util.concurrent.CompletableFuture with timeout
} catch (PatternSyntaxException e) {
throw new InvalidRegexException(pattern);
}
```
The plan does validate invalid regex (step 2.7, test T-10) but doesn't mention ReDoS protection specifically.
---
#### 7. Architecture doc deviation should be noted
The architecture doc (03-Architecture.md) describes:
- Schema-per-tenant (Sprint 2 implemented shared-schema with `tenant_id`)
- `ROLE_CLUB_ADMIN` / `ROLE_PREVENTION_OFFICER` (code uses `ROLE_ADMIN` / boolean flag)
- 8-hour access token (code uses 1-hour)
These are expected evolutionary deviations — the architecture doc reflects initial design, and Sprint 2 made pragmatic choices. The architecture doc should be updated to reflect reality, but this doesn't block Sprint 3.
**Recommendation:** Add a backlog item to sync the wiki architecture doc with actual implementation post-Sprint 3.
---
## Traceability Matrix
| Requirement Source | Plan Step | Test Case | Status |
|-------------------|-----------|-----------|--------|
| Deferred: STAFF role | Phase 1 (1.11.15) | T-01 to T-06, T-25 | ✅ |
| Deferred: Club settings | Phase 2 (2.12.8) | T-07 to T-10 | ✅ |
| Deferred: Staff CRUD + invite | Phase 3 (3.13.15) | T-11 to T-14, T-25 | ✅ |
| Deferred: Reports (US-007, US-008) | Phase 4 (4.14.9) | T-15 to T-18 | ✅ |
| Deferred: Member portal | Phase 5 (5.15.7) | T-19, T-20 | ✅ |
| Deferred: Prevention Officer (US-010) | Phase 6 (6.16.8) | T-21, T-22 | ✅ |
| Deferred: Integration tests | Phase 7 (7.17.8) | T-23 to T-26 | ✅ |
| Decision D1: Token revocation | Phase 1 (1.5, 1.6, 1.10, 1.14) | T-05, T-06, T-26 | ✅ |
| Decision D2: Portal as JSON API | Phase 5 | T-19, T-20 | ✅ |
| Decision D3: Multiple prevention officers | Phase 6 (6.2, 6.7) | T-21 | ✅ |
| Decision D4: Minimal PDF branding | Phase 4 (4.4, 4.5) | T-16 | ✅ |
| Decision D5: Testcontainers | Phase 7 (7.2) | T-23 to T-26 | ✅ |
| Decision D6: Invite flow | Phase 3 (3.23.9) | T-11, T-12, T-25 | ✅ |
| Decision D7: Email domain whitelist | Phase 2 (2.7), Phase 3 (3.5) | T-09, T-10 | ✅ |
---
## Verdict
### ✅ APPROVED
The Sprint 3 plan is complete, technically sound, and ready for implementation. All 10 review checklist items pass. 7 non-blocking warnings are noted as implementation-time improvements — none require plan revision.
**Recommendation:** Proceed to implementation. The implementor should address warnings #3 (logout integration), #4 (portal auth handlers), and #5 (JwtAuthFilter exclusion) during Phase 1 and Phase 5 respectively.
---
## Review Metadata
| Field | Value |
|-------|-------|
| Review iteration | 1 of 3 (max) |
| Plan version reviewed | v2 |
| Time spent | ~15 minutes |
| Confidence | 92% |
| Blocking findings | 0 |
| Non-blocking findings | 7 |
+844
View File
@@ -0,0 +1,844 @@
# CannaManage — Sprint 3 Implementation Plan
**Date:** 2026-06-11
**Author:** Patrick Plate / Lumen (Planner)
**Status:** ✅ APPROVED v2 — GO received
**Base Branch:** `sprint/2-api`
**Sprint Branch:** `sprint/3-staff-portal`
**Sprint Goal:** Staff permission model + Token revocation + Member portal + Club/Report controllers + Prevention Officer + Invite flow
---
## 0. Decisions (Confirmed by Patrick)
| # | Decision | Detail |
|---|----------|--------|
| D1 | JWT invalidation | **Token blacklist**`revoked_tokens` DB table + Caffeine cache (60s TTL). On permission change, all user's tokens revoked. |
| D2 | Portal rendering | **JSON API** — no Thymeleaf. React SPA consumes `/portal/**` with session cookies. |
| D3 | Prevention officer | **Multiple, configurable**`max_prevention_officers` on Club entity (default 2). Enforced on assignment. |
| D4 | PDF branding | **Minimal branding** — club name header, generated-at timestamp footer, page numbers. Inspection-ready. |
| D5 | Integration test DB | **Testcontainers PostgreSQL** — full fidelity for JSONB columns. |
| D6 | Staff creation | **Invite flow** — admin creates account, email invite sent, staff sets own password. Requires Spring Mail. |
| D7 | Email domain whitelist | **Regex pattern on Club settings**`allowed_email_pattern` column, validated on invite. NULL = unrestricted. |
---
## 1. Sprint 2 Recap (Context)
| Delivered | Status |
|-----------|--------|
| JWT auth (login + refresh with token rotation) | ✅ |
| SecurityConfig with ADMIN + MEMBER roles | ✅ |
| TenantFilterAspect (Hibernate @Filter activation) | ✅ |
| MemberController (CRUD) | ✅ |
| DistributionController (compliance-gated) | ✅ |
| StockController (batches) | ✅ |
| ComplianceController (wraps service) | ✅ |
| OpenAPI/Swagger | ✅ |
| Flyway V2 migration | ✅ |
| 25 unit tests passing | ✅ |
**Deferred from Sprint 2:** STAFF role, Member portal, Club settings, Report generation, Prevention Officer, Integration tests.
---
## 2. Sprint 3 Scope
### ✅ IN Scope
| # | Feature | Priority | Effort |
|---|---------|----------|--------|
| 1 | **Staff permission model**`StaffPermission` enum, `staff_accounts` table, `StaffPermissionChecker` SpEL bean | P0 | 1.5 days |
| 2 | **Token revocation**`revoked_tokens` table, `TokenRevocationService`, Caffeine cache, `JwtAuthFilter` integration | P0 | 0.5 days |
| 3 | **Club settings controller**`GET/PUT /clubs/me`, `GET /clubs/me/stats`, email domain whitelist, prevention officer limit | P0 | 0.5 days |
| 4 | **Staff management + invite flow** — CRUD + email invite + set-password endpoint + domain validation | P1 | 1.5 days |
| 5 | **Report controller** — monthly PDF/CSV/JSON, member list, recall report. Minimal branding (OpenPDF). | P1 | 1.5 days |
| 6 | **Member portal (session-based auth)** — second `SecurityFilterChain`, form login, `/portal/**` JSON endpoints | P1 | 1.5 days |
| 7 | **Prevention officer capability** — configurable limit, assignment endpoint, under-21 access gate | P2 | 0.5 days |
| 8 | **Integration tests** — Testcontainers PostgreSQL: auth flow, tenant isolation, staff perms, portal, reports | P2 | 1 day |
**Total estimated effort:** ~9 days (single worker, sequential)
### ❌ OUT of Scope (Sprint 4+)
- Stripe payment integration
- React frontend SPA (admin + portal)
- Schema-per-tenant migration
- Grow calendar / cultivation tracking
- DSGVO consent management UI
- PDF template customization per club (logo upload)
- Password reset flow (separate from invite)
---
## 3. Architecture Decisions
### 3.1 Staff Permission Model
```java
// New enum — cannamanage-domain
public enum StaffPermission {
RECORD_DISTRIBUTION, // can record distributions
VIEW_MEMBER_LIST, // can view member roster
VIEW_MEMBER_QUOTA, // can view individual member quota
ADD_MEMBER, // can register new members
VIEW_STOCK, // can view batch/strain inventory
RECORD_STOCK_IN, // can add new batches
VIEW_COMPLIANCE_REPORT, // can generate/download reports
MANAGE_GROW_CALENDAR // future — cultivation calendar
}
```
**Database design:**
```sql
CREATE TABLE staff_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL REFERENCES users(id),
display_name VARCHAR(255) NOT NULL,
granted_permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
template_name VARCHAR(100), -- 'ausgabe', 'lager', 'vorstand', or NULL
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_staff_tenant_user UNIQUE(tenant_id, user_id)
);
```
**Authorization — custom SpEL bean:**
```java
@Component("staffPermissions")
public class StaffPermissionChecker {
public boolean has(MethodSecurityExpressionOperations root, StaffPermission required) {
Authentication auth = root.getAuthentication();
if (auth.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")))
return true;
if (auth.getAuthorities().stream().noneMatch(a -> a.getAuthority().equals("ROLE_STAFF")))
return false;
StaffAccount staff = staffAccountRepository.findByUserId(getUserId(auth));
return staff != null && staff.getGrantedPermissions().contains(required);
}
}
```
**Usage:**
```java
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
public ResponseEntity<DistributionResponse> recordDistribution(...)
```
**Staff permissions embedded in JWT:**
```json
{
"sub": "user-uuid",
"tenant_id": "tenant-uuid",
"role": "STAFF",
"permissions": ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST", "VIEW_MEMBER_QUOTA"],
"jti": "unique-token-id",
"iat": 1712345678,
"exp": 1712349278
}
```
The `jti` claim enables token revocation. Permissions in the JWT allow stateless checks (with blacklist validation as fallback for revoked tokens).
---
### 3.2 Token Revocation (Decision D1)
**No Redis** — lightweight DB-based approach with in-memory caching:
```sql
CREATE TABLE revoked_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
jti VARCHAR(255) NOT NULL UNIQUE,
user_id UUID NOT NULL,
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NOT NULL DEFAULT NOW(),
reason VARCHAR(100) -- 'permission_change', 'logout', 'admin_action'
);
CREATE INDEX idx_revoked_tokens_jti ON revoked_tokens(jti);
CREATE INDEX idx_revoked_tokens_expires ON revoked_tokens(expires_at);
```
**Components:**
```java
@Service
public class TokenRevocationService {
private final Cache<String, Boolean> blacklistCache = Caffeine.newBuilder()
.expireAfterWrite(60, TimeUnit.SECONDS)
.maximumSize(10_000)
.build();
public boolean isRevoked(String jti) {
return blacklistCache.get(jti, key ->
revokedTokenRepository.existsByJti(key));
}
public void revokeAllForUser(UUID userId) {
// Revoke all active tokens for this user
// Called when permissions change or admin deactivates staff
}
}
```
**JwtAuthFilter integration:**
```java
// In JwtAuthFilter.doFilterInternal():
String jti = jwtService.extractClaim(token, "jti");
if (tokenRevocationService.isRevoked(jti)) {
response.sendError(401, "Token revoked");
return;
}
```
**Cleanup scheduled task:**
```java
@Scheduled(cron = "0 0 3 * * *") // daily at 3 AM
public void cleanupExpiredTokens() {
revokedTokenRepository.deleteByExpiresAtBefore(Instant.now());
}
```
---
### 3.3 Member Portal Auth (Decision D2)
Dual `SecurityFilterChain` — session-based for portal, JWT for API:
```java
@Bean
@Order(2)
public SecurityFilterChain portalSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/portal/**")
.csrf(Customizer.withDefaults()) // CSRF enabled
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1))
.formLogin(form -> form
.loginPage("/portal/login")
.loginProcessingUrl("/portal/login")
.defaultSuccessUrl("/portal/dashboard", true)
.failureUrl("/portal/login?error"))
.logout(logout -> logout
.logoutUrl("/portal/logout")
.logoutSuccessUrl("/portal/login?logout"))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/portal/login", "/portal/css/**", "/portal/js/**").permitAll()
.requestMatchers("/portal/**").hasRole("MEMBER"));
return http.build();
}
```
**Portal JSON endpoints:**
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/portal/login` | POST | Form login (session cookie returned) |
| `/portal/dashboard` | GET | Member dashboard (quota + recent distributions) |
| `/portal/me` | GET | Own profile data |
| `/portal/quota` | GET | Current month quota status |
| `/portal/distributions` | GET | Own distribution history (paginated) |
**Security invariants:**
- Members ONLY access their own data (enforced by `memberId` from session principal)
- All portal endpoints are GET (read-only) — no write operations
- Session timeout: 30 minutes
- `SameSite=Strict` cookie
- CSRF token provided via `CookieCsrfTokenRepository.withHttpOnlyFalse()`
---
### 3.4 Prevention Officer (Decision D3)
**Configurable limit on Club entity:**
```java
// Club.java
@Column(name = "max_prevention_officers")
private Integer maxPreventionOfficers = 2; // default: 2
```
**Enforcement on assignment:**
```java
public void assignPreventionOfficer(UUID userId) {
long currentCount = userRepository.countByTenantIdAndPreventionOfficerTrue(tenantId);
int limit = club.getMaxPreventionOfficers();
if (currentCount >= limit) {
throw new PreventionOfficerLimitExceededException(limit);
}
user.setPreventionOfficer(true);
userRepository.save(user);
}
```
**Access control for under-21 data:**
```java
@PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
public List<MemberResponse> getUnder21Members() { ... }
```
---
### 3.5 Staff Invite Flow (Decision D6)
**Sequence:**
```
1. Admin: POST /api/v1/staff { email, displayName, permissions, templateName? }
2. System: validates email against club's allowed_email_pattern (D7)
3. System: creates User (role=STAFF, active=false, no password)
4. System: creates StaffAccount (permissions JSONB)
5. System: creates InviteToken (72h expiry)
6. System: sends email with link: https://{domain}/auth/set-password?token={token}
7. Staff member: POST /api/v1/auth/set-password { token, password }
8. System: validates token, sets password_hash, sets active=true
9. Staff can now login via POST /api/v1/auth/login
```
**Database:**
```sql
CREATE TABLE invite_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_invite_tokens_token ON invite_tokens(token);
```
**Spring Mail config (dev vs prod):**
```yaml
# application.yml
spring:
mail:
host: ${SMTP_HOST:localhost}
port: ${SMTP_PORT:1025} # Mailpit for dev
username: ${SMTP_USER:}
password: ${SMTP_PASSWORD:}
properties:
mail.smtp.auth: ${SMTP_AUTH:false}
mail.smtp.starttls.enable: ${SMTP_TLS:false}
```
---
### 3.6 Email Domain Whitelist (Decision D7)
**Club setting:**
```java
// Club.java
@Column(name = "allowed_email_pattern")
private String allowedEmailPattern; // regex, NULL = unrestricted
```
**Validation in StaffService:**
```java
private void validateEmailDomain(String email, Club club) {
if (club.getAllowedEmailPattern() == null) return; // unrestricted
if (!email.matches(club.getAllowedEmailPattern())) {
throw new EmailDomainNotAllowedException(email, club.getAllowedEmailPattern());
}
}
```
**Example patterns:**
- `^.*@gruener-daumen-ev\.de$` — club email only
- `^.*@(verein\.de|gmail\.com|gmx\.de)$` — approved domains
- `NULL` — any email accepted (default)
---
### 3.7 Report Generation (Decision D4)
**OpenPDF (LGPL fork of iText 5) with minimal branding:**
```java
@Service
public class PdfReportGenerator {
public byte[] renderMonthlyReport(MonthlyReport data, Club club) {
Document document = new Document(PageSize.A4);
// Header: club name + report title
document.add(new Paragraph(club.getName())
.setFont(PdfFontFactory.createFont(StandardFonts.HELVETICA_BOLD))
.setFontSize(16));
document.add(new Paragraph("Monatsbericht — " + data.getMonth())
.setFontSize(12));
// Content: data tables...
// Footer: generated timestamp + page numbers (via event handler)
document.close();
return baos.toByteArray();
}
}
```
**PDF footer pattern (OpenPDF PdfPageEventHelper):**
```java
public class FooterHandler implements IEventHandler {
@Override
public void handleEvent(Event event) {
PdfDocumentEvent docEvent = (PdfDocumentEvent) event;
// Add: "Erstellt am: {timestamp}" + "Seite {n} von {total}"
}
}
```
---
## 4. Implementation Phases
### Phase 1: Staff Permission Foundation + Token Revocation (Day 1-2)
| Step | Description | Files |
|------|-------------|-------|
| 1.1 | Create `StaffPermission` enum | `cannamanage-domain/.../enums/StaffPermission.java` |
| 1.2 | Create `StaffAccount` JPA entity (JSONB `granted_permissions`) | `cannamanage-domain/.../entity/StaffAccount.java` |
| 1.3 | Flyway V3 migration — all new tables + columns (see §6) | `db/migration/V3__sprint3_staff_portal.sql` |
| 1.4 | Create `StaffAccountRepository` | `cannamanage-service/.../repository/StaffAccountRepository.java` |
| 1.5 | Create `RevokedTokenRepository` | `cannamanage-service/.../repository/RevokedTokenRepository.java` |
| 1.6 | Create `TokenRevocationService` + Caffeine cache | `cannamanage-service/.../service/TokenRevocationService.java` |
| 1.7 | Create `StaffPermissionChecker` (SpEL bean) | `cannamanage-api/.../security/StaffPermissionChecker.java` |
| 1.8 | Create `PreventionOfficerChecker` (SpEL bean) | `cannamanage-api/.../security/PreventionOfficerChecker.java` |
| 1.9 | Update `JwtService` — add `jti` + `permissions` claims for STAFF tokens | `cannamanage-api/.../security/JwtService.java` |
| 1.10 | Update `JwtAuthFilter` — check token blacklist via `TokenRevocationService` | `cannamanage-api/.../security/JwtAuthFilter.java` |
| 1.11 | Update `SecurityConfig` — add STAFF role to relevant endpoint matchers | `cannamanage-api/.../security/SecurityConfig.java` |
| 1.12 | Add Caffeine dependency to POM | `cannamanage-service/pom.xml` |
| 1.13 | Update existing controllers with `@PreAuthorize` for staff access | All 5 controllers |
| 1.14 | Add token cleanup scheduled task | `cannamanage-service/.../service/TokenCleanupScheduler.java` |
| 1.15 | Unit tests for permission evaluation + token revocation | `StaffPermissionCheckerTest.java`, `TokenRevocationServiceTest.java` |
---
### Phase 2: Club Settings Controller (Day 2, half-day)
| Step | Description | Files |
|------|-------------|-------|
| 2.1 | Create `ClubResponse` DTO (includes `maxPreventionOfficers`, `allowedEmailPattern`) | `cannamanage-api/.../dto/club/ClubResponse.java` |
| 2.2 | Create `UpdateClubRequest` DTO | `cannamanage-api/.../dto/club/UpdateClubRequest.java` |
| 2.3 | Create `ClubStatsResponse` DTO | `cannamanage-api/.../dto/club/ClubStatsResponse.java` |
| 2.4 | Create `ClubService` | `cannamanage-service/.../service/ClubService.java` |
| 2.5 | Create `ClubController``GET/PUT /clubs/me`, `GET /clubs/me/stats` | `cannamanage-api/.../controller/ClubController.java` |
| 2.6 | `ClubRepository` (if not exists) | `cannamanage-service/.../repository/ClubRepository.java` |
| 2.7 | Regex validation for `allowedEmailPattern` (reject invalid regex) | In `ClubService` |
| 2.8 | Unit tests | `ClubControllerTest.java`, `ClubServiceTest.java` |
---
### Phase 3: Staff Management + Invite Flow (Day 3-4)
| Step | Description | Files |
|------|-------------|-------|
| 3.1 | Add Spring Mail dependency | `cannamanage-api/pom.xml` |
| 3.2 | Create `InviteToken` JPA entity | `cannamanage-domain/.../entity/InviteToken.java` |
| 3.3 | Create `InviteTokenRepository` | `cannamanage-service/.../repository/InviteTokenRepository.java` |
| 3.4 | Create `EmailService` — sends invite email | `cannamanage-service/.../service/EmailService.java` |
| 3.5 | Create `StaffService` — CRUD + invite + domain validation + template application | `cannamanage-service/.../service/StaffService.java` |
| 3.6 | Create DTOs: `CreateStaffRequest`, `UpdateStaffRequest`, `StaffResponse` | `cannamanage-api/.../dto/staff/` |
| 3.7 | Create `SetPasswordRequest` DTO | `cannamanage-api/.../dto/auth/SetPasswordRequest.java` |
| 3.8 | Create `StaffController` — admin-only CRUD | `cannamanage-api/.../controller/StaffController.java` |
| 3.9 | Add `POST /auth/set-password` endpoint to `AuthController` | `cannamanage-api/.../controller/AuthController.java` |
| 3.10 | Define role templates (Ausgabe, Lager, Vorstand) | `cannamanage-service/.../service/StaffTemplates.java` |
| 3.11 | Update `AuthService.login()` — reject `active=false` users | `cannamanage-service/.../service/AuthService.java` |
| 3.12 | On permission change: call `tokenRevocationService.revokeAllForUser(userId)` | In `StaffService` |
| 3.13 | Email template (plain text for MVP) | `src/main/resources/templates/invite-email.txt` |
| 3.14 | Spring Mail config in `application.yml` | `application.yml` |
| 3.15 | Unit tests | `StaffServiceTest.java`, `StaffControllerTest.java`, `EmailServiceTest.java` |
**Staff controller endpoints:**
| Endpoint | Method | Access | Description |
|----------|--------|--------|-------------|
| `/api/v1/staff` | GET | ADMIN | List all staff accounts |
| `/api/v1/staff` | POST | ADMIN | Create staff + send invite email |
| `/api/v1/staff/{id}` | GET | ADMIN | Get staff details |
| `/api/v1/staff/{id}` | PUT | ADMIN | Update permissions (revokes tokens) |
| `/api/v1/staff/{id}` | DELETE | ADMIN | Deactivate staff (revokes tokens) |
| `/api/v1/staff/templates` | GET | ADMIN | List permission templates |
| `/api/v1/auth/set-password` | POST | Public | Set password from invite token |
---
### Phase 4: Report Controller + PDF Generation (Day 4-5)
| Step | Description | Files |
|------|-------------|-------|
| 4.1 | Add OpenPDF + Commons CSV dependencies to POM | `cannamanage-api/pom.xml` |
| 4.2 | Create report data models | `cannamanage-service/.../model/report/MonthlyReport.java`, `MemberListReport.java`, `RecallReport.java` |
| 4.3 | Create `ReportService` — data aggregation queries | `cannamanage-service/.../service/ReportService.java` |
| 4.4 | Create `PdfReportGenerator` — OpenPDF with minimal branding | `cannamanage-service/.../service/PdfReportGenerator.java` |
| 4.5 | Create `FooterHandler` — OpenPDF PdfPageEventHelper for footer | `cannamanage-service/.../service/PdfFooterHandler.java` |
| 4.6 | Create `CsvReportGenerator` — Apache Commons CSV (UTF-8 BOM) | `cannamanage-service/.../service/CsvReportGenerator.java` |
| 4.7 | Create `ReportController` with `format` query param content negotiation | `cannamanage-api/.../controller/ReportController.java` |
| 4.8 | Report DTOs | `cannamanage-api/.../dto/report/` |
| 4.9 | Unit tests | `ReportServiceTest.java`, `PdfReportGeneratorTest.java` |
**Report endpoints:**
| Endpoint | Formats | Description |
|----------|---------|-------------|
| `GET /reports/monthly?month=2026-03&format=json\|pdf\|csv` | JSON/PDF/CSV | Monthly compliance report |
| `GET /reports/members?format=json\|pdf\|csv&status=ACTIVE` | JSON/PDF/CSV | Member list for authorities |
| `GET /reports/recall/{batchId}?format=json\|pdf` | JSON/PDF | Recall impact report |
---
### Phase 5: Member Portal (Day 5-6)
| Step | Description | Files |
|------|-------------|-------|
| 5.1 | Add portal `SecurityFilterChain` (`@Order(2)`) | `SecurityConfig.java` |
| 5.2 | Create `PortalUserDetailsService` — loads Member user from DB | `cannamanage-api/.../security/PortalUserDetailsService.java` |
| 5.3 | Create `PortalController` — JSON endpoints behind session auth | `cannamanage-api/.../controller/PortalController.java` |
| 5.4 | Portal DTOs — `PortalDashboard`, `PortalQuota`, `PortalDistributionHistory` | `cannamanage-api/.../dto/portal/` |
| 5.5 | `PortalService` — member-scoped queries (own data only) | `cannamanage-service/.../service/PortalService.java` |
| 5.6 | Session configuration — timeout, cookie settings | `application.yml` |
| 5.7 | Unit tests | `PortalControllerTest.java`, `PortalServiceTest.java` |
---
### Phase 6: Prevention Officer (Day 6, half-day)
| Step | Description | Files |
|------|-------------|-------|
| 6.1 | Add `preventionOfficer` field to `User` entity | `User.java` |
| 6.2 | Add `maxPreventionOfficers` field to `Club` entity | `Club.java` |
| 6.3 | `PreventionOfficerChecker` already created in Phase 1.8 | — |
| 6.4 | Add endpoint: `GET /members/under-21` to MemberController | `MemberController.java` |
| 6.5 | Add endpoint: `GET /members/{id}/prevention-data` | `MemberController.java` |
| 6.6 | Add endpoint: `PUT /staff/{id}/prevention-officer` (assign/revoke flag) | `StaffController.java` |
| 6.7 | Limit enforcement in StaffService | `StaffService.java` |
| 6.8 | Unit tests | `PreventionOfficerTest.java` |
---
### Phase 7: Integration Tests (Day 7)
| Step | Description | Files |
|------|-------------|-------|
| 7.1 | Fix Boot 4 `@EntityScan` issue from Sprint 2 | Investigate + fix |
| 7.2 | Base test class with Testcontainers setup | `AbstractIntegrationTest.java` |
| 7.3 | Auth flow integration test (login → JWT → access → refresh → revoke) | `AuthIntegrationTest.java` |
| 7.4 | Tenant isolation test (2 tenants, ensure no data leak) | `TenantIsolationTest.java` |
| 7.5 | Staff permission integration test (invite → set password → login → permission check) | `StaffPermissionIntegrationTest.java` |
| 7.6 | Portal session test (login → session → own data → deny other's data) | `PortalIntegrationTest.java` |
| 7.7 | Report generation test (monthly report with test data) | `ReportIntegrationTest.java` |
| 7.8 | Token revocation integration test (change perms → old token rejected) | `TokenRevocationIntegrationTest.java` |
---
## 5. Execution Strategy (Single Worker — Work Lumen)
All phases executed sequentially by Work Lumen. No parallelization — keeps full context continuity, avoids merge conflicts, and simplifies review.
| Day | Phase | Description |
|-----|-------|-------------|
| 1-2 | Phase 1 | Staff permission foundation + token revocation |
| 2 | Phase 2 | Club controller + settings |
| 3-4 | Phase 3 | Staff CRUD + invite flow + email |
| 4-5 | Phase 4 | Report controller + OpenPDF/CSV |
| 5-6 | Phase 5 | Member portal (session-based auth) |
| 6 | Phase 6 | Prevention officer |
| 7 | Phase 7 | Integration tests (Testcontainers) |
**Branch strategy:** Single branch `sprint/3-staff-portal` off `sprint/2-api`. Atomic commits per phase for clean git history.
---
## 6. Flyway Migration V3
Single migration covering all Sprint 3 schema changes:
```sql
-- V3__sprint3_staff_portal.sql
-- 1. Staff accounts with configurable permissions
CREATE TABLE staff_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL REFERENCES users(id),
display_name VARCHAR(255) NOT NULL,
granted_permissions JSONB NOT NULL DEFAULT '[]'::jsonb,
template_name VARCHAR(100),
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT uq_staff_tenant_user UNIQUE(tenant_id, user_id)
);
CREATE INDEX idx_staff_accounts_tenant ON staff_accounts(tenant_id);
CREATE INDEX idx_staff_accounts_user ON staff_accounts(user_id);
-- 2. Token revocation blacklist
CREATE TABLE revoked_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
jti VARCHAR(255) NOT NULL UNIQUE,
user_id UUID NOT NULL,
expires_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NOT NULL DEFAULT NOW(),
reason VARCHAR(100)
);
CREATE INDEX idx_revoked_tokens_jti ON revoked_tokens(jti);
CREATE INDEX idx_revoked_tokens_expires ON revoked_tokens(expires_at);
-- 3. Invite tokens for staff onboarding
CREATE TABLE invite_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_invite_tokens_token ON invite_tokens(token);
-- 4. User extensions
ALTER TABLE users ADD COLUMN prevention_officer BOOLEAN NOT NULL DEFAULT false;
-- 5. Club extensions
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS registration_number VARCHAR(100);
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255);
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(50);
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS address_street VARCHAR(255);
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS address_city VARCHAR(100);
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS address_postal_code VARCHAR(10);
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS address_state VARCHAR(100);
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS founded_date DATE;
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS max_prevention_officers INTEGER NOT NULL DEFAULT 2;
ALTER TABLE clubs ADD COLUMN IF NOT EXISTS allowed_email_pattern VARCHAR(500);
```
---
## 7. Updated SecurityConfig Structure (Target State)
```java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
// Order 1: API — stateless JWT + token blacklist check
@Bean @Order(1)
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) {
// /api/** — JWT filter (with jti blacklist), no CSRF, stateless
// ADMIN: all, STAFF: per-permission via @PreAuthorize, MEMBER: self-service
}
// Order 2: Portal — session-based for members
@Bean @Order(2)
public SecurityFilterChain portalSecurityFilterChain(HttpSecurity http) {
// /portal/** — form login, CSRF, session, MEMBER only, 30min timeout
}
// Order 3: Public — Swagger, health, set-password
@Bean @Order(3)
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) {
// /swagger-ui/**, /v3/api-docs/**, /actuator/health — permitAll
}
}
```
---
## 8. Test Plan Summary
| ID | Description | Type | Phase |
|----|-------------|------|-------|
| T-01 | Staff permission JSONB serialization/deserialization | Unit | P1 |
| T-02 | StaffPermissionChecker grants/denies correctly | Unit | P1 |
| T-03 | ADMIN bypasses all permission checks | Unit | P1 |
| T-04 | STAFF without RECORD_DISTRIBUTION gets 403 | Unit | P1 |
| T-05 | Token revocation: revoked jti returns 401 | Unit | P1 |
| T-06 | Caffeine cache expires and re-checks DB | Unit | P1 |
| T-07 | Club GET/PUT /me returns/updates correct data | Unit | P2 |
| T-08 | Club stats aggregation queries | Unit | P2 |
| T-09 | Email domain whitelist rejects invalid email | Unit | P2 |
| T-10 | Invalid regex in allowedEmailPattern returns 400 | Unit | P2 |
| T-11 | Staff invite flow: create → email → set-password → login | Unit | P3 |
| T-12 | Expired invite token returns 400 | Unit | P3 |
| T-13 | Permission change revokes all user tokens | Unit | P3 |
| T-14 | Role template application (Ausgabe grants correct perms) | Unit | P3 |
| T-15 | Monthly report data aggregation | Unit | P4 |
| T-16 | PDF generation produces valid output with branding | Unit | P4 |
| T-17 | CSV export with UTF-8 BOM + correct columns | Unit | P4 |
| T-18 | Recall report identifies all affected members | Unit | P4 |
| T-19 | Portal session login + own-data-only access | Integration | P5 |
| T-20 | Portal CSRF protection (POST without token → 403) | Integration | P5 |
| T-21 | Prevention officer limit enforcement | Unit | P6 |
| T-22 | Non-prevention-officer gets 403 on under-21 endpoint | Unit | P6 |
| T-23 | Full auth flow: login → refresh → revoke → reject | Integration | P7 |
| T-24 | Tenant isolation: tenant A cannot see tenant B data | Integration | P7 |
| T-25 | Staff permission E2E: invite → activate → login → permission check | Integration | P7 |
| T-26 | Token revocation E2E: change perms → old JWT rejected | Integration | P7 |
---
## 9. Dependencies & Libraries
| Library | Version | Purpose | New? |
|---------|---------|---------|------|
| OpenPDF (librepdf) | 2.0.4 | PDF report generation (LGPL — SaaS-safe) | ✅ New |
| Apache Commons CSV | 1.11+ | CSV export | ✅ New |
| Caffeine | 3.1+ | In-memory token blacklist cache | ✅ New |
| Spring Boot Starter Mail | (Boot managed) | Email invite sending | ✅ New |
| Testcontainers PostgreSQL | 1.19+ | Integration tests | Already in POM |
| Spring Security Test | (Boot managed) | SecurityMockMvc | Already in POM |
---
## 10. Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| ~~iText 7 license~~ — RESOLVED: using OpenPDF (LGPL) | — | — | No longer a risk. OpenPDF is LGPL 2.1, fully SaaS-compatible. |
| Boot 4 `@EntityScan` blocks integration tests | Medium | Medium | Investigate `@AutoConfiguration` changes. Fallback: explicit `EntityManagerFactory` bean. |
| JSONB + Hibernate 6 serialization issues | Low | Medium | Hibernate 6 supports JSONB via `@JdbcTypeCode(SqlTypes.JSON)`. Test early in Phase 1. |
| SMTP delivery issues in prod | Low | Medium | Use transactional email service (Mailgun/Brevo free tier). Dev uses Mailpit (local). |
| Caffeine cache staleness (60s window) | Low | Low | Acceptable: worst case a revoked token works for 60 more seconds. Not a real security hole for a club app. |
| Portal CSRF + SPA interaction | Low | Low | `CookieCsrfTokenRepository.withHttpOnlyFalse()` → React reads `XSRF-TOKEN` cookie, sends as header. |
---
## 11. Definition of Done
Sprint 3 is **DONE** when:
- [ ] All 7 phases implemented and passing
- [ ] ≥ 26 tests (matching test plan)
- [ ] Flyway V3 migration applies cleanly on fresh PostgreSQL
- [ ] Staff invite flow works end-to-end (create → email → set password → login → permission check)
- [ ] Token revocation works (change perms → old JWT rejected within 60s)
- [ ] Portal login + session auth works independently of JWT
- [ ] Reports generate valid PDF (with club name + footer) and CSV output
- [ ] Prevention officer flag + configurable limit works
- [ ] Email domain whitelist validates on staff invite
- [ ] Integration tests pass with Testcontainers PostgreSQL
- [ ] Branch `sprint/3-staff-portal` green and pushed
---
## Appendix A: File Tree (New Files in Sprint 3)
```
cannamanage-domain/src/main/java/de/cannamanage/domain/
├── enums/
│ └── StaffPermission.java ← NEW
├── entity/
│ ├── StaffAccount.java ← NEW
│ └── InviteToken.java ← NEW
cannamanage-service/src/main/java/de/cannamanage/service/
├── repository/
│ ├── StaffAccountRepository.java ← NEW
│ ├── RevokedTokenRepository.java ← NEW
│ ├── InviteTokenRepository.java ← NEW
│ └── ClubRepository.java ← NEW (if not exists)
├── service/
│ ├── StaffService.java ← NEW
│ ├── StaffTemplates.java ← NEW
│ ├── ClubService.java ← NEW
│ ├── TokenRevocationService.java ← NEW
│ ├── TokenCleanupScheduler.java ← NEW
│ ├── EmailService.java ← NEW
│ ├── PortalService.java ← NEW
│ ├── ReportService.java ← NEW
│ ├── PdfReportGenerator.java ← NEW
│ ├── PdfFooterHandler.java ← NEW
│ └── CsvReportGenerator.java ← NEW
├── model/report/
│ ├── MonthlyReport.java ← NEW
│ ├── MemberListReport.java ← NEW
│ └── RecallReport.java ← NEW
cannamanage-api/src/main/java/de/cannamanage/api/
├── security/
│ ├── StaffPermissionChecker.java ← NEW
│ ├── PreventionOfficerChecker.java ← NEW
│ └── PortalUserDetailsService.java ← NEW
├── controller/
│ ├── ClubController.java ← NEW
│ ├── StaffController.java ← NEW
│ ├── ReportController.java ← NEW
│ └── PortalController.java ← NEW
├── dto/
│ ├── auth/
│ │ └── SetPasswordRequest.java ← NEW
│ ├── club/
│ │ ├── ClubResponse.java ← NEW
│ │ ├── UpdateClubRequest.java ← NEW
│ │ └── ClubStatsResponse.java ← NEW
│ ├── staff/
│ │ ├── CreateStaffRequest.java ← NEW
│ │ ├── UpdateStaffRequest.java ← NEW
│ │ └── StaffResponse.java ← NEW
│ ├── portal/
│ │ ├── PortalDashboard.java ← NEW
│ │ ├── PortalQuota.java ← NEW
│ │ └── PortalDistributionHistory.java ← NEW
│ └── report/
│ ├── MonthlyReportResponse.java ← NEW
│ ├── MemberListResponse.java ← NEW
│ └── RecallReportResponse.java ← NEW
cannamanage-api/src/main/resources/
├── db/migration/
│ └── V3__sprint3_staff_portal.sql ← NEW
├── templates/
│ └── invite-email.txt ← NEW
└── application.yml ← MODIFIED (mail config, session config)
cannamanage-api/src/test/java/de/cannamanage/api/
├── security/
│ ├── StaffPermissionCheckerTest.java ← NEW
│ └── TokenRevocationServiceTest.java ← NEW
├── controller/
│ ├── ClubControllerTest.java ← NEW
│ ├── StaffControllerTest.java ← NEW
│ ├── ReportControllerTest.java ← NEW
│ └── PortalControllerTest.java ← NEW
├── service/
│ ├── StaffServiceTest.java ← NEW
│ ├── EmailServiceTest.java ← NEW
│ ├── ReportServiceTest.java ← NEW
│ └── PdfReportGeneratorTest.java ← NEW
├── PreventionOfficerTest.java ← NEW
└── integration/
├── AbstractIntegrationTest.java ← NEW
├── AuthIntegrationTest.java ← NEW
├── TenantIsolationTest.java ← NEW
├── StaffPermissionIntegrationTest.java ← NEW
├── PortalIntegrationTest.java ← NEW
├── ReportIntegrationTest.java ← NEW
└── TokenRevocationIntegrationTest.java ← NEW
```
**Total new files:** ~50
**Modified files:** ~8 (existing controllers, SecurityConfig, JwtService, JwtAuthFilter, User entity, Club entity, application.yml, POMs)
@@ -0,0 +1,335 @@
# Security Review: CannaManage Sprint 3 — Phases 1-3
**Date:** 2026-06-11
**Module:** cannamanage (all modules)
**Reviewer:** Roo (Security Reviewer)
**Branch:** sprint/3-staff-portal (pre-implementation)
**Type:** Design-level security review (plan analysis)
**Verdict:** ✅ PASS (with advisory findings)
---
## Scope
Pre-implementation security analysis of Sprint 3 Phases 1-3:
- **Phase 1:** Staff Permission Foundation + Token Revocation
- **Phase 2:** Club Settings Controller + Email Domain Whitelist
- **Phase 3:** Staff Management + Invite Flow
---
## Scan Results
| Tool | Status | Notes |
|------|--------|-------|
| SonarQube (SAST) | ⏭️ Not applicable | Design-level review — no code to scan |
| Datarake (Secrets) | ⏭️ Not applicable | Design-level review |
| Snyk Code | ⏭️ Not applicable | Design-level review |
---
## Security Checklist (Adapted for Design Review)
| # | Rule | Check | Result | Notes |
|---|------|-------|--------|-------|
| 1 | SEC-001..004 | No hardcoded credentials in plan | ✅ | SMTP credentials use `${SMTP_*}` env vars, JWT secret uses `${cannamanage.security.jwt.secret}` |
| 2 | SEC-005 | Credentials via @Value/env | ✅ | All sensitive config externalized in `application.yml` via env vars |
| 3 | SEC-011 | No SQL injection vectors | ✅ | All DB access via Spring Data JPA repositories — no raw SQL concatenation |
| 4 | SEC-012 | No path traversal | ✅ | No file I/O in Phases 1-3 |
| 5 | SEC-016 | Input validation on all entry points | ⚠️ | See Finding #1 (invite token validation) and Finding #2 (regex pattern) |
| 6 | SEC-018 | No info disclosure in errors | ⚠️ | See Finding #3 (invite token error messages) |
| 7 | SEC-033 | PII handling | ✅ | Staff email stored in DB (expected); permissions are non-PII |
| 8 | SEC-035 | No PII in LLM processing | ✅ | N/A — no LLM integration |
| 9 | SEC-040 | No sensitive data in logs | ⚠️ | See Finding #4 (invite token logging risk) |
| 10 | — | Privilege escalation vectors | ⚠️ | See Finding #5 (JWT permissions vs DB permissions race) |
| 11 | — | Token revocation completeness | ⚠️ | See Finding #6 (60s window + refresh token interaction) |
| 12 | — | Cryptographic token generation | ⚠️ | See Finding #7 (invite token entropy) |
| 13 | — | Rate limiting on sensitive endpoints | ⚠️ | See Finding #8 (set-password endpoint) |
| 14 | — | SMTP injection | ✅ | Spring Mail handles email header injection prevention |
| 15 | — | Tenant isolation in new tables | ✅ | `staff_accounts` has `tenant_id`; queries via TenantFilterAspect |
---
## Findings
### ⚠️ Medium Severity (should address during implementation)
#### Finding #1: Invite token lacks complexity requirements for password
**Phase:** 3 (Step 3.9: `POST /auth/set-password`)
**Risk:** Password strength not mentioned in plan
**Description:** The `SetPasswordRequest` DTO accepts a password, but the plan doesn't specify minimum complexity requirements. Weak passwords would undermine the security of staff accounts that have elevated permissions.
**Recommendation:**
```java
// In SetPasswordRequest DTO validation
@NotBlank
@Size(min = 12, message = "Password must be at least 12 characters")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
message = "Password must contain uppercase, lowercase, and digit")
private String password;
```
**Severity:** Medium — Staff accounts have elevated permissions (RECORD_DISTRIBUTION, VIEW_MEMBER_LIST with PII access).
---
#### Finding #2: Email domain whitelist regex — ReDoS vulnerability
**Phase:** 2 (Step 2.7) + Phase 3 (Step 3.5)
**Risk:** Denial of Service via catastrophic backtracking
**Description:** The plan uses `email.matches(club.getAllowedEmailPattern())` where the pattern is admin-supplied. A malicious or poorly-constructed regex like `^(a+)+@evil.com$` can cause exponential backtracking.
**Recommendation:**
```java
private void validateEmailDomain(String email, Club club) {
if (club.getAllowedEmailPattern() == null) return;
// 1. Validate pattern syntax
Pattern pattern;
try {
pattern = Pattern.compile(club.getAllowedEmailPattern());
} catch (PatternSyntaxException e) {
throw new InvalidEmailPatternException("Invalid regex pattern on club settings");
}
// 2. Apply with timeout protection
Matcher matcher = pattern.matcher(email);
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Boolean> future = executor.submit(matcher::matches);
try {
boolean matches = future.get(100, TimeUnit.MILLISECONDS);
if (!matches) throw new EmailDomainNotAllowedException(email);
} catch (TimeoutException e) {
future.cancel(true);
throw new InvalidEmailPatternException("Email pattern validation timed out — pattern may be too complex");
} finally {
executor.shutdownNow();
}
}
```
Alternatively, restrict allowed patterns to a simple domain list (no arbitrary regex):
```java
// Simpler, safer approach
@Column(name = "allowed_email_domains")
private String allowedEmailDomains; // comma-separated: "example.de,club.de"
```
**Severity:** Medium — An admin can DoS their own tenant's invite flow. Cross-tenant impact is blocked by TenantFilterAspect.
---
#### Finding #3: Invite token error responses may leak information
**Phase:** 3 (Step 3.9: `POST /auth/set-password`)
**Risk:** Information disclosure / account enumeration
**Description:** Different error messages for "token not found", "token expired", and "token already used" allow an attacker to enumerate valid invite tokens and determine their state.
**Recommendation:** Return a generic error message for all failure cases:
```java
// ❌ AVOID — reveals token state
if (token == null) throw new NotFoundException("Token not found");
if (token.getExpiresAt().isBefore(Instant.now())) throw new BadRequestException("Token expired");
if (token.getUsedAt() != null) throw new BadRequestException("Token already used");
// ✅ PREFERRED — generic response
if (token == null || token.getExpiresAt().isBefore(Instant.now()) || token.getUsedAt() != null) {
throw new BadRequestException("Invalid or expired invitation link. Please contact your club administrator.");
}
```
**Severity:** Medium — The `/auth/set-password` endpoint is public (no auth required). Token enumeration could allow unauthorized password setting if tokens are guessable (see Finding #7).
---
#### Finding #4: Invite token values must not appear in logs
**Phase:** 3 (Steps 3.4, 3.5, 3.9)
**Risk:** Token leakage via log files
**Description:** The invite token is a bearer credential — anyone with the token value can set a password and gain staff access. If logged (e.g., in request logging, error logs, or debug statements), it becomes accessible to anyone with log access.
**Recommendation:**
```java
// In EmailService — do NOT log the full token
log.info("Invite email sent to {} for user {}", email, userId);
// NOT: log.info("Invite sent with token {}", token);
// In AuthController.setPassword — do NOT log the token value
log.info("Password set successfully for invite (user: {})", userId);
// NOT: log.debug("Processing set-password for token: {}", request.getToken());
```
Also ensure that Spring Boot request logging (if enabled) does not capture the token from the request body.
**Severity:** Medium — Log access is typically broader than intended credential access.
---
### ️ Low Severity (advisory — defense-in-depth suggestions)
#### Finding #5: JWT permissions vs. DB permissions — stale permission window
**Phase:** 1 (Steps 1.9, 1.10, 1.12)
**Risk:** Privilege escalation during 60s Caffeine cache TTL
**Description:** Staff permissions are embedded in the JWT. When an admin changes a staff member's permissions, `revokeAllForUser()` is called, which adds the old token's `jti` to the blacklist. However:
1. The Caffeine cache has a 60s TTL — during this window, the old token could still be accepted by another node (if scaled horizontally in the future)
2. More critically: if the revoked token check is cached as "not revoked" by Caffeine BEFORE the revocation occurs, it remains cached for up to 60s
The plan correctly identifies this as an acceptable risk for a club-scale app.
**Recommendation:** Document this 60-second window as a known limitation. For future scaling, consider:
- Reducing Caffeine TTL to 10-15s (acceptable DB load for club scale)
- Using a `LoadingCache` that checks a "last revocation timestamp" before returning cached false results
**Severity:** Low — Acceptable for MVP. A staff member could perform unauthorized actions for at most 60 seconds after permission revocation.
---
#### Finding #6: Token revocation doesn't address refresh tokens
**Phase:** 1 (Step 1.6: `TokenRevocationService`)
**Risk:** Revoked staff can still obtain new access tokens via refresh
**Description:** The plan's `revokeAllForUser()` revokes access token `jti` values in the blacklist. But the refresh token (used at `POST /auth/refresh`) has a separate 30-day lifetime. If only access tokens are revoked, the staff member can use their refresh token to obtain a new (valid) access token with the OLD permissions still embedded.
**Recommendation:** On permission change or staff deactivation:
1. Revoke all access tokens (current plan) ✅
2. Also invalidate the user's refresh token (clear `refresh_token_hash` in `users` table) ✅ (must be explicitly added)
```java
public void revokeAllForUser(UUID userId) {
// 1. Blacklist all active access tokens
List<RevokedToken> tokens = ... ;
revokedTokenRepository.saveAll(tokens);
blacklistCache.invalidateAll(); // force re-check
// 2. Also invalidate refresh token
userRepository.clearRefreshTokenHash(userId);
}
```
**Severity:** Low — The plan's Step 3.11 updates `AuthService.login()` to reject `active=false` users, which partially mitigates this. But a user who is still `active=true` with changed permissions could refresh and get a new token with stale permissions if the refresh endpoint doesn't re-check permissions from DB.
---
#### Finding #7: Invite token must use cryptographically secure random generation
**Phase:** 3 (Step 3.2: `InviteToken` entity)
**Risk:** Predictable tokens enable unauthorized account takeover
**Description:** The plan specifies `token VARCHAR(255) NOT NULL UNIQUE` but doesn't specify the generation method. If `UUID.randomUUID()` is used, it's type-4 (PRNG-based) which is generally secure but predictable under certain JVM conditions.
**Recommendation:** Use `SecureRandom` with sufficient entropy:
```java
// In InviteTokenService or StaffService
private String generateSecureToken() {
byte[] bytes = new byte[32]; // 256 bits of entropy
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
```
This produces a 43-character URL-safe token with 256 bits of entropy — sufficient to prevent brute-force guessing even on the public `/auth/set-password` endpoint.
**Severity:** Low — The 72-hour expiry mitigates brute-force attacks. But with `UUID.randomUUID()` (122 bits of randomness), an attacker would need ~5.3 × 10³⁶ attempts — already computationally infeasible. This finding is defense-in-depth.
---
#### Finding #8: Rate limiting on `POST /auth/set-password`
**Phase:** 3 (Step 3.9)
**Risk:** Brute-force invite token guessing
**Description:** The `POST /auth/set-password` endpoint is public (no authentication required) and accepts a token in the request body. Without rate limiting, an attacker could attempt to brute-force valid tokens.
**Recommendation:** Apply rate limiting per IP:
```java
// In SecurityConfig or via @RateLimiter annotation
.requestMatchers("/api/v1/auth/set-password").permitAll()
// Add: rate limit 5 requests per minute per IP
```
Options:
- Spring Boot Starter Rate Limiter (Bucket4j)
- Custom `Filter` with Caffeine-based per-IP counter
- Nginx-level rate limiting (`limit_req_zone`)
**Severity:** Low — Token entropy (Finding #7) makes brute-force infeasible even without rate limiting. This is defense-in-depth.
---
## Identified False Positives
| Pattern | Why It's Safe |
|---------|--------------|
| SMTP credentials in `application.yml` | All via `${SMTP_*}` env var placeholders — not hardcoded |
| JWT secret in `JwtService` | Via `@Value("${cannamanage.security.jwt.secret}")` — externalized |
| `allowedEmailPattern` stored in DB | Admin-only configurable; only affects their own tenant |
| `staff_accounts.granted_permissions` as JSONB | Not a credential; contains only permission names |
---
## Architecture-Level Security Assessment
### Token Revocation Design (D1)
| Aspect | Security Rating | Notes |
|--------|----------------|-------|
| DB persistence | ✅ Strong | Survives restarts; durable record |
| Caffeine cache | ⚠️ Acceptable | 60s stale window documented and accepted |
| `jti` uniqueness | ✅ Strong | UUID-based, UNIQUE constraint in DB |
| Cleanup scheduler | ✅ Good | Prevents table bloat; respects token expiry |
| Horizontal scale readiness | ⚠️ Weak | Cache is per-instance; need shared cache for multi-node |
### Staff Permission Model
| Aspect | Security Rating | Notes |
|--------|----------------|-------|
| ADMIN bypass | ✅ Correct | `StaffPermissionChecker` returns true for ADMIN first |
| STAFF denial by default | ✅ Correct | If no permissions found → deny |
| JSONB storage | ✅ Good | Flexible, supports audit trail of changes |
| JWT-embedded permissions | ⚠️ Acceptable | Stale for max token lifetime after revocation |
| Tenant isolation | ✅ Strong | `tenant_id` on `staff_accounts`; TenantFilterAspect enforced |
### Invite Flow Security
| Aspect | Security Rating | Notes |
|--------|----------------|-------|
| Token expiry (72h) | ✅ Good | Reasonable window; not too long |
| Single-use enforcement | ✅ Strong | `used_at` timestamp prevents reuse |
| Public endpoint exposure | ⚠️ Acceptable | Only accepts token + password; generic errors recommended |
| Email delivery trust | ⚠️ Inherent risk | Email is not a secure channel; standard for invite flows |
| Active=false until password set | ✅ Strong | Prevents login before activation |
---
## Verdict
### ✅ PASS
No Critical or High severity findings. The design is fundamentally sound for a club-scale application.
**8 findings total:**
- 0 Critical
- 0 High
- 4 Medium (should address during implementation)
- 4 Low (defense-in-depth suggestions)
**Key implementation instructions for the developer:**
1. Add password complexity validation on `SetPasswordRequest` (Finding #1)
2. Add regex timeout or switch to domain list for email whitelist (Finding #2)
3. Use generic error messages on `/auth/set-password` (Finding #3)
4. Never log invite token values (Finding #4)
5. Clear refresh token hash on permission change (Finding #6)
6. Use `SecureRandom` for invite token generation (Finding #7)
---
## Review Metadata
| Field | Value |
|-------|-------|
| Review type | Pre-implementation (design-level) |
| Phases reviewed | 1, 2, 3 |
| OWASP categories checked | A01 (Broken Access Control), A02 (Crypto Failures), A03 (Injection), A04 (Insecure Design), A07 (Auth Failures) |
| Confidence | 88% (design review; actual implementation may introduce additional issues) |
| Re-review required | Yes — after implementation, run full SAST + code-level SEC-* checklist |
File diff suppressed because one or more lines are too long