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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 8 additions & 6 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,13 @@ public class Role implements Serializable {
private Set<User> 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<Privilege> privileges = new HashSet<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ public interface UserRepository extends JpaRepository<User, Long> {
* Find by email, eagerly loading the user's roles and each role's privileges in a single query via an entity graph.
*
* <p>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 &rarr; roles &rarr; 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.</p>
* is {@link jakarta.persistence.FetchType#LAZY}, callers that must traverse a user's roles (and their privileges)
Comment on lines 25 to +28
* 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 &rarr; roles &rarr; 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.</p>
*
* @param email the email
* @return the user with roles and privileges initialized, or {@code null} if none found
Expand Down