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
2 changes: 1 addition & 1 deletion ATTRIBUTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ SOFTWARE.

```

## aignostics-foundry-core (0.12.1) - MIT License
## aignostics-foundry-core (0.13.0) - MIT License

🏭 Foundational infrastructure for Foundry components.

Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,20 @@ ctx = make_context(database=DatabaseSettings(_env_prefix="TEST_DB_", url="sqlite

#### Authentication (`{PREFIX}AUTH_`)

Settings class: `AuthSettings`. Both fields are required — no defaults. Only needed when using
Settings class: `AuthSettings`. All fields are optional with defaults unless `enabled=True`, which
activates several cross-field requirements. Only needed when using
`aignostics_foundry_core.api.auth` dependencies.

| Variable | Required | Description |
|---|---|---|
| `{PREFIX}AUTH_INTERNAL_ORG_ID` | yes | Auth0 organization ID identifying the internal org (used by `require_internal`). |
| `{PREFIX}AUTH_AUTH0_ROLE_CLAIM` | yes | JWT claim name containing the user's role (e.g. `https://myapp.example.com/roles`). |
| Variable | Required | Default | Description |
|---|---|---|---|
| `{PREFIX}AUTH_ENABLED` | no | `false` | Enable Auth0 authentication. When `true`, several other fields become required. |
| `{PREFIX}AUTH_SESSION_SECRET` | when enabled | `""` | Secret to sign session cookies. Required when `AUTH_ENABLED=true`. |
| `{PREFIX}AUTH_SESSION_EXPIRATION` | no | `86400` | Session cookie expiration in seconds (range: 61–31536000). |
| `{PREFIX}AUTH_DOMAIN` | when enabled | `""` | Auth0 domain (e.g. `myapp.eu.auth0.com`). Required when `AUTH_ENABLED=true`. |
| `{PREFIX}AUTH_CLIENT_ID` | when enabled | `""` | Auth0 client ID (max 32 chars). Required when `AUTH_ENABLED=true`. |
| `{PREFIX}AUTH_CLIENT_SECRET` | when enabled | `""` | Auth0 client secret (64 chars). Required when `AUTH_ENABLED=true`. |
| `{PREFIX}AUTH_INTERNAL_ORG_ID` | when enabled | `""` | Auth0 organization ID identifying the internal org (used by `require_internal`). Required when `AUTH_ENABLED=true`. |
| `{PREFIX}AUTH_ROLE_CLAIM` | when enabled | `""` | JWT claim name containing the user's role (e.g. `https://myapp.example.com/roles`). Required when `AUTH_ENABLED=true`. |

#### Console

Expand Down
77 changes: 66 additions & 11 deletions src/aignostics_foundry_core/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- Authentication dependencies (require_authenticated, require_admin, etc.)
- get_user: Get authenticated user from session
- get_auth_client: Get Auth0 client from app state
- AuthSettings: Auth settings whose env prefix is derived from the active FoundryContext
- AuthSettings: Full auth configuration (enabled, session, domain, credentials, org, role claim)
"""

import time
Expand All @@ -15,6 +15,7 @@
from fastapi import Request, Security
from fastapi.security import APIKeyCookie
from loguru import logger
from pydantic import Field, PlainSerializer, SecretStr, StringConstraints, model_validator
from pydantic_settings import SettingsConfigDict

from aignostics_foundry_core.foundry import get_context
Expand All @@ -28,6 +29,7 @@
AUTH0_COOKIE_SCHEME_DESCRIPTION = "Auth0 session cookie authentication scheme."
AUTH0_ROLE_ADMIN = "admin"
USER_NOT_AUTHENTICATED = "User is not authenticated"
AUTH_SESSION_EXPIRATION_DEFAULT = 60 * 60 * 24 # 1 day in seconds


class AuthSettings(OpaqueSettings):
Expand All @@ -37,20 +39,73 @@ class AuthSettings(OpaqueSettings):
``FoundryContext.env_file``, both resolved at instantiation time via
:func:`aignostics_foundry_core.foundry.get_context`.

Both ``internal_org_id`` and ``auth0_role_claim`` are required — they must be
provided via environment variables or ``.env`` files (no defaults).
Fields:
enabled: Enable Auth0 authentication (AUTH_ENABLED).
session_secret: Secret used to sign session cookies (AUTH_SESSION_SECRET).
session_expiration: Session cookie expiration in seconds (AUTH_SESSION_EXPIRATION).
domain: Auth0 domain (AUTH_DOMAIN).
client_id: Auth0 client ID (AUTH_CLIENT_ID).
client_secret: Auth0 client secret (AUTH_CLIENT_SECRET).
internal_org_id: Auth0 org ID for the internal organisation (AUTH_INTERNAL_ORG_ID).
role_claim: JWT claim name containing the user's role (AUTH_ROLE_CLAIM).

Cross-field rules (validated after field assignment):
- enabled=True requires session_secret not None, client_secret not None,
non-empty domain, client_id, internal_org_id, and role_claim
"""

model_config = SettingsConfigDict(extra="ignore")

internal_org_id: str
auth0_role_claim: str
enabled: bool = Field(default=False)
session_secret: Annotated[
SecretStr | None,
PlainSerializer(func=OpaqueSettings.serialize_sensitive_info, return_type=str, when_used="always"),
] = Field(default=None)
session_expiration: int = Field(default=AUTH_SESSION_EXPIRATION_DEFAULT, gt=60, le=31536000)
domain: Annotated[str, StringConstraints(max_length=255)] = Field(default="")
client_id: Annotated[str, StringConstraints(max_length=32)] = Field(default="")
client_secret: Annotated[
SecretStr | None,
PlainSerializer(func=OpaqueSettings.serialize_sensitive_info, return_type=str, when_used="always"),
] = Field(default=None, min_length=64, max_length=64)
internal_org_id: str = ""
role_claim: str = ""

def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
"""Initialise settings, deriving env_prefix and env files from the active FoundryContext."""
ctx = get_context()
super().__init__(_env_prefix=f"{ctx.env_prefix}AUTH_", _env_file=ctx.env_file, **kwargs) # pyright: ignore[reportCallIssue]

@model_validator(mode="after")
def validate_auth_dependencies(self) -> "AuthSettings":
"""Validate cross-field auth dependencies.

Returns:
AuthSettings: The validated settings instance.

Raises:
ValueError: If any cross-field dependency is violated.
"""
if self.enabled and self.session_secret is None:
msg = "AUTH_SESSION_SECRET must not be None when AUTH_ENABLED is True"
raise ValueError(msg)
if self.enabled and self.client_secret is None:
msg = "AUTH_CLIENT_SECRET must not be None when AUTH_ENABLED is True"
raise ValueError(msg)
if self.enabled and not self.domain:
msg = "AUTH_DOMAIN must not be empty when AUTH_ENABLED is True"
raise ValueError(msg)
if self.enabled and not self.client_id:
msg = "AUTH_CLIENT_ID must not be empty when AUTH_ENABLED is True"
raise ValueError(msg)
if self.enabled and not self.internal_org_id:
msg = "AUTH_INTERNAL_ORG_ID must not be empty when AUTH_ENABLED is True"
raise ValueError(msg)
if self.enabled and not self.role_claim:
msg = "AUTH_ROLE_CLAIM must not be empty when AUTH_ENABLED is True"
raise ValueError(msg)
return self


class UnauthenticatedError(Exception):
"""Raised when user is not authenticated."""
Expand Down Expand Up @@ -104,7 +159,7 @@ def get_auth_client(request: Request) -> AuthClient:
name=AUTH0_SESSION_COOKIE_NAME,
scheme_name="Auth0AdminCookie",
description="Auth0 session cookie authentication with admin role requirement. "
f"User must have '{AUTH0_ROLE_ADMIN}' role in their configured auth0_role_claim.",
f"User must have '{AUTH0_ROLE_ADMIN}' role in their configured role_claim.",
auto_error=False,
) # Security scheme specifically for admin endpoints

Expand Down Expand Up @@ -138,7 +193,7 @@ async def _require_authenticated_impl(
request: The incoming request.
_cookie: The session cookie.
role: Optional role required (e.g., "admin"). If specified, user must have
this role in their configured auth0_role_claim.
this role in their configured role_claim.

Raises:
UnauthenticatedError: If the session is not valid or missing.
Expand All @@ -154,7 +209,7 @@ async def _require_authenticated_impl(

# Check role if specified
if role is not None:
user_role = user.get(auth_settings.auth0_role_claim)
user_role = user.get(auth_settings.role_claim)
if user_role != role:
msg = f"User role '{user_role}' does not match required role '{role}'"
logger.warning(msg)
Expand Down Expand Up @@ -237,7 +292,7 @@ async def require_internal_admin(

Checks if the authenticated user is both:
1. A member of the configured internal organization (FOUNDRY_AUTH_INTERNAL_ORG_ID)
2. Has the admin role in their configured auth0_role_claim
2. Has the admin role in their configured role_claim

Args:
request: The incoming request.
Expand All @@ -263,7 +318,7 @@ async def require_internal_admin(
raise ForbiddenError(msg)

# Check admin role
user_role = user.get(auth_settings.auth0_role_claim)
user_role = user.get(auth_settings.role_claim)
if user_role != AUTH0_ROLE_ADMIN:
msg = f"User role '{user_role}' does not match required role '{AUTH0_ROLE_ADMIN}'"
logger.warning(msg)
Expand Down Expand Up @@ -315,7 +370,7 @@ async def me(user: Annotated[dict[str, Any], Depends(get_user)]):
return None
user: dict[str, Any] = raw_user # pyright: ignore[reportUnknownVariableType]

set_sentry_user(user, role_claim=auth_settings.auth0_role_claim)
set_sentry_user(user, role_claim=auth_settings.role_claim)

# Check if expired
exp = user.get("exp")
Expand Down
6 changes: 3 additions & 3 deletions src/aignostics_foundry_core/gui/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ async def get_gui_user(request: Request) -> dict[str, Any] | None:
return None
user: dict[str, Any] = raw_user # pyright: ignore[reportUnknownVariableType]

set_sentry_user(user, role_claim=auth_settings.auth0_role_claim) # pyright: ignore[reportUnknownArgumentType]
set_sentry_user(user, role_claim=auth_settings.role_claim) # pyright: ignore[reportUnknownArgumentType]

exp = user.get("exp")
if not exp:
Expand Down Expand Up @@ -319,7 +319,7 @@ async def wrapper(request: Request) -> None:
return

auth_settings = load_settings(AuthSettings)
role = user.get(auth_settings.auth0_role_claim)
role = user.get(auth_settings.role_claim)
if role != AUTH0_ROLE_ADMIN:
with _frame_context(frame_func, resolved_title, user):
ui.label(f"{MSG_403_FORBIDDEN} - Admin access required").classes(CLASS_FORBIDDEN_ERROR)
Expand Down Expand Up @@ -402,7 +402,7 @@ async def wrapper(request: Request) -> None:

auth_settings = load_settings(AuthSettings)
org_id = user.get("org_id")
role = user.get(auth_settings.auth0_role_claim)
role = user.get(auth_settings.role_claim)

if org_id != auth_settings.internal_org_id or role != AUTH0_ROLE_ADMIN:
with _frame_context(frame_func, resolved_title, user):
Expand Down
2 changes: 1 addition & 1 deletion tests/aignostics_foundry_core/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
from tests.conftest import TEST_PROJECT_PREFIX

INTERNAL_ORG_ID_VAR_NAME = f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID"
AUTH0_ROLE_CLAIM_VAR_NAME = f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM"
ROLE_CLAIM_VAR_NAME = f"{TEST_PROJECT_PREFIX}AUTH_ROLE_CLAIM"
Loading
Loading