Update
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-27 23:28:43 +02:00
parent 21015757bd
commit c76a746cd7
39 changed files with 3268 additions and 519 deletions
+106 -23
View File
@@ -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,
)
)
+20 -2
View File
@@ -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,
+23 -1
View File
@@ -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,
+19
View File
@@ -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():
+86
View File
@@ -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(
+5
View File
@@ -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]):
+48 -6
View File
@@ -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
View File
@@ -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