Series MapLesson 16 / 35
Build CoreOrdered learning track

Learn Java Identity Authentication Authorization Api Platform Part 016 Api Object Level Authorization

11 min read2111 words
PrevNext
Lesson 1635 lesson track0719 Build Core

title: Learn Java Identity, Authentication & Authorization for Secure Enterprise API Platform - Part 016 description: API object-level authorization and BOLA/IDOR defense patterns for Java enterprise resource servers, including tenant scoping, resource guards, query constraints, and negative testing. series: learn-java-identity-authentication-authorization-api-platform seriesTitle: Learn Java Identity, Authentication & Authorization for Secure Enterprise API Platform order: 16 partTitle: API Object-Level Authorization and BOLA Defense tags:

  • java
  • api-security
  • authorization
  • bola
  • idor
  • spring-security
  • multi-tenancy date: 2026-06-28

Part 016 — API Object-Level Authorization and BOLA Defense

1. Problem Framing

Broken Object Level Authorization is one of the most important API authorization failures because it is deceptively simple.

A user changes an identifier:

GET /cases/1001
GET /cases/1002

If the server only checks that the user is authenticated, the API may leak another user's or another tenant's object.

The bug often looks harmless in code:

@GetMapping("/cases/{id}")
public CaseDto getCase(@PathVariable UUID id) {
    return caseService.getCase(id);
}

The problem is not the path variable. The problem is the missing object-level decision:

Can this subject read this specific case in this specific tenant and context?

This part focuses on API object-level authorization, commonly associated with IDOR/BOLA classes of vulnerability.

2. Kaufman Subskill Breakdown

Target skill:

Build Java APIs where every user-controlled object reference is constrained by subject, tenant, relationship, action, and resource state before data or side effects are exposed.

Subskills:

SubskillWhat You Must Be Able to Do
Object reference detectionIdentify every path/query/body/header field that points to a resource.
Authorization interval mappingKnow where the resource ID enters, where the object is loaded, and where side effects happen.
Resource guard designImplement explicit object-level policy checks.
Query constraint designPrevent unauthorized objects from being loaded or listed.
Tenant scopingEnsure tenant boundary is applied consistently.
Action-level checksDistinguish read, update, approve, export, delete, assign, and transition.
Negative testingProve another user's ID, another tenant's ID, and stale relationship IDs are denied.
Audit designRecord denied and permitted object-level decisions.

Practice goal after this part:

Given any API endpoint, you can mark every user-controlled object reference and define the exact object-level authorization check before implementation.

3. The BOLA Mental Model

BOLA is not only about numeric IDs.

Identifiers can be:

LocationExamples
Path/cases/{caseId}, /tenants/{tenantId}/users/{userId}
Query?customerId=..., ?ownerId=..., ?account=...
Body{ "caseId": "...", "targetUserId": "..." }
HeaderX-Tenant-Id, X-Act-As-User, X-Account-Id
Cursorpagination cursor encoding object references
GraphQLnode/global IDs
Event payloadIDs consumed asynchronously

The attack is not defeated by using UUIDs.

Unpredictable ID != authorization

A UUID makes guessing harder. It does not prove the caller is allowed to access the object.

4. Object-Level Authorization Equation

For object-level access, use this equation:

Permit if subject can perform action on object under tenant and context.

More explicitly:

Decision = f(subject, action, object, tenant, relationship, state, assurance, policy)

Example:

Can user-123, through case-portal, read case CASE-9,
where CASE-9 belongs to tenant-a,
when user-123 is a member of the assigned investigation team,
and the case is not sealed,
and the session is fresh enough?

This is the missing decision in most BOLA bugs.

5. Authorization Interval

Every endpoint has an authorization interval.

The interval starts when a user-controlled object reference enters the system and ends when data is returned or a side effect happens.

If the policy check happens after the full object is loaded, sensitive data may already be exposed to logs, caches, metrics, or exceptions.

For sensitive resources, load a minimal authorization projection first.

public record CaseAuthorizationView(
    UUID id,
    String tenantId,
    UUID ownerId,
    String region,
    CaseStatus status,
    boolean sealed
) {}

Then evaluate policy before loading the full object.

6. Common Vulnerable Patterns

6.1 Authenticated-Only Access

Bad:

@GetMapping("/cases/{id}")
@PreAuthorize("isAuthenticated()")
public CaseDto getCase(@PathVariable UUID id) {
    return caseService.getCase(id);
}

This only proves the caller has a session/token.

It does not prove access to id.

Better:

@GetMapping("/cases/{id}")
public CaseDto getCase(@PathVariable UUID id, Authentication authentication) {
    Subject subject = subjectFactory.from(authentication);
    return caseApplicationService.getReadableCase(subject, id);
}

Service:

public CaseDto getReadableCase(Subject subject, UUID caseId) {
    CaseAuthorizationView view = caseRepository.getAuthorizationView(caseId)
        .orElseThrow(() -> new NotFoundException("case_not_found"));

    AuthorizationDecision decision = caseReadPolicy.evaluate(
        subject,
        view,
        AuthorizationContext.current()
    );

    audit.record(subject, "case:read", ResourceRef.caseRef(caseId), decision);

    if (!decision.permitted()) {
        throw new AccessDeniedException(decision.reasonCode());
    }

    return caseMapper.toDto(caseRepository.getRequired(caseId));
}

6.2 Role-Only Access

Bad:

@PreAuthorize("hasAuthority('case:read')")
@GetMapping("/cases/{id}")
public CaseDto getCase(@PathVariable UUID id) {
    return caseService.getCase(id);
}

This checks operation type but not object ownership, assignment, tenant, classification, or workflow state.

Better:

has case:read permission
AND same tenant
AND assigned relationship OR permitted regional access
AND not sealed OR has sealed-case clearance

6.3 Query Parameter Trust

Bad:

@GetMapping("/cases")
public List<CaseDto> listCases(@RequestParam String tenantId) {
    return caseRepository.findByTenantId(tenantId);
}

The caller controls tenantId.

Better:

@GetMapping("/cases")
public List<CaseDto> listCases(Authentication authentication) {
    Subject subject = subjectFactory.from(authentication);
    return caseQueryService.listVisibleCases(subject);
}

Repository query must be constrained using authenticated tenant and visibility rules, not arbitrary request parameters.

6.4 Body ID Confusion

Bad:

POST /cases/123/comments
Content-Type: application/json

{
  "caseId": "999",
  "message": "..."
}

If service uses body caseId, attacker can smuggle a different object reference.

Better:

public record AddCommentRequest(String message) {}

Use path ID as authoritative and do not duplicate it in body unless there is a strict consistency check.

if (!pathCaseId.equals(body.caseId())) {
    throw new BadRequestException("case_id_mismatch");
}

6.5 Indirect Object Reference Through Child Resource

Bad:

GET /documents/{documentId}

Policy only checks document exists.

Better:

document -> parent case -> tenant -> assignment/relationship -> subject

A child object often inherits authorization from a parent aggregate.

6.6 Bulk Operations

Bad:

@PostMapping("/cases/bulk-close")
public void closeCases(@RequestBody List<UUID> caseIds) {
    caseService.closeAll(caseIds);
}

Bulk endpoints multiply BOLA risk.

Better:

public BulkCloseResult closeCases(Subject subject, List<UUID> caseIds) {
    List<CaseAuthorizationView> views = caseRepository.getAuthorizationViews(caseIds);
    Map<UUID, CaseAuthorizationView> byId = views.stream()
        .collect(Collectors.toMap(CaseAuthorizationView::id, Function.identity()));

    BulkCloseResult result = new BulkCloseResult();

    for (UUID caseId : caseIds) {
        CaseAuthorizationView view = byId.get(caseId);
        if (view == null) {
            result.addDenied(caseId, "NOT_FOUND_OR_NOT_VISIBLE");
            continue;
        }

        AuthorizationDecision decision = caseClosePolicy.evaluate(
            subject,
            view,
            AuthorizationContext.current()
        );

        audit.record(subject, "case:close", ResourceRef.caseRef(caseId), decision);

        if (decision.permitted()) {
            caseRepository.close(caseId);
            result.addClosed(caseId);
        } else {
            result.addDenied(caseId, decision.reasonCode());
        }
    }

    return result;
}

Never allow one permitted object to imply all objects are permitted.

7. Tenant Scoping as First-Class Object Authorization

Multi-tenant BOLA is severe because it crosses customer boundaries.

Bad mental model:

The API gateway already checked tenant.

Better mental model:

Every resource access must prove tenant consistency at the application and data boundary.

7.1 Tenant Context Sources

SourceTrust LevelNotes
Path tenant IDlow by itselfUser-controlled; must match authenticated tenant/allowed tenants.
Header tenant IDlow by itselfUseful as selection, not proof.
Token tenant claimmediumMust be issuer-validated and may still be stale.
Server-side membership lookuphighFresher but costs latency.
Resource tenant columnauthoritative for resourceMust be checked against subject tenant.

7.2 Tenant-Safe Access Pattern

public CaseAuthorizationView getAuthorizationViewForTenant(UUID caseId, String tenantId) {
    return jdbcClient.sql("""
        select id, tenant_id, owner_id, region, status, sealed
        from cases
        where id = :caseId
          and tenant_id = :tenantId
        """)
        .param("caseId", caseId)
        .param("tenantId", tenantId)
        .query(CaseAuthorizationView.class)
        .optional()
        .orElseThrow(() -> new NotFoundException("case_not_found"));
}

This query prevents accidentally loading another tenant's object.

Then still run policy for finer rules.

CaseAuthorizationView view = repository.getAuthorizationViewForTenant(
    caseId,
    subject.activeTenantId()
);

AuthorizationDecision decision = caseReadPolicy.evaluate(subject, view, context);

7.3 404 vs 403

Returning 404 Not Found for inaccessible resources can reduce enumeration signals.

But the platform still needs internal audit:

External response: 404
Internal audit: DENY TENANT_MISMATCH for case CASE-9

Policy:

SituationExternal ResponseInternal Event
Object does not exist404not_found
Object exists but wrong tenant404 or 403 by policytenant_mismatch_denied
Object exists same tenant but no permission403access_denied
Step-up required403 or 401 with step-up challenge patternstep_up_required

Do not let response hiding remove audit evidence.

8. Resource Guard Pattern

A resource guard centralizes object-level decisions.

@Component
public class CaseResourceGuard {

    private final CaseRepository caseRepository;
    private final CaseReadPolicy readPolicy;
    private final CaseUpdatePolicy updatePolicy;
    private final AuthorizationAudit audit;

    public CaseAuthorizationView requireReadable(Subject subject, UUID caseId) {
        CaseAuthorizationView view = caseRepository.getAuthorizationViewForTenant(
            caseId,
            subject.activeTenantId()
        );

        AuthorizationDecision decision = readPolicy.evaluate(
            subject,
            view,
            AuthorizationContext.current()
        );

        audit.record(subject, "case:read", ResourceRef.caseRef(caseId), decision);

        if (!decision.permitted()) {
            throw new AccessDeniedException(decision.reasonCode());
        }

        return view;
    }

    public CaseAuthorizationView requireUpdatable(Subject subject, UUID caseId) {
        CaseAuthorizationView view = caseRepository.getAuthorizationViewForTenant(
            caseId,
            subject.activeTenantId()
        );

        AuthorizationDecision decision = updatePolicy.evaluate(
            subject,
            view,
            AuthorizationContext.current()
        );

        audit.record(subject, "case:update", ResourceRef.caseRef(caseId), decision);

        if (!decision.permitted()) {
            throw new AccessDeniedException(decision.reasonCode());
        }

        return view;
    }
}

Usage:

public CaseDto updateCase(Subject subject, UUID caseId, UpdateCaseCommand command) {
    CaseAuthorizationView view = caseGuard.requireUpdatable(subject, caseId);
    CaseRecord updated = caseDomainService.update(view.id(), command);
    return mapper.toDto(updated);
}

Benefits:

  • Prevents repeated ad-hoc checks.
  • Keeps policy near application use cases.
  • Makes audit consistent.
  • Makes tests easier.

Risks:

  • Guard can grow too large.
  • Guard can become a dumping ground for domain logic.
  • Guard must not load full sensitive object before authorization.

9. Query-Time Authorization for Collections

Object-level authorization is not only for GET /resource/{id}.

List endpoints are often worse.

Bad:

@GetMapping("/cases")
public Page<CaseDto> list(Pageable pageable) {
    return caseRepository.findAll(pageable).map(mapper::toDto);
}

This exposes all cases unless filtered later.

9.1 Filter Before Returning

Better:

public Page<CaseSummaryDto> listVisibleCases(Subject subject, Pageable pageable) {
    return caseRepository.findVisibleCases(
        subject.activeTenantId(),
        subject.id(),
        subject.region(),
        subject.authorities(),
        pageable
    ).map(mapper::toSummaryDto);
}

SQL-style predicate:

select c.*
from cases c
left join case_assignments ca on ca.case_id = c.id
where c.tenant_id = :tenantId
  and (
      c.owner_id = :subjectId
      or ca.assignee_id = :subjectId
      or (:canReadRegionalCases = true and c.region = :subjectRegion)
  )
order by c.created_at desc
limit :limit offset :offset

Do not load all rows and filter in memory for sensitive resources.

Bad:

return repository.findAll().stream()
    .filter(caseRecord -> policy.canRead(subject, caseRecord))
    .toList();

This leaks through memory, logs, timings, and pagination inconsistencies.

9.2 Pagination Invariant

Authorization must be applied before pagination.

Bad flow:

select page 1 of all cases -> filter unauthorized -> return 2 results

Good flow:

filter visible cases -> paginate visible cases -> return full page

Otherwise users see broken pagination and may infer hidden data volume.

10. Action-Level Object Authorization

BOLA is not only read access.

Action-level object authorization failures happen when the user can perform a state change on another user's object.

Examples:

POST /cases/{caseId}/approve
POST /payments/{paymentId}/cancel
PUT /users/{userId}/email
DELETE /documents/{documentId}
POST /cases/{caseId}/assign-owner

Each action has a different policy.

Bad:

@PreAuthorize("hasAuthority('case:write')")

This may be too broad.

Better action model:

case:read
case:update-metadata
case:add-comment
case:assign
case:approve
case:reject
case:close
case:export-evidence
case:delete-draft

Then each action gets a policy.

ActionAdditional Object Rule
readvisible through ownership/assignment/regional view
updateeditable state and user has update authority
approvenot creator, correct state, AAL2, SoD satisfied
exportclassification allowed, reason required, audit event mandatory
deletedraft only, creator or admin, retention not active

11. State-Dependent Authorization

Authorization often depends on workflow state.

Policy:

public AuthorizationDecision canEdit(Subject subject, CaseAuthorizationView view) {
    if (view.status() == CaseStatus.CLOSED) {
        return deny("CASE_CLOSED");
    }
    if (view.status() == CaseStatus.APPROVED) {
        return deny("CASE_APPROVED_READ_ONLY");
    }
    if (subject.id().equals(view.ownerId())) {
        return permit("OWNER_CAN_EDIT");
    }
    if (subject.hasAuthority("case:supervise") && subject.region().equals(view.region())) {
        return permit("REGIONAL_SUPERVISOR_CAN_EDIT");
    }
    return deny("NO_EDIT_AUTHORITY");
}

State is part of authorization, not only validation.

12. Parent-Child Authorization

A frequent BOLA bug: endpoint authorizes child resource without checking parent relationship.

Bad:

GET /cases/{caseId}/documents/{documentId}

Code checks readable case but loads documentId without verifying it belongs to caseId.

Case caseRecord = caseGuard.requireReadable(subject, caseId);
Document document = documentRepository.getRequired(documentId);
return mapper.toDto(document);

Attacker can provide a readable caseId and someone else's documentId.

Better:

CaseAuthorizationView caseView = caseGuard.requireReadable(subject, caseId);
Document document = documentRepository.getRequiredByCaseId(documentId, caseView.id());
return mapper.toDto(document);

Repository:

select d.*
from documents d
where d.id = :documentId
  and d.case_id = :caseId
  and d.tenant_id = :tenantId

Invariant:

If the route contains parent and child IDs, verify the child belongs to the authorized parent.

13. Indirect Writes and Confused Object References

Consider assignment:

POST /cases/{caseId}/assign

{
  "assigneeId": "user-789"
}

There are two object-level checks:

ObjectRequired Check
caseIdCaller can assign this case.
assigneeIdTarget user is assignable inside this tenant/domain.

Bad:

caseAssignmentService.assign(caseId, command.assigneeId());

Better:

public void assign(Subject subject, UUID caseId, AssignCommand command) {
    CaseAuthorizationView caseView = caseGuard.requireAssignable(subject, caseId);

    UserAssignmentView target = userRepository.getAssignmentViewForTenant(
        command.assigneeId(),
        subject.activeTenantId()
    );

    AuthorizationDecision decision = assignmentPolicy.evaluate(
        subject,
        caseView,
        target,
        AuthorizationContext.current()
    );

    audit.record(subject, "case:assign", ResourceRef.caseRef(caseId), decision);

    if (!decision.permitted()) {
        throw new AccessDeniedException(decision.reasonCode());
    }

    assignmentRepository.assign(caseId, target.userId());
}

Any endpoint with a target user, target account, target tenant, target group, or target document has multiple object references.

14. Asynchronous BOLA

BOLA can happen outside HTTP request handlers.

Example:

{
  "type": "CASE_EXPORT_REQUESTED",
  "requestedBy": "user-123",
  "caseId": "case-999"
}

A worker consumes the event and exports the case.

Bad:

public void handle(CaseExportRequested event) {
    exportService.exportCase(event.caseId());
}

Better:

public void handle(CaseExportRequested event) {
    Subject subject = subjectSnapshotService.reconstruct(event.requestedBy(), event.tenantId());
    CaseAuthorizationView view = caseRepository.getAuthorizationViewForTenant(
        event.caseId(),
        event.tenantId()
    );

    AuthorizationDecision decision = exportPolicy.evaluate(
        subject,
        view,
        AuthorizationContext.fromEvent(event)
    );

    audit.record(subject, "case:export", ResourceRef.caseRef(event.caseId()), decision);

    if (!decision.permitted()) {
        deadLetter.reject(event, decision.reasonCode());
        return;
    }

    exportService.exportCase(event.caseId());
}

Event-driven systems must not assume the producer already performed sufficient authorization.

15. Caching and Object Authorization

Caching can reintroduce BOLA.

Bad cache key:

case:{caseId}

If cached DTO includes sensitive fields and is reused across users, one user's authorized representation can leak to another user.

Safer keys:

case-summary:{tenantId}:{caseId}:{visibilityProfile}
case-detail:{tenantId}:{caseId}:{subjectId}:{policyVersion}

Better still: cache domain objects internally, but apply authorization and field filtering before response.

15.1 Field-Level Variation

Two users may both read a case, but see different fields.

UserVisible Fields
case ownernormal case fields
investigatorevidence summary
supervisorapproval history
external reviewerredacted fields
audit userfull audit view

Object-level authorization answers:

Can read object?

Field-level authorization answers:

Which representation is allowed?

Do not conflate them.

16. Error Handling

Avoid leaking object existence, tenant existence, or relationship details.

Bad:

{
  "error": "Case CASE-999 belongs to tenant-b, but your tenant is tenant-a"
}

Better external response:

{
  "error": "not_found"
}

Internal audit:

{
  "eventType": "AUTHZ_DENIED",
  "reasonCode": "TENANT_MISMATCH",
  "subjectId": "user-123",
  "resourceType": "case",
  "resourceIdHash": "...",
  "tenantId": "tenant-a"
}

For same-tenant insufficient permission, 403 may be appropriate.

17. Testing BOLA Defense

BOLA tests must be systematic.

17.1 Test Data Layout

Create at least:

tenant-a
  alice owns case-a1
  bob owns case-a2
  cara assigned to case-a1

tenant-b
  dave owns case-b1

Then test:

ActorTargetExpected
alicecase-a1permit
alicecase-a2deny unless shared
alicecase-b1deny/not found
bobcase-a1deny unless shared
caracase-a1permit if assigned
caracase-a2deny
unauthenticatedany401

17.2 MockMvc Example

@Test
void userCannotReadAnotherUsersCase() throws Exception {
    mockMvc.perform(get("/cases/{id}", bobCaseId)
            .with(jwt().jwt(jwt -> jwt
                .subject("alice")
                .claim("tenant", "tenant-a"))
                .authorities(new SimpleGrantedAuthority("case:read"))))
        .andExpect(status().isForbidden());
}

Cross-tenant:

@Test
void userCannotReadCaseFromAnotherTenant() throws Exception {
    mockMvc.perform(get("/cases/{id}", tenantBCaseId)
            .with(jwt().jwt(jwt -> jwt
                .subject("alice")
                .claim("tenant", "tenant-a"))
                .authorities(new SimpleGrantedAuthority("case:read"))))
        .andExpect(status().isNotFound());
}

Parent-child mismatch:

@Test
void documentMustBelongToAuthorizedCase() throws Exception {
    mockMvc.perform(get("/cases/{caseId}/documents/{documentId}", readableCaseId, otherCaseDocumentId)
            .with(jwt().jwt(jwt -> jwt
                .subject("alice")
                .claim("tenant", "tenant-a"))
                .authorities(new SimpleGrantedAuthority("case:read"))))
        .andExpect(status().isNotFound());
}

17.3 Property-Based Negative Idea

Generate random combinations:

subject tenant != resource tenant -> deny
subject not owner and not assigned and not privileged -> deny
case closed and action mutates -> deny
child.parentId != path.parentId -> deny
missing authorization view -> deny/not found

The invariant is more important than the specific examples.

18. API Review Checklist

For every endpoint, ask:

  • What object IDs enter through path?
  • What object IDs enter through query parameters?
  • What object IDs enter through body?
  • What object IDs enter through headers?
  • Are there parent-child IDs?
  • Is the tenant selected by caller or derived from authenticated context?
  • Is the object loaded with tenant constraint?
  • Is the policy action-specific?
  • Is authorization applied before side effects?
  • Is list filtering applied before pagination?
  • Are bulk items authorized independently?
  • Are target users/groups/accounts authorized too?
  • Are event consumers rechecking authorization?
  • Are cache keys authorization-safe?
  • Are deny cases tested?
  • Are denied attempts audited?

19. Common Anti-Patterns

19.1 "UUIDs Are Enough"

UUIDs reduce guessing. They do not authorize access.

19.2 "Gateway Already Checked It"

Gateways can authenticate tokens and enforce coarse scopes. They usually cannot know object-specific domain policy.

19.3 "Frontend Hides the Button"

Frontend checks improve UX. They do not protect APIs.

19.4 "Repository findById Is Fine"

findById(id) is dangerous for tenant-scoped resources unless followed by strict tenant and object policy. Prefer findByIdAndTenantId or authorization projections.

19.5 "We Check on Read, So Update Is Safe"

Read and update are different actions. Each needs its own policy.

19.6 "Admin Can Do Everything"

Even privileged users need tenant, purpose, audit, and sometimes break-glass controls.

20. Production Reference Pattern

A defensible endpoint structure:

@RestController
@RequestMapping("/cases")
public class CaseController {

    private final CaseApplicationService service;
    private final SubjectFactory subjectFactory;

    @GetMapping("/{caseId}")
    public CaseDto getCase(@PathVariable UUID caseId, Authentication authentication) {
        Subject subject = subjectFactory.from(authentication);
        return service.getCase(subject, caseId);
    }

    @PostMapping("/{caseId}/approve")
    public ApprovalDto approve(
        @PathVariable UUID caseId,
        @RequestBody ApprovalCommand command,
        Authentication authentication
    ) {
        Subject subject = subjectFactory.from(authentication);
        return service.approve(subject, caseId, command);
    }
}

Application service:

@Service
public class CaseApplicationService {

    private final CaseResourceGuard guard;
    private final CaseRepository repository;
    private final CaseApprovalService approvalService;

    public CaseDto getCase(Subject subject, UUID caseId) {
        guard.requireReadable(subject, caseId);
        return repository.getDetailForTenant(caseId, subject.activeTenantId())
            .map(CaseDto::from)
            .orElseThrow(() -> new NotFoundException("case_not_found"));
    }

    public ApprovalDto approve(Subject subject, UUID caseId, ApprovalCommand command) {
        CaseAuthorizationView view = guard.requireApprovable(subject, caseId);
        return approvalService.approve(view.id(), command);
    }
}

The pattern is:

Controller extracts subject.
Application service owns use case.
Guard loads authorization projection under tenant constraint.
Policy evaluates object-level decision.
Audit records decision.
Domain mutation happens only after permit.
Repository queries remain tenant constrained.

21. Practice Drill

Analyze this endpoint:

POST /tenants/{tenantId}/cases/{caseId}/documents/{documentId}/share

{
  "targetUserId": "user-789",
  "permission": "read",
  "expiresAt": "2026-07-01T00:00:00Z"
}

Identify all object references:

ReferenceSourceCheck Needed
tenantIdpathcaller may act in tenant
caseIdpathcaller can share documents in case
documentIdpathdocument belongs to case and tenant
targetUserIdbodytarget user belongs to tenant and may receive access
permissionbodycaller may grant this permission
expiresAtbodyexpiry within allowed range

Write the decision:

Can subject grant permission P on document D inside case C and tenant T
to target user U until expiry E under current policy?

If you cannot write that sentence, the endpoint is not ready for implementation.

22. Key Takeaways

  • BOLA is about missing authorization on specific objects, not about ID format.
  • Every user-controlled object reference must be treated as untrusted.
  • Tenant scoping is object-level authorization, not just routing metadata.
  • Role checks are insufficient for object-level access.
  • List endpoints need query-time filtering before pagination.
  • Bulk endpoints must authorize every item independently.
  • Parent-child resource IDs must be bound together in queries.
  • Event consumers and background jobs must not blindly trust producer authorization.
  • Object-level decisions should produce audit evidence.
  • A secure API platform makes object access explicit, testable, and boring.

23. References

  • OWASP API Security Top 10 2023, API1: Broken Object Level Authorization.
  • OWASP Authorization Cheat Sheet.
  • Spring Security Reference, Method Security and OAuth2 Resource Server.
  • NIST SP 800-162, Attribute Based Access Control.
Lesson Recap

You just completed lesson 16 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.