From 5f228a9cf801baf35ae009642b96bc0cd6a6ad38 Mon Sep 17 00:00:00 2001 From: LucasACH Date: Wed, 17 Jun 2026 16:16:33 -0300 Subject: [PATCH] feat(auth): env-driven docente email allowlist Add BUSCASAM_DOCENTE_EMAIL_ALLOWLIST: a comma-separated set of exact emails granted the `docente` role regardless of their `hd` claim. This is an escape hatch for demo / non-UNSAM accounts that must moderate, without weakening the verification gate or hardcoding addresses. role_from_claims checks the allowlist after the email_verified gate and before ROLE_BY_HD, so email_verified != True is still rejected and the hd domain map is unchanged for everyone else. Empty by default (no-op). Documented as ADR-0005 item 17 (a deliberate exception to the hd-is-sole-trust-boundary rule). Tests cover prod grant (case- insensitive), the unverified-email rejection, and non-listed Gmail. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/buscasam/core/auth.py | 7 +++++ backend/src/buscasam/settings.py | 15 +++++++++ backend/tests/unit/test_auth_core.py | 46 ++++++++++++++++++++++++++++ docs/adr/0005-authentication.md | 1 + 4 files changed, 69 insertions(+) diff --git a/backend/src/buscasam/core/auth.py b/backend/src/buscasam/core/auth.py index 605de74..eaeeee3 100644 --- a/backend/src/buscasam/core/auth.py +++ b/backend/src/buscasam/core/auth.py @@ -190,9 +190,16 @@ def role_from_claims(claims: Mapping[str, object]) -> Role | None: stays `estudiante` and every other verified account logs in as `docente` so any test account can moderate. The cookie / DB / redirect machinery is the caller's job; this is the pure decision. + + `BUSCASAM_DOCENTE_EMAIL_ALLOWLIST` overrides the domain map: a verified + email listed there is `docente` regardless of `hd`. `email_verified` is + still required above, so this never bypasses the verification gate. """ if claims.get("email_verified") is not True: return None + email = claims.get("email") + if isinstance(email, str) and email.lower() in settings.docente_emails: + return "docente" hd = claims.get("hd") if settings.env != "prod": return "estudiante" if hd == "estudiantes.unsam.edu.ar" else "docente" diff --git a/backend/src/buscasam/settings.py b/backend/src/buscasam/settings.py index 1d26025..d61b1d2 100644 --- a/backend/src/buscasam/settings.py +++ b/backend/src/buscasam/settings.py @@ -79,6 +79,21 @@ class Settings(BaseSettings): oidc_discovery_url: str = ( "https://accounts.google.com/.well-known/openid-configuration" ) + # Comma-separated exact emails granted the `docente` role even though their + # `hd` is not an UNSAM domain (ADR-0005 §3 override). Intended for demo / + # non-UNSAM accounts that must moderate; `email_verified` is still required, + # so this only loosens the domain map, not the verification gate. Set via + # BUSCASAM_DOCENTE_EMAIL_ALLOWLIST; empty (the default) is a no-op. + docente_email_allowlist: str = "" + + @property + def docente_emails(self) -> frozenset[str]: + """Normalized (lowercased, stripped) set parsed from the allowlist.""" + return frozenset( + e.strip().lower() + for e in self.docente_email_allowlist.split(",") + if e.strip() + ) @field_validator("base_url", mode="after") @classmethod diff --git a/backend/tests/unit/test_auth_core.py b/backend/tests/unit/test_auth_core.py index 4c0f69f..b42cfcd 100644 --- a/backend/tests/unit/test_auth_core.py +++ b/backend/tests/unit/test_auth_core.py @@ -123,6 +123,52 @@ def test_claim_acceptance_matrix_accepts_docente(): assert role == "docente" +def test_allowlist_grants_docente_to_non_unsam_email_in_prod(monkeypatch): + monkeypatch.setattr(auth.settings, "env", "prod") + monkeypatch.setattr( + auth.settings, "docente_email_allowlist", "achaval.lucas@gmail.com" + ) + role = auth.role_from_claims( + { + "email_verified": True, + "sub": "google-sub-3", + "email": "Achaval.Lucas@gmail.com", # case-insensitive match + } + ) + assert role == "docente" + + +def test_allowlist_does_not_bypass_email_verification(monkeypatch): + monkeypatch.setattr(auth.settings, "env", "prod") + monkeypatch.setattr( + auth.settings, "docente_email_allowlist", "achaval.lucas@gmail.com" + ) + role = auth.role_from_claims( + { + "email_verified": False, + "sub": "google-sub-3", + "email": "achaval.lucas@gmail.com", + } + ) + assert role is None + + +def test_non_allowlisted_non_unsam_email_still_rejected_in_prod(monkeypatch): + monkeypatch.setattr(auth.settings, "env", "prod") + monkeypatch.setattr( + auth.settings, "docente_email_allowlist", "achaval.lucas@gmail.com" + ) + role = auth.role_from_claims( + { + "email_verified": True, + "sub": "google-sub-4", + "email": "someone.else@gmail.com", + "hd": "gmail.com", + } + ) + assert role is None + + async def test_jit_user_upsert(session): uid1 = await auth.upsert_user( session, diff --git a/docs/adr/0005-authentication.md b/docs/adr/0005-authentication.md index c79c1b9..9f09b45 100644 --- a/docs/adr/0005-authentication.md +++ b/docs/adr/0005-authentication.md @@ -72,3 +72,4 @@ Authentication is Google OIDC restricted to UNSAM Workspace tenants. Role derive 14. Invalid/expired session on read paths: treated as Guest, not 401. Cookie silently ignored. 15. CSRF: `SameSite=Lax` is defense in depth, not the only check. Authenticated unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`) reject requests whose `Origin` is missing or is not exactly `BUSCASAM_BASE_URL`. OAuth uses the signed state cookie and PKCE from section 4. 16. Rate limiting: ADR-0009 applies limits regardless of unvalidated cookie presence. +17. Email allowlist override (escape hatch for demo / non-UNSAM accounts that must moderate): `BUSCASAM_DOCENTE_EMAIL_ALLOWLIST` is a comma-separated set of exact emails granted `docente` regardless of `hd`. Checked in `role_from_claims` after the `email_verified` gate and before `ROLE_BY_HD`, so it loosens only the domain map — `email_verified != True` is still rejected. Empty by default (no-op). Listed emails should be kept to the minimum and removed when no longer needed; each is a deliberate exception to the `hd`-is-the-sole-trust-boundary rule in item 2.