CodeGym /Courses /Spring Security /Owner-based rules: /api/me and drafts

Owner-based rules: /api/me and drafts

Spring Security
Level 20, Lesson 4
Available

1. authenticated() — only the first barrier

Roles, authorities, and @PreAuthorize have already answered the question, “what kind of user is allowed to do this at all.” But for /api/me and drafts, that is not enough. Here the system must not just let the user into the private zone; it must tie a specific request to a specific resource owner. So it is useful to assemble one working baseline right away for four scenarios: GET /api/me, PATCH /api/me/profile, GET /api/drafts, and GET /api/drafts/{id}.

To be honest, authenticated() is like the lock at the building entrance. It is useful, but it does not answer the question, “which apartment are you going to?” For owner-based access, this is exactly the first barrier: it blocks anonymous users, but it does not decide whether this draft is yours or someone else’s.

Another important point: this scheme will survive the move to a stateless model. What will change is how Authentication gets into the SecurityContext, but the current user, the owner check, and the difference between 401 and 403 will stay the same.

2. Route skeleton: SecurityFilterChain → controller → service → DB

To avoid scattering checks around random places, it is convenient to keep one request route in mind:

flowchart TD
    A["HTTP request"] --> B["SecurityFilterChain
request-level rules"] B -->|"401 if anonymous"| X["AuthenticationEntryPoint"] B --> C["Controller
@AuthenticationPrincipal"] C --> D["Service
business action"] D --> E["Repository/DB
load target object"] E --> F{"owner check:
authorId == currentUserId?"} F -->|"no"| Y["AccessDeniedException -> 403
AccessDeniedHandler + JSON error"] F -->|"yes"| G["Business logic + response"]

The idea is simple: SecurityFilterChain filters out anonymous users and cuts access zones, the controller receives a trusted current user, the service makes the business decision, and the database provides the ownerId of a specific object. The controller does not become a mini security service. It only passes “who came in” to the service as a trusted identifier.

At the authorizeHttpRequests level, the standard DSL is enough here: authenticated(), hasAnyRole(), hasRole(), denyAll(). This is how we separate access zones. Expression-based access through access(...) and SpEL is a separate tool; for this owner-check baseline, we do not hide it there, but keep it in the service, next to the business action.

An example configuration “at level 20” (without unnecessary exotic stuff, just a readable map of zones):

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth
            // Self and drafts: only authenticated users may enter (this is the "first barrier")
            .requestMatchers("/api/me/**", "/api/drafts/**").authenticated()

            // Editor zone: only EDITOR or ADMIN roles
            .requestMatchers("/api/editor/**").hasAnyRole("EDITOR", "ADMIN")

            // Admin zone: only ADMIN
            .requestMatchers("/api/admin/**").hasRole("ADMIN")

            // Everything not explicitly described is closed
            .anyRequest().denyAll()
        );
        return http.build();
    }
}

These rules cut access zones, but they do not automatically turn any draft inside /api/drafts/** into “yours.” The owner-only decision still lives next to the specific business action.

3. Self-endpoints: /api/me/** without userId

Self-endpoints are a small engineering delight. They make the API both safer and simpler. In such endpoints we explicitly say: “this action is being performed on behalf of the current user.” That means we do not accept userId in the path or body, because that immediately creates temptation: “what if we substitute a different id?” The client does not need to tell the server who it is. The server already knows.

Let us start with the simple /api/me. We take the principal from the security context and return minimal information to the client: id and username. Important: we do not hit the database “out of respect for tradition” if the principal already contains userId. If your principal implementation stores only the username, that is fine too, but then you can additionally load the user by username.

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class MeController {

    // Self-endpoint: work only with the current user, without userId from the client
    @GetMapping("/api/me")
    MeResponse me(@AuthenticationPrincipal AppUserPrincipal principal) {
        // Pull the trusted identifier from the principal, not from the request
        return new MeResponse(principal.getUserId(), principal.getUsername());
    }
}

And the DTO can be made as modest as possible. The less you expose, the less you will later have to explain why you accidentally leaked an extra field.

// Minimal response: only what the client really needs
public record MeResponse(Long id, String username) {
}

Now /api/me/profile. The logic is similar, but it is especially important that profile updates do not accept userId. If the client says, “I want to update user 777,” the server should respond, “very interesting, and I want a vacation.”

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class MeProfileController {

    private final ProfileService profileService;

    MeProfileController(ProfileService profileService) {
        this.profileService = profileService;
    }

    // Self-endpoint: update strictly the current user's profile
    @PatchMapping("/api/me/profile")
    void update(@AuthenticationPrincipal AppUserPrincipal principal,
                @RequestBody UpdateProfileRequest request) {
        // Take currentUserId only from the security context (principal)
        profileService.updateOwnProfile(principal.getUserId(), request);
    }
}

And let the profile update request be just “what to change,” without identifiers:

public record UpdateProfileRequest(String displayName, String bio) {
}

Here the owner is effectively built into the API shape: the service does not ask, “does the path userId match currentUserId,” because there is no path userId at all. It simply receives a trusted currentUserId and performs a regular business operation.

4. Drafts: /api/drafts and /api/drafts/{id}

In /api/me/**, the client has nowhere to slip in someone else’s id. Drafts are the opposite: the resource id comes from outside. So here the current user from the principal must meet the resource ownerId from the database.

In the controller, the client still passes only draftId. We take currentUserId from the principal:

import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
class DraftController {

    private final DraftQueryService draftQueryService;

    DraftController(DraftQueryService draftQueryService) {
        this.draftQueryService = draftQueryService;
    }

    // Important: the client passes only the draft id, and we take currentUserId from the principal
    @GetMapping("/api/drafts/{id}")
    ContentItem get(@PathVariable Long id,
                    @AuthenticationPrincipal AppUserPrincipal principal) {
        return draftQueryService.getOwnDraft(id, principal.getUserId());
    }
}

The list logic is even simpler: we do not load “all drafts in the system” just to filter them in memory afterward. We immediately make an owner-bound query in the database.

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

interface ContentItemRepository extends JpaRepository<ContentItem, Long> {

    // Important: filtering by authorId happens at the database level, not "after loading all data"
    List<ContentItem> findAllByAuthorId(Long authorId);
}
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
class DraftService {

    private final ContentItemRepository repository;

    DraftService(ContentItemRepository repository) {
        this.repository = repository;
    }

    // Method security: even the list of "own" drafts requires a separate permission
    @PreAuthorize("hasAuthority('draft:read:own')")
    List<ContentItem> listOwnDrafts(Long currentUserId) {
        // currentUserId must come from a trusted source (principal), not from client request parameters
        return repository.findAllByAuthorId(currentUserId);
    }
}

But GET /api/drafts/{id} already requires an explicit owner check: the current user came from the security layer, the ownerId came from the object itself.

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class DraftQueryService {

    private final ContentItemRepository repository;

    DraftQueryService(ContentItemRepository repository) {
        this.repository = repository;
    }

    // Permission to read "own" drafts
    @PreAuthorize("hasAuthority('draft:read:own')")
    ContentItem getOwnDraft(Long draftId, Long currentUserId) {
        // 1) Find the object by id (this is not authorization yet)
        ContentItem draft = repository.findById(draftId).orElseThrow();

        // 2) Authorization by owner: if it belongs to someone else, return 403 via AccessDeniedException
        if (!draft.getAuthorId().equals(currentUserId)) {
            throw new AccessDeniedException("foreign_draft");
        }
        return draft;
    }
}

If draftId does not exist, the project’s usual not-found path applies. If the object is found but the owner does not match, that is already 403.

If an editor needs access to other people’s drafts for review, that is a separate endpoint and a separate authority such as draft:review. The owner-only method should not suddenly turn into an owner || editor || admin combo.

5. What you get on the way out: 401, 403, 404, and a trace in the logs

Now the route can be read all the way through. If the request came from an anonymous user, SecurityFilterChain stops it, and AuthenticationEntryPoint returns 401. If the draftId does not exist at all, the project’s usual not-found path is triggered — that is 404. If the object exists but the owner check in the service fails, AccessDeniedException turns into 403 through AccessDeniedHandler.

That is the canonical baseline for owner-only scenarios: do not mix up “not found” and “someone else’s.”

Next to the owner check, it is useful to leave a short trace: actor, action, target. A general 403 can additionally be recorded at the boundary level, but the exact owner violation is best visible right next to the access decision.

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.AccessDeniedException;

class DraftOwnership {

    private static final Logger log = LoggerFactory.getLogger(DraftOwnership.class);

    static void requireOwner(Long draftAuthorId, Long currentUserId, Long draftId) {
        // Central decision point: "own/foreign"
        if (!draftAuthorId.equals(currentUserId)) {
            // Log only actor/action/target — no tokens or headers
            log.warn("access_denied action=read_draft actorUserId={} draftId={}",
                    currentUserId, draftId);

            // This then becomes 403 through AccessDeniedHandler
            throw new AccessDeniedException("foreign_draft");
        }
    }
}

That is enough to later find attempts to read other people’s drafts without printing tokens, cookies, or headers.

6. Typical mistakes

Mistake #1: the self-endpoint accepts userId from the client “for convenience.”
This usually starts with the phrase, “well, maybe it is easier for the frontend this way.” Then someone substitutes another userId, and you have suddenly invented an API for viewing other people’s profiles. Self-endpoints are good because they do not require trusting input identifiers at all: current user comes from the security context, and that is final.

Mistake #2: authenticated() on /api/drafts/** is treated as full protection.
authenticated() really does protect against anonymous users, but it does not protect against a “foreign id.” If there is no owner check inside the zone, any logged-in user will be able to read, update, and delete other people’s objects just by trying different identifiers.

Mistake #3: the owner check is done in the controller, and the service remains “blind.”
At first it seems logical: “the controller already has @PathVariable id, so let’s check it there.” But once the business action starts being called from more than one controller (or you add a second endpoint), the checks spread everywhere. The service layer is where the “own/foreign” rule should live in a stable and consistent way.

Mistake #4: AccessDeniedException is replaced with “we will return 404 so we do not reveal that the object exists.”
That is sometimes a deliberate strategy, but for our baseline we distinguish “not found” from “found, but not allowed.” If the draft exists but belongs to someone else, that is 403. If the draft does not exist, that is 404. Mixing these meanings makes support and diagnostics worse, and it also breaks the clear 401/403 map.

Mistake #5: too much gets written to logs — including tokens, cookies, and headers.
An owner-based denial is a great reason for a warn log with actor/action/target. But it is not a reason to log “everything that arrived in the request.” It is especially bad to log values that can later be reused (session id, CSRF token, Authorization header). The log should help you, not the attacker.

1
Task
Spring Security, level 20, lesson 4
Locked
Self-endpoints for the current user
Self-endpoints for the current user
1
Task
Spring Security, level 20, lesson 4
Locked
Build owner-based rules for drafts and the editor area
Build owner-based rules for drafts and the editor area
1
Survey/quiz
Access security, level 20, lesson 4
Unavailable
Access security
Self-operations and context
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION