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:
@@ -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