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:
LincPay
2026-08-06 16:50:10 +05:30
co-authored by Claude Opus 5
parent cf27258e26
commit 1e5f446672
20 changed files with 1189 additions and 51 deletions
@@ -0,0 +1,78 @@
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");
}
}
}
@@ -0,0 +1,290 @@
package com.lincfusion.udyamlinc.apex;
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.Sector;
import com.lincfusion.udyamlinc.master.SectorRepository;
import com.lincfusion.udyamlinc.risk.RiskAlert;
import com.lincfusion.udyamlinc.risk.RiskAlertRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
/**
* What NABARD may see: the portfolio it refinances, in aggregate.
*
* <p><b>The rule this service exists to keep.</b> Not one method here returns an
* enterprise id, a borrower name, or a row an apex user could join back to a
* person. It returns counts, rates and distributions. That is not a limitation
* apologised for — it is the reason portfolio-wide early warning can be granted
* to a supervisor at all, and it is why the tier separation is worth having.
*
* <p>A second rule follows from it: <b>no cell below {@code minCellSize}</b>. A
* district with two borrowers and one alert discloses that borrower as surely as
* naming them. Small cells are reported as suppressed rather than rounded, so a
* reader can tell "too few to publish" from "none".
*/
@Service
@RequiredArgsConstructor
public class ApexService {
private final InstitutionRepository institutionRepository;
private final BranchRepository branchRepository;
private final EnterpriseRepository enterpriseRepository;
private final DistrictRepository districtRepository;
private final SectorRepository sectorRepository;
private final RiskAlertRepository alertRepository;
/** Fewest borrowers in a cell before its numbers may be published. */
@Value("${udyam.apex.min-cell-size:5}")
private int minCellSize;
// ── national dashboard ──────────────────────────────────
/** Headline counts across every refinanced portfolio. */
public Map<String, Object> national() {
List<Enterprise> all = enterpriseRepository.findAll();
List<Institution> lenders = institutionRepository.findByTypeNotAndActiveTrueOrderByName("APEX");
Map<String, Object> out = new LinkedHashMap<>();
out.put("partnerBanks", lenders.size());
out.put("branches", branchRepository.count());
out.put("enterprises", all.size());
out.put("red", count(all, "RED"));
out.put("watch", count(all, "WATCH"));
out.put("standard", count(all, "STANDARD"));
out.put("stressRatePct", pct(count(all, "RED"), all.size()));
out.put("portfolioValue", all.stream()
.map(e -> e.getLoanOutstanding() == null ? BigDecimal.ZERO : e.getLoanOutstanding())
.reduce(BigDecimal.ZERO, BigDecimal::add));
out.put("openAlerts", alertRepository.findAllByOrderByScoreDesc().stream()
.filter(a -> !"CLOSED".equalsIgnoreCase(a.getState()))
.count());
out.put("minCellSize", minCellSize);
return out;
}
// ── partner bank scorecard ──────────────────────────────
/**
* One row per refinanced bank: how big the book is, how much of it is under
* stress, and — the number that actually distinguishes a good partner from a
* bad one — how much of what was flagged has been acted on.
*/
public List<Map<String, Object>> bankScorecard() {
Map<String, RiskAlert> latestAlertByEnterprise = new HashMap<>();
alertRepository.findAllByOrderByScoreDesc()
.forEach(a -> latestAlertByEnterprise.putIfAbsent(a.getEnterpriseId(), a));
List<Map<String, Object>> out = new ArrayList<>();
for (Institution bank : institutionRepository.findByTypeNotAndActiveTrueOrderByName("APEX")) {
List<Enterprise> book = enterpriseRepository.findByInstitutionIdOrderByScoreDesc(bank.getId());
long raised = book.stream()
.filter(e -> latestAlertByEnterprise.containsKey(e.getEnterpriseId()))
.count();
long actioned = book.stream()
.map(e -> latestAlertByEnterprise.get(e.getEnterpriseId()))
.filter(Objects::nonNull)
.filter(a -> !"OPEN".equalsIgnoreCase(a.getState()))
.count();
Map<String, Object> row = new LinkedHashMap<>();
row.put("institutionCode", bank.getCode());
row.put("institutionName", bank.getName());
row.put("shortName", bank.getShortName());
row.put("type", bank.getType());
row.put("branches", branchRepository.findByInstitutionIdAndActiveTrueOrderByName(bank.getId()).size());
row.put("enterprises", book.size());
row.put("suppressed", book.size() < minCellSize);
if (book.size() < minCellSize) {
// Below the threshold nothing else is published — see the class note.
out.add(row);
continue;
}
row.put("red", count(book, "RED"));
row.put("watch", count(book, "WATCH"));
row.put("standard", count(book, "STANDARD"));
row.put("stressRatePct", pct(count(book, "RED"), book.size()));
row.put("avgScore", avgScore(book));
row.put("alertsRaised", raised);
row.put("alertsActioned", actioned);
row.put("responseRatePct", pct(actioned, raised));
row.put("outstanding", book.stream()
.map(e -> e.getLoanOutstanding() == null ? BigDecimal.ZERO : e.getLoanOutstanding())
.reduce(BigDecimal.ZERO, BigDecimal::add));
out.add(row);
}
return out;
}
// ── district and sector views ───────────────────────────
/** Stress by district — the unit NABARD plans ground-level credit in. */
public List<Map<String, Object>> byDistrict() {
Map<Long, String> names = new HashMap<>();
districtRepository.findAll().forEach(d -> names.put(d.getId(), d.getName()));
Map<Long, List<Enterprise>> grouped = new LinkedHashMap<>();
enterpriseRepository.findAll().forEach(e ->
grouped.computeIfAbsent(e.getDistrictId(), k -> new ArrayList<>()).add(e));
List<Map<String, Object>> out = new ArrayList<>();
grouped.forEach((districtId, list) -> out.add(cell(
"districtName", names.getOrDefault(districtId, "—"), list)));
out.sort(Comparator.comparing(m -> String.valueOf(m.get("districtName"))));
return out;
}
/** Stress by sector — the unit a shock actually arrives in. */
public List<Map<String, Object>> bySector() {
Map<Long, String> names = new HashMap<>();
sectorRepository.findAll().forEach(s -> names.put(s.getId(), s.getLabel()));
Map<Long, List<Enterprise>> grouped = new LinkedHashMap<>();
enterpriseRepository.findAll().forEach(e ->
grouped.computeIfAbsent(e.getSectorId(), k -> new ArrayList<>()).add(e));
List<Map<String, Object>> out = new ArrayList<>();
grouped.forEach((sectorId, list) -> out.add(cell(
"sectorName", names.getOrDefault(sectorId, "—"), list)));
out.sort(Comparator.comparing(m -> String.valueOf(m.get("sectorName"))));
return out;
}
/**
* One aggregate cell, suppressed when too few borrowers sit behind it.
*
* <p>Suppression is reported as a flag rather than by omitting the row: a
* missing district reads as "no activity", which is a different and wrong
* claim.
*/
private Map<String, Object> cell(String labelKey, String label, List<Enterprise> list) {
Map<String, Object> row = new LinkedHashMap<>();
row.put(labelKey, label);
row.put("enterprises", list.size());
if (list.size() < minCellSize) {
row.put("suppressed", true);
return row;
}
row.put("suppressed", false);
row.put("red", count(list, "RED"));
row.put("watch", count(list, "WATCH"));
row.put("standard", count(list, "STANDARD"));
row.put("stressRatePct", pct(count(list, "RED"), list.size()));
row.put("avgScore", avgScore(list));
return row;
}
// ── early-warning digest ────────────────────────────────
/**
* Where the portfolio is deteriorating, as districts and sectors — never as
* borrowers.
*
* <p>Ranked by stressed count rather than by rate, because a 40 % stress rate
* over eight borrowers is noise and a 12 % rate over three hundred is a
* programme. Both are shown; the ordering says which NABARD should read
* first.
*/
public Map<String, Object> digest() {
List<Map<String, Object>> districts = byDistrict().stream()
.filter(m -> !Boolean.TRUE.equals(m.get("suppressed")))
.filter(m -> asLong(m.get("red")) > 0)
.sorted((a, b) -> Long.compare(asLong(b.get("red")), asLong(a.get("red"))))
.toList();
List<Map<String, Object>> sectors = bySector().stream()
.filter(m -> !Boolean.TRUE.equals(m.get("suppressed")))
.filter(m -> asLong(m.get("red")) > 0)
.sorted((a, b) -> Long.compare(asLong(b.get("red")), asLong(a.get("red"))))
.toList();
List<Map<String, Object>> banks = bankScorecard().stream()
.filter(m -> !Boolean.TRUE.equals(m.get("suppressed")))
.sorted((a, b) -> Double.compare(asDouble(a.get("responseRatePct")), asDouble(b.get("responseRatePct"))))
.toList();
Map<String, Object> out = new LinkedHashMap<>();
out.put("asOf", LocalDate.now());
out.put("districtsUnderStress", districts);
out.put("sectorsUnderStress", sectors);
// Slowest responders first — an alert nobody acts on is the failure mode
// an apex supervisor is uniquely placed to catch.
out.put("slowestResponders", banks);
return out;
}
// ── coverage of the institutional tier itself ───────────
/** Which banks and branches are on the platform, for the admin screen. */
public List<Map<String, Object>> institutions() {
List<Map<String, Object>> out = new ArrayList<>();
for (Institution institution : institutionRepository.findByActiveTrueOrderByName()) {
List<Branch> branches = branchRepository
.findByInstitutionIdAndActiveTrueOrderByName(institution.getId());
Map<String, Object> row = new LinkedHashMap<>();
row.put("code", institution.getCode());
row.put("name", institution.getName());
row.put("shortName", institution.getShortName());
row.put("type", institution.getType());
row.put("state", institution.getState());
row.put("branches", branches.stream().map(b -> Map.of(
"code", b.getCode(),
"name", b.getName(),
"region", b.getRegion() == null ? "—" : b.getRegion(),
"blocks", b.getBlocks() == null ? "—" : b.getBlocks())).toList());
out.add(row);
}
return out;
}
// ── helpers ─────────────────────────────────────────────
private long count(List<Enterprise> list, String band) {
return list.stream().filter(e -> band.equals(e.getBand())).count();
}
private BigDecimal pct(long part, long whole) {
if (whole <= 0) return BigDecimal.ZERO;
return BigDecimal.valueOf(part * 100.0 / whole).setScale(1, RoundingMode.HALF_UP);
}
private BigDecimal avgScore(List<Enterprise> list) {
if (list.isEmpty()) return BigDecimal.ZERO;
double mean = list.stream().mapToInt(e -> e.getScore() == null ? 0 : e.getScore()).average().orElse(0);
return BigDecimal.valueOf(mean).setScale(1, RoundingMode.HALF_UP);
}
private long asLong(Object value) {
return value instanceof Number n ? n.longValue() : 0L;
}
private double asDouble(Object value) {
return value instanceof Number n ? n.doubleValue() : 0d;
}
/** Days since the newest scoring run touched the book — the freshness of all of the above. */
public long dataAgeDays() {
return enterpriseRepository.findAll().stream()
.map(Enterprise::getLastScoredAt)
.filter(Objects::nonNull)
.max(Comparator.naturalOrder())
.map(t -> ChronoUnit.DAYS.between(t.toLocalDate(), LocalDate.now()))
.orElse(-1L);
}
}
@@ -34,16 +34,30 @@ public class AuthService {
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
/**
* Sign-in for both staff tiers.
*
* <p>The token issued depends on which institution the user belongs to, not
* on anything they send: a NABARD user gets an apex token carrying no
* institution and therefore no borrower entitlement, a bank user gets one
* pinned to their own. The distinction is made here, once, rather than being
* re-derived by every endpoint.
*/
@Transactional
public OfficerSessionResponse officerLogin(OfficerLoginRequest request) {
UdyamUser user = userRepository.findByUsernameIgnoreCase(request.username().trim())
.filter(u -> "OFFICER".equalsIgnoreCase(u.getScope()))
.orElseThrow(() -> new BadCredentialsException("No field officer is registered with that username"));
.filter(u -> "OFFICER".equalsIgnoreCase(u.getScope())
|| "BANK".equalsIgnoreCase(u.getScope())
|| "NABARD".equalsIgnoreCase(u.getScope()))
.orElseThrow(() -> new BadCredentialsException("No console user is registered with that username"));
verify(user, request.password());
touch(user);
String token = jwtUtil.generateOfficerToken(user.getUsername(), user.getRole(), user.getBlocks());
String token = "NABARD".equalsIgnoreCase(user.getScope())
? jwtUtil.generateApexToken(user.getUsername(), user.getRole(), user.getInstitutionId())
: jwtUtil.generateBankToken(user.getUsername(), user.getRole(), user.getBlocks(),
user.getInstitutionId(), user.getBranchId());
List<String> blocks = user.getBlocks() == null || user.getBlocks().isBlank()
? List.of()
: Arrays.stream(user.getBlocks().split(",")).map(String::trim).toList();
@@ -57,6 +57,12 @@ public class UdyamUser {
@Column(length = 96)
private String branch;
/** The institution this user belongs to — NABARD, or one partner bank. */
private Long institutionId;
/** Set for branch-scoped users; null for a head-office or NABARD user. */
private Long branchId;
@Column(nullable = false)
private Boolean active;
@@ -39,12 +39,22 @@ public class SecurityConfig {
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**", "/health").permitAll()
// The officer console and the enterprise app are separate surfaces.
.requestMatchers("/officer/**", "/forecast/**").hasAuthority("SCOPE_OFFICER")
// Three surfaces, three institutional tiers. NABARD is not a
// super-user: it gets its own endpoints, which return
// aggregates, and is refused the borrower-level ones outright
// rather than being trusted to ask nicely.
.requestMatchers("/nabard/**").hasAuthority("SCOPE_NABARD")
.requestMatchers("/officer/**", "/forecast/**")
.hasAnyAuthority("SCOPE_BANK", "SCOPE_OFFICER")
.requestMatchers("/app/**").hasAuthority("SCOPE_ENTERPRISE")
// Any officer may read the configuration; only a supervisor may change it.
.requestMatchers(HttpMethod.GET, "/admin/**").hasAuthority("SCOPE_OFFICER")
.requestMatchers("/admin/**").hasAuthority("ROLE_SUPERVISOR")
// Any bank user may read the configuration; changing it needs a
// role that carries CONFIG:MANAGE.
.requestMatchers(HttpMethod.GET, "/admin/**")
.hasAnyAuthority("SCOPE_BANK", "SCOPE_OFFICER", "SCOPE_NABARD")
.requestMatchers("/admin/**")
.hasAnyAuthority("ROLE_SUPERVISOR", "ROLE_BANK_ADMIN", "ROLE_DATA_STEWARD")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
@@ -49,6 +49,16 @@ public class Enterprise {
@Column(nullable = false)
private Long sectorId;
/**
* The lender carrying this loan, and the branch owning the relationship.
* Together they are the tenancy key: a bank user may only ever read rows
* matching their own institution, and a NABARD user may read none of them
* individually — only counts and rates derived from them.
*/
private Long institutionId;
private Long branchId;
@Column(nullable = false)
private Long districtId;
@@ -20,4 +20,17 @@ public interface EnterpriseRepository extends JpaRepository<Enterprise, Long> {
long countByBand(String band);
long countByBlockAndBand(String block, String band);
// ── tenancy ─────────────────────────────────────────────
// Every borrower-level read a bank user makes goes through one of these.
// The unscoped finders above remain only for the scoring engine and the
// seeder, which run as the system rather than as a user.
List<Enterprise> findByInstitutionIdOrderByScoreDesc(Long institutionId);
List<Enterprise> findByInstitutionIdAndBlockOrderByScoreDesc(Long institutionId, String block);
List<Enterprise> findByInstitutionIdAndBranchIdOrderByScoreDesc(Long institutionId, Long branchId);
Optional<Enterprise> findByEnterpriseIdAndInstitutionId(String enterpriseId, Long institutionId);
}
@@ -0,0 +1,62 @@
package com.lincfusion.udyamlinc.institution;
import jakarta.persistence.*;
import lombok.*;
/**
* A branch of a lending institution — where the loan book actually sits and
* where an alert has to land for somebody to act on it.
*
* <p>An enterprise belongs to a branch, a branch to an institution. That chain is
* what a bank's own hierarchy is made of, and it is what lets a regional manager
* see several branches while a field officer sees the blocks they cover.
*
* <p>NABARD has no branches in this model. It has no borrowers.
*/
@Entity
@Table(name = "branch", indexes = {
@Index(name = "ix_branch_institution", columnList = "institutionId"),
@Index(name = "ix_branch_code", columnList = "institutionId,code", unique = true),
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Branch {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long institutionId;
/** Unique within the institution, not globally — two banks may both have "SEH01". */
@Column(nullable = false, length = 32)
private String code;
@Column(nullable = false, length = 120)
private String name;
@Column(length = 120)
private String nameHi;
private Long districtId;
/** CSV of the blocks this branch covers, matching the officer's own scoping. */
@Column(length = 240)
private String blocks;
/** Grouping for a regional manager's view. */
@Column(length = 60)
private String region;
@Column(nullable = false)
private Boolean active;
@PrePersist
void onCreate() {
if (active == null) active = Boolean.TRUE;
}
}
@@ -0,0 +1,15 @@
package com.lincfusion.udyamlinc.institution;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface BranchRepository extends JpaRepository<Branch, Long> {
List<Branch> findByInstitutionIdAndActiveTrueOrderByName(Long institutionId);
Optional<Branch> findByInstitutionIdAndCode(Long institutionId, String code);
List<Branch> findByInstitutionIdAndRegionAndActiveTrue(Long institutionId, String region);
}
@@ -0,0 +1,83 @@
package com.lincfusion.udyamlinc.institution;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* A participating institution: NABARD itself, or one of the banks it refinances.
*
* <p><b>Why this exists.</b> NABARD does not lend to enterprises. It refinances
* and supervises the banks that do — regional rural banks, cooperative banks and
* small finance banks. A single "field officer" console collapses two different
* institutions into one and quietly casts the apex bank as the lender, which is
* wrong about the sector and wrong about who may see a borrower.
*
* <p>{@code id} is the tenancy key. Every portfolio read is filtered by it, and a
* NABARD user is filtered differently: they are entitled to aggregates across all
* partner banks and to no borrower identity at all. That restriction is what
* makes portfolio-wide early warning grantable in the first place.
*/
@Entity
@Table(name = "institution", indexes = {
@Index(name = "ix_institution_code", columnList = "code", unique = true),
@Index(name = "ix_institution_type", columnList = "type"),
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Institution {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 32)
private String code;
@Column(nullable = false, length = 120)
private String name;
@Column(length = 120)
private String nameHi;
/**
* APEX — NABARD. The rest are the lender types it refinances:
* RRB | COOPERATIVE | SFB | COMMERCIAL.
*/
@Column(nullable = false, length = 16)
private String type;
/** Short form for a dashboard column — "MP Gramin", "Sehore DCCB". */
@Column(length = 40)
private String shortName;
@Column(length = 60)
private String state;
/** Regulatory identifier where one exists. Never a borrower identifier. */
@Column(length = 32)
private String regulatoryRef;
/** Sponsor bank for an RRB, as the sector is actually structured. */
private Long sponsorInstitutionId;
@Column(nullable = false)
private Boolean active;
private LocalDateTime createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now();
if (active == null) active = Boolean.TRUE;
}
/** True for NABARD — the tier that sees aggregates and never a borrower. */
public boolean isApex() {
return "APEX".equals(type);
}
}
@@ -0,0 +1,16 @@
package com.lincfusion.udyamlinc.institution;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface InstitutionRepository extends JpaRepository<Institution, Long> {
Optional<Institution> findByCode(String code);
List<Institution> findByActiveTrueOrderByName();
/** The lenders — everything that is not the apex tier. */
List<Institution> findByTypeNotAndActiveTrueOrderByName(String type);
}
@@ -1,6 +1,7 @@
package com.lincfusion.udyamlinc.officer;
import com.lincfusion.udyamlinc.common.ApiResponse;
import com.lincfusion.udyamlinc.security.TenantContext;
import com.lincfusion.udyamlinc.dto.UdyamRequests.AlertActionRequest;
import com.lincfusion.udyamlinc.dto.UdyamRequests.ScheduleVisitRequest;
import com.lincfusion.udyamlinc.dto.UdyamRequests.VisitOutcomeRequest;
@@ -26,48 +27,54 @@ public class OfficerController {
@GetMapping("/portfolio")
public ApiResponse<PortfolioResponse> portfolio(
TenantContext tenant,
@RequestParam(defaultValue = OfficerService.ALL_BLOCKS) String block,
@RequestParam(required = false) String month) {
return ApiResponse.ok(officerService.portfolio(block, month));
return ApiResponse.ok(officerService.portfolio(tenant, block, month));
}
@GetMapping("/enterprises")
public ApiResponse<List<EnterpriseRow>> enterprises(
TenantContext tenant,
@RequestParam(defaultValue = OfficerService.ALL_BLOCKS) String block) {
return ApiResponse.ok(officerService.enterprises(block));
return ApiResponse.ok(officerService.enterprises(tenant, block));
}
@GetMapping("/enterprises/{enterpriseId}")
public ApiResponse<EnterpriseDetailResponse> detail(@PathVariable String enterpriseId) {
return ApiResponse.ok(officerService.detail(enterpriseId));
public ApiResponse<EnterpriseDetailResponse> detail(TenantContext tenant, @PathVariable String enterpriseId) {
return ApiResponse.ok(officerService.detail(tenant, enterpriseId));
}
@GetMapping("/alerts")
public ApiResponse<List<RiskAlert>> alerts(
TenantContext tenant,
@RequestParam(defaultValue = OfficerService.ALL_BLOCKS) String block,
@RequestParam(required = false) String state) {
return ApiResponse.ok(officerService.alerts(block, state));
return ApiResponse.ok(officerService.alerts(tenant, block, state));
}
@PutMapping("/alerts/{alertId}")
public ApiResponse<RiskAlert> disposition(
TenantContext tenant,
@PathVariable String alertId,
@Valid @RequestBody AlertActionRequest request,
@AuthenticationPrincipal String actor) {
return ApiResponse.ok("Alert updated", officerService.disposition(alertId, request.state(), request.note(), actor));
return ApiResponse.ok("Alert updated", officerService.disposition(tenant, alertId, request.state(), request.note(), actor));
}
@GetMapping("/visits")
public ApiResponse<List<FieldVisit>> visits(
TenantContext tenant,
@RequestParam(defaultValue = OfficerService.ALL_BLOCKS) String block) {
return ApiResponse.ok(officerService.visits(block));
return ApiResponse.ok(officerService.visits(tenant, block));
}
@PostMapping("/visits")
public ApiResponse<FieldVisit> schedule(
TenantContext tenant,
@Valid @RequestBody ScheduleVisitRequest request,
@AuthenticationPrincipal String officer) {
return ApiResponse.ok("Visit scheduled", officerService.schedule(request, officer));
return ApiResponse.ok("Visit scheduled", officerService.schedule(tenant, request, officer));
}
@PutMapping("/visits/{visitId}/outcome")
@@ -85,7 +92,8 @@ public class OfficerController {
@GetMapping("/sectors")
public ApiResponse<List<SectorRollupResponse>> sectors(
TenantContext tenant,
@RequestParam(defaultValue = OfficerService.ALL_BLOCKS) String block) {
return ApiResponse.ok(officerService.sectorRollup(block));
return ApiResponse.ok(officerService.sectorRollup(tenant, block));
}
}
@@ -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));
}
@@ -16,14 +16,23 @@ import java.io.IOException;
import java.util.List;
/**
* Reads the bearer token and grants two authorities: the role (ROLE_FIELD_OFFICER,
* ROLE_OWNER…) and the scope (SCOPE_OFFICER / SCOPE_ENTERPRISE), which is what the
* two API surfaces are separated on.
* Reads the bearer token, grants the role and scope authorities the three API
* surfaces are separated on, and attaches the {@link TenantContext} every
* portfolio query filters by.
*
* <p>The tenant is derived here, from the signed token, and put on the request
* as an attribute. Controllers receive it through
* {@link TenantContextArgumentResolver} rather than reading an institution id
* from a parameter — which is the whole point: a caller who can name their own
* tenancy does not have one.
*/
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
/** Where the resolved tenant rides for the life of the request. */
public static final String TENANT_ATTRIBUTE = "udyam.tenant";
private final JwtUtil jwtUtil;
@Override
@@ -35,14 +44,37 @@ public class JwtAuthFilter extends OncePerRequestFilter {
String token = header.substring(7);
if (jwtUtil.isValid(token) && SecurityContextHolder.getContext().getAuthentication() == null) {
String subject = jwtUtil.subject(token);
String scope = jwtUtil.scope(token);
String role = jwtUtil.role(token);
var authorities = List.of(
new SimpleGrantedAuthority("ROLE_" + jwtUtil.role(token)),
new SimpleGrantedAuthority("SCOPE_" + jwtUtil.scope(token)));
new SimpleGrantedAuthority("ROLE_" + role),
new SimpleGrantedAuthority("SCOPE_" + scope));
var auth = new UsernamePasswordAuthenticationToken(subject, null, authorities);
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
request.setAttribute(TENANT_ATTRIBUTE, tenantOf(token, subject, scope, role));
}
}
chain.doFilter(request, response);
}
private TenantContext tenantOf(String token, String subject, String scope, String role) {
TenantContext.Tier tier = switch (scope) {
case "NABARD" -> TenantContext.Tier.APEX;
case "ENTERPRISE" -> TenantContext.Tier.ENTERPRISE;
// OFFICER is the pre-institution token name, kept so a session issued
// before the three-tier model still resolves to its own bank rather
// than to none.
default -> TenantContext.Tier.BANK;
};
return new TenantContext(
tier,
subject,
role,
jwtUtil.institutionId(token),
jwtUtil.branchId(token),
jwtUtil.enterpriseId(token));
}
}
@@ -26,13 +26,39 @@ public class JwtUtil {
return Keys.hmacShaKeyFor(secret.getBytes());
}
/** Field officer session — scope=OFFICER, carries the role and the blocks in view. */
public String generateOfficerToken(String username, String role, String blocks) {
/**
* Bank console session — scope=BANK, pinned to one institution.
*
* <p>The institution is a signed claim rather than something the console
* sends with each call. A tenancy key the caller can type is not a tenancy
* key, and this is the one place that distinction is enforced for every
* request that follows.
*/
public String generateBankToken(String username, String role, String blocks,
Long institutionId, Long branchId) {
Map<String, Object> claims = new HashMap<>();
claims.put("realm", "UDYAM");
claims.put("scope", "OFFICER");
claims.put("scope", "BANK");
claims.put("role", role);
claims.put("blocks", blocks);
claims.put("institutionId", institutionId);
claims.put("branchId", branchId);
return build(claims, username);
}
/**
* NABARD session — scope=NABARD, deliberately carrying no institution.
*
* <p>An apex user is not a super-user with every tenancy at once; they are a
* user entitled to aggregates and to no borrower row at all. The absent
* claim is what {@code TenantContext} reads to mean exactly that.
*/
public String generateApexToken(String username, String role, Long institutionId) {
Map<String, Object> claims = new HashMap<>();
claims.put("realm", "UDYAM");
claims.put("scope", "NABARD");
claims.put("role", role);
claims.put("institutionId", institutionId);
return build(claims, username);
}
@@ -77,6 +103,31 @@ public class JwtUtil {
return String.valueOf(parse(token).get("role"));
}
public Long institutionId(String token) {
return longClaim(parse(token), "institutionId");
}
public Long branchId(String token) {
return longClaim(parse(token), "branchId");
}
public String enterpriseId(String token) {
Object value = parse(token).get("enterpriseId");
return value == null ? null : String.valueOf(value);
}
/** JWT numerics arrive as Integer or Long depending on magnitude. */
private Long longClaim(Claims claims, String name) {
Object value = claims.get(name);
if (value == null) return null;
if (value instanceof Number number) return number.longValue();
try {
return Long.valueOf(String.valueOf(value));
} catch (NumberFormatException e) {
return null;
}
}
public boolean isValid(String token) {
try {
return parse(token).getExpiration().after(new Date());
@@ -0,0 +1,70 @@
package com.lincfusion.udyamlinc.security;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Who is asking, and what they are entitled to see.
*
* <p>Resolved once per request from the verified JWT and nowhere else. The
* institution is <b>never</b> read from a path variable, a query parameter or a
* request body — a tenancy key the caller can type is not a tenancy key. Every
* portfolio read takes its filter from here.
*
* <p>Three entitlements, and they are not degrees of the same thing:
*
* <ul>
* <li><b>APEX</b> (NABARD) — every institution, but aggregates only. It is not
* a super-user: it may read no borrower row at all, which is precisely why
* portfolio-wide visibility can be granted to it.</li>
* <li><b>BANK</b> — one institution's own book, in full, optionally narrowed to
* a branch.</li>
* <li><b>ENTERPRISE</b> — one enterprise, itself.</li>
* </ul>
*/
@Getter
@RequiredArgsConstructor
public class TenantContext {
public enum Tier { APEX, BANK, ENTERPRISE }
private final Tier tier;
private final String username;
private final String role;
/** Null for APEX — an apex user is scoped to no single institution. */
private final Long institutionId;
/** Set only for a branch-scoped bank user. */
private final Long branchId;
/** Set only for an ENTERPRISE session. */
private final String enterpriseId;
public boolean isApex() {
return tier == Tier.APEX;
}
public boolean isBank() {
return tier == Tier.BANK;
}
/**
* Whether this caller may read rows identifying individual borrowers.
*
* <p>False for NABARD by design, not by omission. A future "just let the
* apex user drill in" change has to come through this method, where the
* reason it is false is written down.
*/
public boolean mayReadBorrowers() {
return tier != Tier.APEX;
}
/**
* The institution a portfolio query must be filtered by, or null when the
* caller is entitled to all of them in aggregate.
*/
public Long portfolioFilter() {
return tier == Tier.APEX ? null : institutionId;
}
}
@@ -0,0 +1,42 @@
package com.lincfusion.udyamlinc.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/**
* Lets a controller take a {@link TenantContext} parameter and receive the one
* resolved from the verified token.
*
* <p>Deliberately not a {@code @RequestParam} or a path variable. Making the
* tenant an ordinary method argument means a handler that needs scoping cannot
* forget to ask for it and cannot be handed one by the caller.
*/
@Configuration
@RequiredArgsConstructor
public class TenantContextArgumentResolver implements HandlerMethodArgumentResolver, WebMvcConfigurer {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return TenantContext.class.equals(parameter.getParameterType());
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mav,
NativeWebRequest request, WebDataBinderFactory binder) {
return request.getAttribute(JwtAuthFilter.TENANT_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(this);
}
}
@@ -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());
}
@@ -137,3 +137,4 @@ udyam.forecast.max-drift=0.30
# on a number nothing computed. Set false before any run whose alerts are meant
# to be believed.
udyam.forecast.trust-seeded=${UDYAM_TRUST_SEEDED_FORECASTS:true}
udyam.apex.min-cell-size=5