Skip to content

Commit 2630e15

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

5 files changed

Lines changed: 47 additions & 35 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."""

tests/aignostics_foundry_core/api/auth_test.py

Lines changed: 28 additions & 24 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,
@@ -24,20 +24,25 @@
2424

2525
_INTERNAL_ORG_ID = "org_internal_123"
2626
_OTHER_ORG_ID = "org_other_456"
27+
_TEST_ROLE_CLAIM = "https://aignostics-platform-bridge/role"
2728
_USER_NOT_AUTHENTICATED = "User is not authenticated"
2829
_USER_SUB = "auth0|x"
2930
_USER_EMAIL = "x@x.com"
3031

3132

3233
@pytest.fixture(autouse=True)
3334
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.
35+
"""Set a real FoundryContext and required AuthSettings env vars for all auth tests.
3536
3637
Yields:
3738
None
3839
"""
3940
set_context(make_context())
41+
os.environ[f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID"] = _INTERNAL_ORG_ID
42+
os.environ[f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM"] = _TEST_ROLE_CLAIM
4043
yield
44+
os.environ.pop(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", None)
45+
os.environ.pop(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", None)
4146
reset_context()
4247

4348

@@ -91,24 +96,23 @@ def test_get_auth_client_returns_client_when_present(self) -> None:
9196

9297
@pytest.mark.unit
9398
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"
99+
"""Tests for AuthSettings."""
105100

106101
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())
102+
"""AuthSettings reads both required fields from env vars using the context's prefix."""
109103
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", "https://custom/role")
110104
settings = AuthSettings()
111105
assert settings.auth0_role_claim == "https://custom/role"
106+
assert settings.internal_org_id == _INTERNAL_ORG_ID
107+
108+
def test_auth_settings_raises_when_required_fields_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
109+
"""AuthSettings raises ValidationError when required env vars are absent."""
110+
import pydantic
111+
112+
monkeypatch.delenv(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", raising=False)
113+
monkeypatch.delenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", raising=False)
114+
with pytest.raises(pydantic.ValidationError):
115+
AuthSettings()
112116

113117

114118
@pytest.mark.integration
@@ -207,7 +211,7 @@ class TestRequireAdmin:
207211

208212
async def test_no_user_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
209213
"""require_admin raises ForbiddenError when no session is available."""
210-
monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False)
214+
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", _TEST_ROLE_CLAIM)
211215
request = MagicMock()
212216
request.app.state = MagicMock(spec=[]) # no auth_client → get_user returns None
213217

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

217221
async def test_wrong_role_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
218222
"""require_admin raises ForbiddenError when user has a non-admin role."""
219-
monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False)
223+
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", _TEST_ROLE_CLAIM)
220224
request = MagicMock()
221-
user = {"sub": _USER_SUB, DEFAULT_AUTH0_ROLE_CLAIM: "viewer", "exp": int(time.time()) + 3600}
225+
user = {"sub": _USER_SUB, _TEST_ROLE_CLAIM: "viewer", "exp": int(time.time()) + 3600}
222226
fake_client = MagicMock()
223227
fake_client.require_session = AsyncMock(return_value={"user": user})
224228
request.app.state.auth_client = fake_client
@@ -228,9 +232,9 @@ async def test_wrong_role_raises_forbidden_error(self, monkeypatch: pytest.Monke
228232

229233
async def test_admin_role_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
230234
"""require_admin returns None without raising when user has the admin role."""
231-
monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False)
235+
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", _TEST_ROLE_CLAIM)
232236
request = MagicMock()
233-
user = {"sub": _USER_SUB, DEFAULT_AUTH0_ROLE_CLAIM: AUTH0_ROLE_ADMIN, "exp": int(time.time()) + 3600}
237+
user = {"sub": _USER_SUB, _TEST_ROLE_CLAIM: AUTH0_ROLE_ADMIN, "exp": int(time.time()) + 3600}
234238
fake_client = MagicMock()
235239
fake_client.require_session = AsyncMock(return_value={"user": user})
236240
request.app.state.auth_client = fake_client
@@ -305,12 +309,12 @@ async def test_wrong_org_raises_forbidden_error(self, monkeypatch: pytest.Monkey
305309
async def test_correct_org_wrong_role_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None:
306310
"""require_internal_admin raises ForbiddenError when user is in internal org but lacks admin role."""
307311
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)
312+
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", _TEST_ROLE_CLAIM)
309313
request = MagicMock()
310314
user = {
311315
"sub": _USER_SUB,
312316
"org_id": _INTERNAL_ORG_ID,
313-
DEFAULT_AUTH0_ROLE_CLAIM: "viewer",
317+
_TEST_ROLE_CLAIM: "viewer",
314318
"exp": int(time.time()) + 3600,
315319
}
316320
fake_client = MagicMock()
@@ -323,12 +327,12 @@ async def test_correct_org_wrong_role_raises_forbidden_error(self, monkeypatch:
323327
async def test_internal_admin_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
324328
"""require_internal_admin returns None without raising when user is internal org admin."""
325329
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)
330+
monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", _TEST_ROLE_CLAIM)
327331
request = MagicMock()
328332
user = {
329333
"sub": _USER_SUB,
330334
"org_id": _INTERNAL_ORG_ID,
331-
DEFAULT_AUTH0_ROLE_CLAIM: AUTH0_ROLE_ADMIN,
335+
_TEST_ROLE_CLAIM: AUTH0_ROLE_ADMIN,
332336
"exp": int(time.time()) + 3600,
333337
}
334338
fake_client = MagicMock()

tests/aignostics_foundry_core/gui/gui_test.py

Lines changed: 12 additions & 3 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,7 +24,7 @@
2324
NavItem,
2425
gui_get_nav_groups,
2526
)
26-
from tests.conftest import TEST_PROJECT_NAME, make_context
27+
from tests.conftest import TEST_PROJECT_NAME, TEST_PROJECT_PREFIX, make_context
2728

2829
_PATCH_GET_GUI_USER = "aignostics_foundry_core.gui.auth.get_gui_user"
2930
_PATH_NAV_LOCATE = "aignostics_foundry_core.gui.nav.locate_subclasses"
@@ -451,9 +452,13 @@ class TestGetGuiUser:
451452

452453
@pytest.fixture(autouse=True)
453454
def _gui_context(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction]
454-
"""Install a minimal context so AuthSettings can be loaded."""
455+
"""Install a minimal context and required AuthSettings env vars."""
455456
set_context(make_context())
457+
os.environ[f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID"] = _INTERNAL_ORG
458+
os.environ[f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM"] = _ROLE_CLAIM
456459
yield
460+
os.environ.pop(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", None)
461+
os.environ.pop(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", None)
457462
reset_context()
458463

459464
async def test_returns_none_when_auth_client_raises(self) -> None:
@@ -533,9 +538,13 @@ class TestRequireGuiUser:
533538

534539
@pytest.fixture(autouse=True)
535540
def _gui_context(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction]
536-
"""Install a minimal context so AuthSettings can be loaded."""
541+
"""Install a minimal context and required AuthSettings env vars."""
537542
set_context(make_context())
543+
os.environ[f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID"] = _INTERNAL_ORG
544+
os.environ[f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM"] = _ROLE_CLAIM
538545
yield
546+
os.environ.pop(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", None)
547+
os.environ.pop(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", None)
539548
reset_context()
540549

541550
async def test_redirects_to_login_when_no_user(self) -> None:

0 commit comments

Comments
 (0)