From 55d8434f358357fb28a3fa2f40305f0fa4e138bb Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Thu, 11 Jun 2026 16:45:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(sprint-3):=20Phase=201=20=E2=80=94=20staff?= =?UTF-8?q?=20permissions=20+=20token=20revocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../api/controller/ComplianceController.java | 2 + .../controller/DistributionController.java | 3 + .../api/controller/MemberController.java | 7 +- .../api/controller/StockController.java | 4 + .../api/security/JwtAuthFilter.java | 13 +- .../cannamanage/api/security/JwtService.java | 75 +- .../security/PreventionOfficerChecker.java | 56 ++ .../api/security/SecurityConfig.java | 15 +- .../api/security/StaffPermissionChecker.java | 57 ++ .../db/migration/V3__sprint3_staff_portal.sql | 45 + .../security/StaffPermissionCheckerTest.java | 123 +++ .../domain/entity/RevokedToken.java | 66 ++ .../domain/entity/StaffAccount.java | 71 ++ .../domain/enums/StaffPermission.java | 17 + cannamanage-service/pom.xml | 10 + .../service/TokenCleanupScheduler.java | 26 + .../service/TokenRevocationService.java | 99 ++ .../repository/RevokedTokenRepository.java | 25 + .../repository/StaffAccountRepository.java | 23 + .../service/TokenRevocationServiceTest.java | 123 +++ .../cannamanage-sprint3-plan-review.md | 316 +++++++ docs/sprint-3/cannamanage-sprint3-plan.md | 844 ++++++++++++++++++ .../cannamanage-sprint3-security-review.md | 335 +++++++ docs/sprint-3/snyk-code-results.json | 1 + 24 files changed, 2333 insertions(+), 23 deletions(-) create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/security/PreventionOfficerChecker.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java create mode 100644 cannamanage-api/src/main/resources/db/migration/V3__sprint3_staff_portal.sql create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/security/StaffPermissionCheckerTest.java create mode 100644 cannamanage-domain/src/main/java/de/cannamanage/domain/entity/RevokedToken.java create mode 100644 cannamanage-domain/src/main/java/de/cannamanage/domain/entity/StaffAccount.java create mode 100644 cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/TokenCleanupScheduler.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/TokenRevocationService.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/repository/RevokedTokenRepository.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/repository/StaffAccountRepository.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/TokenRevocationServiceTest.java create mode 100644 docs/sprint-3/cannamanage-sprint3-plan-review.md create mode 100644 docs/sprint-3/cannamanage-sprint3-plan.md create mode 100644 docs/sprint-3/cannamanage-sprint3-security-review.md create mode 100644 docs/sprint-3/snyk-code-results.json diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/ComplianceController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ComplianceController.java index 8ffe9eb..1d633a8 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/controller/ComplianceController.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ComplianceController.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; 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.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -25,6 +26,7 @@ public class ComplianceController { @GetMapping("/quota/{memberId}") @Operation(summary = "Get member quota status", 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 getQuotaStatus(@PathVariable UUID memberId) { QuotaStatus status = complianceService.getQuotaStatus(memberId); diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/DistributionController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/DistributionController.java index bc060a3..9618159 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/controller/DistributionController.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/DistributionController.java @@ -11,6 +11,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -29,6 +30,7 @@ public class DistributionController { @GetMapping @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> listDistributions() { List distributions = distributionRepository.findAll().stream() .map(this::toResponse) @@ -39,6 +41,7 @@ public class DistributionController { @PostMapping @Operation(summary = "Record a distribution", 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 createDistribution( @Valid @RequestBody CreateDistributionRequest request, Authentication authentication) { diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/MemberController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/MemberController.java index c5d881e..c91eaf7 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/controller/MemberController.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/MemberController.java @@ -13,6 +13,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -31,6 +32,7 @@ public class MemberController { @GetMapping @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> listMembers() { List members = memberRepository.findAll().stream() .map(this::toResponse) @@ -40,6 +42,7 @@ public class MemberController { @GetMapping("/{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 getMember(@PathVariable UUID id) { Member member = memberRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); @@ -48,6 +51,7 @@ public class MemberController { @PostMapping @Operation(summary = "Create a new member") + @PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)") public ResponseEntity createMember(@Valid @RequestBody CreateMemberRequest request) { Member member = new Member(); member.setFirstName(request.firstName()); @@ -65,6 +69,7 @@ public class MemberController { @PutMapping("/{id}") @Operation(summary = "Update a member") + @PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)") public ResponseEntity updateMember(@PathVariable UUID id, @Valid @RequestBody UpdateMemberRequest request) { Member member = memberRepository.findById(id) @@ -99,7 +104,7 @@ public class MemberController { m.getMembershipNumber(), m.getStatus(), m.isUnder21(), - m.isPreventionOfficer() + false // preventionOfficer flag comes from StaffAccount, not Member ); } } diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/StockController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StockController.java index 64d5d30..28b59f6 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/controller/StockController.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StockController.java @@ -11,6 +11,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -26,6 +27,7 @@ public class StockController { @GetMapping @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> listBatches() { List batches = batchRepository.findAll().stream() .map(this::toResponse) @@ -35,6 +37,7 @@ public class StockController { @GetMapping("/{id}") @Operation(summary = "Get batch by ID") + @PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)") public ResponseEntity getBatch(@PathVariable UUID id) { Batch batch = batchRepository.findById(id) .orElseThrow(() -> new org.springframework.web.server.ResponseStatusException( @@ -44,6 +47,7 @@ public class StockController { @PostMapping @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 createBatch(@Valid @RequestBody CreateBatchRequest request) { Batch batch = new Batch(); batch.setStrainId(request.strainId()); diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtAuthFilter.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtAuthFilter.java index 9d92187..868c7b1 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtAuthFilter.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtAuthFilter.java @@ -1,6 +1,7 @@ package de.cannamanage.api.security; import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.service.TokenRevocationService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -21,7 +22,7 @@ import java.util.UUID; /** * JWT authentication filter. * Extracts Bearer token from Authorization header, validates it, - * sets SecurityContext and TenantContext for downstream processing. + * checks token blacklist (revocation), sets SecurityContext and TenantContext. */ @Slf4j @Component @@ -29,6 +30,7 @@ import java.util.UUID; public class JwtAuthFilter extends OncePerRequestFilter { private final JwtService jwtService; + private final TokenRevocationService tokenRevocationService; @Override protected void doFilterInternal(HttpServletRequest request, @@ -48,6 +50,14 @@ public class JwtAuthFilter extends OncePerRequestFilter { 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 tenantId = jwtService.extractTenantId(token); String role = jwtService.extractRole(token); @@ -76,6 +86,7 @@ public class JwtAuthFilter extends OncePerRequestFilter { protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getServletPath(); return path.startsWith("/api/v1/auth/") + || path.startsWith("/portal/") || path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs"); } diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtService.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtService.java index c6b5be9..7d6a127 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtService.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/JwtService.java @@ -9,14 +9,12 @@ import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.time.Instant; -import java.util.Date; -import java.util.Map; -import java.util.UUID; +import java.util.*; import java.util.function.Function; /** * 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. */ @Service @@ -31,19 +29,40 @@ public class JwtService { @Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}") 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) { - return buildToken(Map.of( - "tenant_id", tenantId.toString(), - "role", role, - "email", email - ), userId.toString(), accessTokenExpiry); + Map claims = new HashMap<>(); + claims.put("tenant_id", tenantId.toString()); + claims.put("role", role); + claims.put("email", email); + 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 permissions) { + Map 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) { - return buildToken(Map.of( - "tenant_id", tenantId.toString(), - "type", "refresh" - ), userId.toString(), refreshTokenExpiry); + Map claims = new HashMap<>(); + claims.put("tenant_id", tenantId.toString()); + claims.put("type", "refresh"); + claims.put("jti", UUID.randomUUID().toString()); + + return buildToken(claims, userId.toString(), refreshTokenExpiry); } public String extractSubject(String token) { @@ -66,6 +85,36 @@ public class JwtService { 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 extractPermissions(String token) { + return extractClaim(token, claims -> { + Object perms = claims.get("permissions"); + if (perms instanceof List) { + return (List) 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) { try { extractAllClaims(token); diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/PreventionOfficerChecker.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/PreventionOfficerChecker.java new file mode 100644 index 0000000..5cf6024 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/PreventionOfficerChecker.java @@ -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); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java index bb030de..4b92b17 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java @@ -14,9 +14,8 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; /** - * Security configuration — Sprint 2: API-only with JWT. - * Roles: ADMIN (full access) + MEMBER (self-service endpoints only). - * STAFF role reserved for Sprint 3. + * Security configuration — Sprint 3: API + Staff portal with JWT. + * Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service). */ @Configuration @EnableWebSecurity @@ -28,7 +27,7 @@ public class SecurityConfig { /** * 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 @Order(1) @@ -41,10 +40,10 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") - .requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "MEMBER") - .requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "MEMBER") - .requestMatchers("/api/v1/stock/**").hasRole("ADMIN") - .requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "MEMBER") + .requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER") + .requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF", "MEMBER") + .requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF") + .requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER") .requestMatchers("/api/v1/reports/**").hasRole("ADMIN") .anyRequest().authenticated()) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java new file mode 100644 index 0000000..455121b --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java @@ -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); + } +} diff --git a/cannamanage-api/src/main/resources/db/migration/V3__sprint3_staff_portal.sql b/cannamanage-api/src/main/resources/db/migration/V3__sprint3_staff_portal.sql new file mode 100644 index 0000000..86d1c8a --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V3__sprint3_staff_portal.sql @@ -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; diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/security/StaffPermissionCheckerTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/security/StaffPermissionCheckerTest.java new file mode 100644 index 0000000..46c1e0b --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/security/StaffPermissionCheckerTest.java @@ -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(); + } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/RevokedToken.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/RevokedToken.java new file mode 100644 index 0000000..d25fa1e --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/RevokedToken.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/StaffAccount.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/StaffAccount.java new file mode 100644 index 0000000..eb03969 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/StaffAccount.java @@ -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 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 getGrantedPermissions() { return grantedPermissions; } + public void setGrantedPermissions(Set 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); + } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java new file mode 100644 index 0000000..f2ca250 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java @@ -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 +} diff --git a/cannamanage-service/pom.xml b/cannamanage-service/pom.xml index f9e78e9..229177d 100644 --- a/cannamanage-service/pom.xml +++ b/cannamanage-service/pom.xml @@ -47,6 +47,16 @@ assertj-core test + + + com.github.ben-manes.caffeine + caffeine + + + + org.springframework + spring-context + diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/TokenCleanupScheduler.java b/cannamanage-service/src/main/java/de/cannamanage/service/TokenCleanupScheduler.java new file mode 100644 index 0000000..e2bf064 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/TokenCleanupScheduler.java @@ -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); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/TokenRevocationService.java b/cannamanage-service/src/main/java/de/cannamanage/service/TokenRevocationService.java new file mode 100644 index 0000000..15bf047 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/TokenRevocationService.java @@ -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 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; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/RevokedTokenRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/RevokedTokenRepository.java new file mode 100644 index 0000000..a01212b --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/RevokedTokenRepository.java @@ -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 { + + 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); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/StaffAccountRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/StaffAccountRepository.java new file mode 100644 index 0000000..3f1f417 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/StaffAccountRepository.java @@ -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 { + + Optional findByUserId(UUID userId); + + List findByTenantIdAndActiveTrue(UUID tenantId); + + List findByTenantIdAndPreventionOfficerTrue(UUID tenantId); + + long countByTenantIdAndPreventionOfficerTrueAndActiveTrue(UUID tenantId); + + boolean existsByUserId(UUID userId); +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/TokenRevocationServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/TokenRevocationServiceTest.java new file mode 100644 index 0000000..b9f2cab --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/TokenRevocationServiceTest.java @@ -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 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); + } +} diff --git a/docs/sprint-3/cannamanage-sprint3-plan-review.md b/docs/sprint-3/cannamanage-sprint3-plan-review.md new file mode 100644 index 0000000..21a55bb --- /dev/null +++ b/docs/sprint-3/cannamanage-sprint3-plan-review.md @@ -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.1–1.15) | T-01 to T-06, T-25 | ✅ | +| Deferred: Club settings | Phase 2 (2.1–2.8) | T-07 to T-10 | ✅ | +| Deferred: Staff CRUD + invite | Phase 3 (3.1–3.15) | T-11 to T-14, T-25 | ✅ | +| Deferred: Reports (US-007, US-008) | Phase 4 (4.1–4.9) | T-15 to T-18 | ✅ | +| Deferred: Member portal | Phase 5 (5.1–5.7) | T-19, T-20 | ✅ | +| Deferred: Prevention Officer (US-010) | Phase 6 (6.1–6.8) | T-21, T-22 | ✅ | +| Deferred: Integration tests | Phase 7 (7.1–7.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.2–3.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 | diff --git a/docs/sprint-3/cannamanage-sprint3-plan.md b/docs/sprint-3/cannamanage-sprint3-plan.md new file mode 100644 index 0000000..ccf3ae0 --- /dev/null +++ b/docs/sprint-3/cannamanage-sprint3-plan.md @@ -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 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 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 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) diff --git a/docs/sprint-3/cannamanage-sprint3-security-review.md b/docs/sprint-3/cannamanage-sprint3-security-review.md new file mode 100644 index 0000000..94bda45 --- /dev/null +++ b/docs/sprint-3/cannamanage-sprint3-security-review.md @@ -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 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 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 | diff --git a/docs/sprint-3/snyk-code-results.json b/docs/sprint-3/snyk-code-results.json new file mode 100644 index 0000000..4ce1337 --- /dev/null +++ b/docs/sprint-3/snyk-code-results.json @@ -0,0 +1 @@ +{"$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json","version": "2.1.0","runs": [{"tool": {"driver" : {"name" : "SnykCode","semanticVersion" : "1.1305.1","version" : "1.1305.1","informationUri" : "https://docs.snyk.io/","rules" : [{"id": "java/DisablesCSRFProtection","name": "DisablesCSRFProtection","shortDescription": {"text": "Cross-Site Request Forgery (CSRF)"},"defaultConfiguration": {"level": "error"},"help": {"markdown": "\n## Details\nCross-site request forgery is an attack in which a malicious third party takes advantage of a user's authenticated credentials (such as a browser cookie) to impersonate that trusted user and perform unauthorized actions. The web application server cannot tell the difference between legitimate and malicious requests. This type of attack generally begins by tricking the user with a social engineering attack, such as a link or popup that the user inadvertently clicks, causing an unauthorized request to be sent to the web server. Consequences vary: At a standard user level, attackers can change passwords, transfer funds, make purchases, or connect with contacts; from an administrator account, attackers can then make changes to or even take down the app itself.\n\n## Best practices for prevention\n* Use development frameworks that defend against CSRF, using a nonce, hash, or some other security device to the URL and/or to forms.\n* Implement secure, unique, hidden tokens that are checked by the server each time to validate state-change requests.\n* Never assume that authentication tokens and session identifiers mean a request is legitimate.\n* Understand and implement other safe-cookie techniques, such as double submit cookies.\n* Terminate user sessions when not in use, including automatic timeout.\n* Ensure rigorous coding practices and defenses against other commonly exploited CWEs, since cross-site scripting (XSS), for example, can be used to bypass defenses against CSRF.","text": ""},"properties": {"tags": ["java","DisablesCSRFProtection","Security"],"categories": ["Security"],"exampleCommitFixes": [{"commitURL": "https://github.com/13482477/JFDF/commit/3326a94a203ab334e9185d93beadc38b9c93b100?diff=split#diff-a9f9136673bc42f9fcd901899a430de1862ad2f7cda4150ce17bbddbd5909851L-1","lines": [{"line": "http\r\n","lineNumber": 37,"lineChange": "none"},{"line": ".authorizeRequests()\r\n","lineNumber": 38,"lineChange": "none"},{"line": "\t.antMatchers(new String[] {\r\n","lineNumber": 39,"lineChange": "none"},{"line": "\t\t\t\"/login\",\r\n","lineNumber": 40,"lineChange": "none"},{"line": "\t\t\t\"/logout\",\r\n","lineNumber": 41,"lineChange": "none"},{"line": "\t\t\t\"/**/*.css\",\r\n","lineNumber": 42,"lineChange": "none"},{"line": "\t\t\t\"/**/*.js\",\r\n","lineNumber": 43,"lineChange": "none"},{"line": "\t\t\t\"/**/*.woff\",\r\n","lineNumber": 44,"lineChange": "none"},{"line": "\t\t\t\"/**/*.woff2\",\r\n","lineNumber": 45,"lineChange": "none"},{"line": "\t\t\t\"/**/*.css.map\",\r\n","lineNumber": 46,"lineChange": "none"},{"line": "\t\t\t\"/**/*.ttf\",\r\n","lineNumber": 47,"lineChange": "none"},{"line": "\t\t\t\"/**/*.png\",\r\n","lineNumber": 48,"lineChange": "none"},{"line": "\t\t\t\"/**/*.jpg\",\r\n","lineNumber": 49,"lineChange": "none"},{"line": "\t\t\t\"/**/*.jpeg\",\r\n","lineNumber": 50,"lineChange": "none"},{"line": "\t\t\t\"/**/*.gif\",\r\n","lineNumber": 51,"lineChange": "none"},{"line": "\t\t\t}).permitAll()\r\n","lineNumber": 52,"lineChange": "none"},{"line": "\t.anyRequest().authenticated()\r\n","lineNumber": 53,"lineChange": "none"},{"line": "\t.and()\r\n","lineNumber": 54,"lineChange": "none"},{"line": ".formLogin()\r\n","lineNumber": 55,"lineChange": "none"},{"line": "\t.loginPage(\"/login\")\r\n","lineNumber": 56,"lineChange": "none"},{"line": "\t.defaultSuccessUrl(\"/index\")\r\n","lineNumber": 57,"lineChange": "none"},{"line": "\t.failureHandler(new FeedbackLoginInfoAuthenticationFailureHandler(\"/login\"))\r\n","lineNumber": 58,"lineChange": "none"},{"line": "\t.and()\r\n","lineNumber": 59,"lineChange": "none"},{"line": ".logout()\r\n","lineNumber": 60,"lineChange": "none"},{"line": "\t.logoutUrl(\"/logout\")\r\n","lineNumber": 61,"lineChange": "none"},{"line": "\t.logoutSuccessUrl(\"/login\")\r\n","lineNumber": 62,"lineChange": "none"},{"line": "\t.and()\r\n","lineNumber": 63,"lineChange": "none"},{"line": ".csrf()\r\n","lineNumber": 64,"lineChange": "removed"},{"line": "\t.disable();\r\n","lineNumber": 65,"lineChange": "removed"},{"line": ".csrf();\r\n","lineNumber": 64,"lineChange": "added"}]},{"commitURL": "https://github.com/mraible/java-webapp-security-examples/commit/1ae83aeb6975a107dcdb616eeae63bc846fcadaf?diff=split#diff-b8cb20d5732c784ae693cb1cd9ecb813e912a21fe570c581998875276a2a642dL-1","lines": [{"line": "http\n","lineNumber": 23,"lineChange": "none"},{"line": " .csrf().disable()\n","lineNumber": 24,"lineChange": "removed"},{"line": " .csrf().and()\n","lineNumber": 24,"lineChange": "added"}]},{"commitURL": "https://github.com/jgribonvald/demo-spring-security-cas/commit/3b1ee5ecc5e718513127355b884c165bb4936c7f?diff=split#diff-4ead997b1df6dd1b785b7ba2dbcb18dfb6f6624a1997e5642b016adf46e69d08L-1","lines": [{"line": "\t\t/**\n","lineNumber": 162,"lineChange": "removed"},{"line": "\t\t * \n","lineNumber": 163,"lineChange": "removed"},{"line": "\t\t */\n","lineNumber": 164,"lineChange": "removed"},{"line": "\t\thttp.sessionManagement().sessionFixation().changeSessionId();\n","lineNumber": 165,"lineChange": "removed"},{"line": "\n","lineNumber": 166,"lineChange": "removed"},{"line": "\t\thttp.csrf().disable();\n","lineNumber": 167,"lineChange": "removed"},{"line": "\t\t// http.csrf();\n","lineNumber": 171,"lineChange": "added"}]}],"exampleCommitDescriptions": [],"precision": "very-high","repoDatasetSize": 42,"cwe": ["CWE-352"]}},{"id": "java/HardcodedPassword/test","name": "HardcodedPassword/test","shortDescription": {"text": "Use of Hardcoded Passwords"},"defaultConfiguration": {"level": "note"},"help": {"markdown": "\n## Details\n\nDevelopers may use hardcoded passwords during development to streamline setup or simplify authentication while testing. Although these passwords are intended to be removed before deployment, they are sometimes inadvertently left in the code. This introduces serious security risks, especially if the password grants elevated privileges or is reused across multiple systems.\n\nAn attacker who discovers a hardcoded password can potentially gain unauthorized access, escalate privileges, exfiltrate sensitive data, or disrupt service availability. If the password is reused across different environments or applications, the compromise can spread quickly and broadly.\n\n## Best practices for prevention\n* Plan software architecture such that keys and passwords are always stored outside the code, wherever possible.\n* Plan encryption into software architecture for all credential information and ensure proper handling of keys, credentials, and passwords.\n* Prompt for a secure password on first login rather than hard-code a default password.\n* If a hardcoded password or credential must be used, limit its use, for example, to system console users rather than via the network.\n* Use strong hashes for inbound password authentication, ideally with randomly assigned salts to increase the difficulty level in case of brute-force attack.","text": ""},"properties": {"tags": ["java","HardcodedPassword","Security","InTest"],"categories": ["Security","InTest"],"exampleCommitFixes": [{"commitURL": "https://github.com/clowee/OpenSZZ-Cloud-Native/commit/ee429faf9d384074cf33515eda2a52e4e85ef061?diff=split#diff-a125fdf9d37b8daa4d9f0a7a46aedd416a80a49936bdee4bae40db3f3eef22deL-1","lines": [{"line": "final String username = \"noreply.openszz@gmail.com\";\n","lineNumber": 19,"lineChange": "removed"},{"line": "final String password = \"Aa30011992\";\n","lineNumber": 20,"lineChange": "removed"},{"line": "final String username = System.getenv(\"EMAIL\");\n","lineNumber": 19,"lineChange": "added"},{"line": "final String password = System.getenv(\"PWD\");\n","lineNumber": 20,"lineChange": "added"}]},{"commitURL": "https://github.com/winstonli/writelatex-git-bridge/commit/1117c70f31cee7d9a84c565c143f353d8bbab19e?diff=split#diff-16d090e60dcd386546c2164ae454bd356cbf326d715a66796582e8c003d8af10L-1","lines": [{"line": " public static void setBasicAuth(String username, String password) {\n","lineNumber": 27,"lineChange": "added"},{"line": " USERNAME = username;\n","lineNumber": 28,"lineChange": "added"},{"line": " PASSWORD = password;\n","lineNumber": 29,"lineChange": "added"},{"line": " }\n","lineNumber": 30,"lineChange": "added"},{"line": "\n","lineNumber": 31,"lineChange": "added"},{"line": " public static void setBaseURL(String baseURL) {\n","lineNumber": 32,"lineChange": "added"},{"line": " BASE_URL = baseURL;\n","lineNumber": 33,"lineChange": "added"},{"line": " }\n","lineNumber": 34,"lineChange": "added"},{"line": "\n","lineNumber": 35,"lineChange": "added"}]},{"commitURL": "https://github.com/CheckChe0803/flink-recommandSystem-demo/commit/bbe348d12bc76858d8c0878f2a89ccc0f1e7b05b?diff=split#diff-7cac3b1a67764486d11265840a420739f0339a6cc8ff947d192de102366f8acaL-1","lines": [{"line": "private static String URL = \"jdbc:mysql://localhost/con?serverTimezone=GMT%2B8\";\n","lineNumber": 6,"lineChange": "removed"},{"line": "private static String NAME = \"root\";\n","lineNumber": 7,"lineChange": "removed"},{"line": "private static String PASS = \"root\";\n","lineNumber": 8,"lineChange": "removed"},{"line": "private static String URL = Property.getStrValue(\"mysql.url\");\n","lineNumber": 8,"lineChange": "added"},{"line": "private static String NAME = Property.getStrValue(\"mysql.name\");\n","lineNumber": 9,"lineChange": "added"},{"line": "private static String PASS = Property.getStrValue(\"mysql.pass\");\n","lineNumber": 10,"lineChange": "added"},{"line": "private static Statement stmt;\n","lineNumber": 11,"lineChange": "none"},{"line": "static {\n","lineNumber": 12,"lineChange": "none"},{"line": " try {\n","lineNumber": 13,"lineChange": "none"},{"line": " Class.forName(\"com.mysql.cj.jdbc.Driver\");\n","lineNumber": 14,"lineChange": "none"},{"line": " Connection conn = DriverManager.getConnection(URL, NAME, PASS);\n","lineNumber": 15,"lineChange": "none"}]}],"exampleCommitDescriptions": [],"precision": "very-high","repoDatasetSize": 74,"cwe": ["CWE-798","CWE-259"]}}]}},"results": [{"ruleId": "java/DisablesCSRFProtection","ruleIndex": 0,"level": "error","message": {"text":"CSRF protection is disabled by disable. This allows the attackers to execute requests on a user's behalf.","markdown":"CSRF protection is disabled by {0}. This allows the attackers to execute requests on a user's behalf.","arguments": ["[disable](0)"]},"locations": [{ "id": 0, "physicalLocation": {"artifactLocation": { "uri": "cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java", "uriBaseId": "%SRCROOT%"},"region": { "startLine":61, "endLine":63, "startColumn":9, "endColumn":22} }}],"fingerprints": {"identity": "e7bfa036-d8c0-453d-abd2-239ced27a331","0": "04cfdbea297f59d0da47ea9332da5f4ba165056eae82575b6d9250f28537295d","1": "b5099633.fd6a5963.13c31930.e22980a8.f2a5bca1.4431cad1.7b4155dd.54d46e25.b5099633.fd6a5963.13c31930.de031890.f0e1baa5.102e2858.3953228b.f7c40842","snyk/asset/finding/v1": "e7bfa036-d8c0-453d-abd2-239ced27a331"},"codeFlows": [{"threadFlows": [{"locations": [{"location": {"id": 0,"physicalLocation": {"artifactLocation": { "uri": "cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java", "uriBaseId": "%SRCROOT%"},"region": { "startLine":63, "endLine":63, "startColumn":31, "endColumn":43}}}}]}]}],"properties": {"isAutofixable": true,"priorityScore": 800,"priorityScoreFactors": [ {"label": true,"type": "hotFileCodeFlow" }, {"label": true,"type": "fixExamples" }]}},{"ruleId": "java/HardcodedPassword/test","ruleIndex": 1,"level": "note","message": {"text":"Do not hardcode passwords in code. Found hardcoded password used in here.","markdown":"Do not hardcode passwords in code. Found hardcoded password used in {0}.","arguments": ["[here](0)"]},"locations": [{ "id": 0, "physicalLocation": {"artifactLocation": { "uri": "cannamanage-api/src/test/java/de/cannamanage/api/controller/AuthControllerIntegrationTest.java", "uriBaseId": "%SRCROOT%"},"region": { "startLine":48, "endLine":48, "startColumn":49, "endColumn":65} }}],"fingerprints": {"identity": "a8ecbbe1-6bb2-4f4f-9ba8-9cf1985f0f1b","0": "a7b20b1967684ddcb44b5af14789ff3f8bd7a5e89329849d356794ce1e1802e6","1": "51f4bbec.dd05ec30.c0c79380.de031890.8b2d3351.248bce3c.3f71d1e7.87dfd8cc.51f4bbec.8b9ac446.c0c79380.de031890.5b39032a.248bce3c.c66d287d.54d46e25","snyk/asset/finding/v1": "a8ecbbe1-6bb2-4f4f-9ba8-9cf1985f0f1b"},"codeFlows": [{"threadFlows": [{"locations": [{"location": {"id": 0,"physicalLocation": {"artifactLocation": { "uri": "cannamanage-api/src/test/java/de/cannamanage/api/controller/AuthControllerIntegrationTest.java", "uriBaseId": "%SRCROOT%"},"region": { "startLine":48, "endLine":48, "startColumn":49, "endColumn":65}}}}]}]}],"properties": {"isAutofixable": true,"priorityScore": 400,"priorityScoreFactors": [ {"label": true,"type": "hotFileCodeFlow" }, {"label": true,"type": "fixExamples" }]}}],"properties": {"coverage": [{"files": 56,"isSupported": true,"lang": ".java","type": "SUPPORTED"},{"files": 4,"isSupported": true,"lang": ".xml","type": "SUPPORTED"}]},"automationDetails": {"id":"Snyk/Code/2026-06-11T14:02:09Z"}}]}