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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user