Coverage for apps/accounts/auth.py: 88%

303 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-04 17:24 +0000

1"""Authentication + authorization: principals, pluggable verifier, Ninja guards. 

2 

3Endpoints declare a single required permission via ``requires("...")``; the guard 

4authenticates (staff vs borrower realm), resolves the principal, and checks the 

5permission. Scope (org-unit / own-loan) is enforced through the helpers here. 

6""" 

7 

8from __future__ import annotations 

9 

10import logging 

11from dataclasses import dataclass 

12from datetime import timedelta 

13 

14import jwt 

15from django.conf import settings 

16from django.db.models import Q 

17from django.utils import timezone 

18from jwt import PyJWKClient 

19from ninja.security import HttpBearer 

20 

21from apps.accounts.models import RolePermission, User, UserRole 

22from apps.application_1003.models import Borrower 

23from domain.result import AppError, DomainError 

24 

25logger = logging.getLogger(__name__) 

26 

27 

28@dataclass(frozen=True) 

29class StaffPrincipal: 

30 user_id: int 

31 email: str 

32 permissions: frozenset[str] 

33 org_unit_ids: frozenset[int] 

34 is_hq: bool 

35 kind: str = "staff" 

36 # The real admin's user id when this principal is an Act As impersonation 

37 # (add-staff-act-as); None for an ordinary login. Not consumed anywhere 

38 # yet — a cheap hook for a future audit-log feature, same as other 

39 # nullable "connect this later" fields in this codebase. 

40 acting_admin_id: int | None = None 

41 

42 def has(self, permission: str) -> bool: 

43 return permission in self.permissions 

44 

45 

46@dataclass(frozen=True) 

47class BorrowerPrincipal: 

48 borrower_email: str 

49 loan_ids: frozenset[int] 

50 cognito_sub: str | None = None 

51 kind: str = "borrower" 

52 

53 

54@dataclass(frozen=True) 

55class ServicePrincipal: 

56 """A system-to-system caller (Cognito client-credentials), not a human user.""" 

57 

58 client_id: str 

59 scopes: frozenset[str] 

60 kind: str = "service" 

61 

62 def has_scope(self, scope: str) -> bool: 

63 return scope in self.scopes 

64 

65 

66def effective_permissions(user: User) -> frozenset[str]: 

67 """Union of permission codes across all of the user's roles.""" 

68 role_ids = UserRole.objects.filter(user=user).values_list("role_id", flat=True) 

69 codes = RolePermission.objects.filter(role_id__in=role_ids).values_list( 

70 "permission__code", flat=True 

71 ) 

72 return frozenset(codes) 

73 

74 

75def build_staff_principal(user: User, *, acting_admin_id: int | None = None) -> StaffPrincipal: 

76 bindings = list(UserRole.objects.filter(user=user).select_related("org_unit")) 

77 org_unit_ids = {b.org_unit_id for b in bindings if b.org_unit_id is not None} 

78 # HQ (org-wide) is conferred only by a binding to a headquarters org-unit — NOT by a 

79 # null-org (unscoped) binding. A coarse role bound unscoped (e.g. a freshly provisioned 

80 # Cognito user with no branch yet) therefore grants no loan scope until a branch is added. 

81 is_hq = any(b.org_unit and b.org_unit.is_headquarters for b in bindings) 

82 return StaffPrincipal( 

83 user_id=user.pk, 

84 email=user.email, 

85 permissions=effective_permissions(user), 

86 org_unit_ids=frozenset(org_unit_ids), 

87 is_hq=is_hq, 

88 acting_admin_id=acting_admin_id, 

89 ) 

90 

91 

92def build_borrower_principal(email: str, cognito_sub: str | None = None) -> BorrowerPrincipal: 

93 # Case-insensitive: borrower accounts store emails lower-case, staff may type them any way. 

94 loan_ids = set(Borrower.objects.filter(email__iexact=email).values_list("loan_id", flat=True)) 

95 return BorrowerPrincipal( 

96 borrower_email=email, loan_ids=frozenset(loan_ids), cognito_sub=cognito_sub 

97 ) 

98 

99 

100# --- Pluggable verifiers ------------------------------------------------------- 

101class DevVerifier: 

102 """Dev/test only. Token ``staff:<email>`` or ``borrower:<email>`` maps to a user.""" 

103 

104 def verify_staff(self, token: str) -> StaffPrincipal | None: 

105 if not token or not token.startswith("staff:"): 

106 return None 

107 user = User.objects.filter(email=token.split(":", 1)[1], is_active=True).first() 

108 return build_staff_principal(user) if user else None 

109 

110 def verify_borrower(self, token: str) -> BorrowerPrincipal | None: 

111 if not token or not token.startswith("borrower:"): 

112 return None 

113 email = token.split(":", 1)[1] 

114 from apps.borrower_portal.models import BorrowerAccount 

115 

116 # A disabled borrower account is refused here too, as with local tokens. 

117 if BorrowerAccount.objects.filter(email__iexact=email, is_active=False).exists(): 

118 return None 

119 return build_borrower_principal(email) 

120 

121 def verify_service(self, token: str, required_scope: str | None = None): 

122 """Dev/test M2M. Token ``service:<space-separated scopes>``.""" 

123 if not token or not token.startswith("service:"): 

124 return None 

125 scopes = frozenset(token.split(":", 1)[1].split()) 

126 if required_scope and required_scope not in scopes: 

127 return None 

128 return ServicePrincipal(client_id="dev-service", scopes=scopes) 

129 

130 

131def mint_local_token(user: User, *, acting_admin_id: int | None = None) -> str: 

132 """Issue a Lumen-signed login token (AUTH_VERIFIER=local; add-staff-auth-login). 

133 

134 ``acting_admin_id`` embeds the real admin's id as a separate ``act_as`` 

135 claim for an Act As token (add-staff-act-as) — ``sub`` stays the 

136 *effective* user (``user``), so permission resolution is unchanged. 

137 """ 

138 now = timezone.now() 

139 ttl = timedelta(hours=getattr(settings, "AUTH_LOCAL_TOKEN_TTL_HOURS", 24)) 

140 payload = {"sub": str(user.pk), "email": user.email, "iat": now, "exp": now + ttl} 

141 if acting_admin_id is not None: 

142 payload["act_as"] = str(acting_admin_id) 

143 return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") 

144 

145 

146BORROWER_REALM = "borrower" 

147 

148 

149def mint_borrower_token(account) -> str: 

150 """Issue a Lumen-signed borrower token (add-borrower-portal-api design.md D2). 

151 

152 Same key and TTL as staff tokens, but carries ``realm: "borrower"`` and the 

153 ``BorrowerAccount`` id as ``sub``. ``LocalVerifier.verify_staff`` rejects any token with a 

154 realm claim, so a borrower id can never be read as a staff user id. 

155 """ 

156 now = timezone.now() 

157 ttl = timedelta(hours=getattr(settings, "AUTH_LOCAL_TOKEN_TTL_HOURS", 24)) 

158 payload = { 

159 "sub": str(account.pk), 

160 "email": account.email, 

161 "realm": BORROWER_REALM, 

162 "iat": now, 

163 "exp": now + ttl, 

164 } 

165 return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") 

166 

167 

168def _decode_local_token(token: str) -> dict | None: 

169 try: 

170 return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) 

171 except Exception as e: 

172 logger.warning("Local token verification failed: %s", e) 

173 return None 

174 

175 

176class LocalVerifier: 

177 """Verifies Lumen-signed local login tokens for staff and borrowers. The service realm 

178 stays on Cognito/dev.""" 

179 

180 def verify_staff(self, token: str) -> StaffPrincipal | None: 

181 claims = _decode_local_token(token) 

182 if not claims: 

183 return None 

184 # Staff tokens carry no realm. Any realm-tagged token (a borrower's) is refused, or a 

185 # borrower account id in ``sub`` would be looked up as a staff user id. 

186 if claims.get("realm"): 

187 return None 

188 sub = claims.get("sub") 

189 if not sub: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true

190 return None 

191 user = User.objects.filter(pk=sub, is_active=True).first() 

192 if user is None: 

193 return None 

194 act_as = claims.get("act_as") 

195 return build_staff_principal(user, acting_admin_id=int(act_as) if act_as else None) 

196 

197 def verify_borrower(self, token: str) -> BorrowerPrincipal | None: 

198 claims = _decode_local_token(token) 

199 if not claims or claims.get("realm") != BORROWER_REALM: 

200 return None 

201 sub = claims.get("sub") 

202 if not sub or not str(sub).isdigit(): 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true

203 return None 

204 from apps.borrower_portal.models import BorrowerAccount 

205 

206 account = BorrowerAccount.objects.filter(pk=int(sub), is_active=True).first() 

207 if account is None: 

208 return None 

209 return build_borrower_principal(account.email, cognito_sub=account.cognito_sub) 

210 

211 def verify_service(self, token: str, required_scope: str | None = None): 

212 return None 

213 

214 

215COGNITO_ROLE_MAP: dict[str, str] = { 

216 "SUPER_ADMIN": "Owner", 

217 "Admins": "Company Admin", 

218 "MLO": "Loan Officer", 

219 "Processor": "Processor", 

220} 

221 

222 

223def sync_user_roles_from_cognito(user: User, groups: list[str]) -> None: 

224 """Assign the Cognito-mapped coarse role to the user — **DB-first branch scoping**. 

225 

226 Identity + coarse role come from Cognito; branch (org-unit) membership lives in the Lumen 

227 DB (``OrgUnit`` + ``UserRole``) and is **not** defaulted to headquarters. The coarse role 

228 is bound **unscoped** (``org_unit=None``), which — since a null-org binding no longer 

229 confers HQ (see ``build_staff_principal``) — grants the role's permissions but **no loan 

230 scope**. A Lumen admin then assigns one or more branches (``UserRole`` rows), each of which 

231 adds loan scope. This is the safe default: no company-wide visibility on provisioning. 

232 """ 

233 from apps.accounts.models import Role, UserRole 

234 

235 for group in groups: 

236 role_name = COGNITO_ROLE_MAP.get(group) 

237 if role_name: 237 ↛ 235line 237 didn't jump to line 235 because the condition on line 237 was always true

238 role, _ = Role.objects.get_or_create(name=role_name) 

239 UserRole.objects.get_or_create(user=user, role=role, org_unit=None) 

240 

241 

242def assign_user_branch(user: User, role_name: str, org_unit) -> None: 

243 """Admin branch-assignment: bind ``role_name`` for ``user`` within ``org_unit``. 

244 

245 This is how a provisioned user gains loan scope (DB-first). Callable from the Django 

246 admin, an invite/onboarding flow, or a future admin API. 

247 """ 

248 from apps.accounts.models import Role, UserRole 

249 

250 role, _ = Role.objects.get_or_create(name=role_name) 

251 UserRole.objects.get_or_create(user=user, role=role, org_unit=org_unit) 

252 

253 

254class CognitoVerifier: 

255 """Verifies AWS Cognito ID tokens (RS256 via JWKS, issuer + client ID), maps email and 

256 cognito:groups to staff roles, and handles JIT user provisioning. 

257 """ 

258 

259 def __init__(self, jwks_client: PyJWKClient | None = None): 

260 self.region = getattr(settings, "COGNITO_AWS_REGION", "us-east-1") 

261 self.user_pool_id = getattr(settings, "COGNITO_USER_POOL_ID", "") 

262 self.client_id = getattr(settings, "COGNITO_APP_CLIENT_ID", "") 

263 self.issuer = ( 

264 getattr(settings, "COGNITO_ISSUER", "") 

265 or f"https://cognito-idp.{self.region}.amazonaws.com/{self.user_pool_id}" 

266 ) 

267 self.jwks_url = f"{self.issuer}/.well-known/jwks.json" 

268 self._jwks = jwks_client or PyJWKClient(self.jwks_url) 

269 

270 def _claims(self, token: str, *, verify_audience: bool = True) -> dict | None: 

271 has_iss = bool(self.user_pool_id or getattr(settings, "COGNITO_ISSUER", "")) 

272 # Production MUST verify issuer, and (for user ID tokens) audience. Fail closed if the 

273 # verifier is active but misconfigured, rather than silently skipping verification. 

274 if not settings.DEBUG and (not has_iss or (verify_audience and not self.client_id)): 

275 logger.error( 

276 "Cognito verifier misconfigured for production: issuer and audience are required" 

277 ) 

278 return None 

279 try: 

280 signing_key = self._jwks.get_signing_key_from_jwt(token) 

281 # Service (client-credentials) access tokens carry no `aud`; skip audience there. 

282 verify_aud = verify_audience and bool(self.client_id) 

283 options = {"verify_aud": verify_aud, "verify_iss": has_iss} 

284 kwargs: dict = {"algorithms": ["RS256"], "options": options} 

285 if has_iss: 285 ↛ 287line 285 didn't jump to line 287 because the condition on line 285 was always true

286 kwargs["issuer"] = self.issuer 

287 if verify_aud: 287 ↛ 289line 287 didn't jump to line 289 because the condition on line 287 was always true

288 kwargs["audience"] = self.client_id 

289 return jwt.decode(token, signing_key.key, **kwargs) 

290 except Exception as e: 

291 logger.warning("Cognito token verification failed: %s", e) 

292 return None 

293 

294 def verify_staff(self, token: str) -> StaffPrincipal | None: 

295 claims = self._claims(token) 

296 if not claims: 

297 return None 

298 sub = claims.get("sub") 

299 email = claims.get("email") 

300 if not sub: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true

301 logger.warning("Cognito token has no sub claim") 

302 return None 

303 

304 groups = claims.get("cognito:groups", []) 

305 if isinstance(groups, str): 305 ↛ 306line 305 didn't jump to line 306 because the condition on line 305 was never true

306 groups = [groups] 

307 

308 # Prevent pure applicant/borrower tokens from the staff realm. 

309 has_staff_group = any(g in COGNITO_ROLE_MAP for g in groups) 

310 if not has_staff_group and ("Applicants" in groups or not groups): 

311 logger.warning("Cognito token lacks staff groups for %s: %s", email, groups) 

312 return None 

313 

314 # Resolve by the immutable `sub`; fall back to email (pre-Cognito users), then JIT. 

315 user = User.objects.filter(cognito_sub=sub).first() 

316 if user is None and email: 

317 user = User.objects.filter(email=email).first() 

318 if user is None: 

319 if not email: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true

320 logger.warning("Cannot provision a Cognito user without an email claim") 

321 return None 

322 name = claims.get("name", "") or "" 

323 first_name = claims.get("given_name") or (name.split(" ")[0] if name else "Staff") 

324 last_name = claims.get("family_name") or ( 

325 name.split(" ", 1)[1] if " " in name else "User" 

326 ) 

327 user = User.objects.create_user( 

328 email=email, first_name=first_name, last_name=last_name, is_staff=True 

329 ) 

330 elif not user.is_active: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true

331 return None 

332 

333 # Persist / backfill the `sub` link — the cross-DB key to Tireless's `users.cognito_id`. 

334 if user.cognito_sub != sub: 

335 user.cognito_sub = sub 

336 user.save(update_fields=["cognito_sub"]) 

337 

338 if groups: 338 ↛ 341line 338 didn't jump to line 341 because the condition on line 338 was always true

339 sync_user_roles_from_cognito(user, groups) 

340 

341 return build_staff_principal(user) 

342 

343 def verify_borrower(self, token: str) -> BorrowerPrincipal | None: 

344 claims = self._claims(token) 

345 if not claims: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

346 return None 

347 groups = claims.get("cognito:groups", []) 

348 if isinstance(groups, str): 348 ↛ 349line 348 didn't jump to line 349 because the condition on line 348 was never true

349 groups = [groups] 

350 # Borrower realm: the token must be an Applicant and carry no staff group. 

351 if "Applicants" not in groups or any(g in COGNITO_ROLE_MAP for g in groups): 

352 return None 

353 email = claims.get("email") 

354 if not email: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true

355 return None 

356 return build_borrower_principal(email, cognito_sub=claims.get("sub")) 

357 

358 def verify_service(self, token: str, required_scope: str | None = None): 

359 """Verify a Cognito client-credentials access token (M2M): no user, scope-checked.""" 

360 claims = self._claims(token, verify_audience=False) 

361 if not claims or claims.get("token_use") != "access": 

362 return None 

363 scopes = frozenset((claims.get("scope") or "").split()) 

364 if required_scope and required_scope not in scopes: 

365 logger.warning("Service token missing required scope %s", required_scope) 

366 return None 

367 return ServicePrincipal( 

368 client_id=claims.get("client_id") or claims.get("sub", ""), scopes=scopes 

369 ) 

370 

371 

372_cognito_verifier: CognitoVerifier | None = None 

373_local_verifier: LocalVerifier | None = None 

374 

375 

376def get_verifier(): 

377 global _cognito_verifier, _local_verifier 

378 mode = getattr(settings, "AUTH_VERIFIER", "dev") 

379 if mode == "cognito": 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true

380 if _cognito_verifier is None: 

381 _cognito_verifier = CognitoVerifier() 

382 return _cognito_verifier 

383 if mode == "local": 

384 if _local_verifier is None: 

385 _local_verifier = LocalVerifier() 

386 return _local_verifier 

387 return DevVerifier() 

388 

389 

390# --- Ninja guards -------------------------------------------------------------- 

391class StaffAuth(HttpBearer): 

392 def __init__(self, permission: str | None = None): 

393 self.permission = permission 

394 super().__init__() 

395 

396 def authenticate(self, request, token): 

397 principal = get_verifier().verify_staff(token) 

398 if principal is None: 

399 return None # -> 401 

400 if self.permission and not principal.has(self.permission): 

401 raise DomainError([AppError("FORBIDDEN", f"Missing permission: {self.permission}")]) 

402 request.principal = principal 

403 return principal 

404 

405 

406class BorrowerAuth(HttpBearer): 

407 def authenticate(self, request, token): 

408 principal = get_verifier().verify_borrower(token) 

409 if principal is None: 409 ↛ 410line 409 didn't jump to line 410 because the condition on line 409 was never true

410 return None 

411 request.principal = principal 

412 return principal 

413 

414 

415def requires(permission: str) -> StaffAuth: 

416 return StaffAuth(permission) 

417 

418 

419def borrower() -> BorrowerAuth: 

420 return BorrowerAuth() 

421 

422 

423class PortalAuth(HttpBearer): 

424 """Borrower-only portal endpoints (add-borrower-portal-api design.md D3). 

425 

426 A borrower principal with a portal account is let through and ``request.borrower_account`` 

427 is set. A valid *staff* token is refused with FORBIDDEN (the portal contract: staff get 

428 403); anything else is 401. Cognito borrowers get an account on first use. 

429 """ 

430 

431 def authenticate(self, request, token): 

432 verifier = get_verifier() 

433 principal = verifier.verify_borrower(token) 

434 if principal is None: 

435 if verifier.verify_staff(token) is not None: 

436 raise DomainError( 

437 [AppError("FORBIDDEN", "The borrower portal is for borrowers only.")] 

438 ) 

439 return None 

440 from apps.borrower_portal.services import account_for_principal 

441 

442 account = account_for_principal(principal) 

443 if account is None: 

444 return None 

445 request.principal = principal 

446 request.borrower_account = account 

447 return principal 

448 

449 

450def portal() -> PortalAuth: 

451 return PortalAuth() 

452 

453 

454def borrower_loan(request): 

455 """The signed-in borrower's portal loan, or None (design.md D3). 

456 

457 The newest loan on which they are the primary borrower, else the newest on which they are 

458 any borrower. Only loans in ``principal.loan_ids`` are ever considered, so a borrower can 

459 never reach another borrower's loan. 

460 """ 

461 from apps.core_los.models import Loan 

462 

463 principal = request.principal 

464 if not principal.loan_ids: 

465 return None 

466 loans = Loan.objects.filter(pk__in=principal.loan_ids).order_by("-created_at", "-pk") 

467 primary = loans.filter( 

468 borrowers__is_primary=True, borrowers__email__iexact=principal.borrower_email 

469 ).first() 

470 return primary or loans.first() 

471 

472 

473class ServiceAuth(HttpBearer): 

474 """Authenticates a system-to-system caller (Cognito client-credentials M2M).""" 

475 

476 def __init__(self, scope: str | None = None): 

477 self.scope = scope or getattr(settings, "COGNITO_M2M_SCOPE", "") or None 

478 super().__init__() 

479 

480 def authenticate(self, request, token): 

481 verify_service = getattr(get_verifier(), "verify_service", None) 

482 if verify_service is None: 

483 return None # verifier has no M2M path -> 401 

484 principal = verify_service(token, self.scope) 

485 if principal is None: 

486 return None 

487 request.principal = principal 

488 return principal 

489 

490 

491def requires_service(scope: str | None = None) -> ServiceAuth: 

492 """Guard for service-only endpoints (loan handoff, condition/fraud sync callbacks).""" 

493 return ServiceAuth(scope) 

494 

495 

496# --- Scope enforcement --------------------------------------------------------- 

497def staff_loan_queryset(principal: StaffPrincipal, scope: str = "all"): 

498 """Loans visible to ``principal``, optionally narrowed by pipeline ``scope``. 

499 

500 ``all`` (default) is the existing org-unit/HQ visibility rule, unchanged for 

501 every caller that doesn't pass ``scope``. ``team`` and ``all`` compute the same 

502 base set today (see add-loan-pipeline-api design.md); ``me`` narrows that base to 

503 loans where the principal holds one of the loan-team roles. 

504 """ 

505 from apps.core_los.models import Loan 

506 

507 if principal.is_hq or "VIEW_ALL_PIPELINE" in principal.permissions: 

508 base = Loan.objects.all() 

509 else: 

510 base = Loan.objects.filter(org_unit_id__in=principal.org_unit_ids) 

511 if scope == "me": 

512 return base.filter( 

513 Q(loan_officer_id=principal.user_id) 

514 | Q(additional_lo_id=principal.user_id) 

515 | Q(processor_id=principal.user_id) 

516 | Q(loa_id=principal.user_id) 

517 ) 

518 return base 

519 

520 

521def ensure_staff_scope(principal: StaffPrincipal, loan) -> None: 

522 if principal.is_hq or "VIEW_ALL_PIPELINE" in principal.permissions: 

523 return 

524 if loan.org_unit_id not in principal.org_unit_ids: 524 ↛ exitline 524 didn't return from function 'ensure_staff_scope' because the condition on line 524 was always true

525 raise DomainError([AppError("FORBIDDEN", "Loan is outside your org-unit scope.")])