Class 7: Authorization
Duration: 40 minutes | Difficulty: Intermediate | Prerequisites: Class 6 completed
What You'll Learn
By the end of this class, you will:
- Go past Class 6's coarse
hasRolegate into authorization that depends on the data and on individual fields - Build a review system that requires authentication and enforces a business rule (one review per user per movie)
- Implement ownership-based authorization (owner-or-admin delete) in the service layer
- Implement field-level authorization - protect a single field so only its owner or an admin can read it
- Reuse Spring Security's
AccessDeniedExceptionfor denials and reach for a custom exception type only when a business rule needs one - Understand how an injected
Principal(backed by Spring Security's context) identifies the current user
Class 6 put a coarse hasRole('ADMIN') gate on whole operations, and it put that gate in the service layer. Real authorization is usually finer than a role. Some rules depend on the data - only the author of a review may delete it. Others protect a single field - a user's email should be visible only to that user or an admin. This class builds both, and it keeps every rule in the service layer, exactly where Class 6 (and the official Spring and GraphQL documentation) argued authorization belongs.
The Review Domain
Reviews connect users to movies. A user can leave a score (1 to 10) and an optional comment on any movie, but only once. Duplicate reviews are rejected. Deleting a review requires either being the review's author or an admin.
Step 1: Update the Schema
Before writing any Java code, let's define the API contract. This is the schema-first approach: we decide what the GraphQL API looks like, then build the implementation to match.
The additions are a new Review type, a reviews field on the existing Movie type, and two mutations with their input and response types. The # ... comments mark where new lines join a type you already have:
"""A user's review of a movie"""
type Review {
id: ID!
"""Rating from 1 to 10"""
score: Int!
comment: String
createdAt: String!
user: User!
}
type Movie {
# ... existing fields
reviews: [Review!]!
}
type Mutation {
# ... existing mutations
# Authenticated users
"""Submit a review for a movie (one per user per movie)"""
createMovieReview(input: CreateMovieReviewInput!): Review!
"""Delete a review (owner or admin only)"""
deleteReview(id: ID!): DeleteReviewResponse!
}
"""Input for creating a movie review"""
input CreateMovieReviewInput {
movieId: ID!
"""Rating from 1 to 10"""
score: Int!
comment: String
}
"""Result of deleting a review."""
type DeleteReviewResponse {
success: Boolean!
"""ID of the deleted review, or null if nothing was deleted."""
deletedId: ID
}
With the schema in place, we know exactly what we need to build: a Review type with score, comment, timestamp, and author; a field on Movie to list its reviews; and two mutations for adding and deleting reviews. Let's implement that now.
Step 2: Create the Review Entity
📁 src/main/java/com/graphqlguy/moviedb/review/Review.java
package com.graphqlguy.moviedb.review;
import com.graphqlguy.moviedb.movie.Movie;
import com.graphqlguy.moviedb.user.AppUser;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.OffsetDateTime;
@Entity
@Builder
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "reviews")
public class Review {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private int score;
private String comment;
@Column(nullable = false)
private OffsetDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private AppUser user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "movie_id")
private Movie movie;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = OffsetDateTime.now();
}
}
}
We use OffsetDateTime rather than LocalDateTime because timestamps should carry timezone information. @PrePersist sets the creation time automatically. The client never needs to send it, and the server time is the source of truth.
The user relationship is nullable = false. Every review must have an author. The movie relationship is nullable because in a later class we'll also support TV show reviews, and a review will have either a movie or a TV show (but not both).
Step 3: Create the Repository
📁 src/main/java/com/graphqlguy/moviedb/review/ReviewRepository.java
package com.graphqlguy.moviedb.review;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ReviewRepository extends JpaRepository<Review, Long> {
List<Review> findByMovieIdOrderByCreatedAtDesc(Long movieId);
boolean existsByMovieIdAndUserId(Long movieId, Long userId);
}
existsByMovieIdAndUserId is the duplicate prevention check: it returns true if the given user has already reviewed the given movie. Spring Data generates a simple SELECT COUNT(*) > 0 query, which is very efficient.
Step 4: Create the Exception Type and Handler
📁 src/main/java/com/graphqlguy/moviedb/exception/DuplicateReviewException.java
package com.graphqlguy.moviedb.exception;
public class DuplicateReviewException extends RuntimeException {
public DuplicateReviewException() {
super("You have already reviewed this movie");
}
}
We deliberately need only one new exception here. Duplicate reviews are a business-rule violation, so DuplicateReviewException earns its own type. The owner-or-admin denial, on the other hand, is an authorization failure, and Spring Security already models that with org.springframework.security.access.AccessDeniedException. We reuse it rather than inventing a parallel type, and Class 6's handleAccessDenied handler already maps it to FORBIDDEN.
There is a second reason this matters. On the servlet stack, Spring Boot auto-registers a SecurityDataFetcherExceptionResolver (through GraphQlWebMvcSecurityAutoConfiguration) that already classifies AccessDeniedException as FORBIDDEN for an authenticated caller and UNAUTHORIZED for an anonymous one. Class 6's handleAccessDenied exists only to keep the Class 5 catch-all from swallowing that classification into INTERNAL_ERROR and to control the message the client sees. Either way, throwing AccessDeniedException from the service is what plugs into that machinery. A hand-rolled NotAuthorizedException would only duplicate it.
So we add just the one handler for duplicate reviews to GlobalExceptionHandler (matching the pattern from Class 5):
@GraphQlExceptionHandler
public GraphQLError handleDuplicateReview(DuplicateReviewException ex, DataFetchingEnvironment env) {
return GraphqlErrorBuilder.newError(env)
.message(ex.getMessage())
.errorType(ErrorType.BAD_REQUEST)
.build();
}
GraphqlErrorBuilder.newError(env) (from graphql.GraphqlErrorBuilder) pre-populates the path and source location from the DataFetchingEnvironment, just like in Class 5. ErrorType is Spring GraphQL's org.springframework.graphql.execution.ErrorType, which exposes the standard BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, and INTERNAL_ERROR classifications.
Step 5: Create the Input Record and Delete Response
The CreateMovieReviewInput input type in our schema needs a matching Java record. Spring GraphQL will automatically deserialize the incoming input argument into this record:
📁 src/main/java/com/graphqlguy/moviedb/review/CreateMovieReviewInput.java
package com.graphqlguy.moviedb.review;
public record CreateMovieReviewInput(String movieId, int score, String comment) {}
The schema also declares a DeleteReviewResponse for the delete mutation, and unlike Class 5's DeletePersonResponse it carries no typed error field: deleteReview's only failure modes, a missing review and a caller who is neither owner nor admin, are both thrown as exceptions rather than returned as data, so no expected outcome is left for the client to branch on. Following the per-entity response convention introduced in Class 4, this type lives next to the entity it serves rather than in a shared package:
📁 src/main/java/com/graphqlguy/moviedb/review/DeleteReviewResponse.java
package com.graphqlguy.moviedb.review;
public record DeleteReviewResponse(boolean success, Long deletedId) {}
Step 6: Create the ReviewService
This service contains the core business logic for reviews - authentication checks, duplicate prevention, and owner-or-admin authorization for deletion.
📁 src/main/java/com/graphqlguy/moviedb/review/ReviewService.java
package com.graphqlguy.moviedb.review;
import com.graphqlguy.moviedb.exception.DuplicateReviewException;
import com.graphqlguy.moviedb.exception.EntityNotFoundException;
import com.graphqlguy.moviedb.movie.Movie;
import com.graphqlguy.moviedb.movie.MovieRepository;
import com.graphqlguy.moviedb.user.AppUser;
import com.graphqlguy.moviedb.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class ReviewService {
private final ReviewRepository reviewRepository;
private final MovieRepository movieRepository;
private final UserRepository userRepository;
@Transactional
@PreAuthorize("isAuthenticated()")
public Review createMovieReview(CreateMovieReviewInput input, String username) {
AppUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new IllegalStateException("Authenticated user has no matching record: " + username));
Long movieId = Long.parseLong(input.movieId());
Movie movie = movieRepository.findById(movieId)
.orElseThrow(() -> new EntityNotFoundException("Movie", movieId));
if (reviewRepository.existsByMovieIdAndUserId(movieId, user.getId())) {
throw new DuplicateReviewException();
}
return reviewRepository.save(Review.builder()
.movie(movie)
.user(user)
.score(input.score())
.comment(input.comment())
.build());
}
@Transactional
@PreAuthorize("isAuthenticated()")
public DeleteReviewResponse deleteReview(Long reviewId, String username) {
Review review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new EntityNotFoundException("Review", reviewId));
AppUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new IllegalStateException("Authenticated user has no matching record: " + username));
boolean isOwner = review.getUser().getId().equals(user.getId());
boolean isAdmin = user.getRole().name().equals("ADMIN");
if (!isOwner && !isAdmin) {
throw new AccessDeniedException("You can only delete your own reviews");
}
reviewRepository.delete(review);
return new DeleteReviewResponse(true, reviewId);
}
}
The deleteReview method demonstrates owner-or-admin authorization - a pattern where the action is allowed if you created the resource OR if you're an admin. The decision depends on the data (who created this specific review), not just the user's role, so an annotation like @PreAuthorize("hasRole('ADMIN')") cannot capture it on its own. Spring offers @PostAuthorize("returnObject.user.id == principal.id or hasRole('ADMIN')") as a one-liner alternative for return-value-based decisions, but it runs only after the entire method body has executed - by which point the delete has already happened unless the exception rolls back the transaction - and its principal.id reference assumes a custom UserDetails exposing an id, which this project never builds (Class 6 stores only a username string). We instead check manually inside the service method, before doing the work.
The two "authenticated user has no matching record" guards deserve a note. Both service methods sit behind @PreAuthorize("isAuthenticated()"), so their bodies never run for an unauthenticated caller. If one of those lookups fails, it means an authenticated username has no matching user row - a data-integrity problem, not an authorization failure - so we surface it as an IllegalStateException, which Spring GraphQL's default resolver classifies as INTERNAL_ERROR. The genuine authorization denial, when a non-owner who is not an admin tries to delete, stays an AccessDeniedException so it classifies as FORBIDDEN.
Both service methods take a username, which the controller supplies from an injected java.security.Principal. Spring for GraphQL resolves that Principal from the same Spring Security context our JWT filter populated during request processing - so declaring it as a method parameter is the idiomatic way to learn "who is calling," cleaner than reaching into SecurityContextHolder by hand. Because @PreAuthorize("isAuthenticated()") runs first, the principal is guaranteed to be a real logged-in user by the time the service looks it up by name.
Spring for GraphQL also supports @AuthenticationPrincipal as a handler-method argument, which injects Authentication#getPrincipal() directly. That is handy when the principal is a rich UserDetails you can read fields off without a second lookup, but here it would buy nothing: Class 6 stores only a username string as the principal, so we would still have to load the AppUser from the repository. Injecting Principal and calling getName() is the simpler fit, and leaving the code as-is is reasonable.
A note on where the authorization lives
Every authorization rule in this class sits in the service, not the controller - the same placement Class 6 adopted, and the one Spring's reference and graphql.org both recommend: secure the business layer so a rule holds regardless of which caller reaches it. The coarse @PreAuthorize("isAuthenticated()") gate is on the service methods; the data-dependent owner-or-admin rule is procedural code inside deleteReview, because it cannot be expressed as a static annotation. The controller only injects the Principal and delegates. All of this relies on @EnableMethodSecurity (added in Class 6) to activate the annotations, and on context propagation to carry the security context from the request down into the service.
Step 7: Create the ReviewController
📁 src/main/java/com/graphqlguy/moviedb/review/ReviewController.java
package com.graphqlguy.moviedb.review;
import com.graphqlguy.moviedb.movie.Movie;
import lombok.RequiredArgsConstructor;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.stereotype.Controller;
import java.security.Principal;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class ReviewController {
private final ReviewService reviewService;
private final ReviewRepository reviewRepository;
@SchemaMapping
List<Review> reviews(Movie movie) {
return reviewRepository.findByMovieIdOrderByCreatedAtDesc(movie.getId());
}
@MutationMapping
Review createMovieReview(@Argument CreateMovieReviewInput input, Principal principal) {
return reviewService.createMovieReview(input, principal.getName());
}
@MutationMapping
DeleteReviewResponse deleteReview(@Argument Long id, Principal principal) {
return reviewService.deleteReview(id, principal.getName());
}
}
The controller is deliberately thin: it injects the Principal, hands the username to the service, and does no authorization itself. Following Class 6, both writes carry @PreAuthorize("isAuthenticated()") on the service, not here - any logged-in user may post a review or attempt a delete, and the service then applies the owner-or-admin rule. The @SchemaMapping for reviews resolves the reviews field on the Movie type; it has no gate, because reviews are public to read.
Step 8: Run and Test
Restart and test in GraphiQL.
Login as a Regular User
mutation {
login(input: { username: "user", password: "user123" }) {
token
user { username role }
}
}
Set the token in your headers, then add a review:
mutation {
createMovieReview(input: {
movieId: "1", score: 9, comment: "One of the greatest films ever made"
}) {
id
score
comment
user { username }
}
}
Try Posting a Duplicate Review
Run the same mutation again:
{
"errors": [{
"message": "You have already reviewed this movie",
"extensions": { "classification": "BAD_REQUEST" }
}]
}
Query Movie with Reviews
query {
movie(id: 1) {
title
reviews {
score
comment
user { username }
}
}
}
Step 9: Field-Level Authorization - Protecting email
Everything so far has guarded whole operations. Authorization can also apply to a single field. Our User type exposes an email, and now that reviews carry their author (Review.user), any logged-in user can walk movie → reviews → user → email and read everyone's email address. That is real PII leaking through an ordinary query. The rule we want is simple: a user's email is visible only to that user, or to an admin.
First, a schema decision: relax email to nullable
Field-level read authorization forces a schema change, and it is one worth making on purpose. Right now email is non-null:
type User {
id: ID!
username: String!
email: String!
role: Role!
}
If we deny an unauthorized read by throwing an exception from the field's resolver, GraphQL still has to put something in a field typed String!. It cannot, so the error propagates upward and nulls the entire User object - and a viewer scrolling a movie's reviews would lose every review whose author is not them, not merely the email. The remedy is to make the protected field nullable, so a denial nulls only that field and the rest of the response survives:
type User {
id: ID!
username: String!
"""The user's email. Readable only by that user or an admin; null otherwise."""
email: String
role: Role!
}
This is the partial-response model from Class 5: one field can fail - here, be denied - while the rest of the object resolves normally. Relaxing a field from String! to String is a real change to the API contract, so we make it in the schema deliberately. It is backward-compatible in that existing selections still parse, but every client must now tolerate a null where it used to see a guaranteed string. That trade-off is exactly what field-level authorization asks for, and it should be a conscious decision, not a silent side effect of adding a guard.
Then, guard the field with a resolver
A field resolver (@SchemaMapping) lets us intercept email and apply the rule. Because this is a single, whole-field gate - no branching, unlike the data-dependent delete - it expresses cleanly as a declarative @PreAuthorize, in the same method-security style we use everywhere else:
📁 src/main/java/com/graphqlguy/moviedb/user/UserController.java
package com.graphqlguy.moviedb.user;
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
@Controller
public class UserController {
@SchemaMapping(typeName = "User", field = "email")
@PreAuthorize("#user.username == authentication.name or hasRole('ADMIN')")
public String email(AppUser user) {
return user.getEmail();
}
}
The SpEL reads exactly like the rule: allow when the User being resolved is the caller (#user.username == authentication.name), or when the caller is an admin. #user is the source object Spring for GraphQL passes into the field resolver - the AppUser whose email is being fetched - and authentication is the current Spring Security authentication; both are available to the expression. When neither holds, @PreAuthorize raises AccessDeniedException, Class 6's handleAccessDenied maps it to FORBIDDEN, and because email is now nullable the denial lands as a scoped error on just that field.
Defining a @SchemaMapping for email overrides the default property lookup: instead of Spring reading AppUser.getEmail() automatically, our guarded method runs. Every other User field - username, role, id - keeps its default mapping, so only email pays for the check.
Notice the two authorization shapes now sitting side by side. The owner-or-admin delete was data-dependent in a way no annotation could capture, so it lives as procedural code in the service. The email read is a clean whole-field gate, so it fits a declarative @PreAuthorize on the field resolver. Same principle - decide from the data and the caller - matched to two different mechanisms.
Test it
Log in as user. Your own email resolves; any other author's comes back null, each with a FORBIDDEN scoped to that field while the rest of the response stands:
query {
movie(id: 1) {
reviews {
user { username email }
}
}
}
Log in as admin and run the same query: every email resolves. One line of SpEL, enforced on the field, in the service-layer style Class 6 established.
Exercises
Exercise 1: Test Owner-vs-Admin Delete
Login as "user", create a review, note its ID. Then login as "admin" and delete it - admin can delete anyone's reviews. Then login as "user" again and try to delete another user's review - you should get a FORBIDDEN error.
Exercise 2: Add Score Validation
The schema says score: Int! but doesn't enforce a range. Add validation in ReviewService.createMovieReview to ensure score is between 1 and 10 using InvalidInputException.
Summary
In this class, you learned:
- All authorization lives in the service layer, following Class 6 -
@PreAuthorize("isAuthenticated()")gates the review mutations on the service, and the finer rules run as service code - Business rules like duplicate prevention are enforced in the service layer, not in the schema or controller
- Owner-or-admin authorization is data-dependent - it checks both the resource's creator and the caller's role - so it is procedural code, not a static annotation
- Field-level authorization protects a single field: a
@SchemaMappingresolver onUser.emailwith a declarative@PreAuthorizerestricts it to the owner or an admin, and the field is relaxed to nullable so a denial degrades gracefully instead of nulling the whole object - An injected
Principalidentifies the caller - the idiomatic Spring-for-GraphQL way to read the current user, resolved from the same security context the JWT filter populated - Reusing Spring Security's
AccessDeniedExceptionfor authorization denials plugs into the auto-registeredSecurityDataFetcherExceptionResolver(and Class 6'shandleAccessDenied), so a denial classifies asFORBIDDENwithout a parallel exception type; a custom exception likeDuplicateReviewExceptionis reserved for genuine business-rule violations
What's Next?
In Class 8: N+1 Problem & @BatchMapping, we'll tackle performance:
- Understanding the N+1 query problem through SQL logs
- Solving it with
@BatchMappingfor directors, cast, and reviews - Why batch loading matters for GraphQL more than REST