Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/src/buscasam/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions backend/src/buscasam/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions backend/tests/unit/test_auth_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions docs/adr/0005-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading