495 lines
18 KiB
Python
495 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
from sqlmodel import Session, SQLModel, create_engine
|
|
|
|
from backend.app.db.models import Policy
|
|
from backend.app.schemas.deployment import (
|
|
DeploymentPlanV1,
|
|
validate_compose_runtime_contract,
|
|
)
|
|
from backend.app.services.deployment_control_plane import DeploymentControlPlane
|
|
from backend.app.services.secret_store import set_secret
|
|
|
|
|
|
def _plan(**overrides):
|
|
payload = {
|
|
"application_id": "wsbc-portal",
|
|
"repository": "Jens/Windows-Support-Bundle-Collector",
|
|
"revision": "a" * 40,
|
|
"release_version": "0.23.0",
|
|
"image_repository": "wsbc-portal",
|
|
"image_tag": "0.23.0",
|
|
"topology": {
|
|
"compose_project": "wsbc-portal",
|
|
"containers": ["wsbc-portal"],
|
|
"proxy_network": "itworx-npm",
|
|
"internal_port": 8080,
|
|
},
|
|
"proxy": {"hostname": "support.example.com"},
|
|
"oidc": {
|
|
"application_slug": "wsbc-portal",
|
|
"role_claim": "roles",
|
|
"client_secret_ref": "WSBC_PORTAL_OIDC_CLIENT_SECRET",
|
|
"roles": {
|
|
"administrator_group": "Administrator",
|
|
"support_agent_group": "SupportAgent",
|
|
"read_only_group": "ReadOnly",
|
|
},
|
|
},
|
|
"broker_secret_ref": "WSBC_PORTAL_DEPLOYMENT_BROKER",
|
|
}
|
|
payload.update(overrides)
|
|
return DeploymentPlanV1.model_validate(payload)
|
|
|
|
|
|
class _Broker:
|
|
def preflight(self, plan, *, secret):
|
|
assert secret == "a" * 32
|
|
return {"status": "preflight_ok"}
|
|
|
|
def apply(self, plan, *, secret, idempotency_key):
|
|
assert secret == "a" * 32
|
|
return {"status": "accepted", "request": "b" * 64}
|
|
|
|
def rollback(self, plan, *, secret, idempotency_key):
|
|
return {"status": "accepted", "request": "c" * 64}
|
|
|
|
def status(self, request_id, *, secret):
|
|
return {"status": "deployed", "request": request_id}
|
|
|
|
|
|
class _Portainer:
|
|
def list_containers(self):
|
|
return {"status": "ok", "containers": []}
|
|
|
|
|
|
class _Npm:
|
|
def proxy_hosts(self):
|
|
return {"success": True, "items": []}
|
|
|
|
def ensure_managed_proxy_host(self, **kwargs):
|
|
assert kwargs["forward_host"] == "wsbc-portal"
|
|
return {"success": True, "status": "created", "hostId": 42}
|
|
|
|
|
|
class _Authentik:
|
|
def configure_deployment_oidc_template(self, config, *, issuer_origin=None):
|
|
self.template_config = config
|
|
self.template_issuer = issuer_origin
|
|
|
|
def validate_deployment_oidc_template(self, *, role_claim, role_groups):
|
|
assert role_claim == "roles"
|
|
assert role_groups == ("Administrator", "SupportAgent", "ReadOnly")
|
|
return {"status": "ready"}
|
|
|
|
def groups(self):
|
|
return {"success": True, "items": [{"name": name} for name in ("Administrator", "SupportAgent", "ReadOnly")]}
|
|
|
|
def test_connection(self):
|
|
return {"status": "connected"}
|
|
|
|
def applications(self):
|
|
return {"success": True, "items": []}
|
|
|
|
def ensure_deployment_oidc_application(self, **kwargs):
|
|
assert kwargs["application_slug"] == "wsbc-portal"
|
|
assert kwargs["client_secret"] == "b" * 32
|
|
return {"success": True, "status": "created", "applicationId": "app-1"}
|
|
|
|
|
|
class _Unraid:
|
|
def get_share_summary(self):
|
|
return {"status": "ok"}
|
|
|
|
|
|
def _session(tmp_path):
|
|
engine = create_engine(f"sqlite:///{tmp_path / 'deployment.db'}")
|
|
SQLModel.metadata.create_all(engine)
|
|
return Session(engine)
|
|
|
|
|
|
def _plane():
|
|
return DeploymentControlPlane(
|
|
broker=_Broker(),
|
|
portainer=_Portainer(),
|
|
npm=_Npm(),
|
|
authentik=_Authentik(),
|
|
unraid=_Unraid(),
|
|
)
|
|
|
|
|
|
def test_plan_rejects_latest_tag_and_raw_secret_like_fields():
|
|
raw = _plan().model_dump()
|
|
raw["image_tag"] = "latest"
|
|
try:
|
|
DeploymentPlanV1.model_validate(raw)
|
|
except ValueError as exc:
|
|
assert "non-latest" in str(exc)
|
|
else: # pragma: no cover - assertion clarity
|
|
raise AssertionError("latest tag must be rejected")
|
|
|
|
raw = _plan().model_dump()
|
|
raw["deployment_secret"] = "not-allowed"
|
|
try:
|
|
DeploymentPlanV1.model_validate(raw)
|
|
except ValueError as exc:
|
|
assert "Extra inputs" in str(exc)
|
|
else: # pragma: no cover - assertion clarity
|
|
raise AssertionError("raw secret fields must be rejected")
|
|
|
|
raw = _plan().model_dump()
|
|
raw["proxy"]["force_ssl"] = False
|
|
try:
|
|
DeploymentPlanV1.model_validate(raw)
|
|
except ValueError as exc:
|
|
assert "force HTTPS" in str(exc)
|
|
else: # pragma: no cover - assertion clarity
|
|
raise AssertionError("managed proxy must force HTTPS")
|
|
|
|
|
|
def test_preflight_fails_closed_without_exact_target_policy(tmp_path):
|
|
plan = _plan(oidc=None)
|
|
with _session(tmp_path) as session:
|
|
set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32)
|
|
result = _plane().execute(
|
|
session,
|
|
plan=plan,
|
|
idempotency_key="preflight-without-policy-0001",
|
|
operation="preflight",
|
|
)
|
|
|
|
assert result["status"] == "preflight_failed"
|
|
assert "deployment_policy" in result["preflight"]["blockers"]
|
|
|
|
|
|
def test_preflight_rejects_wildcard_deployment_policy(tmp_path):
|
|
plan = _plan(oidc=None)
|
|
with _session(tmp_path) as session:
|
|
set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32)
|
|
session.add(Policy(
|
|
id="wildcard-deploy",
|
|
action="deployment.apply",
|
|
target="*",
|
|
voice="blocked",
|
|
manual="allowed",
|
|
risk="high",
|
|
reason="Wildcard must not authorize a production deployment.",
|
|
))
|
|
session.commit()
|
|
result = _plane().execute(
|
|
session,
|
|
plan=plan,
|
|
idempotency_key="preflight-wildcard-policy-0001",
|
|
operation="preflight",
|
|
)
|
|
|
|
assert result["status"] == "preflight_failed"
|
|
assert "deployment_policy" in result["preflight"]["blockers"]
|
|
|
|
|
|
def test_compose_contract_rejects_docker_socket_and_unowned_mounts():
|
|
plan = _plan(oidc=None)
|
|
unsafe = """
|
|
services:
|
|
wsbc-portal:
|
|
image: wsbc-portal:0.23.0
|
|
restart: unless-stopped
|
|
read_only: true
|
|
cap_drop: [ALL]
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock
|
|
networks: [itworx-npm]
|
|
networks:
|
|
itworx-npm:
|
|
external: true
|
|
"""
|
|
try:
|
|
validate_compose_runtime_contract(plan, unsafe)
|
|
except ValueError as exc:
|
|
assert "docker socket" in str(exc)
|
|
else: # pragma: no cover - assertion clarity
|
|
raise AssertionError("docker socket mount must be rejected")
|
|
|
|
|
|
def test_compose_contract_rejects_path_traversal_outside_owned_appdata():
|
|
plan = _plan(oidc=None)
|
|
unsafe = """
|
|
services:
|
|
wsbc-portal:
|
|
image: wsbc-portal:0.23.0
|
|
restart: unless-stopped
|
|
read_only: true
|
|
cap_drop: [ALL]
|
|
volumes:
|
|
- /srv/example/appdata/wsbc-portal/../../other-app:/data
|
|
networks: [itworx-npm]
|
|
networks:
|
|
itworx-npm:
|
|
external: true
|
|
"""
|
|
try:
|
|
validate_compose_runtime_contract(plan, unsafe)
|
|
except ValueError as exc:
|
|
assert "normalized" in str(exc)
|
|
else: # pragma: no cover - assertion clarity
|
|
raise AssertionError("path traversal mount must be rejected")
|
|
|
|
|
|
def test_compose_contract_requires_an_owned_persistent_bind_mount():
|
|
plan = _plan(oidc=None)
|
|
unsafe = """
|
|
services:
|
|
wsbc-portal:
|
|
image: wsbc-portal:0.23.0
|
|
restart: unless-stopped
|
|
read_only: true
|
|
cap_drop: [ALL]
|
|
networks: [itworx-npm]
|
|
networks:
|
|
itworx-npm:
|
|
external: true
|
|
"""
|
|
try:
|
|
validate_compose_runtime_contract(plan, unsafe)
|
|
except ValueError as exc:
|
|
assert "persistent" in str(exc)
|
|
else: # pragma: no cover - assertion clarity
|
|
raise AssertionError("persistent bind mount must be required")
|
|
|
|
|
|
def test_preflight_and_apply_are_durable_and_idempotent(tmp_path):
|
|
plan = _plan()
|
|
with _session(tmp_path) as session:
|
|
set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32)
|
|
set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32)
|
|
session.add(Policy(
|
|
id="allow-wsbc-deploy",
|
|
action="deployment.apply",
|
|
target="wsbc-portal",
|
|
voice="blocked",
|
|
manual="allowed",
|
|
risk="high",
|
|
reason="Test exact-target deployment policy.",
|
|
))
|
|
session.commit()
|
|
plane = _plane()
|
|
result = plane.execute(session, plan=plan, idempotency_key="apply-wsbc-0001", operation="apply")
|
|
|
|
assert not result["preflight"]["blockers"], result["preflight"]
|
|
assert result["status"] == "applying", result
|
|
assert result["brokerRequestId"] == "b" * 64
|
|
replay = plane.execute(session, plan=plan, idempotency_key="apply-wsbc-0001", operation="apply")
|
|
assert replay["idempotentReplay"] is True
|
|
assert replay["id"] == result["id"]
|
|
|
|
|
|
def test_verified_controller_apply_completes_only_after_typed_oidc_and_proxy(tmp_path):
|
|
class DirectBroker(_Broker):
|
|
def apply(self, plan, *, secret, idempotency_key):
|
|
return {"status": "deployed", "request": "b" * 64}
|
|
|
|
plan = _plan()
|
|
with _session(tmp_path) as session:
|
|
set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32)
|
|
set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32)
|
|
session.add(Policy(
|
|
id="allow-wsbc-deploy-direct",
|
|
action="deployment.apply",
|
|
target="wsbc-portal",
|
|
voice="blocked",
|
|
manual="allowed",
|
|
risk="high",
|
|
reason="Test exact-target deployment policy.",
|
|
))
|
|
session.commit()
|
|
plane = DeploymentControlPlane(
|
|
broker=DirectBroker(),
|
|
portainer=_Portainer(),
|
|
npm=_Npm(),
|
|
authentik=_Authentik(),
|
|
unraid=_Unraid(),
|
|
)
|
|
result = plane.execute(session, plan=plan, idempotency_key="apply-wsbc-direct-0001", operation="apply")
|
|
|
|
assert result["status"] == "succeeded"
|
|
assert result["stage"] == "controller_and_edge_verified"
|
|
assert result["evidence"]["externalIntegration"]["oidc"]["status"] == "created"
|
|
assert result["evidence"]["externalIntegration"]["proxy"]["status"] == "created"
|
|
|
|
|
|
def test_deployment_evidence_uses_allowlists_and_never_persists_connector_secrets(tmp_path):
|
|
class DirectBroker(_Broker):
|
|
def apply(self, plan, *, secret, idempotency_key):
|
|
return {"status": "deployed", "request": "b" * 64, "token": "broker-secret"}
|
|
|
|
class SecretEchoNpm(_Npm):
|
|
def ensure_managed_proxy_host(self, **kwargs):
|
|
return {"success": True, "status": "created", "hostId": 42, "meta": {"token": "npm-secret"}}
|
|
|
|
class SecretEchoAuthentik(_Authentik):
|
|
def ensure_deployment_oidc_application(self, **kwargs):
|
|
return {"success": True, "status": "created", "applicationId": "app-1", "client_secret_value": "oidc-secret"}
|
|
|
|
plan = _plan()
|
|
with _session(tmp_path) as session:
|
|
set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32)
|
|
set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32)
|
|
session.add(Policy(
|
|
id="allow-wsbc-deploy-evidence",
|
|
action="deployment.apply",
|
|
target="wsbc-portal",
|
|
voice="blocked",
|
|
manual="allowed",
|
|
risk="high",
|
|
reason="Test exact-target deployment policy.",
|
|
))
|
|
session.commit()
|
|
result = DeploymentControlPlane(
|
|
broker=DirectBroker(),
|
|
portainer=_Portainer(),
|
|
npm=SecretEchoNpm(),
|
|
authentik=SecretEchoAuthentik(),
|
|
unraid=_Unraid(),
|
|
).execute(session, plan=plan, idempotency_key="apply-wsbc-evidence-0001", operation="apply")
|
|
|
|
assert result["status"] == "succeeded"
|
|
assert "broker-secret" not in str(result)
|
|
assert "npm-secret" not in str(result)
|
|
assert "oidc-secret" not in str(result)
|
|
|
|
|
|
@pytest.mark.parametrize("swapped_roles", [False, True])
|
|
def test_saved_root_template_is_used_by_existing_deployment_plan(tmp_path, monkeypatch, swapped_roles):
|
|
from uuid import uuid4
|
|
from backend.app.core.config import settings
|
|
from backend.app.db.models import AppSetting
|
|
from backend.app.schemas.enrollment import EnrollmentTrustConfigV1
|
|
from backend.app.services.enrollment_oidc_settings import EnrollmentOidcProfileV1, TEMPLATE_FIELDS
|
|
|
|
monkeypatch.setattr(settings, "authentik_issuer", "https://auth.example.com/application/o/appops-users/")
|
|
profile = EnrollmentOidcProfileV1(issuer="https://auth.example.com", **{field: uuid4() for field in TEMPLATE_FIELDS})
|
|
|
|
class SavedTemplateAuthentik(_Authentik):
|
|
def validate_deployment_oidc_template(self, **kwargs):
|
|
assert self.template_config == profile.template_config()
|
|
# Authentik emits the same filtered set for any permutation. The
|
|
# orchestrator must separately preserve named root role meanings.
|
|
assert kwargs["role_claim"] == "roles"
|
|
assert set(kwargs["role_groups"]) == {"Administrator", "SupportAgent", "ReadOnly"}
|
|
return {"status": "ready"}
|
|
|
|
def ensure_deployment_oidc_application(self, **kwargs):
|
|
assert self.template_config == profile.template_config()
|
|
return super().ensure_deployment_oidc_application(**kwargs)
|
|
|
|
with _session(tmp_path) as session:
|
|
trust = EnrollmentTrustConfigV1(
|
|
controller_identity="unraid-controller", bootstrap_secret_ref="ENROLLMENT_BOOTSTRAP",
|
|
approved_networks=("itworx-npm",), allowed_domain_suffixes=("example.com",),
|
|
oidc_issuer=profile.issuer, administrator_group="Administrator",
|
|
support_agent_group="SupportAgent", read_only_group="ReadOnly",
|
|
)
|
|
session.add(AppSetting(key="deployment_enrollment:trust", value_json=trust.model_dump(mode="json")))
|
|
session.add(AppSetting(key="deployment_enrollment:oidc_template", value_json=profile.model_dump(mode="json")))
|
|
session.commit()
|
|
plane = _plane()
|
|
plane.authentik = SavedTemplateAuthentik()
|
|
plan = _plan()
|
|
if swapped_roles:
|
|
oidc = plan.oidc.model_dump()
|
|
oidc["roles"].update(administrator_group="ReadOnly", read_only_group="Administrator")
|
|
plan = _plan(oidc=oidc)
|
|
# Exercise both consumers; the integration can also run on an async
|
|
# controller completion where this instance never performed preflight.
|
|
preflight = plane.preflight(session, plan)
|
|
if swapped_roles:
|
|
assert "oidc_template" in preflight["blockers"]
|
|
plane.authentik = SavedTemplateAuthentik()
|
|
set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32)
|
|
success, evidence, _ = plane._complete_external_integrations(session, plan)
|
|
assert success is not swapped_roles
|
|
if swapped_roles:
|
|
assert evidence == {}
|
|
else:
|
|
assert evidence["oidc"]["status"] == "created"
|
|
|
|
|
|
def test_invalid_oidc_template_prevents_controller_apply(tmp_path):
|
|
class UnavailableAuthentik(_Authentik):
|
|
def validate_deployment_oidc_template(self, **kwargs):
|
|
return {"status": "unavailable", "reason": "remote-secret-sentinel"}
|
|
|
|
class NoApplyBroker(_Broker):
|
|
def apply(self, *args, **kwargs):
|
|
raise AssertionError("Invalid template must fail before controller apply")
|
|
|
|
with _session(tmp_path) as session:
|
|
set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32)
|
|
set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32)
|
|
session.add(Policy(id="allow-invalid-template-test", action="deployment.apply", target="wsbc-portal", manual="allowed", voice="blocked", risk="high", reason="Test"))
|
|
session.commit()
|
|
plane = _plane()
|
|
plane.authentik = UnavailableAuthentik()
|
|
plane.broker = NoApplyBroker()
|
|
result = plane.execute(session, plan=_plan(), idempotency_key="invalid-template-0001", operation="apply")
|
|
assert result["status"] == "preflight_failed"
|
|
assert "oidc_template" in result["preflight"]["blockers"]
|
|
assert "remote-secret-sentinel" not in str(result)
|
|
|
|
|
|
def test_oidc_template_drift_after_preflight_prevents_provider_write(tmp_path):
|
|
class ChangedAuthentik(_Authentik):
|
|
def validate_deployment_oidc_template(self, **kwargs):
|
|
return {"status": "unavailable", "reason": "secret-sentinel"}
|
|
|
|
def ensure_deployment_oidc_application(self, **kwargs):
|
|
raise AssertionError("Unverified template must never create a provider")
|
|
|
|
with _session(tmp_path) as session:
|
|
set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32)
|
|
plane = _plane()
|
|
plane.authentik = ChangedAuthentik()
|
|
success, evidence, message = plane._complete_external_integrations(session, _plan())
|
|
assert not success
|
|
assert evidence == {}
|
|
assert "secret-sentinel" not in message
|
|
|
|
|
|
@pytest.mark.parametrize("claim,administrator", [("groups", "Administrator"), ("roles", "OtherAdmin")])
|
|
def test_preflight_verifies_requested_claim_and_groups(tmp_path, claim, administrator):
|
|
class RequestedContractAuthentik(_Authentik):
|
|
def validate_deployment_oidc_template(self, *, role_claim, role_groups):
|
|
assert role_claim == claim
|
|
assert role_groups == (administrator, "SupportAgent", "ReadOnly")
|
|
return {"status": "unavailable", "reason": "private-template-diagnostic"}
|
|
|
|
oidc = _plan().oidc.model_dump()
|
|
oidc["role_claim"] = claim
|
|
oidc["roles"]["administrator_group"] = administrator
|
|
with _session(tmp_path) as session:
|
|
plane = _plane()
|
|
plane.authentik = RequestedContractAuthentik()
|
|
result = plane.preflight(session, _plan(oidc=oidc))
|
|
assert "oidc_template" in result["blockers"]
|
|
assert "private-template-diagnostic" not in str(result)
|
|
|
|
|
|
def test_missing_role_group_blocks_provider_write(tmp_path):
|
|
class MissingGroupAuthentik(_Authentik):
|
|
def groups(self):
|
|
return {"success": True, "items": [{"name": "Administrator"}, {"name": "SupportAgent"}]}
|
|
|
|
def ensure_deployment_oidc_application(self, **kwargs):
|
|
raise AssertionError("Missing group must fail before provider write")
|
|
|
|
with _session(tmp_path) as session:
|
|
set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32)
|
|
plane = _plane()
|
|
plane.authentik = MissingGroupAuthentik()
|
|
result = plane.preflight(session, _plan())
|
|
assert "oidc_template" in result["blockers"]
|
|
success, evidence, _ = plane._complete_external_integrations(session, _plan())
|
|
assert not success
|
|
assert evidence == {}
|