Full platform remediation #7
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, Response, status
|
from fastapi import APIRouter, Depends, Request, Response, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
@@ -10,14 +12,22 @@ from app.core.errors import AppError
|
|||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
|
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
|
||||||
from app.services.auth_service import AuthPrincipal, AuthService
|
from app.services.auth_service import AuthPrincipal, AuthService
|
||||||
|
from app.services.authentik_oidc_service import AuthentikOidcService
|
||||||
from app.services.demo_workflow_service import DemoWorkflowService
|
from app.services.demo_workflow_service import DemoWorkflowService
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
COOKIE_NAME = "geointel_session"
|
COOKIE_NAME = "geointel_session"
|
||||||
|
OIDC_FLOW_COOKIE_NAME = "geointel_oidc_flow"
|
||||||
|
logger = logging.getLogger("geointel.auth")
|
||||||
|
|
||||||
|
|
||||||
def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: bool) -> AuthSession:
|
def _session_from_principal(
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
*,
|
||||||
|
guest_access_enabled: bool,
|
||||||
|
authentik_enabled: bool,
|
||||||
|
) -> AuthSession:
|
||||||
return AuthSession(
|
return AuthSession(
|
||||||
authentication_required=True,
|
authentication_required=True,
|
||||||
authenticated=True,
|
authenticated=True,
|
||||||
@@ -25,6 +35,7 @@ def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: b
|
|||||||
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
|
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
|
||||||
role=principal.role,
|
role=principal.role,
|
||||||
guest_access_enabled=guest_access_enabled,
|
guest_access_enabled=guest_access_enabled,
|
||||||
|
authentik_enabled=authentik_enabled,
|
||||||
guest_project_id=principal.project_id,
|
guest_project_id=principal.project_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,11 +43,13 @@ def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: b
|
|||||||
def _session_payload(request: Request) -> AuthSession:
|
def _session_payload(request: Request) -> AuthSession:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled
|
guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled
|
||||||
|
authentik_enabled = AuthentikOidcService(settings).enabled
|
||||||
if not settings.auth_enabled:
|
if not settings.auth_enabled:
|
||||||
return AuthSession(
|
return AuthSession(
|
||||||
authentication_required=False,
|
authentication_required=False,
|
||||||
authenticated=True,
|
authenticated=True,
|
||||||
guest_access_enabled=False,
|
guest_access_enabled=False,
|
||||||
|
authentik_enabled=False,
|
||||||
)
|
)
|
||||||
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
|
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
|
||||||
if principal is None:
|
if principal is None:
|
||||||
@@ -44,10 +57,12 @@ def _session_payload(request: Request) -> AuthSession:
|
|||||||
authentication_required=True,
|
authentication_required=True,
|
||||||
authenticated=False,
|
authenticated=False,
|
||||||
guest_access_enabled=guest_access_enabled,
|
guest_access_enabled=guest_access_enabled,
|
||||||
|
authentik_enabled=authentik_enabled,
|
||||||
)
|
)
|
||||||
return _session_from_principal(
|
return _session_from_principal(
|
||||||
principal,
|
principal,
|
||||||
guest_access_enabled=guest_access_enabled,
|
guest_access_enabled=guest_access_enabled,
|
||||||
|
authentik_enabled=authentik_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -120,10 +135,83 @@ def login(payload: AuthLoginRequest, request: Request, response: Response) -> Au
|
|||||||
data=_session_from_principal(
|
data=_session_from_principal(
|
||||||
principal,
|
principal,
|
||||||
guest_access_enabled=settings.guest_access_enabled,
|
guest_access_enabled=settings.guest_access_enabled,
|
||||||
|
authentik_enabled=AuthentikOidcService(settings).enabled,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authentik/start")
|
||||||
|
def authentik_start(request: Request) -> RedirectResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
service = AuthentikOidcService(settings)
|
||||||
|
try:
|
||||||
|
location, flow = service.start()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Authentik authorization start failed: %s", type(exc).__name__)
|
||||||
|
raise AppError(
|
||||||
|
code="AUTHENTIK_UNAVAILABLE",
|
||||||
|
message="Authentik is momenteel niet beschikbaar.",
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
) from exc
|
||||||
|
response = RedirectResponse(location, status_code=status.HTTP_302_FOUND)
|
||||||
|
response.set_cookie(
|
||||||
|
OIDC_FLOW_COOKIE_NAME,
|
||||||
|
flow,
|
||||||
|
max_age=600,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
path=f"{settings.api_prefix}/auth/authentik",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authentik/callback")
|
||||||
|
def authentik_callback(
|
||||||
|
request: Request,
|
||||||
|
code: str = "",
|
||||||
|
state: str = "",
|
||||||
|
) -> RedirectResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
service = AuthentikOidcService(settings)
|
||||||
|
base_url = settings.public_base_url.rstrip("/")
|
||||||
|
try:
|
||||||
|
service.finish(
|
||||||
|
code=code,
|
||||||
|
state=state,
|
||||||
|
flow_cookie=request.cookies.get(OIDC_FLOW_COOKIE_NAME, ""),
|
||||||
|
)
|
||||||
|
token = AuthService.create_session_token(
|
||||||
|
settings.auth_username or "operator",
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Authentik callback rejected: %s", type(exc).__name__)
|
||||||
|
response = RedirectResponse(
|
||||||
|
f"{base_url}/?authentik=error",
|
||||||
|
status_code=status.HTTP_302_FOUND,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = RedirectResponse(
|
||||||
|
f"{base_url}/",
|
||||||
|
status_code=status.HTTP_302_FOUND,
|
||||||
|
)
|
||||||
|
_set_session_cookie(
|
||||||
|
request=request,
|
||||||
|
response=response,
|
||||||
|
token=token,
|
||||||
|
max_age=settings.auth_session_ttl_seconds,
|
||||||
|
)
|
||||||
|
response.delete_cookie(
|
||||||
|
OIDC_FLOW_COOKIE_NAME,
|
||||||
|
path=f"{settings.api_prefix}/auth/authentik",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/guest", response_model=AuthSessionEnvelope)
|
@router.post("/guest", response_model=AuthSessionEnvelope)
|
||||||
def guest_login(
|
def guest_login(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -163,6 +251,7 @@ def guest_login(
|
|||||||
data=_session_from_principal(
|
data=_session_from_principal(
|
||||||
principal,
|
principal,
|
||||||
guest_access_enabled=True,
|
guest_access_enabled=True,
|
||||||
|
authentik_enabled=AuthentikOidcService(settings).enabled,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,5 +265,6 @@ def logout(response: Response) -> AuthSessionEnvelope:
|
|||||||
authentication_required=settings.auth_enabled,
|
authentication_required=settings.auth_enabled,
|
||||||
authenticated=not settings.auth_enabled,
|
authenticated=not settings.auth_enabled,
|
||||||
guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled,
|
guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled,
|
||||||
|
authentik_enabled=AuthentikOidcService(settings).enabled,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from pydantic import Field, field_validator, model_validator
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from pydantic import AliasChoices, Field, field_validator, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +24,14 @@ class Settings(BaseSettings):
|
|||||||
auth_username: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_USERNAME")
|
auth_username: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_USERNAME")
|
||||||
auth_password_hash: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_PASSWORD_HASH")
|
auth_password_hash: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_PASSWORD_HASH")
|
||||||
auth_session_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_SESSION_SECRET")
|
auth_session_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_SESSION_SECRET")
|
||||||
|
authentik_issuer: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ISSUER")
|
||||||
|
authentik_client_id: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_ID")
|
||||||
|
authentik_client_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_SECRET")
|
||||||
|
authentik_allowed_email: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ALLOWED_EMAIL")
|
||||||
|
public_base_url: str = Field(
|
||||||
|
default="http://localhost:1202",
|
||||||
|
validation_alias="GEOINTEL_PUBLIC_BASE_URL",
|
||||||
|
)
|
||||||
auth_session_ttl_seconds: int = Field(
|
auth_session_ttl_seconds: int = Field(
|
||||||
default=43_200,
|
default=43_200,
|
||||||
ge=900,
|
ge=900,
|
||||||
@@ -54,7 +64,18 @@ class Settings(BaseSettings):
|
|||||||
allow_external_artifact_paths: bool = Field(
|
allow_external_artifact_paths: bool = Field(
|
||||||
default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS"
|
default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS"
|
||||||
)
|
)
|
||||||
max_upload_mb: int = Field(default=500, validation_alias="MAX_UPLOAD_MB")
|
max_upload_mb: int = Field(
|
||||||
|
default=500,
|
||||||
|
ge=1,
|
||||||
|
le=2_048,
|
||||||
|
validation_alias=AliasChoices("GEOINTEL_MAX_UPLOAD_MB", "MAX_UPLOAD_MB"),
|
||||||
|
)
|
||||||
|
max_in_memory_vector_mb: int = Field(
|
||||||
|
default=64,
|
||||||
|
ge=1,
|
||||||
|
le=256,
|
||||||
|
validation_alias="GEOINTEL_MAX_IN_MEMORY_VECTOR_MB",
|
||||||
|
)
|
||||||
orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED")
|
orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED")
|
||||||
orthophoto_wms_url: str = Field(
|
orthophoto_wms_url: str = Field(
|
||||||
default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms",
|
default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms",
|
||||||
@@ -470,6 +491,47 @@ class Settings(BaseSettings):
|
|||||||
self.guest_display_name = self.guest_display_name.strip()
|
self.guest_display_name = self.guest_display_name.strip()
|
||||||
if not self.guest_display_name:
|
if not self.guest_display_name:
|
||||||
raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank")
|
raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank")
|
||||||
|
for field_name in (
|
||||||
|
"authentik_issuer",
|
||||||
|
"authentik_client_id",
|
||||||
|
"authentik_client_secret",
|
||||||
|
"authentik_allowed_email",
|
||||||
|
):
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
setattr(self, field_name, value.strip() if value else None)
|
||||||
|
self.public_base_url = self.public_base_url.strip().rstrip("/")
|
||||||
|
authentik_values = (
|
||||||
|
self.authentik_issuer,
|
||||||
|
self.authentik_client_id,
|
||||||
|
self.authentik_client_secret,
|
||||||
|
self.authentik_allowed_email,
|
||||||
|
)
|
||||||
|
if any(authentik_values) and not all(authentik_values):
|
||||||
|
raise ValueError("All GEOINTEL_AUTHENTIK_* values must be configured together")
|
||||||
|
if all(authentik_values):
|
||||||
|
if not self.auth_enabled:
|
||||||
|
raise ValueError("GEOINTEL_AUTH_ENABLED must be true when Authentik is configured")
|
||||||
|
for label, value in (
|
||||||
|
("GEOINTEL_AUTHENTIK_ISSUER", self.authentik_issuer),
|
||||||
|
("GEOINTEL_PUBLIC_BASE_URL", self.public_base_url),
|
||||||
|
):
|
||||||
|
parsed = urlsplit(str(value))
|
||||||
|
if (
|
||||||
|
parsed.scheme != "https"
|
||||||
|
or not parsed.hostname
|
||||||
|
or parsed.username
|
||||||
|
or parsed.password
|
||||||
|
or parsed.query
|
||||||
|
or parsed.fragment
|
||||||
|
):
|
||||||
|
raise ValueError(f"{label} must be an absolute HTTPS URL without credentials, query or fragment")
|
||||||
|
public_url = urlsplit(self.public_base_url)
|
||||||
|
if public_url.path not in ("", "/"):
|
||||||
|
raise ValueError("GEOINTEL_PUBLIC_BASE_URL must not contain a path")
|
||||||
|
if "@" not in str(self.authentik_allowed_email) or any(
|
||||||
|
character.isspace() for character in str(self.authentik_allowed_email)
|
||||||
|
):
|
||||||
|
raise ValueError("GEOINTEL_AUTHENTIK_ALLOWED_EMAIL must be one valid e-mail address")
|
||||||
if not self.auth_enabled:
|
if not self.auth_enabled:
|
||||||
return self
|
return self
|
||||||
if not (self.auth_username or "").strip():
|
if not (self.auth_username or "").strip():
|
||||||
|
|||||||
+8
-2
@@ -26,7 +26,7 @@ from app.services.aoi_operation_worker import AoiOperationWorker
|
|||||||
|
|
||||||
logger = logging.getLogger("geointel")
|
logger = logging.getLogger("geointel")
|
||||||
SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||||
UNSAFE_HOST = re.compile(r"[/\\@\s\x00-\x1f\x7f]")
|
UNSAFE_HOST = re.compile(r"[/\\@?#\s\x00-\x1f\x7f]")
|
||||||
|
|
||||||
|
|
||||||
def _to_error_payload(
|
def _to_error_payload(
|
||||||
@@ -157,6 +157,8 @@ def create_app() -> FastAPI:
|
|||||||
f"{settings.api_prefix}/auth/login",
|
f"{settings.api_prefix}/auth/login",
|
||||||
f"{settings.api_prefix}/auth/guest",
|
f"{settings.api_prefix}/auth/guest",
|
||||||
f"{settings.api_prefix}/auth/logout",
|
f"{settings.api_prefix}/auth/logout",
|
||||||
|
f"{settings.api_prefix}/auth/authentik/start",
|
||||||
|
f"{settings.api_prefix}/auth/authentik/callback",
|
||||||
}
|
}
|
||||||
direct_loopback_request = (
|
direct_loopback_request = (
|
||||||
request.client is not None
|
request.client is not None
|
||||||
@@ -261,6 +263,7 @@ def create_app() -> FastAPI:
|
|||||||
guest_safe_post_paths = {
|
guest_safe_post_paths = {
|
||||||
f"{settings.api_prefix}/demo/workflow",
|
f"{settings.api_prefix}/demo/workflow",
|
||||||
f"{settings.api_prefix}/external/coverage/resolve",
|
f"{settings.api_prefix}/external/coverage/resolve",
|
||||||
|
f"{settings.api_prefix}/analysis/change-detection",
|
||||||
}
|
}
|
||||||
guest_scoped_analysis_post_paths = {
|
guest_scoped_analysis_post_paths = {
|
||||||
f"{settings.api_prefix}/detection/run",
|
f"{settings.api_prefix}/detection/run",
|
||||||
@@ -274,7 +277,10 @@ def create_app() -> FastAPI:
|
|||||||
f"{settings.api_prefix}/exports/map-result",
|
f"{settings.api_prefix}/exports/map-result",
|
||||||
}
|
}
|
||||||
guest_safe_post_suffixes = (
|
guest_safe_post_suffixes = (
|
||||||
|
"/acquire",
|
||||||
"/vector/select",
|
"/vector/select",
|
||||||
|
"/vector/select/derive",
|
||||||
|
"/raster/tile",
|
||||||
"/raster/bathymetry/select",
|
"/raster/bathymetry/select",
|
||||||
"/raster/terrain/select",
|
"/raster/terrain/select",
|
||||||
"/raster/flood-hazard/select",
|
"/raster/flood-hazard/select",
|
||||||
@@ -313,7 +319,7 @@ def create_app() -> FastAPI:
|
|||||||
status_code=403,
|
status_code=403,
|
||||||
content=_to_error_payload(
|
content=_to_error_payload(
|
||||||
"GUEST_READ_ONLY",
|
"GUEST_READ_ONLY",
|
||||||
"Gasttoegang is een tijdelijke, alleen-lezen demo. Meld u aan als operator om gegevens te wijzigen of taken te starten.",
|
"Gasttoegang laat alleen projectgebonden demo-analyses toe. Meld u aan als operator voor beheerwijzigingen.",
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class AuthSession(BaseModel):
|
|||||||
expires_at: datetime | None = None
|
expires_at: datetime | None = None
|
||||||
role: Literal["operator", "guest"] | None = None
|
role: Literal["operator", "guest"] | None = None
|
||||||
guest_access_enabled: bool = False
|
guest_access_enabled: bool = False
|
||||||
|
authentik_enabled: bool = False
|
||||||
guest_project_id: UUID | None = None
|
guest_project_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import urlencode, urlsplit
|
||||||
|
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
MAX_OIDC_JSON_BYTES = 1_048_576
|
||||||
|
|
||||||
|
|
||||||
|
class _RejectRedirects(HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, ANN201
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthentikOidcService:
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
self.settings = settings
|
||||||
|
self.issuer = (settings.authentik_issuer or "").rstrip("/")
|
||||||
|
self.serializer = URLSafeTimedSerializer(
|
||||||
|
settings.auth_session_secret or "",
|
||||||
|
salt="geointel-authentik-v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return bool(
|
||||||
|
self.issuer
|
||||||
|
and self.settings.authentik_client_id
|
||||||
|
and self.settings.authentik_client_secret
|
||||||
|
and self.settings.authentik_allowed_email
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redirect_uri(self) -> str:
|
||||||
|
return (
|
||||||
|
f"{self.settings.public_base_url.rstrip('/')}"
|
||||||
|
f"{self.settings.api_prefix}/auth/authentik/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _origin(url: str) -> tuple[str, str, int]:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
if parsed.scheme != "https" or not parsed.hostname:
|
||||||
|
raise ValueError("OIDC URLs must use absolute HTTPS URLs")
|
||||||
|
return parsed.scheme, parsed.hostname.casefold(), parsed.port or 443
|
||||||
|
|
||||||
|
def _validate_endpoint(self, url: str) -> str:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
if (
|
||||||
|
self._origin(url) != self._origin(self.issuer)
|
||||||
|
or parsed.username
|
||||||
|
or parsed.password
|
||||||
|
or parsed.fragment
|
||||||
|
):
|
||||||
|
raise ValueError("OIDC endpoint is outside the configured issuer origin")
|
||||||
|
return url
|
||||||
|
|
||||||
|
def _fetch_json(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
data: dict[str, str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
self._validate_endpoint(url)
|
||||||
|
encoded = urlencode(data).encode("utf-8") if data is not None else None
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if encoded is not None:
|
||||||
|
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
request = Request(url, data=encoded, headers=headers)
|
||||||
|
try:
|
||||||
|
with build_opener(_RejectRedirects()).open(request, timeout=10) as response:
|
||||||
|
declared_length = response.headers.get("Content-Length")
|
||||||
|
if declared_length and int(declared_length) > MAX_OIDC_JSON_BYTES:
|
||||||
|
raise ValueError("OIDC response exceeds the configured size limit")
|
||||||
|
raw = response.read(MAX_OIDC_JSON_BYTES + 1)
|
||||||
|
except HTTPError as exc:
|
||||||
|
raise ValueError("OIDC endpoint returned an HTTP error or redirect") from exc
|
||||||
|
if len(raw) > MAX_OIDC_JSON_BYTES:
|
||||||
|
raise ValueError("OIDC response exceeds the configured size limit")
|
||||||
|
payload = json.loads(raw)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("OIDC endpoint did not return a JSON object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _discovery(self) -> dict[str, Any]:
|
||||||
|
document = self._fetch_json(
|
||||||
|
f"{self.issuer}/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
if str(document.get("issuer", "")).rstrip("/") != self.issuer:
|
||||||
|
raise ValueError("OIDC issuer mismatch")
|
||||||
|
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
|
||||||
|
endpoint = document.get(key)
|
||||||
|
if not isinstance(endpoint, str):
|
||||||
|
raise ValueError(f"OIDC discovery is missing {key}")
|
||||||
|
self._validate_endpoint(endpoint)
|
||||||
|
return document
|
||||||
|
|
||||||
|
def start(self) -> tuple[str, str]:
|
||||||
|
if not self.enabled:
|
||||||
|
raise ValueError("Authentik is not configured")
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
nonce = secrets.token_urlsafe(32)
|
||||||
|
verifier = secrets.token_urlsafe(48)
|
||||||
|
flow = self.serializer.dumps(
|
||||||
|
{"state": state, "nonce": nonce, "verifier": verifier}
|
||||||
|
)
|
||||||
|
challenge = (
|
||||||
|
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
||||||
|
.rstrip(b"=")
|
||||||
|
.decode()
|
||||||
|
)
|
||||||
|
discovery = self._discovery()
|
||||||
|
query = urlencode(
|
||||||
|
{
|
||||||
|
"client_id": self.settings.authentik_client_id,
|
||||||
|
"redirect_uri": self.redirect_uri,
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": "openid email profile",
|
||||||
|
"state": state,
|
||||||
|
"nonce": nonce,
|
||||||
|
"code_challenge": challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return f"{discovery['authorization_endpoint']}?{query}", flow
|
||||||
|
|
||||||
|
def finish(self, *, code: str, state: str, flow_cookie: str) -> dict[str, Any]:
|
||||||
|
if not self.enabled or not code:
|
||||||
|
raise ValueError("OIDC flow is incomplete")
|
||||||
|
try:
|
||||||
|
flow = self.serializer.loads(flow_cookie, max_age=600)
|
||||||
|
except (BadSignature, SignatureExpired) as exc:
|
||||||
|
raise ValueError("Invalid OIDC flow") from exc
|
||||||
|
if not isinstance(flow, dict):
|
||||||
|
raise ValueError("Invalid OIDC flow payload")
|
||||||
|
if not state or not secrets.compare_digest(state, str(flow.get("state", ""))):
|
||||||
|
raise ValueError("OIDC state mismatch")
|
||||||
|
verifier = str(flow.get("verifier", ""))
|
||||||
|
nonce = str(flow.get("nonce", ""))
|
||||||
|
if not verifier or not nonce:
|
||||||
|
raise ValueError("OIDC flow payload is incomplete")
|
||||||
|
|
||||||
|
discovery = self._discovery()
|
||||||
|
token_response = self._fetch_json(
|
||||||
|
str(discovery["token_endpoint"]),
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": self.redirect_uri,
|
||||||
|
"client_id": self.settings.authentik_client_id or "",
|
||||||
|
"client_secret": self.settings.authentik_client_secret or "",
|
||||||
|
"code_verifier": verifier,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
token = str(token_response.get("id_token", ""))
|
||||||
|
if not token:
|
||||||
|
raise ValueError("OIDC token response has no ID token")
|
||||||
|
header = jwt.get_unverified_header(token)
|
||||||
|
if header.get("alg") != "RS256" or not header.get("kid"):
|
||||||
|
raise ValueError("OIDC ID token uses an unsupported signing header")
|
||||||
|
jwks = self._fetch_json(str(discovery["jwks_uri"]))
|
||||||
|
matching_keys = [
|
||||||
|
key
|
||||||
|
for key in jwks.get("keys", [])
|
||||||
|
if isinstance(key, dict) and key.get("kid") == header["kid"]
|
||||||
|
]
|
||||||
|
if len(matching_keys) != 1:
|
||||||
|
raise ValueError("OIDC signing key is missing or ambiguous")
|
||||||
|
signing_key = jwt.PyJWK.from_dict(matching_keys[0]).key
|
||||||
|
claims = jwt.decode(
|
||||||
|
token,
|
||||||
|
signing_key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
audience=self.settings.authentik_client_id,
|
||||||
|
issuer=discovery["issuer"],
|
||||||
|
options={
|
||||||
|
"require": [
|
||||||
|
"exp",
|
||||||
|
"iat",
|
||||||
|
"iss",
|
||||||
|
"aud",
|
||||||
|
"sub",
|
||||||
|
"nonce",
|
||||||
|
"email",
|
||||||
|
"email_verified",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not secrets.compare_digest(str(claims.get("nonce", "")), nonce):
|
||||||
|
raise ValueError("OIDC nonce mismatch")
|
||||||
|
email = str(claims.get("email", "")).strip().casefold()
|
||||||
|
allowed = str(self.settings.authentik_allowed_email or "").strip().casefold()
|
||||||
|
if claims.get("email_verified") is not True or not secrets.compare_digest(
|
||||||
|
email, allowed
|
||||||
|
):
|
||||||
|
raise ValueError("OIDC identity is not authorized")
|
||||||
|
return claims
|
||||||
+236
-1
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from types import SimpleNamespace
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -10,7 +11,13 @@ from app.db.session import get_db
|
|||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.schemas.demo import DemoWorkflowResponse
|
from app.schemas.demo import DemoWorkflowResponse
|
||||||
from app.services.auth_service import AuthService
|
from app.services.auth_service import AuthService
|
||||||
|
from app.services.change_detection_service import ChangeDetectionService
|
||||||
|
from app.services.dataset_service import DatasetService
|
||||||
|
from app.services.detection_service import DetectionService
|
||||||
from app.services.demo_workflow_service import DemoWorkflowService
|
from app.services.demo_workflow_service import DemoWorkflowService
|
||||||
|
from app.services.raster_operations_service import RasterOperationsService
|
||||||
|
from app.services.segmentation_service import SegmentationService
|
||||||
|
from app.services.job_service import JobService
|
||||||
|
|
||||||
|
|
||||||
def auth_client(monkeypatch, *, guest_access: bool = False) -> TestClient:
|
def auth_client(monkeypatch, *, guest_access: bool = False) -> TestClient:
|
||||||
@@ -80,6 +87,7 @@ def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) ->
|
|||||||
"expires_at": None,
|
"expires_at": None,
|
||||||
"role": None,
|
"role": None,
|
||||||
"guest_access_enabled": False,
|
"guest_access_enabled": False,
|
||||||
|
"authentik_enabled": False,
|
||||||
"guest_project_id": None,
|
"guest_project_id": None,
|
||||||
}
|
}
|
||||||
assert protected.status_code == 401
|
assert protected.status_code == 401
|
||||||
@@ -113,6 +121,7 @@ def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(m
|
|||||||
"expires_at": login.json()["data"]["expires_at"],
|
"expires_at": login.json()["data"]["expires_at"],
|
||||||
"role": "operator",
|
"role": "operator",
|
||||||
"guest_access_enabled": True,
|
"guest_access_enabled": True,
|
||||||
|
"authentik_enabled": False,
|
||||||
"guest_project_id": None,
|
"guest_project_id": None,
|
||||||
}
|
}
|
||||||
cookie = login.headers["set-cookie"].lower()
|
cookie = login.headers["set-cookie"].lower()
|
||||||
@@ -166,6 +175,18 @@ def test_guest_login_exposes_models_but_rejects_management_and_cross_project_req
|
|||||||
"themes": [],
|
"themes": [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
bounded_acquisition = client.post(
|
||||||
|
f"/api/v1/projects/{project_id}/datasets/orthophoto/acquire",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
cross_project_acquisition = client.post(
|
||||||
|
"/api/v1/projects/00000000-0000-0000-0000-000000000999/datasets/orthophoto/acquire",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
bounded_derived_selection = client.post(
|
||||||
|
f"/api/v1/projects/{project_id}/datasets/{demo.candidate_dataset_id}/vector/select/derive",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
|
||||||
assert guest_login.status_code == 200
|
assert guest_login.status_code == 200
|
||||||
assert guest_login.json()["data"]["role"] == "guest"
|
assert guest_login.json()["data"]["role"] == "guest"
|
||||||
@@ -186,6 +207,218 @@ def test_guest_login_exposes_models_but_rejects_management_and_cross_project_req
|
|||||||
assert cross_project_runs.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
assert cross_project_runs.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||||
assert cross_project_coverage.status_code == 403
|
assert cross_project_coverage.status_code == 403
|
||||||
assert cross_project_coverage.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
assert cross_project_coverage.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||||
|
assert bounded_acquisition.status_code == 422
|
||||||
|
assert bounded_acquisition.json()["error"] != "GUEST_READ_ONLY"
|
||||||
|
assert cross_project_acquisition.status_code == 403
|
||||||
|
assert cross_project_acquisition.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||||
|
assert bounded_derived_selection.status_code == 422
|
||||||
|
assert bounded_derived_selection.json()["error"] != "GUEST_READ_ONLY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_guest_change_detection_binds_both_datasets_to_signed_demo_project(monkeypatch) -> None:
|
||||||
|
project_id = UUID("00000000-0000-0000-0000-000000000123")
|
||||||
|
other_project_id = UUID("00000000-0000-0000-0000-000000000999")
|
||||||
|
source_dataset_id = UUID("00000000-0000-0000-0000-000000000125")
|
||||||
|
target_dataset_id = UUID("00000000-0000-0000-0000-000000000126")
|
||||||
|
cross_project_dataset_id = UUID("00000000-0000-0000-0000-000000000998")
|
||||||
|
demo = DemoWorkflowResponse(
|
||||||
|
project_id=project_id,
|
||||||
|
area_id=UUID("00000000-0000-0000-0000-000000000124"),
|
||||||
|
reference_dataset_id=source_dataset_id,
|
||||||
|
candidate_dataset_id=target_dataset_id,
|
||||||
|
raster_dataset_id=UUID("00000000-0000-0000-0000-000000000127"),
|
||||||
|
quality_check_id=UUID("00000000-0000-0000-0000-000000000128"),
|
||||||
|
metric_count=6,
|
||||||
|
status="ok",
|
||||||
|
message="Demo ready",
|
||||||
|
created=False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo))
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
def get(self, _model, dataset_id):
|
||||||
|
bound_project_id = other_project_id if dataset_id == cross_project_dataset_id else project_id
|
||||||
|
return SimpleNamespace(id=dataset_id, project_id=bound_project_id, dataset_type="vector")
|
||||||
|
|
||||||
|
validated_datasets: list[tuple[UUID, UUID, str]] = []
|
||||||
|
|
||||||
|
def validate_dataset(_db, dataset_id, requested_project_id, label):
|
||||||
|
validated_datasets.append((dataset_id, requested_project_id, label))
|
||||||
|
return SimpleNamespace(id=dataset_id, project_id=requested_project_id, dataset_type="vector")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ChangeDetectionService,
|
||||||
|
"_get_project_vector_dataset",
|
||||||
|
staticmethod(validate_dataset),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
JobService,
|
||||||
|
"run_sync_job",
|
||||||
|
staticmethod(
|
||||||
|
lambda **kwargs: SimpleNamespace(
|
||||||
|
id=uuid4(),
|
||||||
|
job_type=kwargs["job_type"],
|
||||||
|
status="success",
|
||||||
|
project_id=kwargs["project_id"],
|
||||||
|
dataset_id=source_dataset_id,
|
||||||
|
input_dataset_id=source_dataset_id,
|
||||||
|
output_dataset_id=None,
|
||||||
|
parameters_json=kwargs["parameters"],
|
||||||
|
result_json={},
|
||||||
|
error_message=None,
|
||||||
|
created_at=None,
|
||||||
|
started_at=None,
|
||||||
|
finished_at=None,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
client = auth_client(monkeypatch, guest_access=True)
|
||||||
|
|
||||||
|
def fake_db():
|
||||||
|
yield FakeDb()
|
||||||
|
|
||||||
|
client.app.dependency_overrides[get_db] = fake_db
|
||||||
|
assert client.post("/api/v1/auth/guest").status_code == 200
|
||||||
|
|
||||||
|
accepted = client.post(
|
||||||
|
"/api/v1/analysis/change-detection",
|
||||||
|
json={
|
||||||
|
"source_dataset_id": str(source_dataset_id),
|
||||||
|
"target_dataset_id": str(target_dataset_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rejected = client.post(
|
||||||
|
"/api/v1/analysis/change-detection",
|
||||||
|
json={
|
||||||
|
"source_dataset_id": str(cross_project_dataset_id),
|
||||||
|
"target_dataset_id": str(target_dataset_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert accepted.status_code == 200
|
||||||
|
assert accepted.json()["data"]["project_id"] == str(project_id)
|
||||||
|
assert validated_datasets == [
|
||||||
|
(source_dataset_id, project_id, "Source"),
|
||||||
|
(target_dataset_id, project_id, "Target"),
|
||||||
|
]
|
||||||
|
assert rejected.status_code == 403
|
||||||
|
assert rejected.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_guest_can_prepare_tiles_and_queue_project_scoped_detection(monkeypatch) -> None:
|
||||||
|
project_id = UUID("00000000-0000-0000-0000-000000000123")
|
||||||
|
raster_dataset_id = UUID("00000000-0000-0000-0000-000000000127")
|
||||||
|
manifest_path = "/app/storage/tiles/demo/manifest.json"
|
||||||
|
demo = DemoWorkflowResponse(
|
||||||
|
project_id=project_id,
|
||||||
|
area_id=UUID("00000000-0000-0000-0000-000000000124"),
|
||||||
|
reference_dataset_id=UUID("00000000-0000-0000-0000-000000000125"),
|
||||||
|
candidate_dataset_id=UUID("00000000-0000-0000-0000-000000000126"),
|
||||||
|
raster_dataset_id=raster_dataset_id,
|
||||||
|
quality_check_id=UUID("00000000-0000-0000-0000-000000000128"),
|
||||||
|
metric_count=6,
|
||||||
|
status="ok",
|
||||||
|
message="Demo ready",
|
||||||
|
created=False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DatasetService,
|
||||||
|
"get_dataset",
|
||||||
|
staticmethod(lambda _db, _dataset_id: SimpleNamespace(project_id=project_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def job(*, job_type: str, result_json: dict | None = None):
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=uuid4(),
|
||||||
|
job_type=job_type,
|
||||||
|
status="success" if result_json else "queued",
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=raster_dataset_id,
|
||||||
|
input_dataset_id=raster_dataset_id,
|
||||||
|
output_dataset_id=None,
|
||||||
|
parameters_json={},
|
||||||
|
result_json=result_json,
|
||||||
|
error_message=None,
|
||||||
|
created_at=None,
|
||||||
|
started_at=None,
|
||||||
|
finished_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
tile_parameters: dict = {}
|
||||||
|
|
||||||
|
def tile(_db, _dataset_id, **kwargs):
|
||||||
|
tile_parameters.update(kwargs)
|
||||||
|
return {"manifest_path": manifest_path}
|
||||||
|
|
||||||
|
monkeypatch.setattr(RasterOperationsService, "tile", staticmethod(tile))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.routes.datasets._run_job_sync",
|
||||||
|
lambda **kwargs: job(job_type="raster.tile", result_json=kwargs["operation"]()),
|
||||||
|
)
|
||||||
|
queued_parameters: dict = {}
|
||||||
|
|
||||||
|
def enqueue_detection(**kwargs):
|
||||||
|
queued_parameters.update(kwargs)
|
||||||
|
return job(job_type="detection.run")
|
||||||
|
|
||||||
|
monkeypatch.setattr(DetectionService, "enqueue_detection", staticmethod(enqueue_detection))
|
||||||
|
queued_segmentation_parameters: dict = {}
|
||||||
|
|
||||||
|
def enqueue_segmentation(**kwargs):
|
||||||
|
queued_segmentation_parameters.update(kwargs)
|
||||||
|
return job(job_type="segmentation.run")
|
||||||
|
|
||||||
|
monkeypatch.setattr(SegmentationService, "enqueue_segmentation", staticmethod(enqueue_segmentation))
|
||||||
|
client = auth_client(monkeypatch, guest_access=True)
|
||||||
|
|
||||||
|
def fake_db():
|
||||||
|
yield object()
|
||||||
|
|
||||||
|
client.app.dependency_overrides[get_db] = fake_db
|
||||||
|
assert client.post("/api/v1/auth/guest").status_code == 200
|
||||||
|
|
||||||
|
tile_response = client.post(
|
||||||
|
f"/api/v1/projects/{project_id}/datasets/{raster_dataset_id}/raster/tile",
|
||||||
|
json={"tile_size": 512, "overlap": 64},
|
||||||
|
)
|
||||||
|
detection_response = client.post(
|
||||||
|
f"/api/v1/detection/run-async?project_id={project_id}",
|
||||||
|
json={
|
||||||
|
"project_id": str(project_id),
|
||||||
|
"dataset_id": str(raster_dataset_id),
|
||||||
|
"model_id": "yolo-configured",
|
||||||
|
"model_asset_id": "active-model",
|
||||||
|
"confidence_threshold": 0.15,
|
||||||
|
"tile_manifest_path": manifest_path,
|
||||||
|
"parameters_json": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
segmentation_response = client.post(
|
||||||
|
f"/api/v1/segmentation/run-async?project_id={project_id}",
|
||||||
|
json={
|
||||||
|
"project_id": str(project_id),
|
||||||
|
"dataset_id": str(raster_dataset_id),
|
||||||
|
"model_id": "sam-configured",
|
||||||
|
"confidence_threshold": 0.5,
|
||||||
|
"tile_manifest_path": manifest_path,
|
||||||
|
"parameters_json": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tile_response.status_code == 201
|
||||||
|
assert tile_response.json()["data"]["result_json"]["manifest_path"] == manifest_path
|
||||||
|
assert detection_response.status_code == 200
|
||||||
|
assert detection_response.json()["data"]["status"] == "queued"
|
||||||
|
assert segmentation_response.status_code == 200
|
||||||
|
assert segmentation_response.json()["data"]["status"] == "queued"
|
||||||
|
assert queued_parameters["project_id"] == project_id
|
||||||
|
assert queued_parameters["dataset_id"] == raster_dataset_id
|
||||||
|
assert queued_parameters["tile_manifest_path"] == manifest_path
|
||||||
|
assert queued_segmentation_parameters["project_id"] == project_id
|
||||||
|
assert queued_segmentation_parameters["dataset_id"] == raster_dataset_id
|
||||||
|
assert queued_segmentation_parameters["tile_manifest_path"] == manifest_path
|
||||||
|
assert tile_parameters["max_tiles"] == get_settings().yolo_max_tiles
|
||||||
|
|
||||||
|
|
||||||
def test_guest_session_tokens_fail_closed_without_a_project_scope(monkeypatch) -> None:
|
def test_guest_session_tokens_fail_closed_without_a_project_scope(monkeypatch) -> None:
|
||||||
@@ -223,7 +456,9 @@ def test_unraid_runtime_carries_only_hashed_operator_credentials() -> None:
|
|||||||
browser_smoke = (root / "scripts/verify_browser_runtime.sh").read_text(encoding="utf-8")
|
browser_smoke = (root / "scripts/verify_browser_runtime.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert '-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH"' in runner
|
assert '-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH"' in runner
|
||||||
|
assert '-e GEOINTEL_AUTHENTIK_CLIENT_SECRET="$GEOINTEL_AUTHENTIK_CLIENT_SECRET"' in runner
|
||||||
assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example
|
assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example
|
||||||
|
assert "GEOINTEL_AUTHENTIK_CLIENT_SECRET=" in example
|
||||||
assert "GEOINTEL_AUTH_PASSWORD=" not in runner
|
assert "GEOINTEL_AUTH_PASSWORD=" not in runner
|
||||||
assert "GEOINTEL_GUEST_ACCESS_ENABLED=true" in example
|
assert "GEOINTEL_GUEST_ACCESS_ENABLED=true" in example
|
||||||
assert 'GEOINTEL_GUEST_ACCESS_ENABLED="${GEOINTEL_GUEST_ACCESS_ENABLED:-true}"' in runner
|
assert 'GEOINTEL_GUEST_ACCESS_ENABLED="${GEOINTEL_GUEST_ACCESS_ENABLED:-true}"' in runner
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.services.authentik_oidc_service import (
|
||||||
|
MAX_OIDC_JSON_BYTES,
|
||||||
|
AuthentikOidcService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ISSUER = "https://auth.example.test/application/o/geointel"
|
||||||
|
|
||||||
|
|
||||||
|
def configured_settings(**overrides: object) -> Settings:
|
||||||
|
values: dict[str, object] = {
|
||||||
|
"auth_enabled": True,
|
||||||
|
"auth_username": "ITWorx",
|
||||||
|
"auth_password_hash": "pbkdf2_sha256$1$salt$digest",
|
||||||
|
"auth_session_secret": "s" * 48,
|
||||||
|
"authentik_issuer": ISSUER,
|
||||||
|
"authentik_client_id": "geointel-client",
|
||||||
|
"authentik_client_secret": "client-secret",
|
||||||
|
"authentik_allowed_email": "operator@example.test",
|
||||||
|
"public_base_url": "https://geointel.example.test",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return Settings(_env_file=None, **values)
|
||||||
|
|
||||||
|
|
||||||
|
def discovery_document() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"issuer": ISSUER,
|
||||||
|
"authorization_endpoint": f"{ISSUER}/authorize",
|
||||||
|
"token_endpoint": f"{ISSUER}/token",
|
||||||
|
"jwks_uri": f"{ISSUER}/jwks",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentik_configuration_is_all_or_nothing_and_https_only() -> None:
|
||||||
|
with pytest.raises(ValidationError, match="configured together"):
|
||||||
|
configured_settings(authentik_client_secret=None)
|
||||||
|
with pytest.raises(ValidationError, match="absolute HTTPS URL"):
|
||||||
|
configured_settings(authentik_issuer="http://auth.example.test/issuer")
|
||||||
|
with pytest.raises(ValidationError, match="must not contain a path"):
|
||||||
|
configured_settings(public_base_url="https://geointel.example.test/app")
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_uses_same_origin_discovery_and_pkce(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
service = AuthentikOidcService(configured_settings())
|
||||||
|
monkeypatch.setattr(service, "_fetch_json", lambda *_args, **_kwargs: discovery_document())
|
||||||
|
|
||||||
|
location, flow_cookie = service.start()
|
||||||
|
|
||||||
|
parsed = urlsplit(location)
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
flow = service.serializer.loads(flow_cookie, max_age=600)
|
||||||
|
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == f"{ISSUER}/authorize"
|
||||||
|
assert query["redirect_uri"] == [
|
||||||
|
"https://geointel.example.test/api/v1/auth/authentik/callback"
|
||||||
|
]
|
||||||
|
assert query["code_challenge_method"] == ["S256"]
|
||||||
|
assert query["state"] == [flow["state"]]
|
||||||
|
assert query["nonce"] == [flow["nonce"]]
|
||||||
|
assert query["code_challenge"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_discovery_rejects_cross_origin_endpoints(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
service = AuthentikOidcService(configured_settings())
|
||||||
|
document = discovery_document()
|
||||||
|
document["jwks_uri"] = "https://attacker.example.test/jwks"
|
||||||
|
monkeypatch.setattr(service, "_fetch_json", lambda *_args, **_kwargs: document)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="outside the configured issuer origin"):
|
||||||
|
service._discovery()
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_verifies_signature_nonce_and_exact_allowed_email(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
service = AuthentikOidcService(configured_settings())
|
||||||
|
state, nonce, verifier = "state-value", "nonce-value", "verifier-value"
|
||||||
|
flow_cookie = service.serializer.dumps(
|
||||||
|
{"state": state, "nonce": nonce, "verifier": verifier}
|
||||||
|
)
|
||||||
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
public_jwk = jwt.algorithms.RSAAlgorithm.to_jwk(
|
||||||
|
private_key.public_key(), as_dict=True
|
||||||
|
)
|
||||||
|
public_jwk["kid"] = "operator-key"
|
||||||
|
now = int(time.time())
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"iss": ISSUER,
|
||||||
|
"aud": "geointel-client",
|
||||||
|
"sub": "authentik-user-id",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + 300,
|
||||||
|
"nonce": nonce,
|
||||||
|
"email": "Operator@Example.Test",
|
||||||
|
"email_verified": True,
|
||||||
|
},
|
||||||
|
private_key,
|
||||||
|
algorithm="RS256",
|
||||||
|
headers={"kid": "operator-key"},
|
||||||
|
)
|
||||||
|
token_holder = {"value": token}
|
||||||
|
|
||||||
|
def fetch(url: str, data: dict[str, str] | None = None) -> dict:
|
||||||
|
if url.endswith("openid-configuration"):
|
||||||
|
return discovery_document()
|
||||||
|
if url.endswith("/token"):
|
||||||
|
assert data is not None
|
||||||
|
assert data["code_verifier"] == verifier
|
||||||
|
return {"id_token": token_holder["value"]}
|
||||||
|
if url.endswith("/jwks"):
|
||||||
|
return {"keys": [public_jwk]}
|
||||||
|
raise AssertionError(url)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_fetch_json", fetch)
|
||||||
|
|
||||||
|
claims = service.finish(code="authorization-code", state=state, flow_cookie=flow_cookie)
|
||||||
|
|
||||||
|
assert claims["sub"] == "authentik-user-id"
|
||||||
|
token_holder["value"] = jwt.encode(
|
||||||
|
{
|
||||||
|
"iss": ISSUER,
|
||||||
|
"aud": "geointel-client",
|
||||||
|
"sub": "different-user",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + 300,
|
||||||
|
"nonce": nonce,
|
||||||
|
"email": "other@example.test",
|
||||||
|
"email_verified": True,
|
||||||
|
},
|
||||||
|
private_key,
|
||||||
|
algorithm="RS256",
|
||||||
|
headers={"kid": "operator-key"},
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="not authorized"):
|
||||||
|
service.finish(code="authorization-code", state=state, flow_cookie=flow_cookie)
|
||||||
|
with pytest.raises(ValueError, match="state mismatch"):
|
||||||
|
service.finish(
|
||||||
|
code="authorization-code",
|
||||||
|
state="different-state",
|
||||||
|
flow_cookie=flow_cookie,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_json_rejects_declared_oversize_response(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
service = AuthentikOidcService(configured_settings())
|
||||||
|
|
||||||
|
class OversizeResponse:
|
||||||
|
headers = {"Content-Length": str(MAX_OIDC_JSON_BYTES + 1)}
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read(self, _size: int) -> bytes:
|
||||||
|
raise AssertionError("oversized responses must not be read")
|
||||||
|
|
||||||
|
class Opener:
|
||||||
|
def open(self, *_args: object, **_kwargs: object) -> OversizeResponse:
|
||||||
|
return OversizeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.authentik_oidc_service.build_opener",
|
||||||
|
lambda *_args: Opener(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="size limit"):
|
||||||
|
service._fetch_json(f"{ISSUER}/oversized")
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
@@ -6,8 +7,16 @@ from app.main import app
|
|||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_host_request_target_is_rejected_canonically() -> None:
|
@pytest.mark.parametrize(
|
||||||
response = client.get("/health/live", headers={"host": "trusted.example/@admin"})
|
"host",
|
||||||
|
[
|
||||||
|
"trusted.example/@admin",
|
||||||
|
"trusted.example?shadow=admin",
|
||||||
|
"trusted.example#shadow",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_host_request_target_is_rejected_canonically(host: str) -> None:
|
||||||
|
response = client.get("/health/live", headers={"host": host})
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.headers["x-request-id"]
|
assert response.headers["x-request-id"]
|
||||||
@@ -15,6 +24,16 @@ def test_invalid_host_request_target_is_rejected_canonically() -> None:
|
|||||||
assert response.json()["request_id"] == response.headers["x-request-id"]
|
assert response.json()["request_id"] == response.headers["x-request-id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"host",
|
||||||
|
["localhost:1202", "127.0.0.1:8000", "[::1]:8000", "testserver"],
|
||||||
|
)
|
||||||
|
def test_normal_host_forms_remain_available(host: str) -> None:
|
||||||
|
response = client.get("/health/live", headers={"host": host})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_urlencoded_form_body_is_rejected_before_starlette_form_parsing() -> None:
|
def test_urlencoded_form_body_is_rejected_before_starlette_form_parsing() -> None:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/datasets/upload",
|
"/api/v1/datasets/upload",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function App(): JSX.Element {
|
|||||||
<LandingPage
|
<LandingPage
|
||||||
serviceError={sessionError}
|
serviceError={sessionError}
|
||||||
guestAccessEnabled={session.guest_access_enabled}
|
guestAccessEnabled={session.guest_access_enabled}
|
||||||
|
authentikEnabled={session.authentik_enabled}
|
||||||
onAuthenticated={handleAuthenticated}
|
onAuthenticated={handleAuthenticated}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const operatorSession = {
|
|||||||
expires_at: '2026-07-27T20:00:00Z',
|
expires_at: '2026-07-27T20:00:00Z',
|
||||||
role: 'operator' as const,
|
role: 'operator' as const,
|
||||||
guest_access_enabled: true,
|
guest_access_enabled: true,
|
||||||
|
authentik_enabled: false,
|
||||||
guest_project_id: null,
|
guest_project_id: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ const guestSession = {
|
|||||||
expires_at: '2026-07-27T20:00:00Z',
|
expires_at: '2026-07-27T20:00:00Z',
|
||||||
role: 'guest' as const,
|
role: 'guest' as const,
|
||||||
guest_access_enabled: true,
|
guest_access_enabled: true,
|
||||||
|
authentik_enabled: false,
|
||||||
guest_project_id: '00000000-0000-0000-0000-000000000123',
|
guest_project_id: '00000000-0000-0000-0000-000000000123',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +73,15 @@ describe('LandingPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Open de workbench' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'Open de workbench' })).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers Authentik without removing the local operator recovery login', () => {
|
||||||
|
render(<LandingPage onAuthenticated={vi.fn()} authentikEnabled />)
|
||||||
|
|
||||||
|
const authentik = screen.getByRole('link', { name: 'Aanmelden met Authentik' })
|
||||||
|
expect(authentik.getAttribute('href')).toBe('/api/v1/auth/authentik/start')
|
||||||
|
expect(screen.getByLabelText('Gebruikersnaam')).toBeTruthy()
|
||||||
|
expect(screen.getByLabelText('Wachtwoord')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
it('surfaces a useful authentication error without entering the workbench', async () => {
|
it('surfaces a useful authentication error without entering the workbench', async () => {
|
||||||
vi.mocked(login).mockRejectedValue(new Error('Gebruikersnaam of wachtwoord is onjuist.'))
|
vi.mocked(login).mockRejectedValue(new Error('Gebruikersnaam of wachtwoord is onjuist.'))
|
||||||
render(<LandingPage onAuthenticated={vi.fn()} />)
|
render(<LandingPage onAuthenticated={vi.fn()} />)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface LandingPageProps {
|
|||||||
onAuthenticated: (session: AuthSession) => void
|
onAuthenticated: (session: AuthSession) => void
|
||||||
serviceError?: string | null
|
serviceError?: string | null
|
||||||
guestAccessEnabled?: boolean
|
guestAccessEnabled?: boolean
|
||||||
|
authentikEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const capabilityItems = [
|
const capabilityItems = [
|
||||||
@@ -62,6 +63,7 @@ export function LandingPage({
|
|||||||
onAuthenticated,
|
onAuthenticated,
|
||||||
serviceError = null,
|
serviceError = null,
|
||||||
guestAccessEnabled = false,
|
guestAccessEnabled = false,
|
||||||
|
authentikEnabled = false,
|
||||||
}: LandingPageProps): JSX.Element {
|
}: LandingPageProps): JSX.Element {
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -79,6 +81,16 @@ export function LandingPage({
|
|||||||
return () => document.body.classList.remove('landing-body')
|
return () => document.body.classList.remove('landing-body')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = new URLSearchParams(window.location.search)
|
||||||
|
if (query.get('authentik') !== 'error') return
|
||||||
|
setAttempted(true)
|
||||||
|
setAuthError('Aanmelden via Authentik is niet gelukt. Probeer opnieuw of gebruik de lokale operatorlogin.')
|
||||||
|
query.delete('authentik')
|
||||||
|
const suffix = query.toString()
|
||||||
|
window.history.replaceState(null, '', `${window.location.pathname}${suffix ? `?${suffix}` : ''}${window.location.hash}`)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const scrollAccessPanelIntoView = () => {
|
const scrollAccessPanelIntoView = () => {
|
||||||
if (typeof accessPanelRef.current?.scrollIntoView !== 'function') return
|
if (typeof accessPanelRef.current?.scrollIntoView !== 'function') return
|
||||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||||
@@ -247,6 +259,17 @@ export function LandingPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={submitLogin} aria-busy={pendingAction === 'operator'}>
|
<form onSubmit={submitLogin} aria-busy={pendingAction === 'operator'}>
|
||||||
|
{authentikEnabled ? (
|
||||||
|
<a className="landing-authentik-submit" href="/api/v1/auth/authentik/start">
|
||||||
|
<ShieldCheck aria-hidden="true" />
|
||||||
|
Aanmelden met Authentik
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{authentikEnabled ? (
|
||||||
|
<div className="landing-access-divider">
|
||||||
|
<span>of met lokale operatorgegevens</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<label htmlFor="login-username">Gebruikersnaam</label>
|
<label htmlFor="login-username">Gebruikersnaam</label>
|
||||||
<input
|
<input
|
||||||
ref={usernameRef}
|
ref={usernameRef}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const signedOutSession: AuthSession = {
|
|||||||
expires_at: null,
|
expires_at: null,
|
||||||
role: null,
|
role: null,
|
||||||
guest_access_enabled: false,
|
guest_access_enabled: false,
|
||||||
|
authentik_enabled: false,
|
||||||
guest_project_id: null,
|
guest_project_id: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export function useOperatorSession() {
|
|||||||
setSession((current) => ({
|
setSession((current) => ({
|
||||||
...signedOutSession,
|
...signedOutSession,
|
||||||
guest_access_enabled: current?.guest_access_enabled ?? false,
|
guest_access_enabled: current?.guest_access_enabled ?? false,
|
||||||
|
authentik_enabled: current?.authentik_enabled ?? false,
|
||||||
}))
|
}))
|
||||||
setSessionError('Uw sessie is verlopen. Meld u opnieuw aan.')
|
setSessionError('Uw sessie is verlopen. Meld u opnieuw aan.')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { getWorkbenchAccessCapabilities } from './accessCapabilities'
|
||||||
|
|
||||||
|
describe('workbench access capabilities', () => {
|
||||||
|
it('keeps the complete analysis journey available to the demo', () => {
|
||||||
|
const guest = getWorkbenchAccessCapabilities('guest')
|
||||||
|
|
||||||
|
expect(guest).toMatchObject({
|
||||||
|
analyzePersistedData: true,
|
||||||
|
selectModels: true,
|
||||||
|
runQualityChecks: true,
|
||||||
|
exportResults: true,
|
||||||
|
acquireSources: true,
|
||||||
|
writeDerivedDatasets: true,
|
||||||
|
runChangeDetection: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not advertise operator-only mutations to a demo session', () => {
|
||||||
|
const guest = getWorkbenchAccessCapabilities('guest')
|
||||||
|
|
||||||
|
expect(guest).toMatchObject({
|
||||||
|
manageWorkspace: false,
|
||||||
|
manageModels: false,
|
||||||
|
reviewEvidence: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps local open mode and an operator fully capable', () => {
|
||||||
|
expect(getWorkbenchAccessCapabilities('open')).toEqual(getWorkbenchAccessCapabilities('operator'))
|
||||||
|
expect(Object.values(getWorkbenchAccessCapabilities('operator')).every(Boolean)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type WorkbenchAccessMode = 'open' | 'operator' | 'guest'
|
||||||
|
|
||||||
|
export interface WorkbenchAccessCapabilities {
|
||||||
|
analyzePersistedData: boolean
|
||||||
|
selectModels: boolean
|
||||||
|
runQualityChecks: boolean
|
||||||
|
exportResults: boolean
|
||||||
|
acquireSources: boolean
|
||||||
|
manageWorkspace: boolean
|
||||||
|
manageModels: boolean
|
||||||
|
writeDerivedDatasets: boolean
|
||||||
|
reviewEvidence: boolean
|
||||||
|
runChangeDetection: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATOR_CAPABILITIES: WorkbenchAccessCapabilities = {
|
||||||
|
analyzePersistedData: true,
|
||||||
|
selectModels: true,
|
||||||
|
runQualityChecks: true,
|
||||||
|
exportResults: true,
|
||||||
|
acquireSources: true,
|
||||||
|
manageWorkspace: true,
|
||||||
|
manageModels: true,
|
||||||
|
writeDerivedDatasets: true,
|
||||||
|
reviewEvidence: true,
|
||||||
|
runChangeDetection: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUEST_CAPABILITIES: WorkbenchAccessCapabilities = {
|
||||||
|
analyzePersistedData: true,
|
||||||
|
selectModels: true,
|
||||||
|
runQualityChecks: true,
|
||||||
|
exportResults: true,
|
||||||
|
acquireSources: true,
|
||||||
|
manageWorkspace: false,
|
||||||
|
manageModels: false,
|
||||||
|
writeDerivedDatasets: true,
|
||||||
|
reviewEvidence: false,
|
||||||
|
runChangeDetection: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the backend's explicit guest route boundary without weakening it. */
|
||||||
|
export function getWorkbenchAccessCapabilities(mode: WorkbenchAccessMode): WorkbenchAccessCapabilities {
|
||||||
|
return mode === 'guest' ? GUEST_CAPABILITIES : OPERATOR_CAPABILITIES
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export interface AuthSession {
|
|||||||
expires_at: string | null
|
expires_at: string | null
|
||||||
role: 'operator' | 'guest' | null
|
role: 'operator' | 'guest' | null
|
||||||
guest_access_enabled: boolean
|
guest_access_enabled: boolean
|
||||||
|
authentik_enabled: boolean
|
||||||
guest_project_id: string | null
|
guest_project_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user