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

- StaffPermission enum (8 granular permissions)
- StaffAccount JPA entity with permissions collection
- RevokedToken entity for JWT blacklisting
- Flyway V3 migration (staff_accounts, staff_account_permissions, revoked_tokens)
- StaffAccountRepository + RevokedTokenRepository
- TokenRevocationService with Caffeine cache (60s TTL, 10k max)
- StaffPermissionChecker SpEL bean (@staffPermissions.has)
- PreventionOfficerChecker SpEL bean (@preventionOfficer.check)
- JwtService: added jti claim + generateStaffAccessToken + extractJti/extractPermissions
- JwtAuthFilter: token blacklist check via TokenRevocationService
- SecurityConfig: STAFF role added to endpoint matchers
- Controllers updated with @PreAuthorize for fine-grained access
- TokenCleanupScheduler (daily 03:00 cleanup of expired revoked tokens)
- Caffeine dependency added to cannamanage-service
- Unit tests: StaffPermissionCheckerTest (7), TokenRevocationServiceTest (9)
This commit is contained in:
Patrick Plate
2026-06-11 16:45:21 +02:00
parent a1ddec37da
commit 55d8434f35
24 changed files with 2333 additions and 23 deletions
@@ -0,0 +1,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
}