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>
139 lines
4.6 KiB
Java
139 lines
4.6 KiB
Java
package com.lincfusion.udyamlinc.security;
|
|
|
|
import io.jsonwebtoken.Claims;
|
|
import io.jsonwebtoken.Jwts;
|
|
import io.jsonwebtoken.SignatureAlgorithm;
|
|
import io.jsonwebtoken.security.Keys;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.stereotype.Component;
|
|
|
|
import java.security.Key;
|
|
import java.util.Date;
|
|
import java.util.HashMap;
|
|
import java.util.Map;
|
|
|
|
/** Issues and reads the two token flavours: officer console and enterprise app. */
|
|
@Component
|
|
public class JwtUtil {
|
|
|
|
@Value("${jwt.secret}")
|
|
private String secret;
|
|
|
|
@Value("${jwt.expiration:86400000}")
|
|
private long expirationMs;
|
|
|
|
private Key signingKey() {
|
|
return Keys.hmacShaKeyFor(secret.getBytes());
|
|
}
|
|
|
|
/**
|
|
* 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", "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);
|
|
}
|
|
|
|
/**
|
|
* Enterprise app session — scope=ENTERPRISE, pinned to one enterprise id so
|
|
* the owner can only ever read their own record.
|
|
*/
|
|
public String generateEnterpriseToken(String enterpriseId, String sector) {
|
|
Map<String, Object> claims = new HashMap<>();
|
|
claims.put("realm", "UDYAM");
|
|
claims.put("scope", "ENTERPRISE");
|
|
claims.put("role", "OWNER");
|
|
claims.put("enterpriseId", enterpriseId);
|
|
claims.put("sector", sector);
|
|
return build(claims, enterpriseId);
|
|
}
|
|
|
|
private String build(Map<String, Object> claims, String subject) {
|
|
long now = System.currentTimeMillis();
|
|
return Jwts.builder()
|
|
.setClaims(claims)
|
|
.setSubject(subject)
|
|
.setIssuedAt(new Date(now))
|
|
.setExpiration(new Date(now + expirationMs))
|
|
.signWith(signingKey(), SignatureAlgorithm.HS256)
|
|
.compact();
|
|
}
|
|
|
|
public Claims parse(String token) {
|
|
return Jwts.parserBuilder().setSigningKey(signingKey()).build().parseClaimsJws(token).getBody();
|
|
}
|
|
|
|
public String subject(String token) {
|
|
return parse(token).getSubject();
|
|
}
|
|
|
|
public String scope(String token) {
|
|
return String.valueOf(parse(token).get("scope"));
|
|
}
|
|
|
|
public String role(String token) {
|
|
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());
|
|
} catch (Exception e) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|