Skip to content

Recipe: Scoped Approval

Problem: a branch officer may approve loans — but only in their own branch. A tenant-wide APPROVER role is too much; per-branch roles explode combinatorially.

1. Guard the endpoint with a scope parameter

Section titled “1. Guard the endpoint with a scope parameter”
@PostMapping("/branches/{branchScopeId}/loans/{id}/approve")
@RequiresPermission(resource = "loans", action = "approve", scopeParam = "branchScopeId")
fun approveInBranch(@PathVariable branchScopeId: UUID, @PathVariable id: String): LoanDto =
loanService.approve(id)

scopeParam names the path variable carrying the scope id. Resolution then overlays the caller’s scoped role assignments for that scope on top of their tenant-wide roles.

2. Create the scope and assign the role inside it

Section titled “2. Create the scope and assign the role inside it”

Scopes map your domain’s boundaries (branch, project, region) to IAM by an external id:

@Component
class BranchSetup(
private val scopeService: ScopeService,
private val roleRepository: RoleRepositoryPort,
) {
fun grantBranchApprover(tenantId: UUID, userId: UUID) {
val capeTown = scopeService.createScope(
tenantId,
"branch", // scope type — your taxonomy
"cpt-001", // external id — YOUR system's branch key
"Cape Town",
)
val approver = roleRepository.findByTenantIdAndName(tenantId, "BRANCH_APPROVER")!!
scopeService.assignRoleInScope(userId, approver.id!!, capeTown.id!!)
}
}

The same operations exist on the REST plane (/api/iam/v1/scopes, scoped role assignment) for admin UIs.

3. Bind the resource to the scope — host-side

Section titled “3. Bind the resource to the scope — host-side”

IAM has proved role-in-scope. Now prove the loan is in the branch, or a branch approver approves loans everywhere. Resolve the presented scope back to your branch key and compare it to the loan’s own branch:

@Component
class ScopedLoanApprovalPolicy(
private val scopeService: ScopeService,
private val loans: LoanRepository, // your domain — knows each loan's branch
) {
fun isLoanInBranchScope(loanId: String, branchScopeId: UUID): Boolean {
val scope = scopeService.findById(branchScopeId) ?: return false
return loans.branchOf(loanId) == scope.externalScopeId // "cpt-001" == "cpt-001"
}
}
@PostMapping("/branches/{branchScopeId}/loans/{id}/approve")
@RequiresPermission(resource = "loans", action = "approve", scopeParam = "branchScopeId")
fun approveInBranch(@PathVariable branchScopeId: UUID, @PathVariable id: String): ResponseEntity<LoanDto> {
// 404, not 403: a scoped view hides existence rather than confirming the loan is elsewhere.
if (!scopedApproval.isLoanInBranchScope(id, branchScopeId)) return ResponseEntity.notFound().build()
return ResponseEntity.ok(loanService.approve(id))
}

Return the 404 as a ResponseEntity rather than throwing: a thrown exception triggers a servlet ERROR dispatch that re-enters the security chain unauthenticated, masking your 404 as a 401. The kotlin-loan-app example ships this exact ScopedLoanApprovalPolicy; the school-platform reference does the same with a ScopedPersonReadPolicy (school scope → the person’s school; student scope → the person is the student).

Terminal window
# officer approving in Cape Town (their scoped assignment) → 200
POST /api/loans/branches/$CAPE_TOWN/loans/1/approve # 200
# the very same officer, any other branch → 403
POST /api/loans/branches/$JOHANNESBURG/loans/1/approve # 403

Tenant-wide roles keep working everywhere: a LOAN_MANAGER with an unscoped approve ALLOW approves in every branch; the scoped BRANCH_APPROVER only adds power inside its scope.