Skip to content

Commit 49b34f0

Browse files
olivermeyerclaude
andcommitted
fix(api): make AuthSettings fields mandatory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7915e19 commit 49b34f0

6 files changed

Lines changed: 54 additions & 43 deletions

File tree

src/aignostics_foundry_core/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
102102

103103
- **Purpose**: Provides Auth0 cookie-based session authentication dependencies for FastAPI routes. All project-specific settings (org ID, role claim) are loaded from `AuthSettings` whose env prefix is configurable at instantiation.
104104
- **Key Features**:
105-
- `AuthSettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}AUTH_`). Fields: `internal_org_id` (for internal org check), `auth0_role_claim` (JWT claim name for role)
105+
- `AuthSettings(OpaqueSettings)` — uses the active FoundryContext.env_prefix to derive the env prefix (`{ctx.env_prefix}AUTH_`). Fields: `internal_org_id` (required `str`; identifies the internal organization), `auth0_role_claim` (required `str`; JWT claim name for role). Both fields are mandatory — no defaults are provided.
106106
- `UnauthenticatedError(Exception)` — raised when a user session is missing or invalid
107107
- `ForbiddenError(ApiException)``status_code = 403`; raised when user lacks required role or org membership
108108
- `get_auth_client(request)` — retrieves `AuthClient` from `request.app.state.auth_client`; raises `RuntimeError` if not configured
@@ -111,7 +111,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei
111111
- `require_admin` — dependency: requires admin role
112112
- `require_internal` — dependency: requires internal organization membership
113113
- `require_internal_admin` — dependency: requires internal org membership AND admin role
114-
- Auth0 cookie security scheme constants: `AUTH0_SESSION_COOKIE_NAME`, `AUTH0_TRANSACTION_COOKIE_NAME`, `AUTH0_ROLE_ADMIN`, `DEFAULT_AUTH0_ROLE_CLAIM`
114+
- Auth0 cookie security scheme constants: `AUTH0_SESSION_COOKIE_NAME`, `AUTH0_TRANSACTION_COOKIE_NAME`, `AUTH0_ROLE_ADMIN`
115115
- **Location**: `aignostics_foundry_core/api/auth.py`
116116
- **Dependencies**: `auth0-fastapi>=1.0.0b5,<2`, `fastapi>=0.110,<1`, `loguru>=0.7,<1` (all mandatory)
117117
- **Import**: `from aignostics_foundry_core.api.auth import AuthSettings, ForbiddenError, UnauthenticatedError, get_auth_client, get_user, require_authenticated, require_admin, require_internal, require_internal_admin`

src/aignostics_foundry_core/api/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
AUTH0_ROLE_ADMIN,
1313
AUTH0_SESSION_COOKIE_NAME,
1414
AUTH0_TRANSACTION_COOKIE_NAME,
15-
DEFAULT_AUTH0_ROLE_CLAIM,
1615
AuthSettings,
1716
ForbiddenError,
1817
UnauthenticatedError,
@@ -67,7 +66,6 @@
6766
"AUTH0_ROLE_ADMIN",
6867
"AUTH0_SESSION_COOKIE_NAME",
6968
"AUTH0_TRANSACTION_COOKIE_NAME",
70-
"DEFAULT_AUTH0_ROLE_CLAIM",
7169
# exceptions
7270
"AccessDeniedException",
7371
"ApiException",

src/aignostics_foundry_core/api/auth.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,22 @@
2828
AUTH0_COOKIE_SCHEME_DESCRIPTION = "Auth0 session cookie authentication scheme."
2929
AUTH0_ROLE_ADMIN = "admin"
3030
USER_NOT_AUTHENTICATED = "User is not authenticated"
31-
# TODO(oliverm): remove the default; it should not reference Bridge
32-
DEFAULT_AUTH0_ROLE_CLAIM = "https://aignostics-platform-bridge/role"
3331

3432

3533
class AuthSettings(OpaqueSettings):
3634
"""Auth settings whose env prefix is derived from the active FoundryContext.
3735
3836
The effective prefix is ``{FoundryContext.env_prefix}AUTH_``, resolved at
3937
instantiation time via :func:`aignostics_foundry_core.foundry.get_context`.
38+
39+
Both ``internal_org_id`` and ``auth0_role_claim`` are required — they must be
40+
provided via the corresponding environment variables (no defaults).
4041
"""
4142

4243
model_config = SettingsConfigDict(extra="ignore")
4344

44-
internal_org_id: str | None = None # TODO(oliverm): make mandatory
45-
auth0_role_claim: str = DEFAULT_AUTH0_ROLE_CLAIM # TODO(oliverm): make mandatory and remove default
45+
internal_org_id: str
46+
auth0_role_claim: str
4647

4748
def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
4849
"""Initialise settings, deriving env_prefix from the active FoundryContext."""
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
"""Tests for aignostics_foundry_core.api sub-package."""
2+
3+
from tests.conftest import TEST_PROJECT_PREFIX
4+
5+
INTERNAL_ORG_ID_VAR_NAME = f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID"
6+
AUTH0_ROLE_CLAIM_VAR_NAME = f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM"

tests/aignostics_foundry_core/api/auth_test.py

Lines changed: 30 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for aignostics_foundry_core.api.auth."""
22

3+
import os
34
import time
45
from collections.abc import Generator
56
from unittest.mock import AsyncMock, MagicMock
@@ -8,7 +9,6 @@
89

910
from aignostics_foundry_core.api.auth import (
1011
AUTH0_ROLE_ADMIN,
11-
DEFAULT_AUTH0_ROLE_CLAIM,
1212
AuthSettings,
1313
ForbiddenError,
1414
UnauthenticatedError,
@@ -20,24 +20,30 @@
2020
require_internal_admin,
2121
)
2222
from aignostics_foundry_core.foundry import reset_context, set_context
23-
from tests.conftest import TEST_PROJECT_PREFIX, make_context
23+
from tests.aignostics_foundry_core.api import AUTH0_ROLE_CLAIM_VAR_NAME, INTERNAL_ORG_ID_VAR_NAME
24+
from tests.conftest import make_context
2425

2526
_INTERNAL_ORG_ID = "org_internal_123"
2627
_OTHER_ORG_ID = "org_other_456"
28+
_TEST_ROLE_CLAIM = "https://aignostics-platform-bridge/role"
2729
_USER_NOT_AUTHENTICATED = "User is not authenticated"
2830
_USER_SUB = "auth0|x"
2931
_USER_EMAIL = "x@x.com"
3032

3133

3234
@pytest.fixture(autouse=True)
3335
def _auth_context() -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction]
34-
"""Set a real FoundryContext for all auth tests to preserve FOUNDRY_AUTH_* env var names.
36+
"""Set a real FoundryContext and required AuthSettings env vars for all auth tests.
3537
3638
Yields:
3739
None
3840
"""
3941
set_context(make_context())
42+
os.environ[INTERNAL_ORG_ID_VAR_NAME] = _INTERNAL_ORG_ID
43+
os.environ[AUTH0_ROLE_CLAIM_VAR_NAME] = _TEST_ROLE_CLAIM
4044
yield
45+
os.environ.pop(INTERNAL_ORG_ID_VAR_NAME, None)
46+
os.environ.pop(AUTH0_ROLE_CLAIM_VAR_NAME, None)
4147
reset_context()
4248

4349

@@ -91,24 +97,23 @@ def test_get_auth_client_returns_client_when_present(self) -> None:
9197

9298
@pytest.mark.unit
9399
class TestAuthSettings:
94-
"""Tests for AuthSettings defaults."""
95-
96-
def test_auth_settings_defaults(self) -> None:
97-
"""AuthSettings.auth0_role_claim has the expected default role claim URL."""
98-
settings = AuthSettings()
99-
assert settings.auth0_role_claim == DEFAULT_AUTH0_ROLE_CLAIM
100-
assert settings.internal_org_id is None
101-
102-
def test_auth_settings_role_claim_value(self) -> None:
103-
"""The default role claim is the Aignostics platform bridge claim URL."""
104-
assert DEFAULT_AUTH0_ROLE_CLAIM == "https://aignostics-platform-bridge/role"
100+
"""Tests for AuthSettings."""
105101

106102
def test_auth_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None:
107-
"""AuthSettings reads env vars from the prefix supplied by FoundryContext."""
108-
set_context(make_context())
109-
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", "https://custom/role")
103+
"""AuthSettings reads both required fields from env vars using the context's prefix."""
104+
monkeypatch.setenv(AUTH0_ROLE_CLAIM_VAR_NAME, "https://custom/role")
110105
settings = AuthSettings()
111106
assert settings.auth0_role_claim == "https://custom/role"
107+
assert settings.internal_org_id == _INTERNAL_ORG_ID
108+
109+
def test_auth_settings_raises_when_required_fields_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
110+
"""AuthSettings raises ValidationError when required env vars are absent."""
111+
import pydantic
112+
113+
monkeypatch.delenv(INTERNAL_ORG_ID_VAR_NAME, raising=False)
114+
monkeypatch.delenv(AUTH0_ROLE_CLAIM_VAR_NAME, raising=False)
115+
with pytest.raises(pydantic.ValidationError):
116+
AuthSettings()
112117

113118

114119
@pytest.mark.integration
@@ -207,7 +212,7 @@ class TestRequireAdmin:
207212

208213
async def test_no_user_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
209214
"""require_admin raises ForbiddenError when no session is available."""
210-
monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False)
215+
monkeypatch.setenv(AUTH0_ROLE_CLAIM_VAR_NAME, _TEST_ROLE_CLAIM)
211216
request = MagicMock()
212217
request.app.state = MagicMock(spec=[]) # no auth_client → get_user returns None
213218

@@ -216,9 +221,9 @@ async def test_no_user_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPa
216221

217222
async def test_wrong_role_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
218223
"""require_admin raises ForbiddenError when user has a non-admin role."""
219-
monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False)
224+
monkeypatch.setenv(AUTH0_ROLE_CLAIM_VAR_NAME, _TEST_ROLE_CLAIM)
220225
request = MagicMock()
221-
user = {"sub": _USER_SUB, DEFAULT_AUTH0_ROLE_CLAIM: "viewer", "exp": int(time.time()) + 3600}
226+
user = {"sub": _USER_SUB, _TEST_ROLE_CLAIM: "viewer", "exp": int(time.time()) + 3600}
222227
fake_client = MagicMock()
223228
fake_client.require_session = AsyncMock(return_value={"user": user})
224229
request.app.state.auth_client = fake_client
@@ -228,9 +233,9 @@ async def test_wrong_role_raises_forbidden_error(self, monkeypatch: pytest.Monke
228233

229234
async def test_admin_role_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
230235
"""require_admin returns None without raising when user has the admin role."""
231-
monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False)
236+
monkeypatch.setenv(AUTH0_ROLE_CLAIM_VAR_NAME, _TEST_ROLE_CLAIM)
232237
request = MagicMock()
233-
user = {"sub": _USER_SUB, DEFAULT_AUTH0_ROLE_CLAIM: AUTH0_ROLE_ADMIN, "exp": int(time.time()) + 3600}
238+
user = {"sub": _USER_SUB, _TEST_ROLE_CLAIM: AUTH0_ROLE_ADMIN, "exp": int(time.time()) + 3600}
234239
fake_client = MagicMock()
235240
fake_client.require_session = AsyncMock(return_value={"user": user})
236241
request.app.state.auth_client = fake_client
@@ -245,7 +250,6 @@ class TestRequireInternal:
245250

246251
async def test_unauthenticated_user_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
247252
"""require_internal raises ForbiddenError when no session is available."""
248-
monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
249253
request = MagicMock()
250254
request.app.state = MagicMock(spec=[]) # no auth_client → get_user returns None
251255

@@ -254,7 +258,6 @@ async def test_unauthenticated_user_raises_forbidden_error(self, monkeypatch: py
254258

255259
async def test_wrong_org_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
256260
"""require_internal raises ForbiddenError when user belongs to a different org."""
257-
monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
258261
request = MagicMock()
259262
user = {"sub": _USER_SUB, "org_id": _OTHER_ORG_ID, "exp": int(time.time()) + 3600}
260263
fake_client = MagicMock()
@@ -266,7 +269,7 @@ async def test_wrong_org_raises_forbidden_error(self, monkeypatch: pytest.Monkey
266269

267270
async def test_internal_org_member_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
268271
"""require_internal returns None without raising when user is in the internal org."""
269-
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
272+
monkeypatch.setenv(INTERNAL_ORG_ID_VAR_NAME, _INTERNAL_ORG_ID)
270273
request = MagicMock()
271274
user = {"sub": _USER_SUB, "org_id": _INTERNAL_ORG_ID, "exp": int(time.time()) + 3600}
272275
fake_client = MagicMock()
@@ -283,7 +286,6 @@ class TestRequireInternalAdmin:
283286

284287
async def test_unauthenticated_user_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
285288
"""require_internal_admin raises ForbiddenError when no session is available."""
286-
monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
287289
request = MagicMock()
288290
request.app.state = MagicMock(spec=[]) # no auth_client → get_user returns None
289291

@@ -292,7 +294,6 @@ async def test_unauthenticated_user_raises_forbidden_error(self, monkeypatch: py
292294

293295
async def test_wrong_org_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
294296
"""require_internal_admin raises ForbiddenError when user belongs to a different org."""
295-
monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
296297
request = MagicMock()
297298
user = {"sub": _USER_SUB, "org_id": _OTHER_ORG_ID, "exp": int(time.time()) + 3600}
298299
fake_client = MagicMock()
@@ -304,13 +305,11 @@ async def test_wrong_org_raises_forbidden_error(self, monkeypatch: pytest.Monkey
304305

305306
async def test_correct_org_wrong_role_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
306307
"""require_internal_admin raises ForbiddenError when user is in internal org but lacks admin role."""
307-
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
308-
monkeypatch.delenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", raising=False)
309308
request = MagicMock()
310309
user = {
311310
"sub": _USER_SUB,
312311
"org_id": _INTERNAL_ORG_ID,
313-
DEFAULT_AUTH0_ROLE_CLAIM: "viewer",
312+
_TEST_ROLE_CLAIM: "viewer",
314313
"exp": int(time.time()) + 3600,
315314
}
316315
fake_client = MagicMock()
@@ -322,13 +321,11 @@ async def test_correct_org_wrong_role_raises_forbidden_error(self, monkeypatch:
322321

323322
async def test_internal_admin_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
324323
"""require_internal_admin returns None without raising when user is internal org admin."""
325-
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID)
326-
monkeypatch.delenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", raising=False)
327324
request = MagicMock()
328325
user = {
329326
"sub": _USER_SUB,
330327
"org_id": _INTERNAL_ORG_ID,
331-
DEFAULT_AUTH0_ROLE_CLAIM: AUTH0_ROLE_ADMIN,
328+
_TEST_ROLE_CLAIM: AUTH0_ROLE_ADMIN,
332329
"exp": int(time.time()) + 3600,
333330
}
334331
fake_client = MagicMock()

tests/aignostics_foundry_core/gui/gui_test.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for aignostics_foundry_core.gui.*."""
22

3+
import os
34
import sys
45
import time
56
from collections.abc import Generator
@@ -23,6 +24,7 @@
2324
NavItem,
2425
gui_get_nav_groups,
2526
)
27+
from tests.aignostics_foundry_core.api import AUTH0_ROLE_CLAIM_VAR_NAME, INTERNAL_ORG_ID_VAR_NAME
2628
from tests.conftest import TEST_PROJECT_NAME, make_context
2729

2830
_PATCH_GET_GUI_USER = "aignostics_foundry_core.gui.auth.get_gui_user"
@@ -451,9 +453,13 @@ class TestGetGuiUser:
451453

452454
@pytest.fixture(autouse=True)
453455
def _gui_context(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction]
454-
"""Install a minimal context so AuthSettings can be loaded."""
456+
"""Install a minimal context and required AuthSettings env vars."""
455457
set_context(make_context())
458+
os.environ[INTERNAL_ORG_ID_VAR_NAME] = _INTERNAL_ORG
459+
os.environ[AUTH0_ROLE_CLAIM_VAR_NAME] = _ROLE_CLAIM
456460
yield
461+
os.environ.pop(INTERNAL_ORG_ID_VAR_NAME, None)
462+
os.environ.pop(AUTH0_ROLE_CLAIM_VAR_NAME, None)
457463
reset_context()
458464

459465
async def test_returns_none_when_auth_client_raises(self) -> None:
@@ -533,9 +539,13 @@ class TestRequireGuiUser:
533539

534540
@pytest.fixture(autouse=True)
535541
def _gui_context(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction]
536-
"""Install a minimal context so AuthSettings can be loaded."""
542+
"""Install a minimal context and required AuthSettings env vars."""
537543
set_context(make_context())
544+
os.environ[INTERNAL_ORG_ID_VAR_NAME] = _INTERNAL_ORG
545+
os.environ[AUTH0_ROLE_CLAIM_VAR_NAME] = _ROLE_CLAIM
538546
yield
547+
os.environ.pop(INTERNAL_ORG_ID_VAR_NAME, None)
548+
os.environ.pop(AUTH0_ROLE_CLAIM_VAR_NAME, None)
539549
reset_context()
540550

541551
async def test_redirects_to_login_when_no_user(self) -> None:

0 commit comments

Comments
 (0)