Threat Model: Broken Access Control, IDOR, BOLA, BOPLA, BFLA
Learn Java Authorization Pattern - Part 005
Threat model authorization untuk Java backend: Broken Access Control, IDOR, BOLA, BOPLA, BFLA, horizontal/vertical privilege escalation, confused deputy, tenant breakout, dan design mitigations.
Part 005 — Threat Model: Broken Access Control, IDOR, BOLA, BOPLA, BFLA
Authorization yang bagus tidak dimulai dari annotation. Authorization yang bagus dimulai dari pertanyaan:
Bagaimana caller yang sudah authenticated bisa tetap melakukan sesuatu yang seharusnya tidak boleh?
Di production, access control jarang gagal karena engineer tidak tahu konsep role. Ia gagal karena sistem tidak punya model ancaman yang eksplisit.
Sering terjadi:
- endpoint mengecek login, tetapi tidak mengecek kepemilikan object;
- API list sudah difilter, tetapi API detail tidak;
- UI menyembunyikan tombol, tetapi backend menerima request langsung;
- field sensitif ikut keluar karena serializer default;
- service internal dipercaya penuh, lalu menjadi confused deputy;
- JWT claim sudah stale setelah permission dicabut;
- tenant filter lupa dipasang di satu query;
- batch/export/job melewati guard yang ada di request-response path.
OWASP menempatkan Broken Access Control sebagai risiko web utama pada Top 10 2021, dan OWASP API Security 2023 menempatkan Broken Object Level Authorization sebagai risiko API nomor satu. Ini bukan isu teoritis. Ini adalah bug authorization paling praktis, paling mudah dieksploitasi, dan paling sering lolos review karena secara functional endpoint terlihat benar.
Part ini membangun threat model untuk Java authorization. Tujuannya bukan hafal istilah, tetapi mampu melihat access-control bug sebelum bug itu menjadi incident.
1. Authorization Failure: Bentuk Umum
Authorization failure terjadi saat sistem memberi akses yang tidak sesuai dengan policy yang diharapkan.
Secara struktural:
actual_decision != intended_policy_decision
Contoh:
actual_decision = ALLOW user-123 reads invoice-999
intended_policy = DENY unless invoice.owner_id == user-123 or user has FINANCE_VIEW_ALL
Masalahnya: sistem sering tidak punya intended_policy yang tertulis jelas. Yang ada hanya asumsi tersebar di controller, repository, UI, database query, dan service lain.
Maka threat model authorization harus menjawab:
- Siapa subject-nya?
- Action apa yang dilakukan?
- Resource apa yang disentuh?
- Field/property mana yang dibaca/ditulis?
- Dalam tenant/jurisdiction/organization mana?
- Dalam lifecycle state apa?
- Apakah caller bertindak sebagai dirinya sendiri, delegated user, service, batch job, atau admin override?
- Enforcement point mana yang benar-benar mencegah pelanggaran?
- Apa yang terjadi jika attribute/policy/cache/token stale?
- Apakah keputusan itu bisa diaudit dan dijelaskan?
Jika salah satu tidak jelas, biasanya bug authorization sudah menunggu.
2. Deny by Default Bukan Slogan
Deny by default berarti akses tidak diberikan kecuali ada policy eksplisit yang mengizinkan.
Bentuk buruk:
if (user.isAdmin()) {
return orderRepository.findById(orderId);
}
return orderRepository.findById(orderId); // bug: fallback allow
Bentuk yang lebih aman:
Order order = orderRepository.findById(orderId)
.orElseThrow(NotFoundException::new);
authorization.require(
subject,
Action.ORDER_READ,
ResourceRef.order(order.id(), order.tenantId(), order.ownerId()),
RequestContext.current()
);
return order;
Bentuk yang lebih kuat lagi adalah authorize by construction: query tidak bisa mengambil object yang tidak berada dalam authorized scope.
Optional<Order> order = orderRepository.findVisibleOrderById(
orderId,
subject.userId(),
subject.tenantId(),
subject.permissions()
);
return order.orElseThrow(NotFoundException::new);
Perbedaan penting:
findByIdlalu check: raw object sempat masuk memory application.findVisibleOrderById: database query sudah membawa authorization predicate.- idealnya, untuk high-risk object, gunakan keduanya: query scoping + domain guard.
3. Taxonomy Ancaman Authorization
Istilah bisa berbeda di organisasi berbeda, tetapi bentuk kegagalannya biasanya sama: decision boundary tidak cocok dengan domain boundary.
4. Broken Access Control
Broken Access Control adalah payung besar untuk semua kasus ketika user bisa bertindak di luar haknya.
Contoh umum:
GET /admin/users
Endpoint ini seharusnya hanya admin, tetapi hanya mengecek login.
@GetMapping("/admin/users")
public List<UserDto> users() {
return userService.findAll();
}
Bug-nya bukan karena tidak ada authentication. Bug-nya karena tidak ada authorization function-level.
Perbaikan minimal:
@PreAuthorize("hasAuthority('user:admin:list')")
@GetMapping("/admin/users")
public List<UserDto> users() {
return userService.findAll();
}
Perbaikan lebih robust:
@GetMapping("/admin/users")
public List<UserDto> users(Authentication authentication) {
Subject subject = subjectResolver.from(authentication);
authorization.require(subject, Action.USER_ADMIN_LIST, ResourceRef.system(), RequestContext.current());
return userService.findAllForAdmin(subject);
}
Kenapa explicit service lebih baik pada sistem kompleks?
Karena annotation bagus untuk gate sederhana, tetapi domain authorization sering butuh:
- tenant;
- business unit;
- role assignment;
- lifecycle state;
- classification;
- delegated authority;
- emergency override;
- explainable audit reason.
Annotation tetap berguna, tetapi jangan menjadikan annotation sebagai satu-satunya tempat berpikir.
5. Horizontal Privilege Escalation
Horizontal privilege escalation terjadi saat user biasa mengakses object user lain dengan privilege level yang sama.
Contoh:
GET /api/orders/1001
Authorization: Bearer token-for-user-A
Lalu attacker mengganti ID:
GET /api/orders/1002
Authorization: Bearer token-for-user-A
Jika order 1002 milik user B dan tetap berhasil, itu horizontal escalation.
Buggy Java Example
@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable long id) {
Order order = orderRepository.findById(id)
.orElseThrow(NotFoundException::new);
return mapper.toDto(order);
}
Controller hanya membuktikan object ada. Ia tidak membuktikan caller boleh melihat object itu.
Safer Query Scope
public Optional<Order> findVisibleById(long orderId, Subject subject) {
return jdbc.query("""
select *
from orders o
where o.id = ?
and o.tenant_id = ?
and (
o.owner_user_id = ?
or exists (
select 1
from order_assignments a
where a.order_id = o.id
and a.assignee_user_id = ?
)
or exists (
select 1
from user_permissions p
where p.user_id = ?
and p.permission = 'order:read:any'
and p.tenant_id = o.tenant_id
)
)
""",
mapper,
orderId,
subject.tenantId(),
subject.userId(),
subject.userId(),
subject.userId()
).stream().findFirst();
}
Key Invariant
Every object-level read must bind resource identity to subject visibility.
Jangan hanya cek id. Cek relasi subject -> resource.
6. Vertical Privilege Escalation
Vertical privilege escalation terjadi saat user mendapatkan kemampuan yang seharusnya hanya dimiliki role lebih tinggi.
Contoh:
POST /api/users/123/roles/admin
Atau:
PATCH /api/cases/CASE-1/status
{
"status": "APPROVED"
}
Padahal user hanya investigator, bukan supervisor.
Buggy Pattern: UI-Only Authorization
Frontend menyembunyikan tombol approve:
<button v-if="user.roles.includes('SUPERVISOR')">Approve</button>
Tetapi backend:
@PostMapping("/cases/{id}/approve")
public void approve(@PathVariable CaseId id) {
caseService.approve(id);
}
Attacker tidak perlu tombol. Ia hanya perlu HTTP client.
Correct Pattern
@PostMapping("/cases/{id}/approve")
public void approve(@PathVariable CaseId id, Authentication authentication) {
Subject subject = subjectResolver.from(authentication);
caseCommandService.approve(id, subject);
}
@Transactional
public void approve(CaseId id, Subject subject) {
EnforcementCase caze = caseRepository.findByIdForUpdate(id)
.orElseThrow(NotFoundException::new);
authorization.require(
subject,
Action.CASE_APPROVE,
ResourceRef.caseFile(caze.id(), caze.tenantId(), caze.ownerUnitId(), caze.status()),
RequestContext.current()
);
caze.approve(subject.userId(), clock.instant());
caseRepository.save(caze);
}
Key idea: authorization harus dekat dengan state transition.
7. IDOR: Insecure Direct Object Reference
IDOR adalah pola klasik: object internal diekspos lewat reference yang bisa ditebak atau dimanipulasi.
Contoh:
GET /documents/88421/download
Jika user mengganti 88421 ke 88422 dan berhasil mengunduh dokumen lain, itu IDOR/BOLA.
UUID Tidak Menyelesaikan Authorization
Banyak sistem menganggap UUID cukup:
GET /documents/2d931510-d99f-494a-8c67-87feb05e1594/download
UUID mengurangi enumerability. UUID tidak membuktikan authorization.
Rule:
Unpredictable ID is not an authorization control.
UUID boleh dipakai untuk mengurangi guessing. Tetapi authorization tetap wajib.
IDOR Mitigation Matrix
| Mitigation | Apa yang Dicegah | Apa yang Tidak Dicegah |
|---|---|---|
| UUID/random ID | sequential enumeration | stolen/shared link access |
| tenant predicate | cross-tenant access | same-tenant unauthorized access |
| owner predicate | cross-owner access | delegated/team access complexity |
| policy check | invalid action/resource decision | query leakage jika check terlambat |
| signed URL | tampering & expiry | policy drift setelah URL dibuat |
| row-level security | missing app predicate | salah setting session context |
8. BOLA: Broken Object Level Authorization
BOLA terjadi saat API menerima identifier object, tetapi tidak memverifikasi apakah caller boleh mengakses object tersebut.
BOLA biasanya muncul pada endpoint:
GET /users/{userId}
GET /orders/{orderId}
PATCH /orders/{orderId}
DELETE /attachments/{attachmentId}
POST /cases/{caseId}/notes
GET /reports/{reportId}/download
BOLA Is Not Just GET
BOLA sering dipersempit menjadi read bug. Itu salah.
BOLA bisa terjadi pada:
- read detail;
- update object milik orang lain;
- delete object milik orang lain;
- approve/submit/cancel object yang bukan wewenangnya;
- attach evidence ke case lain;
- export report milik unit lain;
- trigger workflow action terhadap resource lain.
BOLA Decision Form
can(subject, action, object, context) == true
BOLA terjadi saat object tidak benar-benar dimasukkan ke authorization decision.
Buggy check:
if (subject.hasAuthority("order:read")) {
return orderRepository.findById(orderId);
}
Ini hanya mengecek function-level permission. Belum mengecek object-level relation.
Better check:
Decision decision = pdp.decide(new AuthorizationRequest(
subject,
Action.ORDER_READ,
new OrderResource(order.id(), order.tenantId(), order.ownerUserId(), order.status()),
context
));
if (decision.denied()) {
throw new AccessDeniedException(decision.safeReason());
}
Lebih baik lagi, gabungkan dengan query scoping:
Order order = orderRepository.findAuthorizedOrderForRead(orderId, subject)
.orElseThrow(NotFoundException::new);
9. BOPLA: Broken Object Property Level Authorization
BOPLA terjadi saat caller boleh mengakses object, tetapi tidak boleh membaca atau menulis property tertentu.
Contoh response terlalu luas:
{
"id": "case-123",
"title": "Market abuse investigation",
"status": "OPEN",
"assignedInvestigator": "u-77",
"whistleblowerName": "...",
"internalRiskScore": 97,
"legalPrivilegeNotes": "..."
}
User mungkin boleh membaca case, tetapi tidak semua field.
Read-Side BOPLA
Buggy:
@GetMapping("/cases/{id}")
public CaseDto get(@PathVariable String id) {
CaseFile caze = caseService.getAuthorizedCase(id);
return mapper.toDto(caze); // maps every field
}
Better:
public CaseDto toDto(CaseFile caze, Subject subject, AuthorizationService authorization) {
return new CaseDto(
caze.id(),
caze.title(),
caze.status(),
authorization.can(subject, Action.CASE_READ_ASSIGNEE, caze)
? caze.assignedInvestigator()
: null,
authorization.can(subject, Action.CASE_READ_WHISTLEBLOWER, caze)
? caze.whistleblowerName()
: Redacted.value(),
authorization.can(subject, Action.CASE_READ_RISK_SCORE, caze)
? caze.internalRiskScore()
: null,
authorization.can(subject, Action.CASE_READ_LEGAL_NOTES, caze)
? caze.legalPrivilegeNotes()
: Redacted.value()
);
}
Write-Side BOPLA
Buggy mass assignment:
@PatchMapping("/users/{id}")
public UserDto patch(@PathVariable long id, @RequestBody Map<String, Object> patch) {
User user = userRepository.findById(id).orElseThrow();
objectMapper.updateValue(user, patch);
userRepository.save(user);
return mapper.toDto(user);
}
Attacker sends:
{
"displayName": "Alice",
"role": "ADMIN",
"accountStatus": "ACTIVE",
"creditLimit": 999999999
}
Better: command object dengan whitelist field.
public record UpdateProfileCommand(
String displayName,
String phoneNumber
) {}
@PatchMapping("/users/{id}/profile")
public UserProfileDto updateProfile(
@PathVariable long id,
@RequestBody UpdateProfileCommand command,
Authentication authentication
) {
Subject subject = subjectResolver.from(authentication);
return userService.updateProfile(id, command, subject);
}
Field-level authorization bukan kosmetik. Ia bagian dari policy.
10. BFLA: Broken Function Level Authorization
BFLA terjadi saat endpoint/action tidak dibatasi berdasarkan privilege function-level.
Contoh:
DELETE /api/users/123
POST /api/reports/system/export
PUT /api/pricing-rules/active
POST /api/cases/123/escalate
Jika authenticated user biasa dapat memanggilnya, itu BFLA.
Typical Root Causes
- Admin endpoint berada di controller yang sama dengan public endpoint.
- Route tersembunyi dari UI, tetapi tetap exposed.
- Endpoint internal tidak dilindungi.
- Method security tidak aktif karena proxy boundary salah.
- API gateway punya rule, tetapi service bisa dipanggil langsung.
- New endpoint ditambahkan tanpa permission matrix.
Permission Matrix
Untuk high-value domain, buat matrix sebelum implementasi.
| Use Case | Action | Resource | Required Rule |
|---|---|---|---|
| View own order | ORDER_READ | Order | owner or assigned support |
| Cancel own order | ORDER_CANCEL | Order | owner and status in cancellable states |
| Refund order | ORDER_REFUND | Order | finance role and amount limit |
| Approve case | CASE_APPROVE | Case | supervisor of same unit and not maker |
| Export evidence | EVIDENCE_EXPORT | Case | legal authorization and reason captured |
Permission matrix bukan dokumentasi pasif. Ia harus menjadi input test.
11. Mass Assignment and Over-Posting
Mass assignment adalah authorization bug karena caller dapat menulis property yang tidak seharusnya writable.
Bad pattern:
@PostMapping("/accounts")
public AccountDto create(@RequestBody Account account) {
return mapper.toDto(accountRepository.save(account));
}
Entity langsung menjadi request body. Ini berbahaya.
Safer pattern:
public record CreateAccountRequest(
String legalName,
String email
) {}
public Account create(CreateAccountRequest request, Subject subject) {
authorization.require(subject, Action.ACCOUNT_CREATE, ResourceRef.tenant(subject.tenantId()), context());
Account account = Account.create(
AccountId.newId(),
subject.tenantId(),
request.legalName(),
request.email(),
AccountStatus.PENDING_VERIFICATION
);
return accountRepository.save(account);
}
Do not deserialize client input into privileged domain state.
12. Tenant Breakout
Tenant breakout terjadi saat caller dari tenant A membaca/menulis data tenant B.
Ini adalah salah satu authorization failure paling serius dalam SaaS dan regulatory systems.
Buggy Query
select * from cases where id = ?
Safer query:
select *
from cases
where id = ?
and tenant_id = ?
Tetapi tenant predicate saja belum cukup jika satu tenant punya multiple units/jurisdictions.
select *
from cases c
where c.id = ?
and c.tenant_id = ?
and exists (
select 1
from case_assignments a
where a.case_id = c.id
and a.user_id = ?
)
Tenant Context Must Be Trusted
Jangan percaya tenant dari request body.
Bad:
{
"tenantId": "tenant-b",
"caseId": "case-123"
}
Better:
TenantId tenantId = subject.tenantId(); // from trusted identity/session context
Jika user dapat memilih tenant aktif, selection harus divalidasi terhadap membership.
if (!membershipService.isMember(subject.userId(), requestedTenantId)) {
throw new AccessDeniedException("invalid tenant context");
}
13. Confused Deputy
Confused deputy terjadi saat service yang punya privilege tinggi melakukan aksi atas nama caller yang tidak berhak.
Contoh:
User -> Report Service -> Document Service
Report Service boleh membaca semua dokumen untuk generate regulatory report. User biasa meminta report untuk document yang bukan scope-nya. Report Service meneruskan request ke Document Service memakai service credential penuh. Document Service melihat service credential dan mengizinkan.
Bug-nya: downstream tidak tahu authority user asli.
Incorrect
GET /documents/doc-123
Authorization: Bearer service-token-report-service
Better
Propagate actor context:
GET /documents/doc-123
Authorization: Bearer service-token-report-service
X-Actor-User: user-123
X-Actor-Tenant: tenant-a
X-Actor-Purpose: REPORT_GENERATION
Tetapi header manual mudah dipalsukan jika network boundary lemah. Lebih baik gunakan signed internal token atau token exchange.
Decision harus melihat dua identitas:
public record ActorChain(
Subject endUser,
ServicePrincipal callingService,
Purpose purpose
) {}
Authorization request:
new AuthorizationRequest(
ActorChain.of(endUser, reportService, Purpose.REPORT_GENERATION),
Action.DOCUMENT_READ_FOR_REPORT,
documentResource,
context
)
Rule:
A service privilege must not erase the user's authorization boundary.
14. Stale Claims and Permission Drift
JWT sering membawa roles/scopes/permissions.
Contoh:
{
"sub": "user-123",
"tenant": "tenant-a",
"roles": ["CASE_SUPERVISOR"],
"exp": 1783065600
}
Masalah: role user dicabut pada pukul 10:00, token berlaku sampai pukul 11:00. Sampai token expired, sistem masih melihat role lama.
Mitigation Options
| Technique | Trade-off |
|---|---|
| Short token lifetime | lebih sering refresh, mengurangi stale window |
| Introspection | lebih fresh, menambah latency/dependency |
| Permission version claim | perlu check version server-side |
| Revocation list | operational complexity |
| Central PDP | keputusan lebih fresh, latency/network dependency |
| Critical action recheck | hemat latency, hanya high-risk action |
Pattern: Permission Version
JWT:
{
"sub": "user-123",
"tenant": "tenant-a",
"permission_version": 42
}
Server:
int currentVersion = permissionVersionRepository.getVersion(subject.userId(), subject.tenantId());
if (subject.permissionVersion() < currentVersion) {
throw new StaleAuthorizationContextException();
}
Jangan membuat semua keputusan high-risk hanya dari stale claims.
15. Search, List, Export, Report Leakage
Banyak tim memperbaiki detail endpoint tetapi lupa list/search/export.
Example:
GET /cases/{id} -> protected
GET /cases?status=OPEN -> leaks all open cases
GET /cases/export -> leaks all cases as CSV
Threat model harus mencakup semua read surface:
- detail;
- list;
- search;
- autocomplete;
- dashboard count;
- metrics;
- export;
- report;
- attachment download;
- preview;
- audit trail;
- history timeline;
- notification feed.
Count Leakage
Bahkan count bisa sensitif.
{
"openInvestigations": 17,
"highRiskCases": 4
}
Jika unit lain dapat melihat count, mereka mungkin mengetahui adanya investigation rahasia.
Authorization tidak hanya tentang row. Kadang aggregation juga resource.
16. Async Bypass
Authorization sering benar di synchronous API, lalu hilang di async flow.
Example:
POST /cases/{id}/export-evidence
Controller mengecek permission, lalu publish job:
publisher.publish(new ExportEvidenceJob(caseId));
Worker:
public void handle(ExportEvidenceJob job) {
CaseFile caze = caseRepository.findById(job.caseId()).orElseThrow();
evidenceExporter.export(caze);
}
Problem:
- worker tidak tahu siapa requester;
- policy bisa berubah sebelum job dieksekusi;
- job bisa direplayed;
- job payload bisa dipalsukan oleh producer lain.
Safer Job Contract
public record ExportEvidenceJob(
UUID jobId,
CaseId caseId,
UserId requestedBy,
TenantId tenantId,
Instant requestedAt,
String authorizationDecisionId,
int policyVersion
) {}
Worker strategy:
- Validate job source.
- Reconstruct subject or service actor chain.
- Recheck authorization for high-risk action.
- Compare tenant from subject/job/resource.
- Emit audit decision.
- Mark idempotency key.
public void handle(ExportEvidenceJob job) {
Subject requester = subjectSnapshotRepository.reconstruct(job.requestedBy(), job.tenantId());
CaseFile caze = caseRepository.findById(job.caseId()).orElseThrow();
authorization.require(
requester,
Action.EVIDENCE_EXPORT,
ResourceRef.caseFile(caze.id(), caze.tenantId(), caze.ownerUnitId(), caze.status()),
RequestContext.forJob(job.jobId(), job.requestedAt())
);
evidenceExporter.export(caze, requester);
}
17. Fail-Open vs Fail-Closed
When authorization infrastructure fails, what happens?
Bad implicit fail-open:
try {
return pdp.decide(request).allowed();
} catch (Exception e) {
log.warn("PDP unavailable, allowing request", e);
return true;
}
This is an incident waiting to happen.
Better default:
try {
Decision decision = pdp.decide(request);
return decision.allowed();
} catch (TimeoutException e) {
throw new AccessDeniedException("authorization temporarily unavailable");
}
But reality is nuanced. Some low-risk reads may tolerate degraded mode if decision cache is fresh. High-risk actions should fail closed.
Failure Policy Matrix
| Action Class | PDP Failure | Cache Allowed? | Notes |
|---|---|---|---|
| Public read | allow via route config | yes | no sensitive data |
| Own profile read | fail closed or recent cache | short TTL | depends on risk |
| Payment/refund | fail closed | rarely | financial risk |
| Role management | fail closed | no | privilege mutation |
| Evidence export | fail closed | no or explicit break-glass | regulatory risk |
| Emergency break-glass | special flow | audited | separate policy |
Rule:
Failure behavior is part of authorization design, not an implementation detail.
18. HTTP Error Semantics: 403 vs 404
Authorization failure often leaks existence.
GET /cases/SECRET-CASE
Returning 403 Forbidden tells attacker the case exists. Returning 404 Not Found hides existence.
Practical Rule
- Use
404when caller has no right to know the resource exists. - Use
403when caller can know the resource exists but cannot perform this action. - Use
401only when authentication is missing/invalid.
Example:
public CaseFile getVisibleCase(CaseId id, Subject subject) {
return caseRepository.findVisibleById(id, subject)
.orElseThrow(NotFoundException::new);
}
For action denial after visible object exists:
authorization.require(subject, Action.CASE_APPROVE, resource, context);
// throws 403 when visible but action denied
19. Authorization Threat Modeling Method
Gunakan format sederhana: enumerate surfaces, resources, actions, relations, context, and failure modes.
Step 1 — Identify Protected Resources
Example regulatory case platform:
| Resource | Sensitivity |
|---|---|
| Case file | high |
| Evidence document | very high |
| Person profile | high |
| Enforcement action | high |
| Internal risk score | very high |
| Audit log | very high |
| Dashboard aggregate | medium/high |
| Notification | medium |
Step 2 — Identify Actions
| Action Type | Examples |
|---|---|
| Read | view case, download evidence, read notes |
| Write | add note, upload evidence, update status |
| Transition | submit, approve, escalate, close |
| Admin | assign investigator, change role, override |
| Export | CSV, PDF, evidence bundle |
| Delegate | grant access, share case |
| Audit | view decision log, view access history |
Step 3 — Identify Access Relations
Step 4 — Define Abuse Cases
For each endpoint:
Can user change ID to another user's object?
Can user cross tenant?
Can user call admin function directly?
Can user set privileged fields?
Can user use batch/export to bypass detail check?
Can user replay stale job/event?
Can service call erase end-user context?
Can revoked access remain valid via cache/token?
Step 5 — Convert Abuse Cases Into Tests
@Test
void userCannotReadCaseAssignedToAnotherUnit() {
Subject alice = subjects.investigator("tenant-a", "unit-1");
CaseFile caseInUnit2 = fixtures.caseFile("tenant-a", "unit-2");
assertThatThrownBy(() -> caseService.get(caseInUnit2.id(), alice))
.isInstanceOf(NotFoundException.class);
}
@Test
void supervisorCannotApproveOwnSubmission() {
Subject supervisor = subjects.supervisor("tenant-a", "unit-1");
CaseFile caze = fixtures.submittedBy(supervisor.userId());
assertThatThrownBy(() -> caseService.approve(caze.id(), supervisor))
.isInstanceOf(AccessDeniedException.class);
}
20. Vulnerability-to-Control Mapping
| Threat | Main Control | Secondary Control |
|---|---|---|
| BOLA | object-level policy check | query scoping, tests |
| BOPLA read | DTO shaping/redaction | field permission tests |
| BOPLA write | command DTO whitelist | validation, mass assignment tests |
| BFLA | function-level permission | route inventory, security tests |
| IDOR | object relation check | random IDs, 404 hiding |
| Tenant breakout | tenant predicate | trusted tenant context, RLS |
| Confused deputy | actor chain | token exchange, downstream recheck |
| Stale claims | short TTL/version check | PDP/introspection, critical recheck |
| Async bypass | job authorization snapshot + recheck | signed events, audit |
| Hidden admin | explicit admin policy | access review, SoD |
| Export leakage | export-specific permission | row/field scoping |
21. Java Design: Authorization Threat Guard Interface
A useful pattern is to formalize threat-oriented guards instead of scattering if statements.
public interface AuthorizationService {
void require(Subject subject, Action action, ResourceRef resource, RequestContext context);
Decision decide(Subject subject, Action action, ResourceRef resource, RequestContext context);
}
public record Decision(
boolean allowed,
String reasonCode,
String policyId,
int policyVersion,
List<Obligation> obligations
) {
public boolean denied() {
return !allowed;
}
}
Use require for command path:
authorization.require(subject, Action.CASE_ESCALATE, resource, context);
Use decide for DTO shaping:
boolean canReadRiskScore = authorization.decide(
subject,
Action.CASE_READ_RISK_SCORE,
resource,
context
).allowed();
Avoid returning raw boolean without reason in internal APIs. You will need reason codes for audit, tests, and incident analysis.
22. Secure Defaults for Java Teams
Controller Rules
- No endpoint without explicit security posture.
- Public endpoint must be annotated/configured as public intentionally.
- Controller must not trust IDs from body for tenant/user ownership.
- Controller should not deserialize privileged entity directly.
Service Rules
- State-changing use case must enforce authorization near transition.
- Use case must receive
Subject, not read global state silently. - High-risk action must emit decision audit.
- Denial reason must be safe for user but detailed for internal log.
Repository Rules
- Query returning protected object must include scope predicate or be documented as raw internal query.
- Search/list/export query must use same or stricter authorization scope than detail endpoint.
- Tenant predicate must be mandatory by construction.
- Avoid generic
findByIdin application services for protected resources.
DTO Rules
- Never expose entity directly.
- Sensitive fields must require explicit allow.
- Patch/update request must whitelist writable fields.
- Redaction must be testable.
23. Review Checklist
Use this checklist when reviewing Java PRs.
Endpoint Review
- Is the endpoint public, authenticated, or authorized?
- Is function-level permission checked?
- Is object-level authorization checked?
- Is tenant boundary enforced?
- Are field-level read/write rules enforced?
- Does batch/search/export use scoped query?
- Does denial leak object existence?
Data Review
- Does repository method include tenant/scope predicate?
- Are joins safe from cross-tenant leakage?
- Are aggregates/counts authorized?
- Are attachments/history/audit trails covered?
Async Review
- Does job/event include actor context?
- Is authorization rechecked before high-risk execution?
- Is event source trusted?
- Is replay/idempotency handled?
Governance Review
- Is admin access explicit?
- Is break-glass audited?
- Are SoD rules represented?
- Is permission change reviewed?
24. Compact Mental Model
Authorization bugs usually happen when one of these bindings is missing:
subject -> action
subject -> resource
subject -> tenant
subject -> field
subject -> lifecycle state
subject -> delegated actor
subject -> time/context
subject -> policy version
The dangerous endpoint is not the one that obviously has no security. The dangerous endpoint is the one that has some security and therefore feels safe.
25. What to Remember
- Authentication proves identity; it does not prove access.
- Function-level permission is not object-level authorization.
- Object-level authorization is required for every API that accepts object identifiers.
- Field-level read and write authorization are separate problems.
- UUIDs reduce guessing but do not authorize access.
- Tenant boundary must be enforced by construction, not convention.
- UI hiding is not authorization.
- Async jobs and exports are authorization surfaces.
- Service-to-service calls can create confused deputy bugs.
- Stale claims are real authorization state, not theoretical edge cases.
- Failure behavior must be designed explicitly.
- Every authorization invariant should become a test.
References
- OWASP Authorization Cheat Sheet — deny by default, server-side access control, least privilege: https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- OWASP API Security 2023 — API1 Broken Object Level Authorization: https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
- OWASP API Security 2023 — API3 Broken Object Property Level Authorization: https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/
- OWASP Top 10 2021 — A01 Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- Spring Security Authorization Architecture: https://docs.spring.io/spring-security/reference/servlet/authorization/architecture.html
- NIST SP 800-162 — Guide to Attribute Based Access Control: https://csrc.nist.gov/pubs/sp/800/162/upd2/final
You just completed lesson 05 in start here. 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.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.