Full platform remediation #7

Merged
Jens merged 11 commits from codex/full-platform-remediation into main 2026-08-30 09:30:42 +00:00
15 changed files with 928 additions and 8 deletions
Showing only changes of commit 96f90373dc - Show all commits
+91 -1
View File
@@ -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,
) )
) )
+64 -2
View File
@@ -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
View File
@@ -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,
), ),
) )
+1
View File
@@ -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
View File
@@ -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")
+21 -2
View File
@@ -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",
+1
View File
@@ -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}
+2
View File
@@ -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)
})
})
+45
View File
@@ -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
}
+1
View File
@@ -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
} }