Securing and testing the API
Dakiya's core works, but an unsecured courier API is a data breach: partners could see each other's shipments, anyone could change a parcel's status. This lesson secures Dakiya — authentication, role-based and object-level authorisation — and tests the risky paths, applying the security and testing modules to the real application. The security here is the part a review scrutinises most.
Authentication and roles
Every client authenticates, and Dakiya has three roles with different powers:
- STAFF — clinic operations: can update parcel status, manage hubs.
- PARTNER — a business: can book parcels and track their own.
- RIDER — updates status for parcels on their route.
The SecurityFilterChain (the security module) authenticates requests (a token for the mobile/partner
clients — the JWT lesson) and applies role rules:
http
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // token API
.authorizeHttpRequests(a -> a
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/parcels/*/status").hasAnyRole("STAFF", "RIDER")
.requestMatchers("/api/v1/**").authenticated()
.anyRequest().denyAll()) // fail closed
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
Rules match top-to-bottom, so the specific status-update rule (STAFF/RIDER only) comes before the general
"authenticated" rule. The default is denyAll() — fail closed, so a forgotten endpoint is protected,
not exposed (the security-mistakes lesson). Passwords are BCrypt-hashed (verified in the security module:
$2a$10$…, salted, matches correct/wrong).
Object-level access: partners see only their own parcels
The critical security requirement, and the one role checks alone do not satisfy: a partner must see
only their own parcels. A hasRole("PARTNER") check lets any partner call GET /parcels/42 — including
another partner's parcel 42. That is an IDOR (the security-mistakes lesson), and the fix is scoping every
read to the authenticated partner:
public Page<Parcel> listForCaller(Authentication auth, Pageable pageable) {
if (isStaff(auth)) return repository.findAll(pageable); // staff see all
Partner partner = partnerOf(auth); // the authenticated partner
return repository.findByPartner(partner, pageable); // ONLY their parcels
}
public Parcel trackForCaller(Long id, Authentication auth) {
Parcel p = repository.findById(id).orElseThrow(() -> new NotFoundException("..."));
if (!isStaff(auth) && !p.getPartner().equals(partnerOf(auth))) {
throw new NotFoundException("No parcel with id " + id); // 404, not 403 — do not reveal it exists
}
return p;
}
Because forbidden parcels are never returned (they are not in findByPartner's result), there is nothing to
leak; and a single-parcel fetch that is not the caller's returns 404, not 403, so the API does not even
reveal the parcel exists. Authorisation uses the authenticated principal (auth), never a
client-supplied partner id — trusting the client would be the IDOR itself. This is the layered model:
authenticated → has the role → sees only their own objects.
Testing the risky paths
Dakiya's tests weight the risky paths — security, status transitions, validation, concurrency — the way the testing module taught. A representative suite (the patterns verified in that module):
// object-level access — the breach test
@Test @WithMockUser(username = "partnerA", roles = "PARTNER")
void partner_seesOnlyTheirOwnParcels() throws Exception {
// partnerA has one parcel; partnerB has another
mvc.perform(get("/api/v1/parcels"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(1)); // only partnerA's
}
// authorisation — a partner cannot change status
@Test @WithMockUser(roles = "PARTNER")
void partner_cannotUpdateStatus() throws Exception {
mvc.perform(post("/api/v1/parcels/1/status").contentType("application/json")
.content("{\"status\":\"DELIVERED\"}"))
.andExpect(status().isForbidden()); // 403
}
// validation — bad input rejected
@Test @WithMockUser(roles = "PARTNER")
void book_rejectsInvalidBody() throws Exception {
mvc.perform(post("/api/v1/parcels").contentType("application/json")
.content("{\"recipientName\":\"\",\"destinationCity\":\"P\"}"))
.andExpect(status().isBadRequest()); // 400
}
// lifecycle — illegal transition rejected (unit test on the service/domain)
@Test void illegalTransition_isRejected() { /* delivered -> booked throws */ }
The tests that matter most are the breach and rule tests: "a partner sees only their own parcels",
"a partner cannot change status", "an illegal transition is rejected". These fail loudly if someone breaks
the scoping, the role rule, or the lifecycle — exactly the regressions a courier API cannot afford. The
patterns are the ones verified in the testing module: unit tests of the service, @WebMvcTest of the
controller with @WithMockUser (authenticated and denied paths), @DataJpaTest of the queries. Test the
denied paths as carefully as the allowed ones.
Check your work
Authentication and roles. All clients authenticate (token/stateless for the API); STAFF/PARTNER/RIDER
roles with different powers, enforced by authorizeHttpRequests (specific rules before general),
fail-closed default (denyAll), BCrypt passwords.
Object-level access. A partner sees only their own parcels — scope every read to the authenticated
partner (findByPartner), so forbidden parcels are never returned; a non-owner single fetch returns 404,
not 403; authorise on the authenticated principal, never a client-supplied id. Role checks alone are not
object-level security (IDOR).
Testing the risky paths. Weight the breach and rule tests: partner-sees-only-own, partner-cannot-update,
invalid-body-rejected, illegal-transition-rejected. Use unit/@WebMvcTest/@DataJpaTest (verified patterns);
test denied paths as carefully as allowed.
Practice
- Configure the
SecurityFilterChainwith STAFF/PARTNER/RIDER roles, a status-update rule restricted to STAFF/RIDER, and adenyAlldefault; confirm a partner gets 403 updating status. - Scope parcel reads to the authenticated partner; create two partners with parcels and confirm each sees only their own (write the breach test).
- Make a single-parcel fetch return 404 (not 403) for a non-owner; reason about why 404 matters for not revealing existence.
- Try authorising with a client-supplied partner id, show it can be forged (IDOR), then rewrite to use the authenticated principal.
- Write the risky-path tests (breach, role, validation, transition) and confirm they pass; break the scoping and watch the breach test fail.
- Confirm passwords are BCrypt-hashed and a wrong password is rejected.
Official documentation
- Spring Security — Authorization — Roles, method security, object-level access.
- Spring Security — Testing —
@WithMockUser, authenticated/denied paths. - OWASP — API Security Top 10 (IDOR) — Broken object-level authorisation.
Next: concurrency-safe status updates, and going live.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship