feat(auth): harden Authentik and guest capability boundaries
This commit is contained in:
+236
-1
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -10,7 +11,13 @@ 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.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.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:
|
||||
@@ -80,6 +87,7 @@ def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) ->
|
||||
"expires_at": None,
|
||||
"role": None,
|
||||
"guest_access_enabled": False,
|
||||
"authentik_enabled": False,
|
||||
"guest_project_id": None,
|
||||
}
|
||||
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"],
|
||||
"role": "operator",
|
||||
"guest_access_enabled": True,
|
||||
"authentik_enabled": False,
|
||||
"guest_project_id": None,
|
||||
}
|
||||
cookie = login.headers["set-cookie"].lower()
|
||||
@@ -166,6 +175,18 @@ def test_guest_login_exposes_models_but_rejects_management_and_cross_project_req
|
||||
"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.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_coverage.status_code == 403
|
||||
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:
|
||||
@@ -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")
|
||||
|
||||
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_AUTHENTIK_CLIENT_SECRET=" 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
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.services.authentik_oidc_service import (
|
||||
MAX_OIDC_JSON_BYTES,
|
||||
AuthentikOidcService,
|
||||
)
|
||||
|
||||
|
||||
ISSUER = "https://auth.example.test/application/o/geointel"
|
||||
|
||||
|
||||
def configured_settings(**overrides: object) -> Settings:
|
||||
values: dict[str, object] = {
|
||||
"auth_enabled": True,
|
||||
"auth_username": "ITWorx",
|
||||
"auth_password_hash": "pbkdf2_sha256$1$salt$digest",
|
||||
"auth_session_secret": "s" * 48,
|
||||
"authentik_issuer": ISSUER,
|
||||
"authentik_client_id": "geointel-client",
|
||||
"authentik_client_secret": "client-secret",
|
||||
"authentik_allowed_email": "operator@example.test",
|
||||
"public_base_url": "https://geointel.example.test",
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(_env_file=None, **values)
|
||||
|
||||
|
||||
def discovery_document() -> dict[str, str]:
|
||||
return {
|
||||
"issuer": ISSUER,
|
||||
"authorization_endpoint": f"{ISSUER}/authorize",
|
||||
"token_endpoint": f"{ISSUER}/token",
|
||||
"jwks_uri": f"{ISSUER}/jwks",
|
||||
}
|
||||
|
||||
|
||||
def test_authentik_configuration_is_all_or_nothing_and_https_only() -> None:
|
||||
with pytest.raises(ValidationError, match="configured together"):
|
||||
configured_settings(authentik_client_secret=None)
|
||||
with pytest.raises(ValidationError, match="absolute HTTPS URL"):
|
||||
configured_settings(authentik_issuer="http://auth.example.test/issuer")
|
||||
with pytest.raises(ValidationError, match="must not contain a path"):
|
||||
configured_settings(public_base_url="https://geointel.example.test/app")
|
||||
|
||||
|
||||
def test_start_uses_same_origin_discovery_and_pkce(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = AuthentikOidcService(configured_settings())
|
||||
monkeypatch.setattr(service, "_fetch_json", lambda *_args, **_kwargs: discovery_document())
|
||||
|
||||
location, flow_cookie = service.start()
|
||||
|
||||
parsed = urlsplit(location)
|
||||
query = parse_qs(parsed.query)
|
||||
flow = service.serializer.loads(flow_cookie, max_age=600)
|
||||
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == f"{ISSUER}/authorize"
|
||||
assert query["redirect_uri"] == [
|
||||
"https://geointel.example.test/api/v1/auth/authentik/callback"
|
||||
]
|
||||
assert query["code_challenge_method"] == ["S256"]
|
||||
assert query["state"] == [flow["state"]]
|
||||
assert query["nonce"] == [flow["nonce"]]
|
||||
assert query["code_challenge"][0]
|
||||
|
||||
|
||||
def test_discovery_rejects_cross_origin_endpoints(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = AuthentikOidcService(configured_settings())
|
||||
document = discovery_document()
|
||||
document["jwks_uri"] = "https://attacker.example.test/jwks"
|
||||
monkeypatch.setattr(service, "_fetch_json", lambda *_args, **_kwargs: document)
|
||||
|
||||
with pytest.raises(ValueError, match="outside the configured issuer origin"):
|
||||
service._discovery()
|
||||
|
||||
|
||||
def test_finish_verifies_signature_nonce_and_exact_allowed_email(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = AuthentikOidcService(configured_settings())
|
||||
state, nonce, verifier = "state-value", "nonce-value", "verifier-value"
|
||||
flow_cookie = service.serializer.dumps(
|
||||
{"state": state, "nonce": nonce, "verifier": verifier}
|
||||
)
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
public_jwk = jwt.algorithms.RSAAlgorithm.to_jwk(
|
||||
private_key.public_key(), as_dict=True
|
||||
)
|
||||
public_jwk["kid"] = "operator-key"
|
||||
now = int(time.time())
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"aud": "geointel-client",
|
||||
"sub": "authentik-user-id",
|
||||
"iat": now,
|
||||
"exp": now + 300,
|
||||
"nonce": nonce,
|
||||
"email": "Operator@Example.Test",
|
||||
"email_verified": True,
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "operator-key"},
|
||||
)
|
||||
token_holder = {"value": token}
|
||||
|
||||
def fetch(url: str, data: dict[str, str] | None = None) -> dict:
|
||||
if url.endswith("openid-configuration"):
|
||||
return discovery_document()
|
||||
if url.endswith("/token"):
|
||||
assert data is not None
|
||||
assert data["code_verifier"] == verifier
|
||||
return {"id_token": token_holder["value"]}
|
||||
if url.endswith("/jwks"):
|
||||
return {"keys": [public_jwk]}
|
||||
raise AssertionError(url)
|
||||
|
||||
monkeypatch.setattr(service, "_fetch_json", fetch)
|
||||
|
||||
claims = service.finish(code="authorization-code", state=state, flow_cookie=flow_cookie)
|
||||
|
||||
assert claims["sub"] == "authentik-user-id"
|
||||
token_holder["value"] = jwt.encode(
|
||||
{
|
||||
"iss": ISSUER,
|
||||
"aud": "geointel-client",
|
||||
"sub": "different-user",
|
||||
"iat": now,
|
||||
"exp": now + 300,
|
||||
"nonce": nonce,
|
||||
"email": "other@example.test",
|
||||
"email_verified": True,
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "operator-key"},
|
||||
)
|
||||
with pytest.raises(ValueError, match="not authorized"):
|
||||
service.finish(code="authorization-code", state=state, flow_cookie=flow_cookie)
|
||||
with pytest.raises(ValueError, match="state mismatch"):
|
||||
service.finish(
|
||||
code="authorization-code",
|
||||
state="different-state",
|
||||
flow_cookie=flow_cookie,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_json_rejects_declared_oversize_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = AuthentikOidcService(configured_settings())
|
||||
|
||||
class OversizeResponse:
|
||||
headers = {"Content-Length": str(MAX_OIDC_JSON_BYTES + 1)}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def read(self, _size: int) -> bytes:
|
||||
raise AssertionError("oversized responses must not be read")
|
||||
|
||||
class Opener:
|
||||
def open(self, *_args: object, **_kwargs: object) -> OversizeResponse:
|
||||
return OversizeResponse()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.authentik_oidc_service.build_opener",
|
||||
lambda *_args: Opener(),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="size limit"):
|
||||
service._fetch_json(f"{ISSUER}/oversized")
|
||||
@@ -1,3 +1,4 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
@@ -6,8 +7,16 @@ from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_invalid_host_request_target_is_rejected_canonically() -> None:
|
||||
response = client.get("/health/live", headers={"host": "trusted.example/@admin"})
|
||||
@pytest.mark.parametrize(
|
||||
"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.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"]
|
||||
|
||||
|
||||
@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:
|
||||
response = client.post(
|
||||
"/api/v1/datasets/upload",
|
||||
|
||||
Reference in New Issue
Block a user