Files
udyamlinc-api/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java
T
LincPayandClaude Opus 5 1e5f446672 feat(institution): three-tier model — NABARD, partner banks, enterprises
NABARD does not lend to enterprises. It refinances and supervises the banks
that do. A single field-officer console collapsed those two institutions
into one and implicitly cast the apex bank as the lender, which is wrong
about the sector and wrong about who may see a borrower.

Institution and Branch are new; enterprise and udyam_user carry
institution_id and branch_id. That pair is the tenancy key.

TenantContext is resolved once per request from the signed token and
injected into handlers as an ordinary argument. The institution is never
read from a path variable, query parameter or body — a tenancy key the
caller can type is not a tenancy key. Every borrower-level read now takes
its filter from there, and fails closed: a caller with no institution sees
nothing rather than everything.

Three entitlements, not three ranks of one. APEX reads aggregates across
every partner and no borrower row at all; that restriction is what makes
portfolio-wide early warning grantable to a supervisor in the first place.
BANK reads its own book, narrowed to a branch for branch-scoped users.
ENTERPRISE reads itself.

Alerts and visits are scoped through the borrowers a caller may see rather
than by a column of their own, and dispositioning an alert re-checks
tenancy — closing another bank's alert would be invisible to them.

A borrower on another bank's book returns the same 'no such enterprise' as
one that does not exist. A distinguishable 403 would confirm the id is real
and on somebody's book, which is itself the leak.

ApexService suppresses any cell below five borrowers and reports the
suppression rather than omitting the row, because a missing district reads
as 'no activity' — a different and wrong claim.

Verified live: MPGB sees 44, DCCB 39, a branch officer 12; NABARD is 403 on
/officer/**, banks are 403 on /nabard/**; a real cross-tenant id is
byte-identical to a fabricated one; and none of the six apex endpoints
emits a single MSME id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:50:10 +05:30

79 lines
2.8 KiB
Java

package com.lincfusion.udyamlinc.apex;
import com.lincfusion.udyamlinc.common.ApiResponse;
import com.lincfusion.udyamlinc.security.TenantContext;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* The NABARD surface. Every endpoint returns aggregates.
*
* <p>Reachable only with {@code SCOPE_NABARD}, enforced in {@code SecurityConfig}
* — an apex user cannot reach {@code /officer/**} and a bank user cannot reach
* this. The two are not ranks of the same permission; they are different
* entitlements, and a bank must be able to say truthfully that its regulator
* cannot read its borrowers through this platform.
*
* <p>The second guard is belt and braces: each handler asserts the caller is apex
* before answering. If a routing rule is ever loosened by accident, these fail
* rather than quietly widening.
*/
@RestController
@RequestMapping("/nabard")
@RequiredArgsConstructor
public class ApexController {
private final ApexService apexService;
@GetMapping("/dashboard")
public ApiResponse<Map<String, Object>> dashboard(TenantContext tenant) {
requireApex(tenant);
Map<String, Object> out = new LinkedHashMap<>(apexService.national());
out.put("dataAgeDays", apexService.dataAgeDays());
return ApiResponse.ok(out);
}
@GetMapping("/banks")
public ApiResponse<List<Map<String, Object>>> banks(TenantContext tenant) {
requireApex(tenant);
return ApiResponse.ok(apexService.bankScorecard());
}
@GetMapping("/districts")
public ApiResponse<List<Map<String, Object>>> districts(TenantContext tenant) {
requireApex(tenant);
return ApiResponse.ok(apexService.byDistrict());
}
@GetMapping("/sectors")
public ApiResponse<List<Map<String, Object>>> sectors(TenantContext tenant) {
requireApex(tenant);
return ApiResponse.ok(apexService.bySector());
}
@GetMapping("/digest")
public ApiResponse<Map<String, Object>> digest(TenantContext tenant) {
requireApex(tenant);
return ApiResponse.ok(apexService.digest());
}
@GetMapping("/institutions")
public ApiResponse<List<Map<String, Object>>> institutions(TenantContext tenant) {
requireApex(tenant);
return ApiResponse.ok(apexService.institutions());
}
private void requireApex(TenantContext tenant) {
if (tenant == null || !tenant.isApex()) {
throw new org.springframework.security.access.AccessDeniedException(
"The NABARD surface is limited to apex users");
}
}
}