From 05c4f9e2cfa6020f22d5baba0c01a9046f50f2ac Mon Sep 17 00:00:00 2001 From: Poojithvsc Date: Fri, 5 Jun 2026 19:29:27 +0200 Subject: [PATCH 1/4] feat(identity): JWT roles claim + seeded admin, ROLE_* authorities (Phase 17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Access tokens now carry a `roles` claim (default ["USER"]); the auth filter maps each role to a ROLE_* GrantedAuthority so method security can gate on it. @EnableMethodSecurity turns on @PreAuthorize. Users gain a denormalised comma-separated `roles` column (Flyway V14), which also seeds one admin (admin@shopsphere.local, roles USER,ADMIN) — dev-only password, documented for rotation. Absent/empty roles normalise to ["USER"] so pre-existing tokens still authenticate. Co-Authored-By: Claude Opus 4.8 --- .../com/shopsphere/identity/AuthService.java | 8 +++---- .../identity/JwtAuthenticationFilter.java | 13 ++++++++-- .../com/shopsphere/identity/JwtIssuer.java | 15 +++++++++--- .../shopsphere/identity/SecurityConfig.java | 2 ++ .../java/com/shopsphere/identity/User.java | 16 +++++++++++++ .../V14__user_roles_and_seed_admin.sql | 24 +++++++++++++++++++ .../shopsphere/identity/JwtIssuerTests.java | 17 ++++++++++--- 7 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 src/main/resources/db/migration/identity/V14__user_roles_and_seed_admin.sql diff --git a/src/main/java/com/shopsphere/identity/AuthService.java b/src/main/java/com/shopsphere/identity/AuthService.java index 6038fbd..1b0275c 100644 --- a/src/main/java/com/shopsphere/identity/AuthService.java +++ b/src/main/java/com/shopsphere/identity/AuthService.java @@ -49,7 +49,7 @@ Token login(String email, String rawPassword) { if (!hasher.matches(rawPassword, user.getPasswordHash())) { throw new BadCredentialsException(); } - return issueTokens(user.getId(), user.getCustomerId()); + return issueTokens(user.getId(), user.getCustomerId(), user.getRoles()); } @Transactional(noRollbackFor = RefreshTokenService.InvalidRefreshTokenException.class) @@ -57,7 +57,7 @@ Token refresh(String presentedRefreshToken) { RefreshTokenService.Issued issued = refreshTokens.rotate(presentedRefreshToken); User user = users.findById(issued.userId()) .orElseThrow(RefreshTokenService.InvalidRefreshTokenException::new); - String access = jwt.issue(user.getId(), user.getCustomerId()); + String access = jwt.issue(user.getId(), user.getCustomerId(), user.getRoles()); return new Token(access, jwt.ttlSeconds(), issued.rawToken(), issued.expiresInSeconds()); } @@ -66,8 +66,8 @@ void logout(String presentedRefreshToken) { refreshTokens.revoke(presentedRefreshToken); } - private Token issueTokens(UUID userId, UUID customerId) { - String access = jwt.issue(userId, customerId); + private Token issueTokens(UUID userId, UUID customerId, java.util.List roles) { + String access = jwt.issue(userId, customerId, roles); RefreshTokenService.Issued issued = refreshTokens.issueNew(userId); return new Token(access, jwt.ttlSeconds(), issued.rawToken(), issued.expiresInSeconds()); } diff --git a/src/main/java/com/shopsphere/identity/JwtAuthenticationFilter.java b/src/main/java/com/shopsphere/identity/JwtAuthenticationFilter.java index 16e7b3c..68b27e6 100644 --- a/src/main/java/com/shopsphere/identity/JwtAuthenticationFilter.java +++ b/src/main/java/com/shopsphere/identity/JwtAuthenticationFilter.java @@ -6,12 +6,14 @@ import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Collection; import java.util.List; -import java.util.UUID; final class JwtAuthenticationFilter extends OncePerRequestFilter { @@ -44,11 +46,18 @@ private static final class JwtAuthentication extends AbstractAuthenticationToken private final AuthenticatedPrincipal principal; JwtAuthentication(JwtIssuer.Verified verified) { - super(List.of()); + super(authorities(verified.roles())); this.principal = new AuthenticatedPrincipal(verified.userId(), verified.customerId()); setAuthenticated(true); } + private static Collection authorities(List roles) { + // Spring's hasRole('ADMIN') checks for a ROLE_ADMIN authority; map each role name across. + return roles.stream() + .map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + } + @Override public Object getCredentials() { return ""; diff --git a/src/main/java/com/shopsphere/identity/JwtIssuer.java b/src/main/java/com/shopsphere/identity/JwtIssuer.java index 17a54fd..603e22c 100644 --- a/src/main/java/com/shopsphere/identity/JwtIssuer.java +++ b/src/main/java/com/shopsphere/identity/JwtIssuer.java @@ -10,6 +10,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Date; +import java.util.List; import java.util.UUID; final class JwtIssuer { @@ -28,12 +29,13 @@ final class JwtIssuer { this.clock = clock; } - String issue(UUID userId, UUID customerId) { + String issue(UUID userId, UUID customerId, List roles) { Instant now = clock.instant(); return Jwts.builder() .subject(userId.toString()) .claim("userId", userId.toString()) .claim("customerId", customerId.toString()) + .claim("roles", normalizeRoles(roles)) .issuedAt(Date.from(now)) .expiration(Date.from(now.plus(ttl))) .signWith(key, Jwts.SIG.HS256) @@ -54,13 +56,20 @@ Verified verify(String token) { .getPayload(); UUID userId = UUID.fromString(claims.get("userId", String.class)); UUID customerId = UUID.fromString(claims.get("customerId", String.class)); - return new Verified(userId, customerId); + @SuppressWarnings("unchecked") + List roles = claims.get("roles", List.class); + return new Verified(userId, customerId, normalizeRoles(roles)); } catch (JwtException | IllegalArgumentException e) { throw new InvalidTokenException(e); } } - record Verified(UUID userId, UUID customerId) { + /** Empty or absent roles default to {@code [USER]} so every authenticated caller has the base role. */ + private static List normalizeRoles(List roles) { + return roles == null || roles.isEmpty() ? List.of("USER") : List.copyOf(roles); + } + + record Verified(UUID userId, UUID customerId, List roles) { } static final class InvalidTokenException extends RuntimeException { diff --git a/src/main/java/com/shopsphere/identity/SecurityConfig.java b/src/main/java/com/shopsphere/identity/SecurityConfig.java index 526d179..bf3fa85 100644 --- a/src/main/java/com/shopsphere/identity/SecurityConfig.java +++ b/src/main/java/com/shopsphere/identity/SecurityConfig.java @@ -4,12 +4,14 @@ import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration +@EnableMethodSecurity class SecurityConfig { @Bean diff --git a/src/main/java/com/shopsphere/identity/User.java b/src/main/java/com/shopsphere/identity/User.java index 36104ef..07a298a 100644 --- a/src/main/java/com/shopsphere/identity/User.java +++ b/src/main/java/com/shopsphere/identity/User.java @@ -6,6 +6,7 @@ import jakarta.persistence.Table; import java.time.Instant; +import java.util.List; import java.util.UUID; @Entity @@ -24,6 +25,12 @@ class User { @Column(name = "password_hash", nullable = false) private String passwordHash; + // Comma-separated role names (e.g. "USER" or "USER,ADMIN"). Deliberately denormalised — the role + // set is fixed and tiny and there is no role-management UI, so a join table would be premature + // (see ADR-0017). Spring authorities are derived by prefixing each with ROLE_. + @Column(nullable = false) + private String roles; + @Column(name = "created_at", nullable = false) private Instant createdAt; @@ -31,10 +38,15 @@ protected User() { } User(UUID id, UUID customerId, String email, String passwordHash, Instant createdAt) { + this(id, customerId, email, passwordHash, "USER", createdAt); + } + + User(UUID id, UUID customerId, String email, String passwordHash, String roles, Instant createdAt) { this.id = id; this.customerId = customerId; this.email = email; this.passwordHash = passwordHash; + this.roles = roles; this.createdAt = createdAt; } @@ -53,4 +65,8 @@ String getEmail() { String getPasswordHash() { return passwordHash; } + + List getRoles() { + return List.of(roles.split(",")); + } } diff --git a/src/main/resources/db/migration/identity/V14__user_roles_and_seed_admin.sql b/src/main/resources/db/migration/identity/V14__user_roles_and_seed_admin.sql new file mode 100644 index 0000000..7d0ffd4 --- /dev/null +++ b/src/main/resources/db/migration/identity/V14__user_roles_and_seed_admin.sql @@ -0,0 +1,24 @@ +-- Phase 17: authorization roles on users + one seeded admin. +-- Roles are stored denormalised as a comma-separated list (e.g. 'USER' or 'USER,ADMIN'). The role +-- set is fixed and tiny and there is no role-management UI, so a join table would be premature +-- (ADR-0017). Existing users default to the base 'USER' role; the JWT roles claim is derived from +-- this column and mapped to ROLE_* authorities. +ALTER TABLE identity.users ADD COLUMN roles VARCHAR(100) NOT NULL DEFAULT 'USER'; + +-- Seed a single admin. The customer row exists only to satisfy the users.customer_id FK; the admin +-- is an operator, not a shopper. The password hash is bcrypt(cost 10) of the DEV-ONLY password +-- 'admin12345admin' (documented in ADR-0017) — it MUST be rotated before any non-local deployment. +INSERT INTO identity.customers (id, created_at) +VALUES ('99999999-9999-9999-9999-999999999999', now()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO identity.users (id, customer_id, email, password_hash, roles, created_at) +VALUES ( + '99999999-9999-9999-9999-999999999990', + '99999999-9999-9999-9999-999999999999', + 'admin@shopsphere.local', + '$2a$10$lkQeGMFGhwPNDxRJ4sq7FeTTeA7WCDUWb/0IuhnIOb5FjquB7x4NW', + 'USER,ADMIN', + now() +) +ON CONFLICT (email) DO NOTHING; diff --git a/src/test/java/com/shopsphere/identity/JwtIssuerTests.java b/src/test/java/com/shopsphere/identity/JwtIssuerTests.java index 095bd6b..37d7c78 100644 --- a/src/test/java/com/shopsphere/identity/JwtIssuerTests.java +++ b/src/test/java/com/shopsphere/identity/JwtIssuerTests.java @@ -6,6 +6,7 @@ import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.util.List; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -24,19 +25,29 @@ class JwtIssuerTests { void issueAndVerifyRoundTrip() { UUID userId = UUID.randomUUID(); UUID customerId = UUID.randomUUID(); - String token = issuer.issue(userId, customerId); + String token = issuer.issue(userId, customerId, List.of("USER", "ADMIN")); JwtIssuer.Verified verified = issuer.verify(token); assertThat(verified.userId()).isEqualTo(userId); assertThat(verified.customerId()).isEqualTo(customerId); + assertThat(verified.roles()).containsExactly("USER", "ADMIN"); + } + + @Test + void missingRolesClaimDefaultsToUser() { + // Tokens minted before roles existed (or with no roles) must still authenticate as a plain user. + UUID userId = UUID.randomUUID(); + String token = issuer.issue(userId, UUID.randomUUID(), List.of()); + + assertThat(issuer.verify(token).roles()).containsExactly("USER"); } @Test void expiredTokenIsRejected() { UUID userId = UUID.randomUUID(); UUID customerId = UUID.randomUUID(); - String token = issuer.issue(userId, customerId); + String token = issuer.issue(userId, customerId, List.of("USER")); Clock later = Clock.fixed(fixed.plus(Duration.ofMinutes(16)), ZoneOffset.UTC); JwtIssuer laterIssuer = new JwtIssuer(SECRET, Duration.ofMinutes(15), later); @@ -49,7 +60,7 @@ void expiredTokenIsRejected() { void tamperedSignatureIsRejected() { UUID userId = UUID.randomUUID(); UUID customerId = UUID.randomUUID(); - String token = issuer.issue(userId, customerId); + String token = issuer.issue(userId, customerId, List.of("USER")); JwtIssuer differentSigner = new JwtIssuer(OTHER_SECRET, Duration.ofMinutes(15), clock); From f32806b953b7ff8bf581dfa56805c78b09ff4215 Mon Sep 17 00:00:00 2001 From: Poojithvsc Date: Fri, 5 Jun 2026 19:29:27 +0200 Subject: [PATCH 2/4] feat(catalog): admin product CRUD guarded by hasRole('ADMIN') (Phase 17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST/PUT/DELETE /api/v1/admin/products, class-level @PreAuthorize("hasRole('ADMIN')"). Authenticated non-admin → 403, anonymous → 401 (security chain), seeded admin → create/edit/delete. AdminProductIT covers all four. Co-Authored-By: Claude Opus 4.8 --- .../catalog/AdminProductController.java | 84 ++++++++++++ .../shopsphere/catalog/AdminProductIT.java | 127 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 src/main/java/com/shopsphere/catalog/AdminProductController.java create mode 100644 src/test/java/com/shopsphere/catalog/AdminProductIT.java diff --git a/src/main/java/com/shopsphere/catalog/AdminProductController.java b/src/main/java/com/shopsphere/catalog/AdminProductController.java new file mode 100644 index 0000000..6fac6c5 --- /dev/null +++ b/src/main/java/com/shopsphere/catalog/AdminProductController.java @@ -0,0 +1,84 @@ +package com.shopsphere.catalog; + +import com.shopsphere.common.Money; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * Operator-only product management. Every method is gated by {@code hasRole('ADMIN')}: an + * authenticated non-admin is rejected 403 (anonymous is 401, handled upstream by the security chain). + * The admin is a seeded operator (Flyway V14), not a shopper. + */ +@RestController +@RequestMapping("/api/v1/admin/products") +@SecurityRequirement(name = "bearerAuth") +@PreAuthorize("hasRole('ADMIN')") +class AdminProductController { + + private final ProductRepository products; + + AdminProductController(ProductRepository products) { + this.products = products; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + ProductDto create(@Valid @RequestBody ProductRequest request) { + Product product = new Product( + UUID.randomUUID(), + request.name(), + request.description(), + request.unitPrice().amount(), + request.unitPrice().currency(), + request.availableQty()); + return ProductMapper.toDto(products.save(product)); + } + + @PutMapping("/{id}") + ResponseEntity update(@PathVariable UUID id, @Valid @RequestBody ProductRequest request) { + return products.findById(id) + .map(existing -> { + Product replacement = new Product( + existing.getId(), + request.name(), + request.description(), + request.unitPrice().amount(), + request.unitPrice().currency(), + request.availableQty()); + return ResponseEntity.ok(ProductMapper.toDto(products.save(replacement))); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + ResponseEntity delete(@PathVariable UUID id) { + if (!products.existsById(id)) { + return ResponseEntity.notFound().build(); + } + products.deleteById(id); + return ResponseEntity.noContent().build(); + } + + record ProductRequest( + @NotBlank String name, + @NotBlank String description, + @NotNull Money unitPrice, + @PositiveOrZero int availableQty) { + } +} diff --git a/src/test/java/com/shopsphere/catalog/AdminProductIT.java b/src/test/java/com/shopsphere/catalog/AdminProductIT.java new file mode 100644 index 0000000..3f79c62 --- /dev/null +++ b/src/test/java/com/shopsphere/catalog/AdminProductIT.java @@ -0,0 +1,127 @@ +package com.shopsphere.catalog; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shopsphere.SharedContainers; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Admin product CRUD is guarded by {@code @PreAuthorize("hasRole('ADMIN')")}. A normal USER token is + * rejected 403; the Flyway-seeded admin can create, edit, and delete. Anonymous callers are 401 + * (handled by the security chain, not method security). + */ +@SpringBootTest +@AutoConfigureMockMvc +class AdminProductIT { + + private static final String CREATE_BODY = """ + {"name":"Test Widget","description":"a widget","unitPrice":{"amount":"500.0000","currency":"INR"},"availableQty":7} + """; + + @DynamicPropertySource + static void containers(DynamicPropertyRegistry registry) { + SharedContainers.registerProperties(registry); + } + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper json; + + @Test + void normalUserCannotCreateProduct() throws Exception { + String userToken = registerAndLogin(); + + mockMvc.perform(post("/api/v1/admin/products") + .header("Authorization", "Bearer " + userToken) + .contentType(MediaType.APPLICATION_JSON).content(CREATE_BODY)) + .andExpect(status().isForbidden()); + } + + @Test + void anonymousCannotCreateProduct() throws Exception { + mockMvc.perform(post("/api/v1/admin/products") + .contentType(MediaType.APPLICATION_JSON).content(CREATE_BODY)) + .andExpect(status().isUnauthorized()); + } + + @Test + void adminCanCreateEditAndDeleteProduct() throws Exception { + String adminToken = loginAsAdmin(); + + MvcResult created = mockMvc.perform(post("/api/v1/admin/products") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON).content(CREATE_BODY)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.name").value("Test Widget")) + .andReturn(); + UUID id = UUID.fromString(json.readTree(created.getResponse().getContentAsString()).get("id").asText()); + + mockMvc.perform(put("/api/v1/admin/products/" + id) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"Renamed Widget","description":"updated","unitPrice":{"amount":"600.0000","currency":"INR"},"availableQty":3} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Renamed Widget")) + .andExpect(jsonPath("$.availableQty").value(3)); + + mockMvc.perform(delete("/api/v1/admin/products/" + id) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/v1/products/" + id) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()); + } + + @Test + void deletingUnknownProductReturns404() throws Exception { + String adminToken = loginAsAdmin(); + mockMvc.perform(delete("/api/v1/admin/products/" + UUID.randomUUID()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()); + } + + private String loginAsAdmin() throws Exception { + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"admin@shopsphere.local","password":"admin12345admin"} + """)) + .andExpect(status().isOk()).andReturn(); + return json.readTree(login.getResponse().getContentAsString()).get("accessToken").asText(); + } + + private String registerAndLogin() throws Exception { + String body = """ + {"email":"admin-it+%s@example.com","password":"hunter2hunter2"} + """.formatted(UUID.randomUUID()); + mockMvc.perform(post("/api/v1/auth/register") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isCreated()); + MvcResult login = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()).andReturn(); + return json.readTree(login.getResponse().getContentAsString()).get("accessToken").asText(); + } +} From 8038d740af83372791fb9aa1d8bf172a7374aaf6 Mon Sep 17 00:00:00 2001 From: Poojithvsc Date: Fri, 5 Jun 2026 19:29:27 +0200 Subject: [PATCH 3/4] feat(catalog): paginate GET /products (?page=&size=, max 100) (Phase 17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/products takes optional Pageable; no params → first page of 20 sorted by name (backward-compatible default). Oversized ?size= is clamped to 100 via spring.data.web.pageable.max-page-size, not rejected. Response is a stable PagedResponse envelope (content + page metadata) defined explicitly rather than serialising Spring's PageImpl. ProductsEndpointIT updated to the envelope + page-size/clamp cases. Co-Authored-By: Claude Opus 4.8 --- .../com/shopsphere/catalog/PagedResponse.java | 23 +++++++++++ .../shopsphere/catalog/ProductController.java | 16 +++++--- src/main/resources/application.yml | 6 +++ .../catalog/ProductsEndpointIT.java | 40 +++++++++++++++---- 4 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/shopsphere/catalog/PagedResponse.java diff --git a/src/main/java/com/shopsphere/catalog/PagedResponse.java b/src/main/java/com/shopsphere/catalog/PagedResponse.java new file mode 100644 index 0000000..9918139 --- /dev/null +++ b/src/main/java/com/shopsphere/catalog/PagedResponse.java @@ -0,0 +1,23 @@ +package com.shopsphere.catalog; + +import org.springframework.data.domain.Page; + +import java.util.List; +import java.util.function.Function; + +/** + * A stable pagination envelope: {@code content} plus a small {@code page} block. Defined explicitly + * rather than serialising Spring Data's {@code Page} so the JSON contract is ours and survives + * Spring upgrades (Boot 3.3 deprecates serialising {@code PageImpl} directly). + */ +record PagedResponse(List content, PageInfo page) { + + static PagedResponse of(Page page, Function mapper) { + List content = page.getContent().stream().map(mapper).toList(); + return new PagedResponse<>(content, new PageInfo( + page.getNumber(), page.getSize(), page.getTotalElements(), page.getTotalPages())); + } + + record PageInfo(int number, int size, long totalElements, int totalPages) { + } +} diff --git a/src/main/java/com/shopsphere/catalog/ProductController.java b/src/main/java/com/shopsphere/catalog/ProductController.java index 62517f3..b3adb4c 100644 --- a/src/main/java/com/shopsphere/catalog/ProductController.java +++ b/src/main/java/com/shopsphere/catalog/ProductController.java @@ -1,14 +1,14 @@ package com.shopsphere.catalog; import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; 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.List; import java.util.UUID; @RestController @@ -22,11 +22,15 @@ class ProductController { this.products = products; } + /** + * Lists products one page at a time. {@code ?page=&size=} are optional; with no params a caller + * gets the first page of 20 sorted by name (backward-compatible default). {@code size} is capped + * at 100 by {@code spring.data.web.pageable.max-page-size}, so an oversized request is clamped, + * not rejected. The response is a {@link PagedResponse} envelope (content + page metadata). + */ @GetMapping - List list() { - return products.findAll(Sort.by("name")).stream() - .map(ProductMapper::toDto) - .toList(); + PagedResponse list(@PageableDefault(size = 20, sort = "name") Pageable pageable) { + return PagedResponse.of(products.findAll(pageable), ProductMapper::toDto); } @GetMapping("/{id}") diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9fbf171..bf0f813 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,6 +12,12 @@ spring: properties: hibernate: default_schema: catalog + data: + web: + pageable: + default-page-size: 20 + # Oversized ?size= requests are clamped to this rather than rejected (see ADR-0017). + max-page-size: 100 flyway: enabled: true default-schema: catalog diff --git a/src/test/java/com/shopsphere/catalog/ProductsEndpointIT.java b/src/test/java/com/shopsphere/catalog/ProductsEndpointIT.java index b4bcb70..9a9cdb5 100644 --- a/src/test/java/com/shopsphere/catalog/ProductsEndpointIT.java +++ b/src/test/java/com/shopsphere/catalog/ProductsEndpointIT.java @@ -16,6 +16,7 @@ import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.lessThanOrEqualTo; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -37,17 +38,42 @@ static void containers(DynamicPropertyRegistry registry) { ObjectMapper json; @Test - void getProducts_returnsSeededCatalog() throws Exception { + void getProducts_returnsSeededCatalogInAPagedEnvelope() throws Exception { String token = authenticatedBearer(); - // Size is "at least 3" because other ITs may seed additional products against the shared - // Postgres. The Flyway-seeded three must always be present. + // No params → the default first page (size 20). The seeded three must always be present; + // "at least 3" because other ITs may seed more against the shared Postgres. mockMvc.perform(get("/api/v1/products").header("Authorization", "Bearer " + token)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()", greaterThanOrEqualTo(3))) - .andExpect(jsonPath("$[*].name", hasItem("Aurora Mechanical Keyboard"))) - .andExpect(jsonPath("$[*].name", hasItem("Nimbus Wireless Mouse"))) - .andExpect(jsonPath("$[*].name", hasItem("Vertex 27-inch 4K Monitor"))); + .andExpect(jsonPath("$.content.length()", greaterThanOrEqualTo(3))) + .andExpect(jsonPath("$.content[*].name", hasItem("Aurora Mechanical Keyboard"))) + .andExpect(jsonPath("$.content[*].name", hasItem("Nimbus Wireless Mouse"))) + .andExpect(jsonPath("$.content[*].name", hasItem("Vertex 27-inch 4K Monitor"))) + .andExpect(jsonPath("$.page.number").value(0)) + .andExpect(jsonPath("$.page.size").value(20)) + .andExpect(jsonPath("$.page.totalElements", greaterThanOrEqualTo(3))); + } + + @Test + void getProducts_respectsPageSize() throws Exception { + String token = authenticatedBearer(); + + mockMvc.perform(get("/api/v1/products?page=0&size=2") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()", lessThanOrEqualTo(2)) + ) + .andExpect(jsonPath("$.page.size").value(2)); + } + + @Test + void getProducts_clampsOversizedPageTo100() throws Exception { + String token = authenticatedBearer(); + + mockMvc.perform(get("/api/v1/products?size=200") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.size").value(100)); } private String authenticatedBearer() throws Exception { From 2f28ddac0ee9f2f6b662e209ae54626d8543a9ae Mon Sep 17 00:00:00 2001 From: Poojithvsc Date: Fri, 5 Jun 2026 19:30:07 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(adr):=20ADR-0017=20=E2=80=94=20JWT=20r?= =?UTF-8?q?oles,=20admin=20API,=20pagination=20envelope=20(Phase=2017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the roles model (token claim + ROLE_* authorities; denormalised comma-separated column over a join table, YAGNI), the guarded /admin product controller (403 vs 401), and the pagination envelope (stable DTO over PageImpl; oversize clamped not rejected). Honest limits: token-carried roles → up to 15-min revocation lag; seeded admin password is dev-only. Image-upload guard deferred with Phase 16. Cites DDD/PoEAA/APoSD/XP/PragProg. Co-Authored-By: Claude Opus 4.8 --- ...0017-jwt-roles-admin-api-and-pagination.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/adr/0017-jwt-roles-admin-api-and-pagination.md diff --git a/docs/adr/0017-jwt-roles-admin-api-and-pagination.md b/docs/adr/0017-jwt-roles-admin-api-and-pagination.md new file mode 100644 index 0000000..8a1219e --- /dev/null +++ b/docs/adr/0017-jwt-roles-admin-api-and-pagination.md @@ -0,0 +1,38 @@ +--- +status: accepted +date: 2026-06-05 +cites: DDD, PoEAA, APoSD, XP, PragProg +--- + +# 0017 — JWT roles, an admin API, and a pagination envelope + +Phase 17 adds authorization (not just authentication), an operator-only product API, and pagination on the public product list. Three changes, one theme: the API grows a privileged surface and a way to page through data, without disturbing the shopper-facing contract. + +## Roles live in the token, authorities are derived + +The access token gains a `roles` claim — a JSON array of role names, defaulting to `["USER"]`. The auth filter maps each name to a Spring `ROLE_` authority, and `@EnableMethodSecurity` lets endpoints gate on `hasRole('ADMIN')`. **PragProg / PoEAA** — putting roles *in the signed token* keeps authorization stateless: a guarded request needs no database read to know what the caller may do, matching the stateless-session design already chosen for auth. The honest cost is recorded below (revocation latency). + +Roles are stored on `identity.users` as a **denormalised comma-separated column** (`'USER'` or `'USER,ADMIN'`), not a `user_roles` join table. **XP YAGNI / APoSD** — the role set is fixed and tiny, there is no role-management UI, and nothing in the system queries "all users with role X." A join table would add a repository, a mapping, and a join to serve a need that does not exist; the column is the simplest thing that fully works. The normalized table is the obvious deepening *if* roles ever become dynamic or independently queryable, and migrating to it is a contained change. **DDD** — authorization is an Identity concern, so the role data lives in the Identity schema next to the user it describes. + +A single **admin is seeded by Flyway `V14`** (`admin@shopsphere.local`, roles `USER,ADMIN`) with a bcrypt hash of a *dev-only* password documented in the migration. It is an operator account, not a shopper; the customer row exists only to satisfy the `users.customer_id` FK. The seed is `ON CONFLICT DO NOTHING` so re-running migrations is safe, and the password must be rotated before any non-local deployment. + +## The admin API is a separate, guarded controller + +`POST/PUT/DELETE /api/v1/admin/products` live in their own `AdminProductController` under an `/admin` path prefix, with a class-level `@PreAuthorize("hasRole('ADMIN')")`. **APoSD** — keeping the privileged operations in a distinct controller with a single guard at the top makes "what requires admin" obvious at a glance, rather than scattering annotations across the read controller. An authenticated non-admin gets **403** (method security throws `AccessDeniedException`); an anonymous caller gets **401** from the security chain before method security is reached — the two failure modes are deliberately distinct and both tested. The read endpoints (`GET /products`, `GET /products/{id}`) are unchanged and remain available to any authenticated user. + +> **Deferred (depends on Phase 16):** the issue also calls for the `POST /api/v1/admin/products/{id}/image` upload endpoint to gain the same guard. That endpoint does not exist yet — Phase 16 (S3 images) is not built — so the guard travels with it when Phase 16 ships. Recorded here so the gap is intentional, not forgotten. + +## Pagination: a stable envelope, clamped not rejected + +`GET /api/v1/products` takes optional `?page=&size=` via Spring Data `Pageable`. With no params a caller gets the **first page of 20 sorted by name** — behaviourally backward-compatible with the MVP default. The response shape, however, changes from a bare JSON array to a **`PagedResponse` envelope** (`content` + a `page` block of `number/size/totalElements/totalPages`). This is a deliberate, documented contract change: the only consumers are our own QA and tests, and pagination metadata has to live *somewhere*. The envelope is defined as an explicit record rather than serialising Spring's `Page`/`PageImpl` (which Boot 3.3 deprecates serialising directly), so the JSON contract is ours and stable across Spring upgrades. **PoEAA** — this is the Remote Façade returning a DTO we control, not leaking a framework type onto the wire. + +An oversized `?size=` is **clamped to 100** (`spring.data.web.pageable.max-page-size`) rather than rejected with 400. **PragProg / robustness** — clamping is the more forgiving contract: a client asking for too much gets the maximum the server will give instead of an error it must special-case, and the server is still protected from unbounded page sizes. The default cap (20) and the maximum (100) are configuration, not code. + +## Consequences + +Authorization is now expressible per-endpoint and the catalog has an operator surface, with no change to the shopper-facing read or checkout flows (`mvn verify` green, 105 tests). Two honest limits: + +- **Token-carried roles mean revocation lag.** Because authority lives in the signed access token, a role change (granting or revoking ADMIN) only takes effect when the access token next expires — at most the 15-minute access-token TTL. For this project that window is acceptable; a system needing instant revocation would consult a store on each request or shorten the TTL. **Deferred, recorded, not designed around.** +- **The admin password is a seeded dev secret.** Fine for local and CI; it must be rotated (or the seed disabled) before any real deployment, exactly as the `V14` comment states. + +Both are the kind of deliberate, written-down limitation this project prefers over silent gaps — the same posture as ADR-0014's "grows unbounded" note (since closed) and ADR-0015's deferred Stripe adapter.