- Fix critical Hibernate @Filter activation bug (TenantFilterAspect)
- Rename UserRole.ROLE_MANAGER → ROLE_STAFF (future-proofing)
- SecurityConfig: ADMIN + MEMBER roles only for Sprint 2
- AuthController: POST /auth/login + POST /auth/refresh with JWT
- AuthService: login, refresh token rotation, hashed refresh storage
- MemberController: CRUD (GET/POST/PUT /members)
- DistributionController: list + record distributions (CanG §26)
- StockController: batch management (GET/POST /stock/batches)
- ComplianceController: quota check (GET /compliance/quota/{id})
- OpenAPI/Swagger config with bearer-jwt security scheme
- GlobalExceptionHandler: full RFC 9457 problem+json coverage
- UserRepository: findByEmail, findByEmailAndTenantId
- Flyway V2: role rename migration + login indexes
- Testcontainers + test profile infrastructure (integration tests deferred)
- Parent POM: Testcontainers BOM, entity scan via properties
Controllers use validated DTOs (Jakarta Bean Validation records).
Compliance checks run before distribution recording.
Tenant filter AOP aspect ensures multi-tenant data isolation.
21 KiB
CannaManage — Sprint 2 Architecture Review & Implementation Plan
Date: 2026-06-11
Author: Patrick Plate / Lumen (Planner)
Status: Draft v1 — awaiting GO
Branch: sprint/2-api
Sprint Goal: REST API layer + Spring Security + OpenAPI + Tenant Filter integration
1. Current State Assessment
1.1 Sprint 1 Deliverables (Complete ✅)
| Component | Status | Notes |
|---|---|---|
| 8 JPA entities | ✅ | Club, Member, Batch, Distribution, MonthlyQuota, StockMovement, Strain, User |
AbstractTenantEntity |
✅ | Hibernate @Filter + @PrePersist sets tenantId from TenantContext |
TenantContext |
✅ | ThreadLocal UUID holder |
ComplianceService |
✅ | Full CanG §19 enforcement: daily/monthly/batch/THC checks |
| 5 repositories | ✅ | Member, Distribution, Batch, MonthlyQuota, Strain |
| 25 tests, 100% JaCoCo | ✅ |
1.2 Sprint 2 Draft Code (Exists — Needs Revision)
| File | Status | Issues Identified |
|---|---|---|
JwtService.java |
⚠️ Functional but needs decisions | Token expiry: 1h access + 30d refresh (matches API spec) |
JwtAuthFilter.java |
⚠️ Good pattern | Sets TenantContext correctly, cleans up in finally |
SecurityConfig.java |
⚠️ Needs role alignment | Uses STAFF role not defined in UserRole enum |
GlobalExceptionHandler.java |
✅ Solid | RFC 9457 ProblemDetail pattern, covers key exceptions |
application.properties |
⚠️ | Dev secret hardcoded (acceptable for dev), flyway.enabled=false |
2. Architecture Conflicts Identified
🔴 CONFLICT 1: Multi-Tenancy Model Mismatch
Architecture Doc says: Schema-per-tenant (CREATE SCHEMA tenant_{id}, SET search_path)
Sprint 1 implemented: Shared-schema with Hibernate @Filter on tenant_id column
Analysis:
The architecture doc (Section 2) explicitly argues for schema-per-tenant with a TenantRoutingDataSource. However, Sprint 1 actually implemented a shared-schema approach using @FilterDef/@Filter on AbstractTenantEntity. Every entity has a tenant_id column and @PrePersist sets it from TenantContext.
Impact: This is not a bug — it's a pragmatic MVP decision. Schema-per-tenant is operationally complex for a solo dev. The shared-schema + Hibernate filter approach works for MVP scale (<50 clubs). The code is internally consistent.
Decision needed:
- Option A: Keep shared-schema +
@Filterfor MVP (recommended — less ops complexity) - Option B: Refactor to schema-per-tenant as documented (correct long-term, but 2-3 days of plumbing)
Recommendation: Option A. The Hibernate @Filter approach works and the data model already has tenant_id everywhere. We can migrate to schema-per-tenant in v2 when onboarding > 50 clubs. The critical invariant — tenant isolation — is preserved either way.
🟡 CONFLICT 2: Role Model Divergence
Architecture Doc roles: ROLE_CLUB_ADMIN, ROLE_STAFF, ROLE_MEMBER, ROLE_PREVENTION_OFFICER
API Spec roles: ADMIN, MEMBER (only two roles mentioned in endpoints)
Sprint 1 UserRole enum: ROLE_ADMIN, ROLE_MANAGER, ROLE_MEMBER, ROLE_PREVENTION_OFFICER
SecurityConfig draft: References ADMIN, STAFF, MEMBER
Problems:
ROLE_MANAGERexists in the enum but is not in the architecture doc (which hasROLE_STAFF)SecurityConfigreferencesSTAFFbut the enum hasMANAGER- The API spec only distinguishes
ADMINandMEMBER— no STAFF endpoints at all - Architecture doc defines a
StaffPermissionenum with fine-grained permissions — not implemented
Decision needed:
- Option A: Sprint 2 MVP uses only ADMIN + MEMBER (matches API spec, simplest)
- Option B: Implement ADMIN + STAFF + MEMBER now (matches architecture doc intent)
- Option C: Full permission model with
StaffPermissionJSONB column
Recommendation: Option A for Sprint 2. The API spec only defines ADMIN and MEMBER roles with clear permission boundaries. STAFF with configurable permissions (Option C) is a Sprint 3/4 feature — it requires a staff_accounts table, permission grants, and a custom PermissionEvaluator. The ROLE_PREVENTION_OFFICER is effectively a member with extra read access to under-21 data — can be a flag on the User entity rather than a separate role.
🟡 CONFLICT 3: Token Expiry Discrepancy
Architecture Doc: 8-hour access token
API Spec: 1-hour access token (expiresIn: 3600)
Current Code: 1-hour (matches API spec)
Recommendation: Keep 1-hour. Shorter tokens are more secure for a compliance SaaS handling personal data. The refresh token (30 days) handles UX. The architecture doc value was aspirational; the API spec value is the contract.
🟢 NON-ISSUE: Spring Boot 4.0.6 + Spring Security 7
The current code is already on Boot 4.0.6. Key observations:
SecurityFilterChainpattern — already used correctly (no deprecatedWebSecurityConfigurerAdapter)SessionCreationPolicy.STATELESS— correct for JWT API- JJWT 0.12.6 — latest stable, works with Boot 4
- Spring Security 7 modularization —
spring-security-accessmodule may be needed if using@PreAuthorize. Boot starter pulls it in transitively. springdoc-openapi-starter-webmvc-ui— compatible with Boot 4.x (version needs checking in POM)
3. Sprint 2 Scope
✅ IN Scope
| # | Feature | User Stories | Priority |
|---|---|---|---|
| 1 | Auth Controller — login, refresh, logout | US-011 | Must |
| 2 | Member Controller — CRUD + quota endpoint | US-002, US-014 | Must |
| 3 | Distribution Controller — create + list | US-003, US-004 | Must |
| 4 | Stock Controller — strains CRUD, batches CRUD, recall | US-005, US-009 | Must |
| 5 | Compliance Controller — quota check, dry-run check | US-004 | Must |
| 6 | Report Controller — monthly JSON (PDF deferred) | US-007 | Must (JSON only) |
| 7 | Club Controller — /clubs/me, /clubs/me/stats | US-006 | Must |
| 8 | OpenAPI/Swagger UI — full API documentation | — | Must |
| 9 | Hibernate Filter activation — enable tenantFilter on every request |
— | Must |
| 10 | Integration tests — MockMvc + H2 for all controllers | — | Must |
❌ OUT of Scope (Sprint 3+)
| Feature | Reason |
|---|---|
| STAFF role + StaffPermission model | Requires separate table + custom PermissionEvaluator |
| PDF/CSV report generation | Requires iText 7 dependency + report templates |
| Member portal (session-based auth) | Different security chain, Sprint 3 |
| Email notifications | Requires Jakarta Mail setup |
| Stripe integration | v2 feature (US-015) |
| Rate limiting on login | Nice-to-have, can add via Spring Cloud Gateway or bucket4j later |
| CORS configuration | Needs frontend domain — add when React app exists |
| Schema-per-tenant migration | v2 operational maturity |
4. Architecture Decisions
AD-01: Shared-Schema Multi-Tenancy (Keep Current)
Context: Architecture doc prescribes schema-per-tenant, Sprint 1 uses Hibernate @Filter.
Decision: Keep @Filter approach for MVP.
Rationale: Reduces ops complexity for solo dev. Hibernate filters are reliable when properly activated. The data model is already correct — migration to schema-per-tenant later only requires infrastructure changes, not entity redesign.
Risk: If the filter is not enabled on a session, queries return cross-tenant data. Mitigation: the TenantFilterAspect (new component, see Step 4 below) will guarantee activation.
AD-02: Two-Role Model for Sprint 2
Context: Architecture doc defines 4 roles + configurable staff permissions.
Decision: Sprint 2 uses ADMIN and MEMBER only.
Rationale: API spec only documents these two. SecurityConfig URL rules are sufficient. @PreAuthorize annotations on controller methods provide method-level granularity.
Consequence: Rename UserRole.ROLE_MANAGER → remove it. Keep ROLE_PREVENTION_OFFICER as a marker for later but don't enforce it in Sprint 2 security rules.
AD-03: JwtService as Single Source of Auth Truth
Context: No UserDetailsService loading from DB on every request.
Decision: JWT claims are sufficient for authorization. No DB hit per request.
Rationale: Stateless JWT. The token contains sub (userId), tenant_id, and role. These are all SecurityConfig needs. If we need to check user.active in real-time, we'll add a refresh-token-revocation mechanism (invalidate refresh token → user is forced to re-login within 1 hour).
Tradeoff: A deactivated user remains authenticated until their access token expires (max 1 hour). Acceptable for MVP.
AD-04: ProblemDetail (RFC 9457) for All Errors
Context: API spec defines a custom error format with status, error, code, message, timestamp, path.
Decision: Use Spring's native ProblemDetail + custom properties.
Rationale: Already implemented in GlobalExceptionHandler. Native Spring Boot support since 3.x. The custom error codes (QUOTA_EXCEEDED_MONTHLY, etc.) map to ProblemDetail.setProperty("code", ...).
AD-05: No Custom UserDetailsService
Context: Spring Security typically expects a UserDetailsService bean.
Decision: AuthController handles login directly via repository lookup + BCrypt verification. No Spring Security UserDetailsService.
Rationale: For JWT-only auth, the authentication manager is only needed at login time. The JwtAuthFilter bypasses Spring's standard auth flow entirely — it sets the SecurityContext directly from JWT claims. This is cleaner for stateless APIs.
5. Implementation Steps (Ordered)
Phase 1: Foundation Fixes (Day 1)
| Step | What | Files |
|---|---|---|
| 1.1 | Fix UserRole enum — rename MANAGER→STAFF, keep PREVENTION_OFFICER as future placeholder |
cannamanage-domain/.../enums/UserRole.java |
| 1.2 | Add UserRepository to service module |
cannamanage-service/.../repository/UserRepository.java |
| 1.3 | Create TenantFilterAspect — AOP aspect that enables Hibernate filter on EntityManager before every @Transactional service call |
cannamanage-service/.../config/TenantFilterAspect.java |
| 1.4 | Add updatedAt field to AbstractTenantEntity (currently missing vs. architecture doc) |
cannamanage-domain/.../entity/AbstractTenantEntity.java |
| 1.5 | Create H2 dev profile with spring.flyway.enabled=true + initial migration |
cannamanage-api/src/main/resources/application-dev.properties, db/migration/V1__init.sql |
Phase 2: Auth Controller (Day 1-2)
| Step | What | Files |
|---|---|---|
| 2.1 | Create AuthController with POST /login, POST /refresh, POST /logout |
cannamanage-api/.../controller/AuthController.java |
| 2.2 | Create LoginRequest, LoginResponse, RefreshRequest, RefreshResponse DTOs |
cannamanage-api/.../dto/auth/ |
| 2.3 | Create AuthService — handles login logic, password verification, refresh token rotation |
cannamanage-service/.../AuthService.java |
| 2.4 | Add domain exception handlers for QuotaExceededException, MemberNotFoundException, BatchNotFoundException to GlobalExceptionHandler |
cannamanage-api/.../exception/GlobalExceptionHandler.java |
Phase 3: Member Controller (Day 2-3)
| Step | What | Files |
|---|---|---|
| 3.1 | Create MemberController — GET list (paginated), POST create, GET by id, PUT update, DELETE soft-delete |
cannamanage-api/.../controller/MemberController.java |
| 3.2 | Create MemberService — business logic bridge between controller and repositories |
cannamanage-service/.../MemberService.java |
| 3.3 | Create DTOs: CreateMemberRequest, UpdateMemberRequest, MemberResponse, MemberSummaryResponse |
cannamanage-api/.../dto/member/ |
| 3.4 | Add GET /members/{id}/quota endpoint (delegates to ComplianceService) |
MemberController.java |
| 3.5 | Add GET /members/me for MEMBER-role self-view |
MemberController.java |
Phase 4: Distribution Controller (Day 3-4)
| Step | What | Files |
|---|---|---|
| 4.1 | Create DistributionController — POST create, GET list (paginated + filtered), GET by id, POST /{id}/notes |
cannamanage-api/.../controller/DistributionController.java |
| 4.2 | Create DistributionService — orchestrates ComplianceService + persists Distribution + updates batch stock |
cannamanage-service/.../DistributionService.java |
| 4.3 | Create DTOs: CreateDistributionRequest, DistributionResponse, AddCorrectionNoteRequest |
cannamanage-api/.../dto/distribution/ |
| 4.4 | Implement batch stock decrement on distribution creation (atomic with compliance check) | DistributionService.java |
Phase 5: Stock Controller (Day 4-5)
| Step | What | Files |
|---|---|---|
| 5.1 | Create StockController — strains CRUD + batches CRUD + recall |
cannamanage-api/.../controller/StockController.java |
| 5.2 | Create StockService — batch management, recall workflow |
cannamanage-service/.../StockService.java |
| 5.3 | Create DTOs: CreateStrainRequest, StrainResponse, CreateBatchRequest, BatchResponse, RecallBatchRequest |
cannamanage-api/.../dto/stock/ |
| 5.4 | Implement recall — flags batch, returns affected members | StockService.java |
Phase 6: Compliance + Club + Reports (Day 5-6)
| Step | What | Files |
|---|---|---|
| 6.1 | Create ComplianceController — GET /compliance/quota/{memberId}, GET /compliance/check (dry-run) |
cannamanage-api/.../controller/ComplianceController.java |
| 6.2 | Create ClubController — GET /clubs/me, PUT /clubs/me, GET /clubs/me/stats |
cannamanage-api/.../controller/ClubController.java |
| 6.3 | Create ClubService — club profile + dashboard stats aggregation |
cannamanage-service/.../ClubService.java |
| 6.4 | Create ReportController — GET /reports/monthly?format=json (JSON only for Sprint 2) |
cannamanage-api/.../controller/ReportController.java |
| 6.5 | Create DTOs for all above | cannamanage-api/.../dto/club/, .../dto/compliance/, .../dto/report/ |
Phase 7: OpenAPI + Integration Tests (Day 6-7)
| Step | What | Files |
|---|---|---|
| 7.1 | Add @Tag, @Operation, @ApiResponse annotations to all controllers |
All controllers |
| 7.2 | Configure OpenApiConfig — info, security scheme, server URL |
cannamanage-api/.../config/OpenApiConfig.java |
| 7.3 | Write AuthControllerTest — MockMvc tests for login/refresh/logout |
cannamanage-api/src/test/... |
| 7.4 | Write MemberControllerTest — CRUD + auth + tenant isolation |
cannamanage-api/src/test/... |
| 7.5 | Write DistributionControllerTest — compliance rejection scenarios |
cannamanage-api/src/test/... |
| 7.6 | Write StockControllerTest — batch lifecycle + recall |
cannamanage-api/src/test/... |
| 7.7 | Write ComplianceControllerTest — dry-run check |
cannamanage-api/src/test/... |
| 7.8 | Ensure JaCoCo stays ≥ 80% on service module | Build config |
6. Key Technical Patterns
6.1 Tenant Filter Activation (New — Critical)
The Hibernate @Filter requires explicit activation on each Session. Currently nothing activates it. We need:
@Aspect
@Component
@RequiredArgsConstructor
public class TenantFilterAspect {
private final EntityManager entityManager;
@Before("@within(org.springframework.stereotype.Service)")
public void enableTenantFilter() {
UUID tenantId = TenantContext.getCurrentTenant();
if (tenantId != null) {
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", tenantId);
}
}
}
Alternative: A HandlerInterceptor that runs after the JwtAuthFilter and activates the filter. Both work — AOP is more robust as it catches service-to-service calls too.
6.2 Controller DTO Mapping Pattern
@RestController
@RequestMapping("/api/v1/members")
@RequiredArgsConstructor
@Tag(name = "Members", description = "Member management")
public class MemberController {
private final MemberService memberService;
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
@ResponseStatus(HttpStatus.CREATED)
public MemberResponse create(@Valid @RequestBody CreateMemberRequest request) {
return memberService.createMember(request);
}
}
6.3 Pagination Response Pattern
Use Spring's Page<T> mapped to a custom envelope matching the API spec:
public record PageResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages
) {
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages()
);
}
}
7. Risk Assessment
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
Hibernate @Filter not activated → cross-tenant data leak |
High (if forgotten) | Critical | TenantFilterAspect AOP guarantee + integration tests verifying isolation |
| Spring Security 7 API changes break SecurityConfig | Low | Medium | Code is already using Boot 4.0.6 patterns — verified compatible |
| springdoc-openapi incompatibility with Boot 4 | Medium | Low | Check version in POM, use 2.8.x which supports Boot 4 |
| JaCoCo coverage drops below 80% with new controllers | Medium | Low | Write MockMvc tests for every endpoint, assert status codes |
@PreAuthorize not working without @EnableMethodSecurity |
Low | High | Already enabled in SecurityConfig ✅ |
| JWT secret hardcoded in properties committed to Git | Medium (already there) | Medium | Replace with ${JWT_SECRET} env var pattern before any production use |
8. Open Questions for Patrick
-
Tenancy model: Architecture doc says schema-per-tenant, Sprint 1 uses shared-schema + Hibernate
@Filter. I recommend keeping@Filterfor MVP. Agree? -
Role model for Sprint 2: Keep it to ADMIN + MEMBER only? Or do you want STAFF (with basic URL-level permissions, not the full
StaffPermissionJSONB model) included now? -
UserRolecleanup: The current enum hasROLE_MANAGERwhich doesn't exist in any doc. Replace withROLE_STAFF? Or just remove and keep ADMIN + MEMBER + PREVENTION_OFFICER for now? -
Test database: Use H2 in-memory for Sprint 2 tests, or set up Testcontainers PostgreSQL? H2 is simpler for dev speed; Testcontainers is more realistic but slower.
-
Flyway migrations: Should I create the full schema migration now (all 8 entities → DDL), or keep
ddl-auto=updatefor rapid iteration and create migrations when stable? -
Member portal auth (Sprint 3 preview): The architecture doc and SecurityConfig both hint at a session-based
/portal/**chain. Confirm this is deferred to Sprint 3? -
Prevention Officer: The architecture doc says this is a separate role with access to under-21 member data. Should this be:
- A separate
UserRolevalue with specific@PreAuthorizerules? - Or a boolean flag on the User/Member that grants additional read access via service-layer checks?
- A separate
9. Definition of Done — Sprint 2
- All 7 controllers implemented with full CRUD per API spec
- JWT auth flow working: login → access token → authenticated requests → refresh → logout
- Tenant isolation verified: integration tests proving cross-tenant queries return 0 results
- Compliance checks enforced on distribution creation (fail with 422 + custom error codes)
- OpenAPI Swagger UI accessible at
/swagger-ui.htmlwith all endpoints documented @PreAuthorizeannotations on all endpoints with correct role requirements- GlobalExceptionHandler covers all domain exceptions with RFC 9457 ProblemDetail
- MockMvc integration tests for all controllers (≥ 2 tests per endpoint: happy path + auth failure)
- JaCoCo ≥ 80% on
cannamanage-service, ≥ 70% oncannamanage-api - No hardcoded secrets in committed code (move JWT secret to env var pattern)
- Build passes:
mvn clean verify
10. Estimated Effort
| Phase | Effort | Cumulative |
|---|---|---|
| Phase 1: Foundation Fixes | 3-4 hours | Day 1 |
| Phase 2: Auth Controller | 4-5 hours | Day 1-2 |
| Phase 3: Member Controller | 4-5 hours | Day 2-3 |
| Phase 4: Distribution Controller | 4-5 hours | Day 3-4 |
| Phase 5: Stock Controller | 4-5 hours | Day 4-5 |
| Phase 6: Compliance + Club + Reports | 4-5 hours | Day 5-6 |
| Phase 7: OpenAPI + Tests | 6-8 hours | Day 6-7 |
| Total | ~30-37 hours | ~7 working days |
This plan covers the REST API backbone. Sprint 3 will address: member portal (session auth), PDF/CSV reports (iText 7), email notifications, and the STAFF permission model.