diff --git a/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java b/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java new file mode 100644 index 0000000..aabb883 --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/apex/ApexController.java @@ -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. + * + *

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. + * + *

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> dashboard(TenantContext tenant) { + requireApex(tenant); + Map out = new LinkedHashMap<>(apexService.national()); + out.put("dataAgeDays", apexService.dataAgeDays()); + return ApiResponse.ok(out); + } + + @GetMapping("/banks") + public ApiResponse>> banks(TenantContext tenant) { + requireApex(tenant); + return ApiResponse.ok(apexService.bankScorecard()); + } + + @GetMapping("/districts") + public ApiResponse>> districts(TenantContext tenant) { + requireApex(tenant); + return ApiResponse.ok(apexService.byDistrict()); + } + + @GetMapping("/sectors") + public ApiResponse>> sectors(TenantContext tenant) { + requireApex(tenant); + return ApiResponse.ok(apexService.bySector()); + } + + @GetMapping("/digest") + public ApiResponse> digest(TenantContext tenant) { + requireApex(tenant); + return ApiResponse.ok(apexService.digest()); + } + + @GetMapping("/institutions") + public ApiResponse>> 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"); + } + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/apex/ApexService.java b/src/main/java/com/lincfusion/udyamlinc/apex/ApexService.java new file mode 100644 index 0000000..0ca1c7c --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/apex/ApexService.java @@ -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. + * + *

The rule this service exists to keep. 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. + * + *

A second rule follows from it: no cell below {@code minCellSize}. 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 national() { + List all = enterpriseRepository.findAll(); + List lenders = institutionRepository.findByTypeNotAndActiveTrueOrderByName("APEX"); + + Map 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> bankScorecard() { + Map latestAlertByEnterprise = new HashMap<>(); + alertRepository.findAllByOrderByScoreDesc() + .forEach(a -> latestAlertByEnterprise.putIfAbsent(a.getEnterpriseId(), a)); + + List> out = new ArrayList<>(); + for (Institution bank : institutionRepository.findByTypeNotAndActiveTrueOrderByName("APEX")) { + List 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 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> byDistrict() { + Map names = new HashMap<>(); + districtRepository.findAll().forEach(d -> names.put(d.getId(), d.getName())); + + Map> grouped = new LinkedHashMap<>(); + enterpriseRepository.findAll().forEach(e -> + grouped.computeIfAbsent(e.getDistrictId(), k -> new ArrayList<>()).add(e)); + + List> 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> bySector() { + Map names = new HashMap<>(); + sectorRepository.findAll().forEach(s -> names.put(s.getId(), s.getLabel())); + + Map> grouped = new LinkedHashMap<>(); + enterpriseRepository.findAll().forEach(e -> + grouped.computeIfAbsent(e.getSectorId(), k -> new ArrayList<>()).add(e)); + + List> 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. + * + *

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 cell(String labelKey, String label, List list) { + Map 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. + * + *

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 digest() { + List> 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> 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> 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 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> institutions() { + List> out = new ArrayList<>(); + for (Institution institution : institutionRepository.findByActiveTrueOrderByName()) { + List branches = branchRepository + .findByInstitutionIdAndActiveTrueOrderByName(institution.getId()); + Map 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 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 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); + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/auth/AuthService.java b/src/main/java/com/lincfusion/udyamlinc/auth/AuthService.java index af91972..c7ffd26 100644 --- a/src/main/java/com/lincfusion/udyamlinc/auth/AuthService.java +++ b/src/main/java/com/lincfusion/udyamlinc/auth/AuthService.java @@ -34,16 +34,30 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; + /** + * Sign-in for both staff tiers. + * + *

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 blocks = user.getBlocks() == null || user.getBlocks().isBlank() ? List.of() : Arrays.stream(user.getBlocks().split(",")).map(String::trim).toList(); diff --git a/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUser.java b/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUser.java index 91a92c1..e00dd75 100644 --- a/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUser.java +++ b/src/main/java/com/lincfusion/udyamlinc/auth/UdyamUser.java @@ -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; diff --git a/src/main/java/com/lincfusion/udyamlinc/config/SecurityConfig.java b/src/main/java/com/lincfusion/udyamlinc/config/SecurityConfig.java index 013e196..35eb153 100644 --- a/src/main/java/com/lincfusion/udyamlinc/config/SecurityConfig.java +++ b/src/main/java/com/lincfusion/udyamlinc/config/SecurityConfig.java @@ -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(); diff --git a/src/main/java/com/lincfusion/udyamlinc/enterprise/Enterprise.java b/src/main/java/com/lincfusion/udyamlinc/enterprise/Enterprise.java index 4c0669d..209047b 100644 --- a/src/main/java/com/lincfusion/udyamlinc/enterprise/Enterprise.java +++ b/src/main/java/com/lincfusion/udyamlinc/enterprise/Enterprise.java @@ -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; diff --git a/src/main/java/com/lincfusion/udyamlinc/enterprise/EnterpriseRepository.java b/src/main/java/com/lincfusion/udyamlinc/enterprise/EnterpriseRepository.java index 1b8bfc8..f628c5b 100644 --- a/src/main/java/com/lincfusion/udyamlinc/enterprise/EnterpriseRepository.java +++ b/src/main/java/com/lincfusion/udyamlinc/enterprise/EnterpriseRepository.java @@ -20,4 +20,17 @@ public interface EnterpriseRepository extends JpaRepository { 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 findByInstitutionIdOrderByScoreDesc(Long institutionId); + + List findByInstitutionIdAndBlockOrderByScoreDesc(Long institutionId, String block); + + List findByInstitutionIdAndBranchIdOrderByScoreDesc(Long institutionId, Long branchId); + + Optional findByEnterpriseIdAndInstitutionId(String enterpriseId, Long institutionId); } diff --git a/src/main/java/com/lincfusion/udyamlinc/institution/Branch.java b/src/main/java/com/lincfusion/udyamlinc/institution/Branch.java new file mode 100644 index 0000000..9bf7765 --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/institution/Branch.java @@ -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. + * + *

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. + * + *

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; + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/institution/BranchRepository.java b/src/main/java/com/lincfusion/udyamlinc/institution/BranchRepository.java new file mode 100644 index 0000000..865109c --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/institution/BranchRepository.java @@ -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 { + + List findByInstitutionIdAndActiveTrueOrderByName(Long institutionId); + + Optional findByInstitutionIdAndCode(Long institutionId, String code); + + List findByInstitutionIdAndRegionAndActiveTrue(Long institutionId, String region); +} diff --git a/src/main/java/com/lincfusion/udyamlinc/institution/Institution.java b/src/main/java/com/lincfusion/udyamlinc/institution/Institution.java new file mode 100644 index 0000000..6604fb8 --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/institution/Institution.java @@ -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. + * + *

Why this exists. 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. + * + *

{@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); + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/institution/InstitutionRepository.java b/src/main/java/com/lincfusion/udyamlinc/institution/InstitutionRepository.java new file mode 100644 index 0000000..8404bd3 --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/institution/InstitutionRepository.java @@ -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 { + + Optional findByCode(String code); + + List findByActiveTrueOrderByName(); + + /** The lenders — everything that is not the apex tier. */ + List findByTypeNotAndActiveTrueOrderByName(String type); +} diff --git a/src/main/java/com/lincfusion/udyamlinc/officer/OfficerController.java b/src/main/java/com/lincfusion/udyamlinc/officer/OfficerController.java index 1cf1da7..d1cb276 100644 --- a/src/main/java/com/lincfusion/udyamlinc/officer/OfficerController.java +++ b/src/main/java/com/lincfusion/udyamlinc/officer/OfficerController.java @@ -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 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> 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 detail(@PathVariable String enterpriseId) { - return ApiResponse.ok(officerService.detail(enterpriseId)); + public ApiResponse detail(TenantContext tenant, @PathVariable String enterpriseId) { + return ApiResponse.ok(officerService.detail(tenant, enterpriseId)); } @GetMapping("/alerts") public ApiResponse> 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 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> 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 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> sectors( + TenantContext tenant, @RequestParam(defaultValue = OfficerService.ALL_BLOCKS) String block) { - return ApiResponse.ok(officerService.sectorRollup(block)); + return ApiResponse.ok(officerService.sectorRollup(tenant, block)); } } diff --git a/src/main/java/com/lincfusion/udyamlinc/officer/OfficerService.java b/src/main/java/com/lincfusion/udyamlinc/officer/OfficerService.java index dfd9400..547066d 100644 --- a/src/main/java/com/lincfusion/udyamlinc/officer/OfficerService.java +++ b/src/main/java/com/lincfusion/udyamlinc/officer/OfficerService.java @@ -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 rows = enterprisesIn(block); + public PortfolioResponse portfolio(TenantContext tenant, String block, String month) { + List 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 enterprises(String block) { - return toRows(enterprisesIn(block)); + public List 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 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 alerts(TenantContext tenant, String block, String state) { + Map blockOf = visibleBlocks(tenant); + if (blockOf.isEmpty()) return List.of(); + List 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 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 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 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 visits(TenantContext tenant, String block) { + Map blockOf = visibleBlocks(tenant); + if (blockOf.isEmpty()) return List.of(); + + List 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 sectorRollup(String block) { - List rows = enterprisesIn(block); + public List sectorRollup(TenantContext tenant, String block) { + List rows = enterprisesIn(tenant, block); return sectorRepository.findByActiveTrue().stream() .map(sector -> { List mine = rows.stream() @@ -169,10 +194,34 @@ public class OfficerService { // ── helpers ───────────────────────────────────────────── - private List 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. + * + *

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 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 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. + * + *

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)); } diff --git a/src/main/java/com/lincfusion/udyamlinc/security/JwtAuthFilter.java b/src/main/java/com/lincfusion/udyamlinc/security/JwtAuthFilter.java index 829fc1b..534a345 100644 --- a/src/main/java/com/lincfusion/udyamlinc/security/JwtAuthFilter.java +++ b/src/main/java/com/lincfusion/udyamlinc/security/JwtAuthFilter.java @@ -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. + * + *

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)); + } } diff --git a/src/main/java/com/lincfusion/udyamlinc/security/JwtUtil.java b/src/main/java/com/lincfusion/udyamlinc/security/JwtUtil.java index fa8f1cb..43ac34d 100644 --- a/src/main/java/com/lincfusion/udyamlinc/security/JwtUtil.java +++ b/src/main/java/com/lincfusion/udyamlinc/security/JwtUtil.java @@ -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. + * + *

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 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. + * + *

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 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()); diff --git a/src/main/java/com/lincfusion/udyamlinc/security/TenantContext.java b/src/main/java/com/lincfusion/udyamlinc/security/TenantContext.java new file mode 100644 index 0000000..262899f --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/security/TenantContext.java @@ -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. + * + *

Resolved once per request from the verified JWT and nowhere else. The + * institution is never 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. + * + *

Three entitlements, and they are not degrees of the same thing: + * + *

+ */ +@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. + * + *

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; + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/security/TenantContextArgumentResolver.java b/src/main/java/com/lincfusion/udyamlinc/security/TenantContextArgumentResolver.java new file mode 100644 index 0000000..7e0bb9a --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/security/TenantContextArgumentResolver.java @@ -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. + * + *

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 resolvers) { + resolvers.add(this); + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/InstitutionSeeder.java b/src/main/java/com/lincfusion/udyamlinc/seed/InstitutionSeeder.java new file mode 100644 index 0000000..1956b80 --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/seed/InstitutionSeeder.java @@ -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. + * + *

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 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 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 seedBranches() { + Map 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 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 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. + * + *

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 branches) { + List unassigned = enterpriseRepository.findAll().stream() + .filter(e -> e.getInstitutionId() == null) + .toList(); + if (unassigned.isEmpty()) return; + + Map> 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 fallback = branches.values().stream() + .sorted(Comparator.comparing(Branch::getCode)).toList(); + if (fallback.isEmpty()) return; + + int assigned = 0; + for (Enterprise e : unassigned) { + List 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 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 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()); + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java b/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java index 6e7c93a..da02f30 100644 --- a/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java +++ b/src/main/java/com/lincfusion/udyamlinc/seed/UdyamDataSeeder.java @@ -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()); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8937432..0d59724 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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