Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/adr/0017-jwt-roles-admin-api-and-pagination.md
Original file line number Diff line number Diff line change
@@ -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_<name>` 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.
84 changes: 84 additions & 0 deletions src/main/java/com/shopsphere/catalog/AdminProductController.java
Original file line number Diff line number Diff line change
@@ -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<ProductDto> 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<Void> 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) {
}
}
23 changes: 23 additions & 0 deletions src/main/java/com/shopsphere/catalog/PagedResponse.java
Original file line number Diff line number Diff line change
@@ -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<T>(List<T> content, PageInfo page) {

static <E, T> PagedResponse<T> of(Page<E> page, Function<E, T> mapper) {
List<T> 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) {
}
}
16 changes: 10 additions & 6 deletions src/main/java/com/shopsphere/catalog/ProductController.java
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<ProductDto> list() {
return products.findAll(Sort.by("name")).stream()
.map(ProductMapper::toDto)
.toList();
PagedResponse<ProductDto> list(@PageableDefault(size = 20, sort = "name") Pageable pageable) {
return PagedResponse.of(products.findAll(pageable), ProductMapper::toDto);
}

@GetMapping("/{id}")
Expand Down
8 changes: 4 additions & 4 deletions src/main/java/com/shopsphere/identity/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ 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)
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());
}

Expand All @@ -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<String> roles) {
String access = jwt.issue(userId, customerId, roles);
RefreshTokenService.Issued issued = refreshTokens.issueNew(userId);
return new Token(access, jwt.ttlSeconds(), issued.rawToken(), issued.expiresInSeconds());
}
Expand Down
13 changes: 11 additions & 2 deletions src/main/java/com/shopsphere/identity/JwtAuthenticationFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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<GrantedAuthority> authorities(List<String> 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 "";
Expand Down
15 changes: 12 additions & 3 deletions src/main/java/com/shopsphere/identity/JwtIssuer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,12 +29,13 @@ final class JwtIssuer {
this.clock = clock;
}

String issue(UUID userId, UUID customerId) {
String issue(UUID userId, UUID customerId, List<String> 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)
Expand All @@ -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<String> 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<String> normalizeRoles(List<String> roles) {
return roles == null || roles.isEmpty() ? List.of("USER") : List.copyOf(roles);
}

record Verified(UUID userId, UUID customerId, List<String> roles) {
}

static final class InvalidTokenException extends RuntimeException {
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/shopsphere/identity/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/shopsphere/identity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import jakarta.persistence.Table;

import java.time.Instant;
import java.util.List;
import java.util.UUID;

@Entity
Expand All @@ -24,17 +25,28 @@ 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;

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;
}

Expand All @@ -53,4 +65,8 @@ String getEmail() {
String getPasswordHash() {
return passwordHash;
}

List<String> getRoles() {
return List.of(roles.split(","));
}
}
6 changes: 6 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading