diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f4d6cba..5a7a5e1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/) for its own public API; the supported Spring Boot versions are tracked separately (see the README compatibility matrix) and are **not** tied to this library's major version. +## [5.0.1] - Unreleased +### Changed +- Reverted `Role.privileges` to `FetchType.EAGER` (it was changed to `LAZY` in 5.0.0). The 5.0.0 change made `role.getPrivileges()` throw `LazyInitializationException` when accessed outside an open transaction/session — a surprising footgun for consumers — in exchange for a negligible gain, since privileges are small, static reference data with no bulk-load path. `User.roles` remains `LAZY` (that is where the real N+1 win is), and the authentication path still loads the full `User → roles → privileges` graph in one query via `UserRepository.findWithRolesByEmail`. This is a **non-breaking relaxation**: code written against 5.0.0 continues to work unchanged. See `MIGRATION.md` ("Lazy fetching of roles and privileges"). + ## [5.0.0] - 2026-06-15 > **Major release — contains breaking changes** across the Java API, HTTP/response contracts, database schema, required configuration, and bean/auto-configuration structure. Read [`MIGRATION.md`](MIGRATION.md) ("Migrating to 5.0.x") before upgrading. diff --git a/MIGRATION.md b/MIGRATION.md index 1d31897d..f67b8607 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -168,20 +168,22 @@ CREATE UNIQUE INDEX ux_privilege_name ON privilege (name); ### Lazy fetching of roles and privileges -**What changed:** The `roles` collection on `User` and the `privileges` collection on `Role` were previously `FetchType.EAGER`. They are now `FetchType.LAZY`. The authentication path (`DSUserDetailsService.loadUserByUsername`) now loads the full `User` → `roles` → `privileges` graph via a new `@EntityGraph` repository finder, `UserRepository.findWithRolesByEmail(String email)`, which fetches everything in a **single query**. +**What changed:** The `roles` collection on `User` was previously `FetchType.EAGER` and is now `FetchType.LAZY`. The authentication path (`DSUserDetailsService.loadUserByUsername`) loads the full `User` → `roles` → `privileges` graph via a new `@EntityGraph` repository finder, `UserRepository.findWithRolesByEmail(String email)`, which fetches everything in a **single query**. -**Why:** The old two-level eager fetch loaded every role and every privilege on *every* `User` load — even for operations that never touch authorities (token lookups, lockout-counter updates, existence checks) — and caused an N+1 query pattern. Making the collections lazy and loading them explicitly only where they are needed removes that overhead while keeping authentication behavior identical. +> **Note (5.0.0 → 5.0.1):** `Role.privileges` was also switched to `LAZY` in 5.0.0, but that was reverted to `EAGER` in **5.0.1** because it made `role.getPrivileges()` throw outside a transaction for marginal benefit. If you are on 5.0.1 or later, only `User.roles` is lazy — `role.getPrivileges()` is safe everywhere. The guidance below is written for 5.0.1+; on 5.0.0 specifically, the privilege caveats also apply. -**Impact / risk:** Because the collections are now lazy, **any code that accesses `user.getRoles()` (or iterates `role.getPrivileges()`) on a detached entity — i.e. outside an open Hibernate session/transaction — will throw `LazyInitializationException`.** Code that accesses these collections *within* an active transaction (the common case for service methods) is unaffected. The framework's own authentication, OAuth2/OIDC, and GDPR-export paths have been updated to initialize the graph correctly. +**Why:** The old eager fetch loaded every role (and, transitively, every privilege) on *every* `User` load — even for operations that never touch authorities (token lookups, lockout-counter updates, existence checks) — and caused an N+1 query pattern across user loads. Making `User.roles` lazy and loading it explicitly only where it is needed removes that overhead while keeping authentication behavior identical. `Role.privileges` stays eager because privileges are small, static reference data and there is no path that bulk-loads `Role`s. -**Remediation patterns for consumers** that traverse roles/privileges on a `User` they obtained outside a transaction: +**Impact / risk:** Because `User.roles` is now lazy, **any code that accesses `user.getRoles()` on a detached entity — i.e. outside an open Hibernate session/transaction — will throw `LazyInitializationException`.** Code that accesses it *within* an active transaction (the common case for service methods) is unaffected. Once the roles collection is loaded, `role.getPrivileges()` works regardless of transaction state (privileges are eager, as of 5.0.1). The framework's own authentication, OAuth2/OIDC, and GDPR-export paths have been updated to initialize the graph correctly. + +**Remediation patterns for consumers** that traverse a user's roles on a `User` they obtained outside a transaction: - **Load through the authentication path or the entity-graph finder.** Use `UserRepository.findWithRolesByEmail(email)` (it initializes roles and privileges in one query) instead of the plain `findByEmail(email)` when you need authorities. - **Access the collections inside a transaction.** Annotate the method that reads `user.getRoles()` with `@Transactional` so the persistence session is still open when the lazy collection is first touched. - **Use a DTO projection.** Map the roles/privileges you need into a DTO while still inside the session, then pass the DTO around the detached boundary. -- **Initialize before detaching.** If you must hand a `User` to detached code, call `Hibernate.initialize(user.getRolesAsSet())` (and the nested privileges) while the session is open. +- **Initialize before detaching.** If you must hand a `User` to detached code, call `Hibernate.initialize(user.getRolesAsSet())` while the session is open (privileges come along eagerly once the roles are loaded). -The plain `UserRepository.findByEmail(String)` finder is retained unchanged for callers that do not need the authority graph (token lookups, existence checks, lockout counters); it intentionally leaves `roles`/`privileges` uninitialized. +The plain `UserRepository.findByEmail(String)` finder is retained unchanged for callers that do not need the authority graph (token lookups, existence checks, lockout counters); it intentionally leaves the user's `roles` collection uninitialized. ### Entity equals/hashCode now identity-based diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java index 100bacee..58431a6e 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java @@ -44,8 +44,13 @@ public class Role implements Serializable { private Set users = new HashSet<>(); /** The privileges. */ + // EAGER by design: privileges are small, static reference data and there is no hot path that loads many Roles at + // once, so eager-loading them is cheap. Keeping this EAGER lets consumers call role.getPrivileges() outside a + // transaction without a LazyInitializationException. The user-load N+1 is addressed by User.roles being LAZY (see + // User), and the authentication path still fetches the full graph in one query via + // UserRepository.findWithRolesByEmail. (Role.privileges was briefly LAZY in 5.0.0; reverted in 5.0.1.) @ToString.Exclude - @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY) + @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.EAGER) @JoinTable(name = "roles_privileges", joinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "privilege_id", referencedColumnName = "id")) private Set privileges = new HashSet<>(); diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java index eb5ab136..53ee36ad 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/UserRepository.java @@ -25,12 +25,13 @@ public interface UserRepository extends JpaRepository { * Find by email, eagerly loading the user's roles and each role's privileges in a single query via an entity graph. * *

This is the finder used on the authentication path (see {@code DSUserDetailsService}). Because {@code User.roles} - * and {@code Role.privileges} are now {@link jakarta.persistence.FetchType#LAZY}, callers that must traverse - * roles/privileges after the persistence session closes (e.g. building Spring Security authorities for a detached - * principal) must load the user through this method. The {@code @EntityGraph} ensures the full - * User → roles → privileges graph is initialized in one round trip, avoiding both the N+1 problem and a - * {@code LazyInitializationException}. The plain {@link #findByEmail(String)} remains for callers (token lookups, - * existence checks, lockout counters) that do not need the authority graph.

+ * is {@link jakarta.persistence.FetchType#LAZY}, callers that must traverse a user's roles (and their privileges) + * after the persistence session closes (e.g. building Spring Security authorities for a detached principal) must load + * the user through this method. ({@code Role.privileges} is {@code EAGER}, but the plain finder never loads the + * roles collection itself.) The {@code @EntityGraph} ensures the full User → roles → privileges graph is + * initialized in one round trip, avoiding both the N+1 problem and a {@code LazyInitializationException}. The plain + * {@link #findByEmail(String)} remains for callers (token lookups, existence checks, lockout counters) that do not + * need the authority graph.

* * @param email the email * @return the user with roles and privileges initialized, or {@code null} if none found