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:
@Componentclass 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:
@Componentclass 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).
What you get
Section titled “What you get”# officer approving in Cape Town (their scoped assignment) → 200POST /api/loans/branches/$CAPE_TOWN/loans/1/approve # 200
# the very same officer, any other branch → 403POST /api/loans/branches/$JOHANNESBURG/loans/1/approve # 403Tenant-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.