Update
This commit is contained in:
+106
-23
@@ -2,30 +2,71 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Request, Response, status
|
||||
from fastapi import APIRouter, Depends, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.db.session import get_db
|
||||
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.auth_service import AuthPrincipal, AuthService
|
||||
from app.services.demo_workflow_service import DemoWorkflowService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
COOKIE_NAME = "geointel_session"
|
||||
|
||||
|
||||
def _session_payload(request: Request) -> AuthSession:
|
||||
settings = get_settings()
|
||||
if not settings.auth_enabled:
|
||||
return AuthSession(authentication_required=False, authenticated=True)
|
||||
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
|
||||
if principal is None:
|
||||
return AuthSession(authentication_required=True, authenticated=False)
|
||||
def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: bool) -> AuthSession:
|
||||
return AuthSession(
|
||||
authentication_required=True,
|
||||
authenticated=True,
|
||||
username=principal.username,
|
||||
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
|
||||
role=principal.role,
|
||||
guest_access_enabled=guest_access_enabled,
|
||||
guest_project_id=principal.project_id,
|
||||
)
|
||||
|
||||
|
||||
def _session_payload(request: Request) -> AuthSession:
|
||||
settings = get_settings()
|
||||
guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled
|
||||
if not settings.auth_enabled:
|
||||
return AuthSession(
|
||||
authentication_required=False,
|
||||
authenticated=True,
|
||||
guest_access_enabled=False,
|
||||
)
|
||||
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
|
||||
if principal is None:
|
||||
return AuthSession(
|
||||
authentication_required=True,
|
||||
authenticated=False,
|
||||
guest_access_enabled=guest_access_enabled,
|
||||
)
|
||||
return _session_from_principal(
|
||||
principal,
|
||||
guest_access_enabled=guest_access_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _set_session_cookie(
|
||||
*,
|
||||
request: Request,
|
||||
response: Response,
|
||||
token: str,
|
||||
max_age: int,
|
||||
) -> None:
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
secure=forwarded_proto == "https" or request.url.scheme == "https",
|
||||
samesite="strict",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
@@ -69,29 +110,71 @@ def login(payload: AuthLoginRequest, request: Request, response: Response) -> Au
|
||||
message="De beveiligde sessie kon niet worden aangemaakt.",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
_set_session_cookie(
|
||||
request=request,
|
||||
response=response,
|
||||
token=token,
|
||||
max_age=settings.auth_session_ttl_seconds,
|
||||
httponly=True,
|
||||
secure=forwarded_proto == "https" or request.url.scheme == "https",
|
||||
samesite="strict",
|
||||
path="/",
|
||||
)
|
||||
return AuthSessionEnvelope(
|
||||
data=AuthSession(
|
||||
authentication_required=True,
|
||||
authenticated=True,
|
||||
username=principal.username,
|
||||
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
|
||||
data=_session_from_principal(
|
||||
principal,
|
||||
guest_access_enabled=settings.guest_access_enabled,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/guest", response_model=AuthSessionEnvelope)
|
||||
def guest_login(
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> AuthSessionEnvelope:
|
||||
settings = get_settings()
|
||||
if not settings.auth_enabled or not settings.guest_access_enabled:
|
||||
raise AppError(
|
||||
code="GUEST_ACCESS_DISABLED",
|
||||
message="Gasttoegang is niet ingeschakeld op deze GeoIntel-installatie.",
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
demo = DemoWorkflowService.seed(db)
|
||||
token = AuthService.create_session_token(
|
||||
settings.guest_display_name,
|
||||
settings,
|
||||
role="guest",
|
||||
project_id=demo.project_id,
|
||||
ttl_seconds=settings.guest_session_ttl_seconds,
|
||||
)
|
||||
principal = AuthService.verify_session_token(token, settings)
|
||||
if principal is None: # pragma: no cover - defensive invariant
|
||||
raise AppError(
|
||||
code="SESSION_CREATION_FAILED",
|
||||
message="De tijdelijke gastensessie kon niet worden aangemaakt.",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
_set_session_cookie(
|
||||
request=request,
|
||||
response=response,
|
||||
token=token,
|
||||
max_age=settings.guest_session_ttl_seconds,
|
||||
)
|
||||
return AuthSessionEnvelope(
|
||||
data=_session_from_principal(
|
||||
principal,
|
||||
guest_access_enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=AuthSessionEnvelope)
|
||||
def logout(response: Response) -> AuthSessionEnvelope:
|
||||
settings = get_settings()
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/", httponly=True, samesite="strict")
|
||||
return AuthSessionEnvelope(
|
||||
data=AuthSession(authentication_required=True, authenticated=False)
|
||||
data=AuthSession(
|
||||
authentication_required=settings.auth_enabled,
|
||||
authenticated=not settings.auth_enabled,
|
||||
guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
@@ -43,6 +43,19 @@ def _assert_project_exists(db: Session, project_id):
|
||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||
|
||||
|
||||
def _assert_guest_project_scope(request: Request, project_id) -> None:
|
||||
principal = getattr(request.state, "auth_principal", None)
|
||||
if (
|
||||
getattr(principal, "role", None) == "guest"
|
||||
and getattr(principal, "project_id", None) != project_id
|
||||
):
|
||||
raise AppError(
|
||||
code="GUEST_PROJECT_SCOPE_REQUIRED",
|
||||
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_layer_input(layers: list[str] | None) -> list[str]:
|
||||
return [layer.strip() for layer in (layers or []) if isinstance(layer, str) and layer.strip()]
|
||||
|
||||
@@ -66,7 +79,12 @@ def get_coverage_catalog() -> dict:
|
||||
|
||||
|
||||
@router.post("/coverage/resolve", response_model=Envelope[CoverageResolveResponse])
|
||||
def resolve_project_coverage(payload: CoverageResolveRequest, db: Session = Depends(get_db)) -> dict:
|
||||
def resolve_project_coverage(
|
||||
payload: CoverageResolveRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
_assert_guest_project_scope(request, payload.project_id)
|
||||
result = CoverageRegistryService.resolve(
|
||||
db,
|
||||
project_id=payload.project_id,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
@@ -17,12 +17,34 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
@router.get("", response_model=Envelope[ProjectList])
|
||||
def list_projects(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
name: str | None = Query(default=None, min_length=1, max_length=255),
|
||||
project_status: Literal["active", "archived", "all"] = Query(default="active", alias="status"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
principal = getattr(request.state, "auth_principal", None)
|
||||
if principal is not None and principal.role == "guest":
|
||||
project = ProjectService.get_project(db, principal.project_id)
|
||||
status_matches = bool(
|
||||
project is not None
|
||||
and (project_status == "all" or project.status == project_status)
|
||||
)
|
||||
name_matches = bool(
|
||||
project is not None
|
||||
and (name is None or name.casefold() in project.name.casefold())
|
||||
)
|
||||
matches = project is not None and status_matches and name_matches
|
||||
visible = [project] if matches and offset == 0 else []
|
||||
return envelope(
|
||||
{
|
||||
"items": [ProjectRead.model_validate(item).model_dump() for item in visible[:limit]],
|
||||
"total": 1 if matches else 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
)
|
||||
projects, total = ProjectService.list_projects(
|
||||
db,
|
||||
limit=limit,
|
||||
|
||||
@@ -28,6 +28,22 @@ class Settings(BaseSettings):
|
||||
le=604_800,
|
||||
validation_alias="GEOINTEL_AUTH_SESSION_TTL_SECONDS",
|
||||
)
|
||||
guest_access_enabled: bool = Field(
|
||||
default=True,
|
||||
validation_alias="GEOINTEL_GUEST_ACCESS_ENABLED",
|
||||
)
|
||||
guest_display_name: str = Field(
|
||||
default="Gast",
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
validation_alias="GEOINTEL_GUEST_DISPLAY_NAME",
|
||||
)
|
||||
guest_session_ttl_seconds: int = Field(
|
||||
default=7_200,
|
||||
ge=900,
|
||||
le=86_400,
|
||||
validation_alias="GEOINTEL_GUEST_SESSION_TTL_SECONDS",
|
||||
)
|
||||
database_url: str = Field(
|
||||
default="postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1",
|
||||
validation_alias="DATABASE_URL",
|
||||
@@ -406,6 +422,9 @@ class Settings(BaseSettings):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_operator_auth(self) -> "Settings":
|
||||
self.guest_display_name = self.guest_display_name.strip()
|
||||
if not self.guest_display_name:
|
||||
raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank")
|
||||
if not self.auth_enabled:
|
||||
return self
|
||||
if not (self.auth_username or "").strip():
|
||||
|
||||
@@ -147,6 +147,7 @@ def create_app() -> FastAPI:
|
||||
public_auth_paths = {
|
||||
f"{settings.api_prefix}/auth/session",
|
||||
f"{settings.api_prefix}/auth/login",
|
||||
f"{settings.api_prefix}/auth/guest",
|
||||
f"{settings.api_prefix}/auth/logout",
|
||||
}
|
||||
direct_loopback_request = (
|
||||
@@ -177,6 +178,91 @@ def create_app() -> FastAPI:
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
request.state.auth_principal = principal
|
||||
if principal.role == "guest":
|
||||
project_path_prefix = f"{settings.api_prefix}/projects/"
|
||||
guest_project_root = f"{project_path_prefix}{principal.project_id}"
|
||||
if raw_path.startswith(project_path_prefix):
|
||||
scoped_path = raw_path[len(project_path_prefix):]
|
||||
requested_project_id = scoped_path.split("/", 1)[0]
|
||||
if str(principal.project_id) != requested_project_id:
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content=_to_error_payload(
|
||||
"GUEST_PROJECT_SCOPE_REQUIRED",
|
||||
"Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
query_project_id = request.query_params.get("project_id")
|
||||
if query_project_id and query_project_id != str(principal.project_id):
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content=_to_error_payload(
|
||||
"GUEST_PROJECT_SCOPE_REQUIRED",
|
||||
"Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
guest_safe_read_paths = {
|
||||
f"{settings.api_prefix}/projects",
|
||||
f"{settings.api_prefix}/external/providers",
|
||||
}
|
||||
normalized_path = raw_path.rstrip("/") or "/"
|
||||
guest_project_read = (
|
||||
normalized_path == guest_project_root
|
||||
or normalized_path.startswith(f"{guest_project_root}/")
|
||||
)
|
||||
is_read_request = request.method in {"GET", "HEAD", "OPTIONS"}
|
||||
if is_read_request:
|
||||
if normalized_path not in guest_safe_read_paths and not guest_project_read:
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content=_to_error_payload(
|
||||
"GUEST_ROUTE_NOT_AVAILABLE",
|
||||
"Deze API-route maakt geen deel uit van de afgeschermde GeoIntel-demo.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
else:
|
||||
guest_safe_post_paths = {
|
||||
f"{settings.api_prefix}/demo/workflow",
|
||||
f"{settings.api_prefix}/external/coverage/resolve",
|
||||
}
|
||||
guest_safe_post_suffixes = (
|
||||
"/vector/select",
|
||||
"/raster/bathymetry/select",
|
||||
"/raster/terrain/select",
|
||||
"/raster/flood-hazard/select",
|
||||
"/raster/thematic/select",
|
||||
"/raster/walous/select",
|
||||
"/temporal/compare",
|
||||
"/datasets/vector/partitions/select",
|
||||
"/datasets/bathymetry/profiles/partitions/select",
|
||||
)
|
||||
is_guest_safe_post = request.method == "POST" and (
|
||||
raw_path in guest_safe_post_paths
|
||||
or (
|
||||
raw_path.startswith(project_path_prefix)
|
||||
and raw_path.endswith(guest_safe_post_suffixes)
|
||||
)
|
||||
)
|
||||
if not is_guest_safe_post:
|
||||
response = JSONResponse(
|
||||
status_code=403,
|
||||
content=_to_error_payload(
|
||||
"GUEST_READ_ONLY",
|
||||
"Gasttoegang is een tijdelijke, alleen-lezen demo. Meld u aan als operator om gegevens te wijzigen of taken te starten.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
response = await call_next(request)
|
||||
response.headers["x-request-id"] = request_id
|
||||
logger.info(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -17,6 +19,9 @@ class AuthSession(BaseModel):
|
||||
authenticated: bool
|
||||
username: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
role: Literal["operator", "guest"] | None = None
|
||||
guest_access_enabled: bool = False
|
||||
guest_project_id: UUID | None = None
|
||||
|
||||
|
||||
class AuthSessionEnvelope(Envelope[AuthSession]):
|
||||
|
||||
@@ -9,6 +9,8 @@ import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
@@ -17,6 +19,8 @@ from app.core.config import Settings
|
||||
class AuthPrincipal:
|
||||
username: str
|
||||
expires_at: int
|
||||
role: Literal["operator", "guest"] = "operator"
|
||||
project_id: UUID | None = None
|
||||
|
||||
|
||||
class AuthService:
|
||||
@@ -93,15 +97,32 @@ class AuthService:
|
||||
return username_matches and password_matches
|
||||
|
||||
@classmethod
|
||||
def create_session_token(cls, username: str, settings: Settings, *, now: int | None = None) -> str:
|
||||
def create_session_token(
|
||||
cls,
|
||||
username: str,
|
||||
settings: Settings,
|
||||
*,
|
||||
role: Literal["operator", "guest"] = "operator",
|
||||
project_id: UUID | None = None,
|
||||
ttl_seconds: int | None = None,
|
||||
now: int | None = None,
|
||||
) -> str:
|
||||
issued_at = int(time.time() if now is None else now)
|
||||
if role == "guest" and project_id is None:
|
||||
raise ValueError("Guest sessions must be scoped to a demo project")
|
||||
resolved_ttl = ttl_seconds if ttl_seconds is not None else (
|
||||
settings.guest_session_ttl_seconds if role == "guest" else settings.auth_session_ttl_seconds
|
||||
)
|
||||
payload = {
|
||||
"exp": issued_at + settings.auth_session_ttl_seconds,
|
||||
"exp": issued_at + resolved_ttl,
|
||||
"iat": issued_at,
|
||||
"jti": secrets.token_urlsafe(12),
|
||||
"role": role,
|
||||
"sub": username,
|
||||
"v": 1,
|
||||
"v": 2,
|
||||
}
|
||||
if project_id is not None:
|
||||
payload["project_id"] = str(project_id)
|
||||
encoded_payload = cls._b64_encode(
|
||||
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
)
|
||||
@@ -136,14 +157,35 @@ class AuthService:
|
||||
username = str(payload.get("sub") or "")
|
||||
expires_at = int(payload.get("exp") or 0)
|
||||
issued_at = int(payload.get("iat") or 0)
|
||||
version = int(payload.get("v") or 0)
|
||||
role_value = str(payload.get("role") or "operator")
|
||||
current = int(time.time() if now is None else now)
|
||||
if payload.get("v") != 1 or username != settings.auth_username:
|
||||
if version not in {1, 2} or role_value not in {"operator", "guest"}:
|
||||
return None
|
||||
role = cast(Literal["operator", "guest"], role_value)
|
||||
if issued_at <= 0 or issued_at > current + 60 or expires_at <= current:
|
||||
return None
|
||||
if expires_at - issued_at > settings.auth_session_ttl_seconds:
|
||||
if role == "operator":
|
||||
if username != settings.auth_username:
|
||||
return None
|
||||
max_ttl = settings.auth_session_ttl_seconds
|
||||
project_id = None
|
||||
else:
|
||||
if not settings.guest_access_enabled or username != settings.guest_display_name:
|
||||
return None
|
||||
max_ttl = settings.guest_session_ttl_seconds
|
||||
raw_project_id = payload.get("project_id")
|
||||
if not raw_project_id:
|
||||
return None
|
||||
project_id = UUID(str(raw_project_id))
|
||||
if expires_at - issued_at > max_ttl:
|
||||
return None
|
||||
return AuthPrincipal(username=username, expires_at=expires_at)
|
||||
return AuthPrincipal(
|
||||
username=username,
|
||||
expires_at=expires_at,
|
||||
role=role,
|
||||
project_id=project_id,
|
||||
)
|
||||
except (TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
+126
-3
@@ -1,14 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.session import get_db
|
||||
from app.main import create_app
|
||||
from app.schemas.demo import DemoWorkflowResponse
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.demo_workflow_service import DemoWorkflowService
|
||||
|
||||
|
||||
def auth_client(monkeypatch) -> TestClient:
|
||||
def auth_client(monkeypatch, *, guest_access: bool = False) -> TestClient:
|
||||
password_hash = AuthService.hash_password(
|
||||
"correct horse battery staple",
|
||||
salt=b"geointel-test-salt",
|
||||
@@ -18,9 +23,48 @@ def auth_client(monkeypatch) -> TestClient:
|
||||
monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator")
|
||||
monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash)
|
||||
monkeypatch.setenv("GEOINTEL_AUTH_SESSION_SECRET", "test-session-secret-that-is-long-enough")
|
||||
monkeypatch.setenv("GEOINTEL_GUEST_ACCESS_ENABLED", "true" if guest_access else "false")
|
||||
monkeypatch.setenv("GEOINTEL_GUEST_DISPLAY_NAME", "Gast")
|
||||
monkeypatch.setenv("GEOINTEL_GUEST_SESSION_TTL_SECONDS", "7200")
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
def test_guest_access_defaults_on_when_operator_authentication_is_enabled(monkeypatch) -> None:
|
||||
password_hash = AuthService.hash_password(
|
||||
"correct horse battery staple",
|
||||
salt=b"geointel-test-salt",
|
||||
iterations=100_000,
|
||||
)
|
||||
monkeypatch.setenv("GEOINTEL_AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator")
|
||||
monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash)
|
||||
monkeypatch.setenv("GEOINTEL_AUTH_SESSION_SECRET", "test-session-secret-that-is-long-enough")
|
||||
monkeypatch.delenv("GEOINTEL_GUEST_ACCESS_ENABLED", raising=False)
|
||||
|
||||
client = TestClient(create_app())
|
||||
session = client.get("/api/v1/auth/session")
|
||||
|
||||
assert session.status_code == 200
|
||||
assert session.json()["data"]["authentication_required"] is True
|
||||
assert session.json()["data"]["guest_access_enabled"] is True
|
||||
|
||||
|
||||
def test_guest_default_is_inactive_but_valid_when_operator_authentication_is_disabled(monkeypatch) -> None:
|
||||
monkeypatch.setenv("GEOINTEL_AUTH_ENABLED", "false")
|
||||
monkeypatch.delenv("GEOINTEL_AUTH_USERNAME", raising=False)
|
||||
monkeypatch.delenv("GEOINTEL_AUTH_PASSWORD_HASH", raising=False)
|
||||
monkeypatch.delenv("GEOINTEL_AUTH_SESSION_SECRET", raising=False)
|
||||
monkeypatch.delenv("GEOINTEL_GUEST_ACCESS_ENABLED", raising=False)
|
||||
|
||||
client = TestClient(create_app())
|
||||
session = client.get("/api/v1/auth/session")
|
||||
|
||||
assert session.status_code == 200
|
||||
assert session.json()["data"]["authentication_required"] is False
|
||||
assert session.json()["data"]["authenticated"] is True
|
||||
assert session.json()["data"]["guest_access_enabled"] is False
|
||||
|
||||
|
||||
def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) -> None:
|
||||
client = auth_client(monkeypatch)
|
||||
|
||||
@@ -34,6 +78,9 @@ def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) ->
|
||||
"authenticated": False,
|
||||
"username": None,
|
||||
"expires_at": None,
|
||||
"role": None,
|
||||
"guest_access_enabled": False,
|
||||
"guest_project_id": None,
|
||||
}
|
||||
assert protected.status_code == 401
|
||||
assert protected.json()["error"] == "AUTHENTICATION_REQUIRED"
|
||||
@@ -41,7 +88,7 @@ def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) ->
|
||||
|
||||
|
||||
def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(monkeypatch) -> None:
|
||||
client = auth_client(monkeypatch)
|
||||
client = auth_client(monkeypatch, guest_access=True)
|
||||
|
||||
invalid = client.post(
|
||||
"/api/v1/auth/login",
|
||||
@@ -59,16 +106,90 @@ def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(m
|
||||
assert invalid.status_code == 401
|
||||
assert invalid.json()["error"] == "INVALID_CREDENTIALS"
|
||||
assert login.status_code == 200
|
||||
assert login.json()["data"]["username"] == "operator"
|
||||
assert login.json()["data"] == {
|
||||
"authentication_required": True,
|
||||
"authenticated": True,
|
||||
"username": "operator",
|
||||
"expires_at": login.json()["data"]["expires_at"],
|
||||
"role": "operator",
|
||||
"guest_access_enabled": True,
|
||||
"guest_project_id": None,
|
||||
}
|
||||
cookie = login.headers["set-cookie"].lower()
|
||||
assert "httponly" in cookie
|
||||
assert "samesite=strict" in cookie
|
||||
assert authenticated.json()["data"]["authenticated"] is True
|
||||
assert authenticated.json()["data"]["role"] == "operator"
|
||||
assert protected_after_login.status_code == 404
|
||||
assert logout.status_code == 200
|
||||
assert logout.json()["data"]["guest_access_enabled"] is True
|
||||
assert protected_after_logout.status_code == 401
|
||||
|
||||
|
||||
def test_guest_login_seeds_scoped_demo_and_rejects_mutating_or_cross_project_requests(monkeypatch) -> None:
|
||||
project_id = UUID("00000000-0000-0000-0000-000000000123")
|
||||
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=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))
|
||||
client = auth_client(monkeypatch, guest_access=True)
|
||||
|
||||
def fake_db():
|
||||
yield object()
|
||||
|
||||
client.app.dependency_overrides[get_db] = fake_db
|
||||
|
||||
guest_login = client.post("/api/v1/auth/guest")
|
||||
guest_session = client.get("/api/v1/auth/session")
|
||||
mutation = client.post("/api/v1/projects", json={"name": "Not allowed"})
|
||||
other_project = client.get("/api/v1/projects/00000000-0000-0000-0000-000000000999")
|
||||
unscoped_read = client.get("/api/v1/detection/models")
|
||||
cross_project_coverage = client.post(
|
||||
"/api/v1/external/coverage/resolve",
|
||||
json={
|
||||
"project_id": "00000000-0000-0000-0000-000000000999",
|
||||
"bbox": {"minx": 4.9, "miny": 51.0, "maxx": 5.0, "maxy": 51.1},
|
||||
"themes": [],
|
||||
},
|
||||
)
|
||||
|
||||
assert guest_login.status_code == 200
|
||||
assert guest_login.json()["data"]["role"] == "guest"
|
||||
assert guest_login.json()["data"]["username"] == "Gast"
|
||||
assert guest_login.json()["data"]["guest_project_id"] == str(project_id)
|
||||
assert "httponly" in guest_login.headers["set-cookie"].lower()
|
||||
assert guest_session.json()["data"]["role"] == "guest"
|
||||
assert mutation.status_code == 403
|
||||
assert mutation.json()["error"] == "GUEST_READ_ONLY"
|
||||
assert other_project.status_code == 403
|
||||
assert other_project.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||
assert unscoped_read.status_code == 403
|
||||
assert unscoped_read.json()["error"] == "GUEST_ROUTE_NOT_AVAILABLE"
|
||||
assert cross_project_coverage.status_code == 403
|
||||
assert cross_project_coverage.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
|
||||
|
||||
|
||||
def test_guest_session_tokens_fail_closed_without_a_project_scope(monkeypatch) -> None:
|
||||
client = auth_client(monkeypatch, guest_access=True)
|
||||
settings = get_settings()
|
||||
|
||||
try:
|
||||
AuthService.create_session_token("Gast", settings, role="guest")
|
||||
except ValueError as error:
|
||||
assert "demo project" in str(error)
|
||||
else: # pragma: no cover - defensive assertion
|
||||
raise AssertionError("An unscoped guest token should not be created")
|
||||
|
||||
|
||||
def test_password_hash_and_session_signatures_fail_closed(monkeypatch) -> None:
|
||||
client = auth_client(monkeypatch)
|
||||
login = client.post(
|
||||
@@ -94,4 +215,6 @@ def test_unraid_runtime_carries_only_hashed_operator_credentials() -> None:
|
||||
assert '-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH"' in runner
|
||||
assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example
|
||||
assert "GEOINTEL_AUTH_PASSWORD=" not in runner
|
||||
assert "GEOINTEL_GUEST_ACCESS_ENABLED=true" in example
|
||||
assert 'GEOINTEL_GUEST_ACCESS_ENABLED="${GEOINTEL_GUEST_ACCESS_ENABLED:-true}"' in runner
|
||||
assert "/api/v1/auth/session" in browser_smoke
|
||||
|
||||
Reference in New Issue
Block a user