RizTech Academy logo
RizTech Academy
Capstone: Dakiya, end to endLesson 4 of 545 min

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

  1. Configure the SecurityFilterChain with STAFF/PARTNER/RIDER roles, a status-update rule restricted to STAFF/RIDER, and a denyAll default; confirm a partner gets 403 updating status.
  2. Scope parcel reads to the authenticated partner; create two partners with parcels and confirm each sees only their own (write the breach test).
  3. Make a single-parcel fetch return 404 (not 403) for a non-owner; reason about why 404 matters for not revealing existence.
  4. Try authorising with a client-supplied partner id, show it can be forged (IDOR), then rewrite to use the authenticated principal.
  5. Write the risky-path tests (breach, role, validation, transition) and confirm they pass; break the scoping and watch the breach test fail.
  6. Confirm passwords are BCrypt-hashed and a wrong password is rejected.

Official documentation

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