- 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)
35 KiB
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
// 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:
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:
@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:
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
public ResponseEntity<DistributionResponse> recordDistribution(...)
Staff permissions embedded in JWT:
{
"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:
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:
@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:
// In JwtAuthFilter.doFilterInternal():
String jti = jwtService.extractClaim(token, "jti");
if (tokenRevocationService.isRevoked(jti)) {
response.sendError(401, "Token revoked");
return;
}
Cleanup scheduled task:
@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:
@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
memberIdfrom session principal) - All portal endpoints are GET (read-only) — no write operations
- Session timeout: 30 minutes
SameSite=Strictcookie- CSRF token provided via
CookieCsrfTokenRepository.withHttpOnlyFalse()
3.4 Prevention Officer (Decision D3)
Configurable limit on Club entity:
// Club.java
@Column(name = "max_prevention_officers")
private Integer maxPreventionOfficers = 2; // default: 2
Enforcement on assignment:
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:
@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:
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):
# 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:
// Club.java
@Column(name = "allowed_email_pattern")
private String allowedEmailPattern; // regex, NULL = unrestricted
Validation in StaffService:
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 domainsNULL— any email accepted (default)
3.7 Report Generation (Decision D4)
OpenPDF (LGPL fork of iText 5) with minimal branding:
@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):
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:
-- 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)
@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 |
|---|---|---|---|
| — | — | 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-portalgreen 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)