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:
@@ -0,0 +1,273 @@
|
||||
package com.lincfusion.udyamlinc.seed;
|
||||
|
||||
import com.lincfusion.udyamlinc.auth.UdyamUser;
|
||||
import com.lincfusion.udyamlinc.auth.UdyamUserRepository;
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.enterprise.EnterpriseRepository;
|
||||
import com.lincfusion.udyamlinc.institution.Branch;
|
||||
import com.lincfusion.udyamlinc.institution.BranchRepository;
|
||||
import com.lincfusion.udyamlinc.institution.Institution;
|
||||
import com.lincfusion.udyamlinc.institution.InstitutionRepository;
|
||||
import com.lincfusion.udyamlinc.master.District;
|
||||
import com.lincfusion.udyamlinc.master.DistrictRepository;
|
||||
import com.lincfusion.udyamlinc.master.Role;
|
||||
import com.lincfusion.udyamlinc.master.RoleRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* The institutional tier: NABARD, the banks it refinances, their branches, and
|
||||
* the users of each.
|
||||
*
|
||||
* <p>Runs after {@link UdyamDataSeeder} because it assigns the portfolio that
|
||||
* seeder created. Every step is guarded individually rather than behind one
|
||||
* "have we seeded?" check — a database populated before the three-tier model
|
||||
* existed must still receive it, which the loan-record gap taught this codebase
|
||||
* the hard way.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class InstitutionSeeder {
|
||||
|
||||
public static final String NABARD_CODE = "NABARD";
|
||||
|
||||
/** The password every seeded console user shares. Demo only. */
|
||||
public static final String DEFAULT_PASSWORD = UdyamDataSeeder.DEFAULT_OFFICER_PASSWORD;
|
||||
|
||||
private final InstitutionRepository institutionRepository;
|
||||
private final BranchRepository branchRepository;
|
||||
private final EnterpriseRepository enterpriseRepository;
|
||||
private final DistrictRepository districtRepository;
|
||||
private final UdyamUserRepository userRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public void seed() {
|
||||
seedRoles();
|
||||
Institution nabard = seedInstitutions();
|
||||
Map<String, Branch> branches = seedBranches();
|
||||
assignPortfolio(branches);
|
||||
seedUsers(nabard, branches);
|
||||
}
|
||||
|
||||
// ── roles ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Permissions are the authority; the role code is the handle. Splitting them
|
||||
* this way is what lets a bank add a role without a deploy, and what keeps
|
||||
* "may read" and "may act" separable — a data steward uploads a loan book and
|
||||
* cannot close an alert on it.
|
||||
*/
|
||||
private void seedRoles() {
|
||||
record R(String code, String name, String permissions) {}
|
||||
List<R> roles = List.of(
|
||||
// NABARD — aggregates only. No permission here grants a borrower row;
|
||||
// that is enforced in TenantContext, and stated here so the two agree.
|
||||
new R("NABARD_ADMIN", "NABARD Administrator",
|
||||
"APEX:VIEW,APEX:SCHEME,INSTITUTION:MANAGE,USER:MANAGE,STANDARD:MANAGE,REPORT:VIEW"),
|
||||
new R("NABARD_OFFICER", "NABARD Officer",
|
||||
"APEX:VIEW,APEX:SCHEME,REPORT:VIEW"),
|
||||
new R("NABARD_AUDITOR", "NABARD Auditor",
|
||||
"APEX:VIEW,REPORT:VIEW,AUDIT:VIEW"),
|
||||
// Bank — the tier that actually carries borrowers.
|
||||
new R("BANK_ADMIN", "Bank Administrator",
|
||||
"PORTFOLIO:VIEW,ALERT:VIEW,ALERT:ACT,VISIT:VIEW,VISIT:LOG,REPORT:VIEW,"
|
||||
+ "CONFIG:MANAGE,RULE:MANAGE,USER:MANAGE,BRANCH:MANAGE,DATA:ENTRY"),
|
||||
new R("REGIONAL_MANAGER", "Regional Manager",
|
||||
"PORTFOLIO:VIEW,ALERT:VIEW,ALERT:ACT,VISIT:VIEW,VISIT:LOG,REPORT:VIEW"),
|
||||
new R("BRANCH_MANAGER", "Branch Manager",
|
||||
"PORTFOLIO:VIEW,ALERT:VIEW,ALERT:ACT,VISIT:VIEW,VISIT:LOG,REPORT:VIEW"),
|
||||
new R("DATA_STEWARD", "Data Steward",
|
||||
"PORTFOLIO:VIEW,DATA:ENTRY,REPORT:VIEW"));
|
||||
|
||||
int written = 0;
|
||||
for (R r : roles) {
|
||||
if (roleRepository.findByCode(r.code()).isEmpty()) {
|
||||
roleRepository.save(Role.builder()
|
||||
.code(r.code()).name(r.name()).systemRole(true).permissions(r.permissions()).build());
|
||||
written++;
|
||||
}
|
||||
}
|
||||
if (written > 0) log.info("Seeded {} institutional roles", written);
|
||||
}
|
||||
|
||||
// ── institutions ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* NABARD plus three lenders of the kinds it actually refinances: a regional
|
||||
* rural bank, a district central cooperative bank and a small finance bank.
|
||||
* Three rather than one, because a single partner bank makes cross-tenant
|
||||
* isolation unfalsifiable — there would be nothing to be wrongly visible to.
|
||||
*/
|
||||
private Institution seedInstitutions() {
|
||||
Institution nabard = upsertInstitution(NABARD_CODE,
|
||||
"National Bank for Agriculture and Rural Development", "राष्ट्रीय कृषि और ग्रामीण विकास बैंक",
|
||||
"APEX", "NABARD", "Madhya Pradesh");
|
||||
|
||||
upsertInstitution("MPGB", "Madhya Pradesh Gramin Bank", "मध्य प्रदेश ग्रामीण बैंक",
|
||||
"RRB", "MP Gramin", "Madhya Pradesh");
|
||||
upsertInstitution("SEHDCCB", "Sehore District Central Cooperative Bank", "सीहोर जिला सहकारी बैंक",
|
||||
"COOPERATIVE", "Sehore DCCB", "Madhya Pradesh");
|
||||
upsertInstitution("VSFB", "Vindhya Small Finance Bank", "विंध्य स्मॉल फाइनेंस बैंक",
|
||||
"SFB", "Vindhya SFB", "Madhya Pradesh");
|
||||
|
||||
return nabard;
|
||||
}
|
||||
|
||||
private Institution upsertInstitution(String code, String name, String nameHi,
|
||||
String type, String shortName, String state) {
|
||||
return institutionRepository.findByCode(code).orElseGet(() ->
|
||||
institutionRepository.save(Institution.builder()
|
||||
.code(code).name(name).nameHi(nameHi).type(type)
|
||||
.shortName(shortName).state(state).active(true).build()));
|
||||
}
|
||||
|
||||
// ── branches ────────────────────────────────────────────
|
||||
|
||||
/** Keyed "INSTCODE:BRANCHCODE" so the assignment step can address one directly. */
|
||||
private Map<String, Branch> seedBranches() {
|
||||
Map<String, Long> districtIds = new HashMap<>();
|
||||
districtRepository.findAll().forEach(d -> districtIds.put(d.getName(), d.getId()));
|
||||
|
||||
record B(String institution, String code, String name, String block, String region) {}
|
||||
List<B> plan = List.of(
|
||||
new B("MPGB", "SEH01", "Sehore Main Branch", "Sehore", "Bhopal Region"),
|
||||
new B("MPGB", "VID01", "Vidisha Main Branch", "Vidisha", "Bhopal Region"),
|
||||
new B("SEHDCCB", "SEH02", "Sehore Cooperative Branch", "Sehore", "Sehore Region"),
|
||||
new B("SEHDCCB", "RAI01", "Raisen Cooperative Branch", "Raisen", "Sehore Region"),
|
||||
new B("VSFB", "BPL01", "Bhopal City Branch", "Bhopal", "Bhopal Region"),
|
||||
new B("VSFB", "RAI02", "Raisen Branch", "Raisen", "Bhopal Region"));
|
||||
|
||||
Map<String, Branch> out = new LinkedHashMap<>();
|
||||
for (B b : plan) {
|
||||
Institution institution = institutionRepository.findByCode(b.institution()).orElse(null);
|
||||
if (institution == null) continue;
|
||||
|
||||
Branch branch = branchRepository.findByInstitutionIdAndCode(institution.getId(), b.code())
|
||||
.orElseGet(() -> branchRepository.save(Branch.builder()
|
||||
.institutionId(institution.getId())
|
||||
.code(b.code())
|
||||
.name(b.name())
|
||||
.blocks(b.block())
|
||||
.region(b.region())
|
||||
.districtId(districtIds.get(b.block()))
|
||||
.active(true)
|
||||
.build()));
|
||||
out.put(b.institution() + ":" + b.code(), branch);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── portfolio assignment ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gives every borrower a lender.
|
||||
*
|
||||
* <p>Assignment follows the block, because that is how the sector really
|
||||
* works — a Sehore enterprise banks in Sehore — and enterprises in a block
|
||||
* served by more than one branch are split between them deterministically by
|
||||
* id, so the same demo database always produces the same division.
|
||||
*/
|
||||
private void assignPortfolio(Map<String, Branch> branches) {
|
||||
List<Enterprise> unassigned = enterpriseRepository.findAll().stream()
|
||||
.filter(e -> e.getInstitutionId() == null)
|
||||
.toList();
|
||||
if (unassigned.isEmpty()) return;
|
||||
|
||||
Map<String, List<Branch>> byBlock = new HashMap<>();
|
||||
branches.values().forEach(b ->
|
||||
byBlock.computeIfAbsent(b.getBlocks(), k -> new ArrayList<>()).add(b));
|
||||
// Stable order, so the split does not depend on map iteration.
|
||||
byBlock.values().forEach(list -> list.sort(Comparator.comparing(Branch::getCode)));
|
||||
|
||||
List<Branch> fallback = branches.values().stream()
|
||||
.sorted(Comparator.comparing(Branch::getCode)).toList();
|
||||
if (fallback.isEmpty()) return;
|
||||
|
||||
int assigned = 0;
|
||||
for (Enterprise e : unassigned) {
|
||||
List<Branch> candidates = byBlock.getOrDefault(e.getBlock(), fallback);
|
||||
Branch branch = candidates.get(Math.abs(e.getEnterpriseId().hashCode()) % candidates.size());
|
||||
e.setInstitutionId(branch.getInstitutionId());
|
||||
e.setBranchId(branch.getId());
|
||||
assigned++;
|
||||
}
|
||||
enterpriseRepository.saveAll(unassigned);
|
||||
log.info("Assigned {} enterprises to partner-bank branches", assigned);
|
||||
}
|
||||
|
||||
// ── users ───────────────────────────────────────────────
|
||||
|
||||
private void seedUsers(Institution nabard, Map<String, Branch> branches) {
|
||||
Institution mpgb = institutionRepository.findByCode("MPGB").orElse(null);
|
||||
Institution dccb = institutionRepository.findByCode("SEHDCCB").orElse(null);
|
||||
if (mpgb == null || dccb == null) return;
|
||||
|
||||
Branch sehore = branches.get("MPGB:SEH01");
|
||||
Branch dccbSehore = branches.get("SEHDCCB:SEH02");
|
||||
|
||||
// NABARD — no branch, no institution filter on portfolio reads, and no
|
||||
// borrower entitlement at all.
|
||||
upsertUser("nabard.admin", "NABARD Administrator", "NABARD_ADMIN", "NABARD",
|
||||
nabard.getId(), null, null);
|
||||
upsertUser("nabard.officer", "NABARD Regional Officer", "NABARD_OFFICER", "NABARD",
|
||||
nabard.getId(), null, null);
|
||||
|
||||
// Two banks, so isolation can actually be demonstrated rather than asserted.
|
||||
upsertUser("mpgb.admin", "MP Gramin — Head Office", "BANK_ADMIN", "BANK",
|
||||
mpgb.getId(), null, "Sehore,Vidisha,Raisen,Bhopal");
|
||||
upsertUser("mpgb.officer", "MP Gramin — Sehore Officer", "FIELD_OFFICER", "BANK",
|
||||
mpgb.getId(), sehore == null ? null : sehore.getId(), "Sehore");
|
||||
upsertUser("dccb.admin", "Sehore DCCB — Head Office", "BANK_ADMIN", "BANK",
|
||||
dccb.getId(), null, "Sehore,Raisen");
|
||||
upsertUser("dccb.officer", "Sehore DCCB — Branch Officer", "BRANCH_MANAGER", "BANK",
|
||||
dccb.getId(), dccbSehore == null ? null : dccbSehore.getId(), "Sehore");
|
||||
|
||||
backfillLegacyUsers(mpgb, sehore);
|
||||
}
|
||||
|
||||
private void upsertUser(String username, String fullName, String role, String scope,
|
||||
Long institutionId, Long branchId, String blocks) {
|
||||
if (userRepository.findByUsernameIgnoreCase(username).isPresent()) return;
|
||||
userRepository.save(UdyamUser.builder()
|
||||
.username(username)
|
||||
.fullName(fullName)
|
||||
.password(passwordEncoder.encode(DEFAULT_PASSWORD))
|
||||
.role(role)
|
||||
.scope(scope)
|
||||
.institutionId(institutionId)
|
||||
.branchId(branchId)
|
||||
.blocks(blocks)
|
||||
.active(true)
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-institution logins — {@code supervisor} and {@code aditya.vyas} —
|
||||
* would otherwise resolve to no institution and, because the tenancy filter
|
||||
* fails closed, see an empty portfolio. Attaching them to a bank keeps every
|
||||
* existing demo credential working.
|
||||
*/
|
||||
private void backfillLegacyUsers(Institution bank, Branch branch) {
|
||||
List<UdyamUser> legacy = userRepository.findAll().stream()
|
||||
.filter(u -> !"ENTERPRISE".equalsIgnoreCase(u.getScope()))
|
||||
.filter(u -> u.getInstitutionId() == null)
|
||||
.toList();
|
||||
if (legacy.isEmpty()) return;
|
||||
|
||||
for (UdyamUser user : legacy) {
|
||||
user.setInstitutionId(bank.getId());
|
||||
user.setScope("BANK");
|
||||
if ("FIELD_OFFICER".equals(user.getRole()) && branch != null) {
|
||||
user.setBranchId(branch.getId());
|
||||
}
|
||||
}
|
||||
userRepository.saveAll(legacy);
|
||||
log.info("Attached {} pre-institution console users to {}", legacy.size(), bank.getShortName());
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ public class UdyamDataSeeder implements CommandLineRunner {
|
||||
private final TripWireRuleRepository ruleRepository;
|
||||
private final UdyamUserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final InstitutionSeeder institutionSeeder;
|
||||
|
||||
@Value("${udyam.seed.enabled:true}")
|
||||
private boolean seedEnabled;
|
||||
@@ -110,6 +111,9 @@ public class UdyamDataSeeder implements CommandLineRunner {
|
||||
featureCatalog.seed();
|
||||
seedOfficers();
|
||||
seedPortfolio();
|
||||
// The institutional tier last: it assigns the portfolio the steps above
|
||||
// created, so it has to see a finished book.
|
||||
institutionSeeder.seed();
|
||||
log.info("UdyamLinc seed complete — {} enterprises on book", enterpriseRepository.count());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user