feat: Sprint 2 REST API layer — full implementation

- 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.
This commit is contained in:
Patrick Plate
2026-06-11 12:05:52 +02:00
parent 86c922e1f9
commit 2ede872d11
30 changed files with 1232 additions and 32 deletions
+18 -2
View File
@@ -80,13 +80,24 @@
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.6</version>
</dependency>
<!-- H2 for tests -->
<!-- H2 for unit tests -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Test -->
<!-- Testcontainers PostgreSQL for integration tests -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -97,6 +108,11 @@
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -2,15 +2,19 @@ package de.cannamanage.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* CannaManage Spring Boot application entry point.
* Sprint 2: REST API + Spring Security + OpenAPI.
*
* Entity scanning and repository detection handled automatically
* via scanBasePackages covering the full de.cannamanage hierarchy.
* Multi-module scanning:
* - scanBasePackages: component scanning (controllers, services)
* - EnableJpaRepositories: Spring Data JPA repository interfaces
* - Entity scanning configured via spring.jpa properties
*/
@SpringBootApplication(scanBasePackages = "de.cannamanage")
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
public class CannaManageApplication {
public static void main(String[] args) {
@@ -0,0 +1,35 @@
package de.cannamanage.api.config;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Contact;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.info.License;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.servers.Server;
import org.springframework.context.annotation.Configuration;
@Configuration
@OpenAPIDefinition(
info = @Info(
title = "CannaManage API",
version = "1.0.0",
description = "Cannabis Social Club Management — CanG Compliance Platform API",
contact = @Contact(name = "CannaManage", email = "info@cannamanage.de"),
license = @License(name = "Proprietary")
),
servers = {
@Server(url = "/", description = "Current server")
},
security = @SecurityRequirement(name = "bearer-jwt")
)
@SecurityScheme(
name = "bearer-jwt",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT",
description = "JWT access token — obtain via POST /api/v1/auth/login"
)
public class OpenApiConfig {
}
@@ -0,0 +1,38 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.auth.LoginRequest;
import de.cannamanage.api.dto.auth.LoginResponse;
import de.cannamanage.api.dto.auth.RefreshRequest;
import de.cannamanage.api.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Tag(name = "Authentication", description = "Login and token management")
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
@Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens")
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
LoginResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
@PostMapping("/refresh")
@Operation(summary = "Refresh access token", description = "Exchanges a valid refresh token for new token pair")
public ResponseEntity<LoginResponse> refresh(@Valid @RequestBody RefreshRequest request) {
LoginResponse response = authService.refresh(request);
return ResponseEntity.ok(response);
}
}
@@ -0,0 +1,42 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.compliance.QuotaResponse;
import de.cannamanage.service.ComplianceService;
import de.cannamanage.service.dto.QuotaStatus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/compliance")
@RequiredArgsConstructor
@Tag(name = "Compliance", description = "CanG §19 compliance quota checks")
public class ComplianceController {
private final ComplianceService complianceService;
@GetMapping("/quota/{memberId}")
@Operation(summary = "Get member quota status",
description = "Returns current monthly remaining quota for a member per CanG §19")
public ResponseEntity<QuotaResponse> getQuotaStatus(@PathVariable UUID memberId) {
QuotaStatus status = complianceService.getQuotaStatus(memberId);
QuotaResponse response = new QuotaResponse(
status.totalAllowed(),
status.totalUsed(),
status.remaining(),
status.isUnder21(),
status.year(),
status.month()
);
return ResponseEntity.ok(response);
}
}
@@ -0,0 +1,75 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.distribution.CreateDistributionRequest;
import de.cannamanage.api.dto.distribution.DistributionResponse;
import de.cannamanage.domain.entity.Distribution;
import de.cannamanage.service.ComplianceService;
import de.cannamanage.service.repository.DistributionRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/distributions")
@RequiredArgsConstructor
@Tag(name = "Distributions", description = "Cannabis distribution recording (CanG §26)")
public class DistributionController {
private final DistributionRepository distributionRepository;
private final ComplianceService complianceService;
@GetMapping
@Operation(summary = "List all distributions", description = "Returns all distribution records for the current tenant")
public ResponseEntity<List<DistributionResponse>> listDistributions() {
List<DistributionResponse> distributions = distributionRepository.findAll().stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(distributions);
}
@PostMapping
@Operation(summary = "Record a distribution",
description = "Records a cannabis distribution after compliance checks pass (CanG §19)")
public ResponseEntity<DistributionResponse> createDistribution(
@Valid @RequestBody CreateDistributionRequest request,
Authentication authentication) {
// Run compliance checks — throws QuotaExceededException if violated
complianceService.checkDistributionAllowed(
request.memberId(), request.batchId(), request.quantityGrams());
UUID recordedBy = (UUID) authentication.getPrincipal();
Distribution distribution = new Distribution();
distribution.setMemberId(request.memberId());
distribution.setBatchId(request.batchId());
distribution.setQuantityGrams(request.quantityGrams());
distribution.setDistributedAt(Instant.now());
distribution.setRecordedBy(recordedBy);
distribution.setNotes(request.notes());
Distribution saved = distributionRepository.save(distribution);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved));
}
private DistributionResponse toResponse(Distribution d) {
return new DistributionResponse(
d.getId(),
d.getMemberId(),
d.getBatchId(),
d.getQuantityGrams(),
d.getDistributedAt(),
d.getRecordedBy(),
d.getNotes()
);
}
}
@@ -0,0 +1,105 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.member.CreateMemberRequest;
import de.cannamanage.api.dto.member.MemberResponse;
import de.cannamanage.api.dto.member.UpdateMemberRequest;
import de.cannamanage.domain.entity.Member;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.MemberStatus;
import de.cannamanage.service.repository.MemberRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.time.Period;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/members")
@RequiredArgsConstructor
@Tag(name = "Members", description = "Club member management")
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping
@Operation(summary = "List all members", description = "Returns all members for the current tenant")
public ResponseEntity<List<MemberResponse>> listMembers() {
List<MemberResponse> members = memberRepository.findAll().stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(members);
}
@GetMapping("/{id}")
@Operation(summary = "Get member by ID")
public ResponseEntity<MemberResponse> getMember(@PathVariable UUID id) {
Member member = memberRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
return ResponseEntity.ok(toResponse(member));
}
@PostMapping
@Operation(summary = "Create a new member")
public ResponseEntity<MemberResponse> createMember(@Valid @RequestBody CreateMemberRequest request) {
Member member = new Member();
member.setFirstName(request.firstName());
member.setLastName(request.lastName());
member.setEmail(request.email());
member.setDateOfBirth(request.dateOfBirth());
member.setMembershipDate(request.membershipDate());
member.setMembershipNumber(request.membershipNumber());
member.setClubId(TenantContext.getCurrentTenant()); // club == tenant for MVP
member.setUnder21(isUnder21(request.dateOfBirth()));
Member saved = memberRepository.save(member);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved));
}
@PutMapping("/{id}")
@Operation(summary = "Update a member")
public ResponseEntity<MemberResponse> updateMember(@PathVariable UUID id,
@Valid @RequestBody UpdateMemberRequest request) {
Member member = memberRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
if (request.firstName() != null) member.setFirstName(request.firstName());
if (request.lastName() != null) member.setLastName(request.lastName());
if (request.email() != null) member.setEmail(request.email());
if (request.dateOfBirth() != null) {
member.setDateOfBirth(request.dateOfBirth());
member.setUnder21(isUnder21(request.dateOfBirth()));
}
if (request.membershipNumber() != null) member.setMembershipNumber(request.membershipNumber());
if (request.status() != null) member.setStatus(MemberStatus.valueOf(request.status()));
Member saved = memberRepository.save(member);
return ResponseEntity.ok(toResponse(saved));
}
private boolean isUnder21(LocalDate dateOfBirth) {
return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21;
}
private MemberResponse toResponse(Member m) {
return new MemberResponse(
m.getId(),
m.getFirstName(),
m.getLastName(),
m.getEmail(),
m.getDateOfBirth(),
m.getMembershipDate(),
m.getMembershipNumber(),
m.getStatus(),
m.isUnder21(),
m.isPreventionOfficer()
);
}
}
@@ -0,0 +1,70 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.stock.BatchResponse;
import de.cannamanage.api.dto.stock.CreateBatchRequest;
import de.cannamanage.domain.entity.Batch;
import de.cannamanage.domain.enums.BatchStatus;
import de.cannamanage.service.repository.BatchRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/stock/batches")
@RequiredArgsConstructor
@Tag(name = "Stock", description = "Batch and inventory management")
public class StockController {
private final BatchRepository batchRepository;
@GetMapping
@Operation(summary = "List all batches", description = "Returns all batches for the current tenant")
public ResponseEntity<List<BatchResponse>> listBatches() {
List<BatchResponse> batches = batchRepository.findAll().stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(batches);
}
@GetMapping("/{id}")
@Operation(summary = "Get batch by ID")
public ResponseEntity<BatchResponse> getBatch(@PathVariable UUID id) {
Batch batch = batchRepository.findById(id)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
HttpStatus.NOT_FOUND, "Batch not found"));
return ResponseEntity.ok(toResponse(batch));
}
@PostMapping
@Operation(summary = "Create a new batch", description = "Registers a new cannabis batch in inventory")
public ResponseEntity<BatchResponse> createBatch(@Valid @RequestBody CreateBatchRequest request) {
Batch batch = new Batch();
batch.setStrainId(request.strainId());
batch.setQuantityGrams(request.quantityGrams());
batch.setHarvestDate(request.harvestDate());
batch.setBatchCode(request.batchCode());
batch.setStatus(BatchStatus.AVAILABLE);
Batch saved = batchRepository.save(batch);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved));
}
private BatchResponse toResponse(Batch b) {
return new BatchResponse(
b.getId(),
b.getStrainId(),
b.getQuantityGrams(),
b.getHarvestDate(),
b.getBatchCode(),
b.getStatus(),
b.isContaminationFlag()
);
}
}
@@ -0,0 +1,13 @@
package de.cannamanage.api.dto.auth;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank(message = "Email is required")
@Email(message = "Must be a valid email address")
String email,
@NotBlank(message = "Password is required")
String password
) {}
@@ -0,0 +1,8 @@
package de.cannamanage.api.dto.auth;
public record LoginResponse(
String accessToken,
String refreshToken,
long expiresIn,
String role
) {}
@@ -0,0 +1,8 @@
package de.cannamanage.api.dto.auth;
import jakarta.validation.constraints.NotBlank;
public record RefreshRequest(
@NotBlank(message = "Refresh token is required")
String refreshToken
) {}
@@ -0,0 +1,12 @@
package de.cannamanage.api.dto.compliance;
import java.math.BigDecimal;
public record QuotaResponse(
BigDecimal totalAllowed,
BigDecimal totalUsed,
BigDecimal remaining,
boolean under21,
int year,
int month
) {}
@@ -0,0 +1,21 @@
package de.cannamanage.api.dto.distribution;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.util.UUID;
public record CreateDistributionRequest(
@NotNull(message = "Member ID is required")
UUID memberId,
@NotNull(message = "Batch ID is required")
UUID batchId,
@NotNull(message = "Quantity in grams is required")
@DecimalMin(value = "0.01", message = "Quantity must be greater than zero")
BigDecimal quantityGrams,
String notes
) {}
@@ -0,0 +1,15 @@
package de.cannamanage.api.dto.distribution;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record DistributionResponse(
UUID id,
UUID memberId,
UUID batchId,
BigDecimal quantityGrams,
Instant distributedAt,
UUID recordedBy,
String notes
) {}
@@ -0,0 +1,30 @@
package de.cannamanage.api.dto.member;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Past;
import java.time.LocalDate;
public record CreateMemberRequest(
@NotBlank(message = "First name is required")
String firstName,
@NotBlank(message = "Last name is required")
String lastName,
@NotBlank(message = "Email is required")
@Email(message = "Must be a valid email")
String email,
@NotNull(message = "Date of birth is required")
@Past(message = "Date of birth must be in the past")
LocalDate dateOfBirth,
@NotNull(message = "Membership date is required")
LocalDate membershipDate,
@NotBlank(message = "Membership number is required")
String membershipNumber
) {}
@@ -0,0 +1,19 @@
package de.cannamanage.api.dto.member;
import de.cannamanage.domain.enums.MemberStatus;
import java.time.LocalDate;
import java.util.UUID;
public record MemberResponse(
UUID id,
String firstName,
String lastName,
String email,
LocalDate dateOfBirth,
LocalDate membershipDate,
String membershipNumber,
MemberStatus status,
boolean under21,
boolean preventionOfficer
) {}
@@ -0,0 +1,17 @@
package de.cannamanage.api.dto.member;
import jakarta.validation.constraints.Email;
import java.time.LocalDate;
public record UpdateMemberRequest(
String firstName,
String lastName,
@Email(message = "Must be a valid email")
String email,
LocalDate dateOfBirth,
String membershipNumber,
String status
) {}
@@ -0,0 +1,17 @@
package de.cannamanage.api.dto.stock;
import de.cannamanage.domain.enums.BatchStatus;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
public record BatchResponse(
UUID id,
UUID strainId,
BigDecimal quantityGrams,
LocalDate harvestDate,
String batchCode,
BatchStatus status,
boolean contaminationFlag
) {}
@@ -0,0 +1,23 @@
package de.cannamanage.api.dto.stock;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
public record CreateBatchRequest(
@NotNull(message = "Strain ID is required")
UUID strainId,
@NotNull(message = "Quantity in grams is required")
@DecimalMin(value = "0.01", message = "Quantity must be greater than zero")
BigDecimal quantityGrams,
LocalDate harvestDate,
@NotBlank(message = "Batch code is required")
String batchCode
) {}
@@ -1,5 +1,9 @@
package de.cannamanage.api.exception;
import de.cannamanage.api.service.AuthService;
import de.cannamanage.service.exception.BatchNotFoundException;
import de.cannamanage.service.exception.MemberNotFoundException;
import de.cannamanage.service.exception.QuotaExceededException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
@@ -8,9 +12,11 @@ import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
import java.net.URI;
import java.time.Instant;
import java.util.stream.Collectors;
/**
* Global exception handler producing application/problem+json responses.
@@ -20,6 +26,17 @@ import java.time.Instant;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AuthService.AuthenticationException.class)
public ProblemDetail handleAuthException(AuthService.AuthenticationException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNAUTHORIZED, ex.getMessage());
problem.setTitle("Authentication Failed");
problem.setType(URI.create("urn:cannamanage:error:AUTHENTICATION_FAILED"));
problem.setProperty("code", "AUTHENTICATION_FAILED");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(BadCredentialsException.class)
public ProblemDetail handleBadCredentials(BadCredentialsException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
@@ -51,20 +68,51 @@ public class GlobalExceptionHandler {
problem.setProperty("code", "VALIDATION_FAILED");
problem.setProperty("timestamp", Instant.now().toString());
var fieldErrors = ex.getBindingResult().getFieldErrors().stream()
var errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.toList();
problem.setProperty("errors", fieldErrors);
.collect(Collectors.toList());
problem.setProperty("errors", errors);
return problem;
}
@ExceptionHandler(IllegalArgumentException.class)
public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) {
@ExceptionHandler(QuotaExceededException.class)
public ProblemDetail handleQuotaExceeded(QuotaExceededException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, ex.getMessage());
problem.setTitle("Bad Request");
problem.setType(URI.create("urn:cannamanage:error:BAD_REQUEST"));
problem.setProperty("code", "BAD_REQUEST");
HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Compliance Violation");
problem.setType(URI.create("urn:cannamanage:error:QUOTA_EXCEEDED"));
problem.setProperty("code", ex.getCode().name());
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(MemberNotFoundException.class)
public ProblemDetail handleMemberNotFound(MemberNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Not Found");
problem.setType(URI.create("urn:cannamanage:error:MEMBER_NOT_FOUND"));
problem.setProperty("code", "MEMBER_NOT_FOUND");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(BatchNotFoundException.class)
public ProblemDetail handleBatchNotFound(BatchNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Not Found");
problem.setType(URI.create("urn:cannamanage:error:BATCH_NOT_FOUND"));
problem.setProperty("code", "BATCH_NOT_FOUND");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@ExceptionHandler(ResponseStatusException.class)
public ProblemDetail handleResponseStatus(ResponseStatusException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.valueOf(ex.getStatusCode().value()), ex.getReason());
problem.setTitle(HttpStatus.valueOf(ex.getStatusCode().value()).getReasonPhrase());
problem.setProperty("timestamp", Instant.now().toString());
return problem;
}
@@ -75,7 +123,7 @@ public class GlobalExceptionHandler {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
problem.setTitle("Internal Server Error");
problem.setType(URI.create("urn:cannamanage:error:INTERNAL"));
problem.setType(URI.create("urn:cannamanage:error:INTERNAL_ERROR"));
problem.setProperty("code", "INTERNAL_ERROR");
problem.setProperty("timestamp", Instant.now().toString());
return problem;
@@ -4,8 +4,6 @@ import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -16,9 +14,9 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Dual SecurityFilterChain configuration:
* - /api/** → stateless JWT (Bearer token)
* - /portal/** → session-based (future Sprint 3)
* Security configuration — Sprint 2: API-only with JWT.
* Roles: ADMIN (full access) + MEMBER (self-service endpoints only).
* STAFF role reserved for Sprint 3.
*/
@Configuration
@EnableWebSecurity
@@ -43,12 +41,11 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "MEMBER")
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "MEMBER")
.requestMatchers("/api/v1/stock/**").hasRole("ADMIN")
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "MEMBER")
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/me/**").authenticated()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
@@ -73,9 +70,4 @@ public class SecurityConfig {
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
@@ -0,0 +1,43 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.TenantContext;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.hibernate.Session;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* CRITICAL FIX: Activates the Hibernate @Filter("tenantFilter") on every repository call.
* Without this, the filter defined on AbstractTenantEntity is never enabled,
* meaning ALL queries return data across ALL tenants — a severe data leak.
*
* This aspect intercepts every Spring Data JPA repository method and enables
* the tenant filter with the current tenant ID from TenantContext.
*/
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class TenantFilterAspect {
private final EntityManager entityManager;
@Before("execution(* de.cannamanage.service.repository.*.*(..))")
public void activateTenantFilter() {
UUID tenantId = TenantContext.getCurrentTenant();
if (tenantId == null) {
log.trace("No tenant in context — filter not activated (public endpoint or system call)");
return;
}
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", tenantId);
log.trace("Tenant filter activated for tenant {}", tenantId);
}
}
@@ -0,0 +1,103 @@
package de.cannamanage.api.service;
import de.cannamanage.api.dto.auth.LoginRequest;
import de.cannamanage.api.dto.auth.LoginResponse;
import de.cannamanage.api.dto.auth.RefreshRequest;
import de.cannamanage.api.security.JwtService;
import de.cannamanage.domain.entity.User;
import de.cannamanage.service.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.UUID;
/**
* Authentication service — handles login and token refresh.
* Stateless JWT approach: no UserDetailsService needed.
* Refresh tokens are hashed and stored on the User entity for revocation support.
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final JwtService jwtService;
private final PasswordEncoder passwordEncoder;
@Transactional
public LoginResponse login(LoginRequest request) {
User user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
if (!user.isActive()) {
throw new AuthenticationException("Account is disabled");
}
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
throw new AuthenticationException("Invalid credentials");
}
// Generate tokens
String roleName = user.getRole().name().replace("ROLE_", "");
String accessToken = jwtService.generateAccessToken(
user.getId(), user.getTenantId(), roleName, user.getEmail());
String refreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId());
// Store hashed refresh token for revocation
user.setRefreshTokenHash(passwordEncoder.encode(refreshToken));
user.setLastLogin(Instant.now());
userRepository.save(user);
log.info("User {} logged in for tenant {}", user.getEmail(), user.getTenantId());
return new LoginResponse(accessToken, refreshToken, 3600L, roleName);
}
@Transactional
public LoginResponse refresh(RefreshRequest request) {
String token = request.refreshToken();
if (!jwtService.isTokenValid(token)) {
throw new AuthenticationException("Invalid or expired refresh token");
}
UUID userId = jwtService.extractUserId(token);
User user = userRepository.findById(userId)
.orElseThrow(() -> new AuthenticationException("User not found"));
if (!user.isActive()) {
throw new AuthenticationException("Account is disabled");
}
// Verify the refresh token matches stored hash (revocation check)
if (user.getRefreshTokenHash() == null ||
!passwordEncoder.matches(token, user.getRefreshTokenHash())) {
throw new AuthenticationException("Refresh token has been revoked");
}
// Rotate refresh token
String roleName = user.getRole().name().replace("ROLE_", "");
String newAccessToken = jwtService.generateAccessToken(
user.getId(), user.getTenantId(), roleName, user.getEmail());
String newRefreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId());
user.setRefreshTokenHash(passwordEncoder.encode(newRefreshToken));
userRepository.save(user);
return new LoginResponse(newAccessToken, newRefreshToken, 3600L, roleName);
}
/**
* Custom authentication exception — caught by GlobalExceptionHandler.
*/
public static class AuthenticationException extends RuntimeException {
public AuthenticationException(String message) {
super(message);
}
}
}
@@ -1,6 +1,7 @@
spring.application.name=cannamanage
# Default profile — override with -Dspring.profiles.active=local
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.packagesToScan=de.cannamanage.domain.entity
spring.flyway.enabled=false
# JWT Security
@@ -13,3 +14,7 @@ springdoc.api-docs.path=/v3/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.tags-sorter=alpha
springdoc.swagger-ui.operations-sorter=method
# Enable Spring AOP for TenantFilterAspect
spring.aop.auto=true
spring.aop.proxy-target-class=true
@@ -0,0 +1,9 @@
-- CannaManage V2 — Sprint 2 schema adjustments
-- 1. Rename ROLE_MANAGER → ROLE_STAFF in users table
-- 2. Add index on users.email for login lookup
UPDATE users SET role = 'ROLE_STAFF' WHERE role = 'ROLE_MANAGER';
-- Optimize login queries
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_tenant_email ON users(tenant_id, email);
@@ -0,0 +1,19 @@
spring.application.name=cannamanage-test
spring.datasource.url=jdbc:h2:mem:cannamanage_test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver
# Let Hibernate create schema from entities (H2 doesn't support all Postgres DDL)
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.flyway.enabled=false
# JWT test secret
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
cannamanage.security.jwt.access-token-expiry=3600
cannamanage.security.jwt.refresh-token-expiry=2592000
# AOP
spring.aop.auto=true
spring.aop.proxy-target-class=true