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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package com.lincfusion.udyamlinc.officer;
|
||||
|
||||
import com.lincfusion.udyamlinc.common.ResourceNotFoundException;
|
||||
import com.lincfusion.udyamlinc.security.TenantContext;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamRequests.ScheduleVisitRequest;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.EnterpriseDetailResponse;
|
||||
import com.lincfusion.udyamlinc.dto.UdyamResponses.EnterpriseRow;
|
||||
@@ -50,8 +51,8 @@ public class OfficerService {
|
||||
private final RiskAlertRepository alertRepository;
|
||||
private final FieldVisitRepository visitRepository;
|
||||
|
||||
public PortfolioResponse portfolio(String block, String month) {
|
||||
List<Enterprise> rows = enterprisesIn(block);
|
||||
public PortfolioResponse portfolio(TenantContext tenant, String block, String month) {
|
||||
List<Enterprise> rows = enterprisesIn(tenant, block);
|
||||
long red = countBand(rows, "RED");
|
||||
long watch = countBand(rows, "WATCH");
|
||||
long standard = rows.size() - red - watch;
|
||||
@@ -61,12 +62,12 @@ public class OfficerService {
|
||||
minutesSinceLastScore(rows), toRows(rows));
|
||||
}
|
||||
|
||||
public List<EnterpriseRow> enterprises(String block) {
|
||||
return toRows(enterprisesIn(block));
|
||||
public List<EnterpriseRow> enterprises(TenantContext tenant, String block) {
|
||||
return toRows(enterprisesIn(tenant, block));
|
||||
}
|
||||
|
||||
public EnterpriseDetailResponse detail(String enterpriseId) {
|
||||
Enterprise enterprise = requireEnterprise(enterpriseId);
|
||||
public EnterpriseDetailResponse detail(TenantContext tenant, String enterpriseId) {
|
||||
Enterprise enterprise = requireEnterprise(tenant, enterpriseId);
|
||||
StressScore score = scoreRepository.findFirstByEnterpriseIdOrderByComputedAtDesc(enterpriseId).orElse(null);
|
||||
|
||||
return new EnterpriseDetailResponse(
|
||||
@@ -81,23 +82,41 @@ public class OfficerService {
|
||||
ledgerRepository.findByEnterpriseIdOrderByEntryDateDesc(enterpriseId).stream().limit(60).toList());
|
||||
}
|
||||
|
||||
public List<RiskAlert> alerts(String block, String state) {
|
||||
/**
|
||||
* Alerts are stored against an enterprise, not an institution, so they are
|
||||
* scoped through the borrowers this caller may see rather than by a column of
|
||||
* their own. Anything raised against another bank's borrower is not filtered
|
||||
* out of the page — it never enters it.
|
||||
*/
|
||||
public List<RiskAlert> alerts(TenantContext tenant, String block, String state) {
|
||||
Map<String, String> blockOf = visibleBlocks(tenant);
|
||||
if (blockOf.isEmpty()) return List.of();
|
||||
|
||||
List<RiskAlert> all = state == null || state.isBlank() || "ALL".equalsIgnoreCase(state)
|
||||
? alertRepository.findAllByOrderByScoreDesc()
|
||||
: alertRepository.findByStateOrderByScoreDesc(state.toUpperCase());
|
||||
if (ALL_BLOCKS.equalsIgnoreCase(block)) return all;
|
||||
|
||||
// One lookup for the whole page rather than a query per alert.
|
||||
Map<String, String> blockOf = enterpriseRepository.findAll().stream()
|
||||
return all.stream()
|
||||
.filter(a -> blockOf.containsKey(a.getEnterpriseId()))
|
||||
.filter(a -> ALL_BLOCKS.equalsIgnoreCase(block)
|
||||
|| block.equalsIgnoreCase(blockOf.get(a.getEnterpriseId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Enterprise id → block, for every borrower this caller may see. One query. */
|
||||
private Map<String, String> visibleBlocks(TenantContext tenant) {
|
||||
return enterprisesIn(tenant, ALL_BLOCKS).stream()
|
||||
.collect(Collectors.toMap(Enterprise::getEnterpriseId, Enterprise::getBlock, (a, b) -> a));
|
||||
return all.stream().filter(a -> block.equalsIgnoreCase(blockOf.get(a.getEnterpriseId()))).toList();
|
||||
}
|
||||
|
||||
/** Acknowledge / escalate / snooze / close. Every change records who and when. */
|
||||
@Transactional
|
||||
public RiskAlert disposition(String alertId, String state, String note, String actor) {
|
||||
public RiskAlert disposition(TenantContext tenant, String alertId, String state, String note, String actor) {
|
||||
RiskAlert alert = alertRepository.findByAlertId(alertId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No alert " + alertId));
|
||||
// Acting on an alert is a write, so the tenancy check matters more here
|
||||
// than on a read: closing another bank's alert would be invisible to them.
|
||||
requireEnterprise(tenant, alert.getEnterpriseId());
|
||||
String next = state.toUpperCase();
|
||||
if (!List.of("OPEN", "ACKNOWLEDGED", "ESCALATED", "SNOOZED", "CLOSED").contains(next)) {
|
||||
throw new IllegalArgumentException("Unknown alert state: " + state);
|
||||
@@ -109,15 +128,21 @@ public class OfficerService {
|
||||
return alertRepository.save(alert);
|
||||
}
|
||||
|
||||
public List<FieldVisit> visits(String block) {
|
||||
return ALL_BLOCKS.equalsIgnoreCase(block)
|
||||
? visitRepository.findAllByOrderByPriorityAscDueDateAsc()
|
||||
: visitRepository.findByBlockOrderByPriorityAscDueDateAsc(block);
|
||||
/** Scoped the same way as alerts — through the borrowers this caller may see. */
|
||||
public List<FieldVisit> visits(TenantContext tenant, String block) {
|
||||
Map<String, String> blockOf = visibleBlocks(tenant);
|
||||
if (blockOf.isEmpty()) return List.of();
|
||||
|
||||
List<FieldVisit> all = visitRepository.findAllByOrderByPriorityAscDueDateAsc();
|
||||
return all.stream()
|
||||
.filter(v -> blockOf.containsKey(v.getEnterpriseId()))
|
||||
.filter(v -> ALL_BLOCKS.equalsIgnoreCase(block) || block.equalsIgnoreCase(v.getBlock()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public FieldVisit schedule(ScheduleVisitRequest request, String officer) {
|
||||
Enterprise enterprise = requireEnterprise(request.enterpriseId());
|
||||
public FieldVisit schedule(TenantContext tenant, ScheduleVisitRequest request, String officer) {
|
||||
Enterprise enterprise = requireEnterprise(tenant, request.enterpriseId());
|
||||
PlaybookCatalog.Action action = PlaybookCatalog.byCode(request.actionCode());
|
||||
|
||||
FieldVisit visit = FieldVisit.builder()
|
||||
@@ -149,8 +174,8 @@ public class OfficerService {
|
||||
}
|
||||
|
||||
/** Band mix and average score for each sector pack. */
|
||||
public List<SectorRollupResponse> sectorRollup(String block) {
|
||||
List<Enterprise> rows = enterprisesIn(block);
|
||||
public List<SectorRollupResponse> sectorRollup(TenantContext tenant, String block) {
|
||||
List<Enterprise> rows = enterprisesIn(tenant, block);
|
||||
return sectorRepository.findByActiveTrue().stream()
|
||||
.map(sector -> {
|
||||
List<Enterprise> mine = rows.stream()
|
||||
@@ -169,10 +194,34 @@ public class OfficerService {
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────
|
||||
|
||||
private List<Enterprise> enterprisesIn(String block) {
|
||||
return block == null || block.isBlank() || ALL_BLOCKS.equalsIgnoreCase(block)
|
||||
? enterpriseRepository.findAllByOrderByScoreDesc()
|
||||
: enterpriseRepository.findByBlockOrderByScoreDesc(block);
|
||||
/**
|
||||
* The borrowers this caller may see, narrowed further by the block they asked
|
||||
* for.
|
||||
*
|
||||
* <p>The institution comes from the verified token, never from the request.
|
||||
* A caller with no institution on their token — which after the three-tier
|
||||
* change should only be an apex user reaching a bank endpoint they are
|
||||
* already barred from — gets nothing rather than everything. Failing closed
|
||||
* is the only safe direction for a tenancy filter.
|
||||
*/
|
||||
private List<Enterprise> enterprisesIn(TenantContext tenant, String block) {
|
||||
Long institutionId = tenant == null ? null : tenant.portfolioFilter();
|
||||
if (institutionId == null) return List.of();
|
||||
|
||||
boolean allBlocks = block == null || block.isBlank() || ALL_BLOCKS.equalsIgnoreCase(block);
|
||||
|
||||
// A branch-scoped user sees their branch, whatever block they ask for.
|
||||
if (tenant.getBranchId() != null) {
|
||||
List<Enterprise> branchRows = enterpriseRepository
|
||||
.findByInstitutionIdAndBranchIdOrderByScoreDesc(institutionId, tenant.getBranchId());
|
||||
return allBlocks
|
||||
? branchRows
|
||||
: branchRows.stream().filter(e -> block.equalsIgnoreCase(e.getBlock())).toList();
|
||||
}
|
||||
|
||||
return allBlocks
|
||||
? enterpriseRepository.findByInstitutionIdOrderByScoreDesc(institutionId)
|
||||
: enterpriseRepository.findByInstitutionIdAndBlockOrderByScoreDesc(institutionId, block);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,8 +291,19 @@ public class OfficerService {
|
||||
return districtRepository.findById(e.getDistrictId()).map(District::getName).orElse(null);
|
||||
}
|
||||
|
||||
private Enterprise requireEnterprise(String enterpriseId) {
|
||||
return enterpriseRepository.findByEnterpriseId(enterpriseId)
|
||||
/**
|
||||
* One borrower, if this caller's institution carries them.
|
||||
*
|
||||
* <p>A row belonging to another bank raises the same "no such enterprise" as
|
||||
* one that does not exist. Returning a distinguishable 403 would confirm the
|
||||
* id is real and on somebody's book, which is itself the leak — an outsider
|
||||
* could enumerate a competitor's portfolio without reading a single field.
|
||||
*/
|
||||
private Enterprise requireEnterprise(TenantContext tenant, String enterpriseId) {
|
||||
Long institutionId = tenant == null ? null : tenant.portfolioFilter();
|
||||
if (institutionId == null) throw new ResourceNotFoundException("No enterprise " + enterpriseId);
|
||||
|
||||
return enterpriseRepository.findByEnterpriseIdAndInstitutionId(enterpriseId, institutionId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("No enterprise " + enterpriseId));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user