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
@@ -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);
}
}