Role-Based Access Control (RBAC) & Ownership Security

Business Scenario

In this lab, we will enhance the Medicare backend by implementing Role-Based Access Control (RBAC) and ownership security using Spring Security and JWT authentication.

Pre-Lab Preparation

Module:

1) Mastering Springboot Security​​

  • In Phase 9, we implemented JWT Authentication.
  • Now JWT tells us:
WHO is making the request?
  • In Phase 10, we implement Role-Based Access Control (RBAC) and Ownership Security. This phase answers:
  • WHAT is that user allowed to do?
  • WHAT is that user allowed to do?

Our Final Role Rules

  • We currently have:
PATIENT
DOCTOR

PATIENT can :

Login
View/search doctors
Book an appointment
View own appointments
Create DoctorProfile
Modify DoctorProfile
View another patient's appointments
View doctor's private schedule

PATIENT cannot :

View own appointment details
Cancel own appointment
View own profile
Complete an appointment
Create prescriptions later

DOCTOR can :

Login
Search/view doctors
Manage own DoctorProfile
View own assigned appointments
View appointment assigned
Mark own appointment COMPLETED
Create prescriptions later
Book appointment as PATIENT
View another doctor's schedule
Modify another doctor's profile
View unrelated patient appointments

DOCTOR cannot :

First Important Security Fix — Appointment Responses

  • This is more than simple RBAC.
  • We also need: OWNERSHIP SECURITY because: A patient having ROLE_PATIENT must not automatically gain access to every patient's data.
  • After Phase 8, our User entity contains: password
  • So we should no longer return Appointment entities directly.
  • Otherwise nested JSON could expose things we don't want
dto/
└─ AppointmentResponse.java

Create AppointmentResponse

1

package com.medicare.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import com.medicare.entity.AppointmentStatus;
import com.medicare.entity.PaymentStatus;
import com.medicare.entity.TimeSlot;

public class AppointmentResponse {

    private Long id;

    private Long patientId;
    private String patientName;

    private Long doctorId;
    private String doctorName;
    private String specialization;

    private LocalDate appointmentDate;
    private TimeSlot timeSlot;

    private BigDecimal consultationFee;

    private AppointmentStatus appointmentStatus;
    private PaymentStatus paymentStatus;

    private LocalDateTime createdAt;

    public AppointmentResponse(
            Long id,
            Long patientId,
            String patientName,
            Long doctorId,
            String doctorName,
            String specialization,
            LocalDate appointmentDate,
            TimeSlot timeSlot,
            BigDecimal consultationFee,
            AppointmentStatus appointmentStatus,
            PaymentStatus paymentStatus,
            LocalDateTime createdAt) {

        this.id = id;
        this.patientId = patientId;
        this.patientName = patientName;
        this.doctorId = doctorId;
        this.doctorName = doctorName;
        this.specialization = specialization;
        this.appointmentDate = appointmentDate;
        this.timeSlot = timeSlot;
        this.consultationFee = consultationFee;
        this.appointmentStatus = appointmentStatus;
        this.paymentStatus = paymentStatus;
        this.createdAt = createdAt;
    }

    public Long getId() {
        return id;
    }

    public Long getPatientId() {
        return patientId;
    }

    public String getPatientName() {
        return patientName;
    }

    public Long getDoctorId() {
        return doctorId;
    }

    public String getDoctorName() {
        return doctorName;
    }

    public String getSpecialization() {
        return specialization;
    }

    public LocalDate getAppointmentDate() {
        return appointmentDate;
    }
    public TimeSlot getTimeSlot() {
        return timeSlot;
    }

    public BigDecimal getConsultationFee() {
        return consultationFee;
    }

    public AppointmentStatus getAppointmentStatus() {
        return appointmentStatus;
    }

    public PaymentStatus getPaymentStatus() {
        return paymentStatus;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }
}

Fix AppointmentRequest

2

  • Currently it contains: private Long patientId;
  • This is now dangerous because Rahul could login and send:
{
    "patientId": 5
}
  • And attempt to create an appointment for someone else.
  • So remove: private Long patientId; completely.
  • The new request becomes:
package com.medicare.dto;

import java.time.LocalDate;
import com.medicare.entity.TimeSlot;
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.NotNull;

public class AppointmentRequest {
    @NotNull(message = "Doctor id is required")
    private Long doctorId;

    @NotNull(message = "Appointment date is required")
    @FutureOrPresent(message = "Appointment date cannot be in the past")
    private LocalDate appointmentDate;

    @NotNull(message = "Time slot is required")
    private TimeSlot timeSlot;

    public Long getDoctorId() {
        return doctorId;
    }

    public void setDoctorId(Long doctorId) {
        this.doctorId = doctorId;
    }

    public LocalDate getAppointmentDate() {
        return appointmentDate;
    }

    public void setAppointmentDate(
            LocalDate appointmentDate) {
        this.appointmentDate = appointmentDate;
    }

    public TimeSlot getTimeSlot() {
        return timeSlot;
    }
    public void setTimeSlot(
            TimeSlot timeSlot) {

        this.timeSlot = timeSlot;
    }
}

How Do We Know the Patient Now ?

  • Remember:
JWT
 ↓
email
 ↓
SecurityContextHolder
 ↓
Authentication
  • So if Rahul sends: Authorization: Bearer <rahul-token>
  • Spring already knows: rahul@example.com
  • The frontend no longer tells us: "I am patient 5"
  • The backend determines who the user actually is and this approach is much safer.

Enable Method Security

3

  • Open : SecurityConfig.java
  • Add:
import org.springframework.security.config.annotation.method.configuration.
EnableMethodSecurity;
  • Then:
  • Now we can use: @PreAuthorize(...)

@Configuration
@EnableMethodSecurity
public class SecurityConfig {
  • Spring Security's current documentation recommends @EnableMethodSecurity; once enabled, @PreAuthorize can guard Spring-managed methods.

Understanding hasRole()

PATIENT
 ↓
ROLE_PATIENT
  • and:
DOCTOR
 ↓
ROLE_DOCTOR
  • When using: hasRole("PATIENT") do not write: hasRole("ROLE_PATIENT") because hasRole automatically applies the usual ROLE_ prefix. 

Update DoctorProfileRepository

4

  • We'll need to find the doctor profile belonging to the logged-in doctor.
  • Add:
Optional<DoctorProfile> findByUserId( Long userId );
  • So repository now includes:
boolean existsByUserId(Long userId);

Optional<DoctorProfile> findByUserId(
        Long userId
);
  • Import:
import java.util.Optional;

Secure Doctor Profile Ownership

5

  • Our old DoctorProfileRequest contained: userId
  • Remove it.
  • Doctor should never tell the backend:Update Doctor User 5.
JWT
 ↓
Current Doctor User
 ↓
Their DoctorProfile
  • New request:
package com.medicare.dto;
import java.math.BigDecimal;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

public class DoctorProfileRequest {

    @NotBlank(message = "Specialization is required")
    @Size(max = 100)
    private String specialization;

    @NotNull(message = "Experience is required")
    @Min(0)
    @Max(60)
    private Integer experienceYears;

    @NotNull(message = "Consultation fee is required")
    @Positive
    private BigDecimal consultationFee;

    @Size(max = 500)
    private String photoUrl;

    public String getSpecialization() {
        return specialization;
    }

    public void setSpecialization(
            String specialization) {
        this.specialization = specialization;
    }
    public Integer getExperienceYears() {
        return experienceYears;
    }

    public void setExperienceYears(
            Integer experienceYears) {
        this.experienceYears = experienceYears;
    }

    public BigDecimal getConsultationFee() {
        return consultationFee;
    }

    public void setConsultationFee(
            BigDecimal consultationFee) {
        this.consultationFee = consultationFee;
    }

    public String getPhotoUrl() {
        return photoUrl;
    }

    public void setPhotoUrl(
            String photoUrl) {

        this.photoUrl = photoUrl;
    }
}

Doctor Profile Endpoints Change

6

  • Remove the insecure approach:
PUT /api/doctors/{doctorId}
DELETE /api/doctors/{doctorId}

for profile management.

  • Use:
POST   /api/doctors/profile
GET    /api/doctors/profile/me
PUT    /api/doctors/profile/me
DELETE /api/doctors/profile/me
  • Meaning: "Operate on MY doctor profile."
  • No doctor ID required.

Update DoctorService

7

  • Add methods such as:
@PreAuthorize("hasRole('DOCTOR')")
public DoctorResponse createMyProfile(DoctorProfileRequest request,String email) {

    User user = findUserByEmail(email);

    if (doctorRepository.existsByUserId(user.getId())) {
        throw new IllegalArgumentException( "Doctor profile already exists");
    }

    DoctorProfile doctor = new DoctorProfile();
    doctor.setUser(user);
    doctor.setSpecialization(request.getSpecialization());
    doctor.setExperienceYears(request.getExperienceYears());
    doctor.setConsultationFee(request.getConsultationFee());
    doctor.setPhotoUrl(request.getPhotoUrl());

    return mapToDoctorResponse(doctorRepository.save(doctor));
}
@PreAuthorize("hasRole('DOCTOR')")
public DoctorResponse getMyProfile(String email) {
    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());
    
    return mapToDoctorResponse(doctor);
}
  • Add getMyProfile
  • Add updateMyProfile
@PreAuthorize("hasRole('DOCTOR')")
public DoctorResponse updateMyProfile(String email,DoctorProfileRequest request) {
    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());

    doctor.setSpecialization(request.getSpecialization());

    doctor.setExperienceYears(request.getExperienceYears());

    doctor.setConsultationFee(request.getConsultationFee());

    doctor.setPhotoUrl(request.getPhotoUrl());

    return mapToDoctorResponse(doctorRepository.save(doctor));
}
  • Add deleteMyProfile
@PreAuthorize("hasRole('DOCTOR')")
public void deleteMyProfile(String email) {

    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());
    doctorRepository.delete(doctor);
}

Add Helper Methods

8

  • Inside DoctorService:
private User findUserByEmail(String email) {
    return userRepository.findByEmailIgnoreCase(email).orElseThrow(() ->
                    new UserNotFoundException("User not found")
            );
}
private DoctorProfile findDoctorByUserId(Long userId) {
    return doctorRepository.findByUserId(userId).orElseThrow(() ->
                    new DoctorNotFoundException("Doctor profile not found")
            );
}
  • Add

Before proceeding to the next steps, we need to clean up the existing DoctorService code by removing outdated methods

  • Fix 1 — Delete the old createDoctor()
  • Fix 2 — Remove old updateDoctor() and deleteDoctor()
  • Fix 1 — Delete the old createDoctor()
  • Fix 3 — Remove findUserById()
  • Fix 4 — Remove unused Role import

Update DoctorController

9

  • Add
@PostMapping("/profile")
public ResponseEntity<DoctorResponse>createMyProfile(
                @Valid @RequestBodyDoctorProfileRequest request,
                Authentication authentication) {

    DoctorResponse response = doctorService.createMyProfile(request,
                    authentication.getName());
    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
  • Get own profile:
@GetMapping("/profile/me")
public ResponseEntity<DoctorResponse>getMyProfile(Authentication authentication) {
    return ResponseEntity.ok(doctorService.getMyProfile(authentication.getName())
    );
}
  • Update:
@PutMapping("/profile/me")
public ResponseEntity<DoctorResponse>updateMyProfile(
                @Valid @RequestBody DoctorProfileRequest request,
                Authentication authentication) {

    return ResponseEntity.ok(doctorService.updateMyProfile(
                    authentication.getName(),request)
    );
}
  • Delete
@DeleteMapping("/profile/me")
public ResponseEntity<Void> deleteMyProfile(Authentication authentication) {
    doctorService.deleteMyProfile(authentication.getName());
    return ResponseEntity.noContent().build();
}
  • Import :
import org.springframework.security.core.Authentication;

Fix Appointment Slot Query

10

  • Now that patients can cancel appointments, a cancelled slot should become available again.
  • Change
existsByDoctorIdAndAppointmentDateAndTimeSlot(...)
  • To:
boolean existsByDoctorIdAndAppointmentDateAndTimeSlotAndAppointmentStatusNot(
        Long doctorId,
        LocalDate appointmentDate,
        TimeSlot timeSlot,
        AppointmentStatus appointmentStatus
);
  • Then we'll check: AppointmentStatus.CANCELLED

Refactor AppointmentService

11

  • Our service will now use the authenticated email.
  • Create Appointment
@PreAuthorize("hasRole('PATIENT')")
public AppointmentResponse createAppointment(AppointmentRequest request,
                    String email) {

    User patient = findUserByEmail(email);
    DoctorProfile doctor = doctorRepository.findById(request.getDoctorId())
                    .orElseThrow(() -> new DoctorNotFoundException(
                           "Doctor not found with id: "+ request.getDoctorId()
                            )
                    );
    boolean booked = appointmentRepository
     .existsByDoctorIdAndAppointmentDateAndTimeSlotAndAppointmentStatusNot(
     doctor.getId(),request.getAppointmentDate(),request.getTimeSlot(),
     AppointmentStatus.CANCELLED);
    if (booked) {
        throw new AppointmentConflictException(
        "Selected appointment slot is already booked");
    }

    Appointment appointment = new Appointment();
    appointment.setPatient(patient);
    appointment.setDoctor(doctor);
    appointment.setAppointmentDate(request.getAppointmentDate());
    appointment.setTimeSlot(request.getTimeSlot());
    appointment.setConsultationFee(doctor.getConsultationFee());
    appointment.setAppointmentStatus(AppointmentStatus.PENDING);
    appointment.setPaymentStatus(PaymentStatus.UNPAID);
    Appointment savedAppointment = appointmentRepository.save(appointment);

    return mapToAppointmentResponse(savedAppointment);
}

Patient — My Appointments

12

@PreAuthorize("hasRole('PATIENT')")
public List<AppointmentResponse>
        getMyAppointments(String email) {
    User patient = findUserByEmail(email);

    List<Appointment> appointments = appointmentRepository
                    .findByPatientId(patient.getId());

    return mapAppointmentList(appointments);
}
  • Now the endpoint can simply be: GET /api/appointments/my instead of /api/appointments/patient/5

Doctor — My Schedule

13

@PreAuthorize("hasRole('DOCTOR')")
public List<AppointmentResponse>
        getMyDoctorAppointments(
                String email) {

    User user = findUserByEmail(email);

    DoctorProfile doctor = findDoctorByUserId(user.getId());
    List<Appointment> appointments = appointmentRepository
                    .findByDoctorId(doctor.getId());
                    

    return mapAppointmentList(appointments);
    
}
  • Endpoint: GET /api/appointments/doctor/me
  • No doctor ID from frontend.

Secure Appointment by ID

14

  • Suppose Rahul owns Appointment 10.
  • Another patient tries: GET /api/appointments/10
  • Having: ROLE_PATIENT isn't enough.
  • We must verify ownership.
@PreAuthorize("hasAnyRole('PATIENT', 'DOCTOR')")
public AppointmentResponse getAppointmentById( Long appointmentId,String email) {

    Appointment appointment = findAppointmentById(appointmentId);

    User user = findUserByEmail(email);
    verifyAppointmentAccess(appointment,user);

    return mapToAppointmentResponse(appointment);
}

Ownership Check

15

  • Add :
private void verifyAppointmentAccess(Appointment appointment,User user) {

    if (user.getRole() == Role.PATIENT) {
        if (!appointment.getPatient().getId().equals(user.getId())) {
            throw new AccessDeniedException( "You cannot access this appointment");
        }
        return;
    }

    if (user.getRole() == Role.DOCTOR) {
    
        DoctorProfile doctor = findDoctorByUserId(user.getId());

        if (!appointment.getDoctor().getId().equals(doctor.getId())) {
            throw new AccessDeniedException("You cannot access this appointment");
        }
    }
}
  • Import:
import org.springframework.security.access.AccessDeniedException;
  • Spring Security uses an access-denied exception to represent failed authorization, which results in a 403 during an HTTP request.

Patient Cancel Appointment

16

  • Add :
@PreAuthorize("hasRole('PATIENT')")
public AppointmentResponse cancelAppointment(Long appointmentId,String email) {

    User patient = findUserByEmail(email);
    Appointment appointment = findAppointmentById(appointmentId);

    if (!appointment.getPatient().getId().equals(patient.getId())) {
        throw new AccessDeniedException("You cannot cancel this appointment");
    }

    if (appointment.getAppointmentStatus()== AppointmentStatus.COMPLETED) {
        throw new IllegalArgumentException(
        "Completed appointment cannot be cancelled");
    }

    if (appointment.getAppointmentStatus() == AppointmentStatus.CANCELLED) {
        throw new IllegalArgumentException("Appointment is already cancelled");
    }

    appointment.setAppointmentStatus(AppointmentStatus.CANCELLED);
    
    return mapToAppointmentResponse(appointmentRepository.save(appointment));
}

Doctor Complete Appointment

17

  • Add :
@PreAuthorize("hasRole('DOCTOR')")
public AppointmentResponse completeAppointment(Long appointmentId,String email) {

    User user = findUserByEmail(email);
    DoctorProfile doctor = findDoctorByUserId(user.getId());
    Appointment appointment = findAppointmentById(appointmentId);

    if (!appointment.getDoctor().getId().equals(doctor.getId())) {
        throw new AccessDeniedException("You cannot complete this appointment");
    }

    if (appointment.getAppointmentStatus() == AppointmentStatus.CANCELLED) {
      throw new IllegalArgumentException("Cancelled appointment cannot be completed");
    }

    appointment.setAppointmentStatus(AppointmentStatus.COMPLETED);

    return mapToAppointmentResponse(appointmentRepository.save(appointment)
    );
}
    return mapToAppointmentResponse(appointmentRepository.save(appointment)
    );
}

Appointment Mapping

18

  • Add :
private AppointmentResponse mapToAppointmentResponse(Appointment appointment) {

    return new AppointmentResponse(appointment
           .getId(),appointment.getPatient().getId(),
            appointment.getPatient().getName(),
            appointment.getDoctor().getId(),
            appointment.getDoctor().getUser().getName(),
            appointment.getDoctor().getSpecialization(),
            
            appointment.getAppointmentDate(),
            appointment.getTimeSlot(),
            appointment.getConsultationFee(),
            appointment.getAppointmentStatus(),
            appointment.getPaymentStatus(),
            appointment.getCreatedAt()
    );
}
  • List mapper:
private List<AppointmentResponse>mapAppointmentList(
                List<Appointment> appointments) {

    List<AppointmentResponse> responses = new ArrayList<>();

    for (Appointment appointment : appointments) {

        responses.add(mapToAppointmentResponse(appointment));
    }

    return responses;
}

Update AppointmentController

19

  • Our appointment controller becomes much safer.
@PostMapping
public ResponseEntity<AppointmentResponse>
        createAppointment(@Valid @RequestBody AppointmentRequest request,
                Authentication authentication) {

    AppointmentResponse response = appointmentService.createAppointment(
                            request,authentication.getName()
                    );

    return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
  • Patient appointments
@GetMapping("/my")
public ResponseEntity<List<AppointmentResponse>>
        getMyAppointments(Authentication authentication) {
    return ResponseEntity.ok(appointmentService.getMyAppointments(
  authentication.getName()
                    )
    );
}
  • Doctor schedule
@GetMapping("/doctor/me")
public ResponseEntity<List<AppointmentResponse>>
        getMyDoctorAppointments(Authentication authentication) {
    return ResponseEntity.ok(appointmentService.getMyDoctorAppointments(
                            authentication.getName()
                    )
    );
}
@GetMapping("/{id}")
  • Doctor schedule
public ResponseEntity<AppointmentResponse>
        getAppointmentById( @PathVariable Long id,Authentication authentication) {

    return ResponseEntity.ok(appointmentService.getAppointmentById(
                            id,authentication.getName()
                    )
    );
}
  • Cancel
@PatchMapping("/{id}/cancel")
public ResponseEntity<AppointmentResponse>
        cancelAppointment(@PathVariable Long id, Authentication authentication) {

    return ResponseEntity.ok(appointmentService.cancelAppointment(
                            id,authentication.getName()
                    )
    );
}
  • Complete
@PatchMapping("/{id}/complete")
public ResponseEntity<AppointmentResponse>
        completeAppointment(@PathVariable Long id,Authentication authentication) {
    return ResponseEntity.ok(appointmentService.completeAppointment(
                            id,authentication.getName()
                    )
    );
}

Remove Insecure Appointment Endpoints

20

  • Remove:
GET /api/appointments
GET /api/appointments/patient/{patientId}
GET /api/appointments/doctor/{doctorId}
  • Because these make ID manipulation much easier.
  • Now:
PATIENT
 ↓
/appointments/my
  • And:
DOCTOR
 ↓
/appointments/doctor/me

Remove Insecure Appointment Endpoints

21

  • Previously: GET /api/users/me just showed username/authorities.
  • Now make it useful for React.
  • UserService:
public UserResponse getCurrentUser(String email) {

    User user = userRepository.findByEmailIgnoreCase(email).orElseThrow(() ->
                            new UserNotFoundException("User not found")
                    );
                    
    return mapToUserResponse(user);
}
  • Controller:
@GetMapping("/me")
public ResponseEntity<UserResponse>getCurrentUser(Authentication authentication) {
    return ResponseEntity.ok(userService.getCurrentUser(authentication.getName())
    );
}
  • Result:
{
    "id": 1,
    "name": "Rahul Shah",
    "email": "rahul@example.com",
    "phone": "9876543210",
    "role": "PATIENT"
}
  • Remove public:
GET /api/users
GET /api/users/{id}

Configure Endpoint-Level RBAC

22

  • Now update the authorization section in SecurityConfig.

from the controller for now.

  • There is no business reason for patients/doctors to list every user.
  • Order matters: Spring Security evaluates matching authorization rules in the declared order.
.authorizeHttpRequests(auth -> auth

        // Public APIs
        .requestMatchers("/api/health","/api/auth/register","/api/auth/login")
        .permitAll()

        // Current user's profile
        .requestMatchers("/api/users/me").authenticated()

        // Doctor can manage only own profile
        .requestMatchers("/api/doctors/profile","/api/doctors/profile/**")
        .hasRole("DOCTOR")

        // Doctor search/listing
        .requestMatchers(HttpMethod.GET,"/api/doctors/**")
        .hasAnyRole("PATIENT", "DOCTOR")

        // Patient books appointments
        .requestMatchers(HttpMethod.POST,"/api/appointments")
        .hasRole("PATIENT")

        // Patient appointment history
        .requestMatchers("/api/appointments/my").hasRole("PATIENT")

        // Doctor schedule
        .requestMatchers("/api/appointments/doctor/**").hasRole("DOCTOR")

        // Patient cancellation
        .requestMatchers(HttpMethod.PATCH,"/api/appointments/*/cancel")
        .hasRole("PATIENT")

        // Doctor completion
        .requestMatchers(HttpMethod.PATCH,"/api/appointments/*/complete")
        .hasRole("DOCTOR")

        // Appointment details
        .requestMatchers(HttpMethod.GET,"/api/appointments/*")
        .hasAnyRole("PATIENT", "DOCTOR")

        /*
         * Safer default: Anything we forgot to configure is denied.
         */
         
        .anyRequest()
        .denyAll()
        
)
  • Using a deny-by-default fallback helps avoid accidentally exposing new endpoints that were forgotten in the security configuration.
  • Spring Security's documentation explicitly lists denyAll as a supported authorization rule and shows it as a defensive default.

Why Both SecurityConfig AND @PreAuthorize?

  • You might ask: Aren't we checking the role twice?

Yes — intentionally for sensitive business actions.

  • For Example :
HTTP endpoint:
POST /appointments
      ↓
SecurityConfig:
PATIENT required
      ↓
Controller
      ↓
Service:
@PreAuthorize("hasRole('PATIENT')")
  • Why? -- Because someday another service could call: appointmentService.createAppointment(...) without coming through that controller.
  • Method security protects the business logic too.

Proper 401 and 403 JSON Responses

23

  • One issue:ResponseEntity works inside controllers.
security/
└── CustomAuthenticationEntryPoint.java
  • But: 401 and  403 often happen before the controller runs.
  • So our normal: @RestControllerAdvice is not the right place for every security-filter failure.
  • We'll create dedicated security handlers.
  • Create CustomAuthenticationEntryPoint
package com.medicare.security;

import java.io.IOException;
import java.time.LocalDateTime;

import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import com.medicare.response.ErrorResponse;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

@Component
public class CustomAuthenticationEntryPoint
        implements AuthenticationEntryPoint {

    private final JsonMapper jsonMapper;

    public CustomAuthenticationEntryPoint(
            JsonMapper jsonMapper) {

        this.jsonMapper = jsonMapper;
    }

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException exception)
            throws IOException {

        ErrorResponse errorResponse =
                new ErrorResponse(
                        LocalDateTime.now(),
                        HttpStatus.UNAUTHORIZED.value(),
                        "Authentication is required",
                        request.getRequestURI()
                );

        response.setStatus(
                HttpStatus.UNAUTHORIZED.value()
        );

        response.setContentType(
                MediaType.APPLICATION_JSON_VALUE
        );

        response.getWriter().write(
                jsonMapper.writeValueAsString(
                        errorResponse
                )
        );
    }
}
import java.io.IOException;
import java.time.LocalDateTime;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import com.medicare.response.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final JsonMapper jsonMapper;
    public CustomAuthenticationEntryPoint(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
    }

    @Override
    public void commence(HttpServletRequest request,HttpServletResponse response,
            AuthenticationException exception) throws IOException {

        ErrorResponse errorResponse = new ErrorResponse(
                        LocalDateTime.now(),
                        HttpStatus.UNAUTHORIZED.value(),
                        "Authentication is required",
                        request.getRequestURI()
                );
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(jsonMapper.writeValueAsString(errorResponse));
    }
}

Create CustomAccessDeniedHandler

24

security/
└── CustomAccessDeniedHandler.java
package com.medicare.security;
import java.io.IOException;
import java.time.LocalDateTime;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import com.medicare.response.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final JsonMapper jsonMapper;

    public CustomAccessDeniedHandler(JsonMapper jsonMapper) {
        this.jsonMapper = jsonMapper;
    }

    @Override
    public void handle(
            HttpServletRequest request,HttpServletResponse response,
            AccessDeniedException exception) throws IOException {

        ErrorResponse errorResponse = new ErrorResponse(
                        LocalDateTime.now(), HttpStatus.FORBIDDEN.value(),
                        "You do not have permission to access this resource",
                        request.getRequestURI()
                );

        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(jsonMapper.writeValueAsString(errorResponse)
        );
    }
}

Create CustomAccessDeniedHandler

25

  • Inject them into: securityFilterChain(...)
  • Then:
.exceptionHandling(exception ->
        exception.authenticationEntryPoint(customAuthenticationEntryPoint)
                .accessDeniedHandler(customAccessDeniedHandler)
)
  • So now:
Not authenticated - 401 UNAUTHORIZED
Authenticated but wrong role - 403 FORBIDDEN

Important Difference — Role vs Ownership

  • Role check
Is Rahul a PATIENT? --> YES
Does not mean: Rahul can view every patient appointment
We still need: Does this appointment belong to Rahul?
  • So security becomes:
Authentication
     ↓
    Role
     ↓
 Ownership
  • This prevents a common authorization issue where changing an object ID gives access to someone else's record.

Testing — Patient Books Appointment

26

  • Login as Rahul: ROLE_PATIENT

  • Request and body:
POST /api/appointments
Authorization: Bearer <patient-token>
{
    "doctorId": 1,
    "appointmentDate": "2026-09-25",
    "timeSlot": "MORNING_10_AM"
}
  • Notice : We do not pass patientId from the frontend because the backend automatically identifies the logged-in patient, Rahul, using the JWT token.
  • Expected: 201 CREATED

Doctor Tries to Book Appointment

27

  • Login as doctor.
  • Call: POST /api/appointments
  • Expected: 403 FORBIDDEN
Because: DOCTOR ≠ PATIENT

Patient — My Appointments

28

GET /api/appointments/my
Authorization: Bearer <patient-token>

Doctor Tries /appointments/my

29

  • Expected: 200 OK
  • Using doctor token: GET /api/appointments/my
  • Expected: 403 FORBIDDEN

Doctor — Own Schedule

30

GET /api/appointments/doctor/me
Authorization: Bearer <doctor-token>
  • Expected: 200 OK

Patient Tries Doctor Schedule

31

GET /api/appointments/doctor/me
  • using patient token.
  • Expected: 403 FORBIDDEN

Test Patient Ownership

32

Rahul owns Appointment 5
Priya owns Appointment 6
  • Suppose:
GET /api/appointments/5
  • Rahul:
  • Expected: 200 OK
GET /api/appointments/6
  • Rahul:
  • Expected : 403 FORBIDDEN
  • This confirms ownership protection.

Test Doctor Ownership

33

  • Appointment 5 belongs to: Dr. Ananya

GET /api/appointments/5
  • Expected: 200
  • Another doctor:
GET /api/appointments/5
  • Expected: 403 FORBIDDEN

Patient Cancel Own Appointment

34

PATCH /api/appointments/5/cancel
  • Expected: 200
  • Status becomes: CANCELLED

Another Patient Tries to Cancel It

35

PATCH /api/appointments/5/complete
  • Expected: 200
  • Expected: 403 FORBIDDEN

Doctor Completes Own Appointment

36

  • using assigned doctor token.
  • Status becomes: COMPLETED

Patient Tries Complete

37

  • Expected: 403 FORBIDDEN

Doctor Profile Security

38

  • Doctor :
POST /api/doctors/profile
  • Body:
{
    "specialization": "Cardiologist",
    "experienceYears": 8,
    "consultationFee": 1200,
    "photoUrl": "..."
}
  • Expected: 201 CREATED

Patient Tries Creating Doctor Profile

39

  • Expected: 403 FORBIDDEN

We are done with this lab. The latest source code has been uploaded to GitHub. You can access the latest commit using the link below: 

 

Great job!

You have successfully implemented Role-Based Access Control (RBAC) and ownership security in the Medicare backend using Spring Security and JWT authentication.

Checkpoint

   Git Push

git push origin branchName

Next-Lab Preparation

Module:

1) Mastering Springboot Security​​

SpringBoot Lab - 11

By Content ITV

SpringBoot Lab - 11

  • 196