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