diff --git a/.gitleaks.toml b/.gitleaks.toml index 562c942..56b8d56 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -6,8 +6,40 @@ description = "Pydantic enrollment secrets container declaration, no secret valu paths = ['backend/app/schemas/enrollment\.py$'] regexTarget = "line" regexes = ['secrets: EnrollmentSecretsV1 = EnrollmentSecretsV1\(\)'] +condition = "AND" +targetRules = ["generic-api-key"] [[allowlists]] description = "Synthetic noncredential test encryption key and idempotency/reason fixtures" paths = ['backend/tests/test_(repopulse_adoption_runner|deployment_control_plane|enrollment_plane|mcp_rag_n8n_integrations)\.py$'] regexTarget = "line" regexes = ['appops-test-key-with-at-least-32-bytes', 'apply-wsbc-(direct|evidence)-0001', 'enroll-wsbc-(interrupted|compensate)-0001', 'N8N-REASON-SYNTHETIC'] +condition = "AND" +targetRules = ["generic-api-key"] +[[allowlists]] +description = "Synthetic forge bootstrap idempotency race fixture only" +paths = ['backend/tests/test_forge_bootstrap_apply\.py$'] +regexTarget = "line" +regexes = ['^\s*key = "forge-integrity-race-0001"\r?$'] +condition = "AND" +targetRules = ["generic-api-key"] +[[allowlists]] +description = "Synthetic Forge OIDC API secret-key setting fixture only" +paths = ['backend/tests/test_forge_oidc_api\.py$'] +regexTarget = "line" +regexes = ['^\s*monkeypatch\.setattr\(settings, "secret_key", "forge-oidc-api-secret-key-material-0123456789"\)\r?$'] +condition = "AND" +targetRules = ["generic-api-key"] +[[allowlists]] +description = "Synthetic Forge secret-delivery secret-key setting fixture only" +paths = ['backend/tests/test_forge_secret_delivery\.py$'] +regexTarget = "line" +regexes = ['^\s*monkeypatch\.setattr\(settings, "secret_key", "delivery-test-secret-key-material-0123456789"\)\r?$'] +condition = "AND" +targetRules = ["generic-api-key"] +[[allowlists]] +description = "Synthetic MCP apply idempotency fixture only" +paths = ['backend/tests/test_mcp_rag_n8n_integrations\.py$'] +regexTarget = "line" +regexes = ['^\s*\{"plan": profile, "idempotencyKey": "forge-mcp-apply-0001"\},\r?$'] +condition = "AND" +targetRules = ["generic-api-key"] diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 77ab982..082057a 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `e854334923cca574482d22b8424ba3ce46ec6aa1`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `451646fee806a47e3256731766980a05a262dcdd`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index c5d7d7b..a26fd79 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "e854334923cca574482d22b8424ba3ce46ec6aa1", + "sourceRevision": "451646fee806a47e3256731766980a05a262dcdd", "files": [ { "path": ".dockerignore", @@ -24,8 +24,8 @@ }, { "path": ".gitleaks.toml", - "sha256": "48380b6edbe1185d5a3e2c95f065ae94c909a5993148cd1e6666ac52aa3e4ad2", - "bytes": 761 + "sha256": "e631ebe31e0b4fc034379618fc0b16c2a239015b087eab4d3a57a81f74c384de", + "bytes": 2107 }, { "path": "CHANGELOG.md", @@ -104,8 +104,8 @@ }, { "path": "backend/app/api/routes_authentik.py", - "sha256": "29834b61c6661af4cf6b0a7376e033f34b206170f7d16af774b6252a1319d387", - "bytes": 2105 + "sha256": "5a723b8426167791377db18494e40e9d901a600d74068b44857118fc064a047a", + "bytes": 4569 }, { "path": "backend/app/api/routes_connectors.py", @@ -124,8 +124,8 @@ }, { "path": "backend/app/api/routes_deployments.py", - "sha256": "0441f74d508dde2ca0641cae32b8a7f386496c8f6f0f2df80ec619a6409bc9b3", - "bytes": 4955 + "sha256": "c77cea58701694af4ef519e351fd27a68856e45cd4e61dc4028c652359d6392a", + "bytes": 7717 }, { "path": "backend/app/api/routes_enrollments.py", @@ -279,8 +279,8 @@ }, { "path": "backend/app/connectors/authentik.py", - "sha256": "72e21c055ff6344c90a4c6d74f920d340332e2559e0e59aebb2f2bc2c91d1604", - "bytes": 41931 + "sha256": "2bac45ebbee05c3878ec6eb03e92145d971ae9ae875ed7d90ba609e1329e38b2", + "bytes": 92086 }, { "path": "backend/app/connectors/base.py", @@ -354,8 +354,8 @@ }, { "path": "backend/app/core/config.py", - "sha256": "83441a676b4e6f415f80b283897bb613805ecbf82a6d405d767532e086dbc5fc", - "bytes": 25697 + "sha256": "9a839001e681edb19926a45e859c5103b398cc97922d20042fa739cfcbf84126", + "bytes": 27341 }, { "path": "backend/app/core/network_security.py", @@ -384,8 +384,8 @@ }, { "path": "backend/app/db/init_db.py", - "sha256": "0e68b799a282d33dae139308c40ab6ec10d9ca7aba7c0bd123e4b9adbdc57b8d", - "bytes": 69933 + "sha256": "450abf3cdf8c8897938cffeef193d69f7fc0fb1d4cd7a713597db1ca5bb811a7", + "bytes": 70586 }, { "path": "backend/app/db/models.py", @@ -404,8 +404,8 @@ }, { "path": "backend/app/mcp_server.py", - "sha256": "7cb748e5350231bfe938c91867b7eef408dd3589ba4e7ec078e07589338554c6", - "bytes": 35626 + "sha256": "aa1246a23ba269a0e52cb5f95d0cd3bde8e4e5aa3943872a0a4059dbaa441f18", + "bytes": 38109 }, { "path": "backend/app/runner_statistics_mcp.py", @@ -442,6 +442,21 @@ "sha256": "12db5aa43594bbcc9e4306a173769afdeabb86d364e0939e637b80775ea73629", "bytes": 1208 }, + { + "path": "backend/app/schemas/forge_bootstrap_apply.py", + "sha256": "113f92c4571bd1733bb9b1fc39d49dea0b4a97d0ebef6af8379f29ecb31649cb", + "bytes": 1977 + }, + { + "path": "backend/app/schemas/forge_oidc.py", + "sha256": "8d55f7a8263191f07813d1689eb0697dabec99a1bbd5aff5a704e66e6bf86c47", + "bytes": 6045 + }, + { + "path": "backend/app/schemas/forge_secret_delivery.py", + "sha256": "5209fdfaabee4dbc56b7edd2f6080c35bbd3d679577a754dbd65ca353347b5e5", + "bytes": 3015 + }, { "path": "backend/app/services/__init__.py", "sha256": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", @@ -454,8 +469,8 @@ }, { "path": "backend/app/services/action_registry.py", - "sha256": "11add43608abc05d51d89d29d772fe17f6d429a032c471376ad8f6d8400976b2", - "bytes": 21035 + "sha256": "a5c575ee262ca721906a65156383ae2d1c2b08c52b2a5f9145c841894496c31f", + "bytes": 21316 }, { "path": "backend/app/services/ai_remediation_planner.py", @@ -514,8 +529,8 @@ }, { "path": "backend/app/services/deployment_broker.py", - "sha256": "829fe26916967dad3c6907989a5a8825a0aabbc294aa2cf600e701bc76c5e1e3", - "bytes": 24904 + "sha256": "34d6a4bf90d59604d51d34bc4ba7885ac6f5837d7015c80c8c0fe7eee9c060d4", + "bytes": 33010 }, { "path": "backend/app/services/deployment_control_plane.py", @@ -547,10 +562,30 @@ "sha256": "36cf235e02e4289b7b1851bb6240584bf8d6f6fbb9b2ae22ac521ec8012ba025", "bytes": 2538 }, + { + "path": "backend/app/services/forge_bootstrap_apply.py", + "sha256": "4928e08a887094efb9134fc2c6a40b707d5635c0f26d202c93773ba132b61f69", + "bytes": 37394 + }, { "path": "backend/app/services/forge_bootstrap_preflight.py", - "sha256": "b5ced0a85cc1585386fec21cc1cfa5bc88b5ccecf211beff93f99d79736f413f", - "bytes": 14808 + "sha256": "b86dbfb578d881e60b6a147d7095c255a734b38ce35e1e940a24d27d02576640", + "bytes": 19371 + }, + { + "path": "backend/app/services/forge_controller_reader.py", + "sha256": "fbffc3974b399810c86e22160ae49a7325d296afc57fd451446f9fa74c78d204", + "bytes": 67909 + }, + { + "path": "backend/app/services/forge_oidc_provisioning.py", + "sha256": "25d32b9f1acfdf2a2f4032b242f8843ba0e8a48ab1c2c0cf4f048181906c0af1", + "bytes": 31091 + }, + { + "path": "backend/app/services/forge_secret_delivery.py", + "sha256": "a0f16bd7745f30d54781c10808ff076b45a9db0c432eeaa36ea6947bf654c17e", + "bytes": 23765 }, { "path": "backend/app/services/gitea_project_auto_registration.py", @@ -649,8 +684,8 @@ }, { "path": "backend/app/services/policy_engine.py", - "sha256": "8213e8d213498b58c41bf7a9ae2a905e814b6ad9b6cfad2d7752df69d444634b", - "bytes": 10925 + "sha256": "d00f8d1be911d49519791a5c517d261a666a9ff04bb037954501a7f79a1b4dcf", + "bytes": 12023 }, { "path": "backend/app/services/portfolio_deployment_enrollment.py", @@ -769,8 +804,8 @@ }, { "path": "backend/app/services/setup_service.py", - "sha256": "d0589c176a3becba804885b2473eaa86312844faf9042f5b3c13c1987ff25383", - "bytes": 27766 + "sha256": "deba3682594002b307c0a0c9a0c473c07ce6010036dc970109f4430458bf91e8", + "bytes": 30316 }, { "path": "backend/app/services/unraid_vm_service.py", @@ -854,8 +889,8 @@ }, { "path": "backend/tests/test_controller_artifact_bootstrap.py", - "sha256": "09b4691f9505d561bc63e1383ea199d5dcb090dac4e42baac062c03fd4b09421", - "bytes": 48924 + "sha256": "2694d9e601bb06aaabc9491c85fd7a17e796f20ea089792cb964c4a6fe93aaf6", + "bytes": 60349 }, { "path": "backend/tests/test_controller_artifact_promoter.py", @@ -879,18 +914,18 @@ }, { "path": "backend/tests/test_controller_hub_release_volume.py", - "sha256": "704ed05a58455bc051ce598dfbaa4787b5337d8910e852621165bfc424c4c0e9", - "bytes": 45100 + "sha256": "2b89660fe44b8c5a46a4cad6b9cb68ac34f0716b4e72ac171830dcf61b0ac2c8", + "bytes": 73499 }, { "path": "backend/tests/test_controller_root_procedure_contract.py", - "sha256": "ff2c7b80c3bc71b1ab06fc5637fce0c2c73df78e6d6123a47a34b3036f3caa8c", - "bytes": 3299 + "sha256": "964b3e21828a9d815d05ffb0e51acf6e9e45efe56af9fa3b70f39200d3a5c336", + "bytes": 5125 }, { "path": "backend/tests/test_controller_state_reconciliation.py", - "sha256": "d9ddea9971414954a0b34738ef8de6bb330a867f7f41376063e473855ff5d17e", - "bytes": 10957 + "sha256": "d1d90f6d4fe35e434797dccd2c0ec10357fc347900e6e148c0f85face9d12598", + "bytes": 11084 }, { "path": "backend/tests/test_deluge_connector.py", @@ -899,8 +934,8 @@ }, { "path": "backend/tests/test_deploy_broker.py", - "sha256": "56dffec32ac7383b9d9cf0f82bfb7e586458cb2780bfefb0bfc5785b97bf4736", - "bytes": 381065 + "sha256": "9aaefa351db5868436a6dc06ed7c42202e8d1a8568bcaa4d71501ec60446bb85", + "bytes": 381318 }, { "path": "backend/tests/test_deployment_auto_rollback.py", @@ -942,15 +977,70 @@ "sha256": "ddd08c246eb0719556f307691ca231093584dc4b85e78bb849bee83ccbde8b1f", "bytes": 8374 }, + { + "path": "backend/tests/test_forge_backup_paths.py", + "sha256": "9e8a95f438e9863be4a9e84e0a189bebd84eea2e3991e9e6099a2b79920e9898", + "bytes": 12018 + }, + { + "path": "backend/tests/test_forge_bootstrap_apply.py", + "sha256": "f7be48b418a9a09fbbaea7f6e1765c7ba4bac949994435b423885c3b4b2c6d8e", + "bytes": 43558 + }, { "path": "backend/tests/test_forge_bootstrap_controller.py", - "sha256": "442a57109bc2dc0e4d4b9eb50c84ae8bac5685f94c9ec41fcfdadd0911352e44", - "bytes": 10829 + "sha256": "eff595a5af1453fb428243ef8a9727caa748eb2f8f6023c2601abdc8be48a937", + "bytes": 11068 }, { "path": "backend/tests/test_forge_bootstrap_preflight.py", - "sha256": "b9ef73cd20bcdb99eb4afe42d26604397e82258f24f4b5dbc25d182c1c6a95da", - "bytes": 8518 + "sha256": "e92678fe83679ac1614e4bb48ab77ac565a0d1da8a265ea30f9760a2b64592aa", + "bytes": 11186 + }, + { + "path": "backend/tests/test_forge_controller_reader.py", + "sha256": "e32672659a12a1389220f1725a79f250e94e8dd410ef194c5fdf4c6de4a1c03d", + "bytes": 42939 + }, + { + "path": "backend/tests/test_forge_database_credentials.py", + "sha256": "fdab22c57d546a7a1bd912abc63777ca4a1e03c3826ddcebb9301553130c42a3", + "bytes": 17599 + }, + { + "path": "backend/tests/test_forge_first_install_controller.py", + "sha256": "9c4abc31c564e2974b125ce0c0f9bd581af499ab70dcc2b21dc2824a331522f2", + "bytes": 84984 + }, + { + "path": "backend/tests/test_forge_oidc_api.py", + "sha256": "4d902a3bb967de92156019d00ca65cfb7120935f669c61d255cab630b3081100", + "bytes": 11095 + }, + { + "path": "backend/tests/test_forge_oidc_provisioning.py", + "sha256": "a0fff62f8855dafebd49e2d91abdf8f211b7a4df452c05d91ed8305fe74d3b0a", + "bytes": 82116 + }, + { + "path": "backend/tests/test_forge_reader_api.py", + "sha256": "dc4e9d176a9b4c33fd24e27b2cb83919040babe412d021109a22de41c54edb8b", + "bytes": 6424 + }, + { + "path": "backend/tests/test_forge_secret_bundle.py", + "sha256": "ee3555f9711b23005f8a2970537217ffed9b06b1fbe96424180a26c379440cd2", + "bytes": 25851 + }, + { + "path": "backend/tests/test_forge_secret_delivery.py", + "sha256": "29f138ab1df97248cd3393aac84e50967e4cf10b2194a8db03820dd7973a30fb", + "bytes": 37715 + }, + { + "path": "backend/tests/test_forge_secret_delivery_controller.py", + "sha256": "1695383ed99777000bec2df4ae97d252dc242b690430f102284fa101679465ed", + "bytes": 31582 }, { "path": "backend/tests/test_frontend_file_boundary.py", @@ -1074,8 +1164,8 @@ }, { "path": "backend/tests/test_mcp_rag_n8n_integrations.py", - "sha256": "89f557049d6e2c65fada94321503df65bf7c82bd567038241ea3bdad7c866ed1", - "bytes": 50584 + "sha256": "0c90431bb77eab27f0da21afd02dd5fb232141483062f7a19c3340d1d91f6c9d", + "bytes": 52814 }, { "path": "backend/tests/test_media_codec_dashboard_deployment_reconciliation.py", @@ -1309,8 +1399,8 @@ }, { "path": "backend/tests/test_runner_capacity_controller.py", - "sha256": "fbc1025db04ab98f5aaeee7c94bb6b5dfb1a5652bfe696d92f2ac5f631620448", - "bytes": 18051 + "sha256": "f1c12a7799f11b7dcb78e79820303a5dce6828dded26b258898e4c80f80a3ed8", + "bytes": 21587 }, { "path": "backend/tests/test_runner_capacity_policy.py", @@ -1339,8 +1429,8 @@ }, { "path": "backend/tests/test_runner_fleet_specialize.py", - "sha256": "e2b142ed5656e118156d44baf6f1f8568ba25297ea9af13b817861ce3c4fd4da", - "bytes": 107149 + "sha256": "3329a6ac547afa73b0804caad9001a0cf7de0755b6d355e3139cb46d550fccf5", + "bytes": 111208 }, { "path": "backend/tests/test_runner_fleet_v2_execution_surface.py", @@ -1399,8 +1489,8 @@ }, { "path": "backend/tests/test_setup_onboarding.py", - "sha256": "c6160e0e163e23b640886bdbbe21c8d6ec4cebf4b102e00f0e78072c85586464", - "bytes": 12566 + "sha256": "8b52ec3202d4c99a06d7610fef5b2b418c68e29a3ed36cf1dbb5335ba07c04f3", + "bytes": 14465 }, { "path": "backend/tests/test_setup_reconciliation.py", diff --git a/backend/app/api/routes_authentik.py b/backend/app/api/routes_authentik.py index 4241b07..dda110b 100644 --- a/backend/app/api/routes_authentik.py +++ b/backend/app/api/routes_authentik.py @@ -1,13 +1,23 @@ from __future__ import annotations -from typing import Any +from typing import Annotated, Any -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request +from sqlmodel import Session from ..connectors.authentik import AuthentikConnector +from ..core.security import require_operator_request +from ..db.session import get_session +from ..schemas.forge_oidc import ForgeOidcProvisionRequestV1, ForgeOidcProvisionResultV1 +from ..schemas.forge_secret_delivery import ( + ForgeControllerReaderProvisionRequestV1, + ForgeControllerReaderProvisionResultV1, +) +from ..services.forge_controller_reader import provision_forge_controller_reader +from ..services.forge_oidc_provisioning import provision_forge_oidc from .connector_runtime import load_connector_runtime -router = APIRouter(prefix='/api/apps/authentik', dependencies=[Depends(load_connector_runtime)]) +router = APIRouter(prefix='/api/apps/authentik') def _shape(payload: dict[str, Any]) -> dict[str, Any]: @@ -27,66 +37,103 @@ def _connector() -> AuthentikConnector: return AuthentikConnector() -@router.get('/health') +def _require_forge_oidc_operator(request: Request) -> str: + return require_operator_request(request) + + +@router.get('/health', dependencies=[Depends(load_connector_runtime)]) def health(): return _shape(_connector().health()) -@router.get('/system') +@router.get('/system', dependencies=[Depends(load_connector_runtime)]) def system_status(): return _shape(_connector().system_status()) -@router.get('/applications') +@router.get('/applications', dependencies=[Depends(load_connector_runtime)]) def applications(): return _shape(_connector().applications()) -@router.get('/providers') +@router.get('/providers', dependencies=[Depends(load_connector_runtime)]) def providers(): return _shape(_connector().providers()) -@router.get('/providers/proxy') +@router.get('/providers/proxy', dependencies=[Depends(load_connector_runtime)]) def proxy_providers(): return _shape(_connector().proxy_providers()) -@router.get('/providers/oauth2') +@router.get('/providers/oauth2', dependencies=[Depends(load_connector_runtime)]) def oauth2_providers(): return _shape(_connector().oauth2_providers()) -@router.get('/outposts') +@router.get('/outposts', dependencies=[Depends(load_connector_runtime)]) def outposts(): return _shape(_connector().outposts()) -@router.get('/outposts/health') +@router.get('/outposts/health', dependencies=[Depends(load_connector_runtime)]) def outpost_health(): return _shape(_connector().outpost_health()) -@router.get('/flows') +@router.get('/flows', dependencies=[Depends(load_connector_runtime)]) def flows_summary(): return _shape(_connector().flows_summary()) -@router.get('/policies') +@router.get('/policies', dependencies=[Depends(load_connector_runtime)]) def policies_summary(): return _shape(_connector().policies_summary()) -@router.get('/events') +@router.get('/events', dependencies=[Depends(load_connector_runtime)]) def events(limit: int = 25): return _shape(_connector().events(limit)) -@router.get('/auth-coverage') +@router.get('/auth-coverage', dependencies=[Depends(load_connector_runtime)]) def auth_coverage(): return _shape(_connector().auth_coverage()) -@router.get('/security-warnings') +@router.get('/security-warnings', dependencies=[Depends(load_connector_runtime)]) def security_warnings(): return _shape(_connector().security_warnings()) + + +@router.post( + '/forge/oidc/provision', + response_model=ForgeOidcProvisionResultV1, +) +def provision_forge_oidc_application( + payload: ForgeOidcProvisionRequestV1, + request: Request, + session: Annotated[Session, Depends(get_session)], + actor_identity: Annotated[str, Depends(_require_forge_oidc_operator)], +): + """Run the fixed, manually authorized Forge OIDC provisioning action.""" + # The payload is intentionally empty: callers cannot select a provider, + # redirect URI, mapping, groups, or secret. The service repeats the exact + # policy check before making any connector request. + _ = payload + return provision_forge_oidc(session, actor_identity=actor_identity) + + +@router.post( + '/forge/controller-reader/provision', + response_model=ForgeControllerReaderProvisionResultV1, +) +def provision_forge_controller_reader_account( + payload: ForgeControllerReaderProvisionRequestV1, + request: Request, + session: Annotated[Session, Depends(get_session)], + actor_identity: Annotated[str, Depends(_require_forge_oidc_operator)], +): + """Provision only the fixed read-only Forge controller service account.""" + _ = payload, request + return provision_forge_controller_reader(session, actor_identity=actor_identity) diff --git a/backend/app/api/routes_deployments.py b/backend/app/api/routes_deployments.py index 2023a94..3653f30 100644 --- a/backend/app/api/routes_deployments.py +++ b/backend/app/api/routes_deployments.py @@ -16,6 +16,11 @@ from ..core.security import require_operator_request from ..db.session import get_session from ..schemas.deployment import DeploymentPlanV1 from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1 +from ..schemas.forge_bootstrap_apply import ForgeBootstrapApplyV1 +from ..schemas.forge_secret_delivery import ( + ForgeSecretDeliveryRequestV1, + ForgeSecretDeliveryResultV1, +) from ..services.deployment_broker import DeploymentBrokerClient from ..services.deployment_control_plane import ( DeploymentControlError, @@ -117,6 +122,62 @@ def preflight_forge_bootstrap( raise HTTPException(status_code=409, detail=str(exc)) from exc +@router.post( + "/forge-bootstrap/secrets", + response_model=ForgeSecretDeliveryResultV1, +) +def prepare_forge_bootstrap_secrets( + payload: ForgeSecretDeliveryRequestV1, + actor_identity: Annotated[str, Depends(_require_deployment_operator)], + session: DeploymentSession, + idempotency_key: DeploymentIdempotencyKey = None, +): + """Deliver only the verified protected Forge inputs to the fixed controller.""" + from ..services.forge_secret_delivery import prepare_forge_secret_delivery + + try: + return prepare_forge_secret_delivery( + session, + profile=payload.profile, + idempotency_key=_idempotency_header(idempotency_key), + actor_identity=actor_identity, + ) + except ValueError as exc: + # Service messages are fixed safe codes; never surface upstream text. + raise HTTPException(status_code=409, detail="forge_secret_delivery_failed") from exc + + +@router.post("/forge-bootstrap/first-install") +def apply_forge_first_install( + payload: ForgeBootstrapApplyV1, + session: DeploymentSession, + idempotency_key: DeploymentIdempotencyKey = None, +): + from ..services.forge_bootstrap_apply import ( + ForgeBootstrapApplyError, + ForgeBootstrapApplyService, + ) + + try: + return ForgeBootstrapApplyService().apply( + session, + payload, + idempotency_key=_idempotency_header(idempotency_key), + ) + except ForgeBootstrapApplyError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.post("/forge-bootstrap/first-install/{job_id}/resume") +def resume_forge_first_install(job_id: str, session: DeploymentSession): + from ..services.forge_bootstrap_apply import ForgeBootstrapApplyService + + result = ForgeBootstrapApplyService().resume(session, job_id) + if result is None: + raise HTTPException(status_code=404, detail="Forge first-install request not found.") + return result + + @router.get("/workmesh/promotions/{promotion_id}") def get_workmesh_promotion(promotion_id: str, session: DeploymentSession): from ..services.workmesh_promotion import WorkMeshPromotionService @@ -137,6 +198,16 @@ def list_workmesh_promotions( return {"items": WorkMeshPromotionService.list_promotions(session, limit=limit)} +@router.get("/forge-bootstrap/first-install/{job_id}") +def get_forge_first_install(job_id: str, session: DeploymentSession): + from ..services.forge_bootstrap_apply import ForgeBootstrapApplyService + + result = ForgeBootstrapApplyService().status(session, job_id) + if result is None: + raise HTTPException(status_code=404, detail="Forge first-install request not found.") + return result + + @router.get("") def list_deployments(session: DeploymentSession, limit: DeploymentLimit = 100): return {"items": DeploymentControlPlane().list_jobs(session, limit=limit)} @@ -150,6 +221,11 @@ def get_delivery_status(): @router.get("/{job_id}") def get_deployment(job_id: str, session: DeploymentSession): + if job_id.startswith("forge-install-"): + raise HTTPException( + status_code=404, + detail="Use the fixed Forge first-install status route for this job.", + ) try: return DeploymentControlPlane().refresh(session, job_id) except DeploymentControlError as exc: diff --git a/backend/app/connectors/authentik.py b/backend/app/connectors/authentik.py index 0065cbd..3fe0332 100644 --- a/backend/app/connectors/authentik.py +++ b/backend/app/connectors/authentik.py @@ -1,16 +1,154 @@ from __future__ import annotations +import hashlib +import hmac import re +from collections.abc import Callable from dataclasses import dataclass from typing import Any -from urllib.parse import urljoin, urlparse +from urllib.parse import quote, urljoin, urlparse +from uuid import RFC_4122, UUID import httpx from ..core.config import settings from ..core.redaction import redact_message +from ..schemas.forge_oidc import ( + FORGE_OIDC_APPLICATION_NAME, + FORGE_OIDC_CALLBACK_URL, + FORGE_OIDC_CLIENT_ID, + FORGE_OIDC_DISCOVERY_URL, + FORGE_OIDC_EMAIL_MAPPING_EXPRESSION_SHA256, + FORGE_OIDC_EMAIL_MAPPING_MANAGED, + FORGE_OIDC_GROUPS, + FORGE_OIDC_ISSUER, + FORGE_OIDC_LAUNCH_URL, + FORGE_OIDC_MANAGED_DESCRIPTION, + FORGE_OIDC_OPENID_MAPPING_EXPRESSION_SHA256, + FORGE_OIDC_OPENID_MAPPING_MANAGED, + FORGE_OIDC_PROFILE_MAPPING_EXPRESSION, + FORGE_OIDC_PROFILE_MAPPING_EXPRESSION_SHA256, + FORGE_OIDC_PROFILE_MAPPING_NAME, + FORGE_OIDC_PROVIDER_NAME, + ForgeOidcScopeMappingIdsV1, +) from .base import BaseConnector +_FORGE_OIDC_AUTHENTIK_URL = 'https://auth.example-host.com' +_FORGE_OIDC_SCOPE_MAPPING_IDS = ( + 'AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING', + 'AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING', + 'AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING', +) +_FORGE_OIDC_GROUP_READ_PARAMS = { + 'include_users': 'false', + 'include_children': 'true', + 'include_parents': 'true', + 'include_inherited_roles': 'false', +} + + +def _authentik_id(value: Any) -> str: + if isinstance(value, dict): + value = value.get('pk') or value.get('id') + return str(value or '') + + +def _authentik_enum(value: Any) -> str: + if isinstance(value, dict): + value = value.get('value') or value.get('type') or value.get('name') or value.get('slug') + return str(value or '').strip().lower() + + +def _forge_oidc_redirect_is_exact(value: Any) -> bool: + if not isinstance(value, list) or len(value) != 1 or not isinstance(value[0], dict): + return False + redirect = value[0] + return ( + str(redirect.get('url') or '') == FORGE_OIDC_CALLBACK_URL + and _authentik_enum(redirect.get('matching_mode')) == 'strict' + and set(redirect) == {'matching_mode', 'url'} + ) + + +def _normalized_expression_sha256(value: Any) -> str: + if not isinstance(value, str): + return '' + normalized = value.replace('\r\n', '\n').strip() + return hashlib.sha256(normalized.encode('utf-8')).hexdigest() + + +def _forge_oidc_scope_mapping_matches( + mapping: Any, + *, + mapping_id: str, + scope: str, +) -> bool: + if not isinstance(mapping, dict) or _authentik_id(mapping) != mapping_id: + return False + if str(mapping.get('scope_name') or '') != scope: + return False + if scope == 'openid': + return ( + mapping.get('managed') == FORGE_OIDC_OPENID_MAPPING_MANAGED + and _normalized_expression_sha256(mapping.get('expression')) + == FORGE_OIDC_OPENID_MAPPING_EXPRESSION_SHA256 + ) + if scope == 'email': + return ( + mapping.get('managed') == FORGE_OIDC_EMAIL_MAPPING_MANAGED + and _normalized_expression_sha256(mapping.get('expression')) + == FORGE_OIDC_EMAIL_MAPPING_EXPRESSION_SHA256 + ) + return ( + scope == 'profile' + and str(mapping.get('name') or '') == FORGE_OIDC_PROFILE_MAPPING_NAME + and not mapping.get('managed') + and _normalized_expression_sha256(mapping.get('expression')) + == FORGE_OIDC_PROFILE_MAPPING_EXPRESSION_SHA256 + and str(mapping.get('expression') or '').replace('\r\n', '\n').strip() + == FORGE_OIDC_PROFILE_MAPPING_EXPRESSION + ) + + +def _forge_oidc_provider_id(value: Any) -> str | None: + candidate = str(value or '') + return candidate if re.fullmatch(r'[1-9][0-9]{0,9}', candidate) else None + + +def _forge_oidc_application_id(value: Any) -> str | None: + candidate = str(value or '') + try: + parsed = UUID(candidate) + except (TypeError, ValueError, AttributeError): + return None + if str(parsed) != candidate or parsed.variant != RFC_4122 or parsed.version not in range(1, 9): + return None + return candidate + + +def _forge_oidc_group_contract_matches( + group: Any, + *, + require_empty_users: bool = False, + require_users: bool = False, +) -> bool: + if not isinstance(group, dict): + return False + valid = ( + isinstance(group.get('name'), str) + and group.get('is_superuser') is False + and group.get('attributes') == {} + and group.get('parents') == [] + and group.get('children') == [] + and group.get('roles') == [] + ) + if require_users: + valid = valid and isinstance(group.get('users'), list) + if require_empty_users: + valid = valid and group.get('users') == [] + return bool(valid) + @dataclass class _AuthentikConfig: @@ -460,6 +598,880 @@ class AuthentikConnector(BaseConnector): 'clientSecretCreated': True, } + def _forge_oidc_static_preflight(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'blocked', 'message': 'authentik_missing_config'} + config = self._config() + if not config or config.url.rstrip('/') != _FORGE_OIDC_AUTHENTIK_URL: + return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_authentik_url_mismatch'} + if config.verify_tls is not True: + return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_tls_verification_required'} + + required_settings = ( + 'AUTHENTIK_OIDC_AUTHORIZATION_FLOW', + 'AUTHENTIK_OIDC_INVALIDATION_FLOW', + 'AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID', + ) + values = {key: str(settings.apis.get(key) or '').strip() for key in required_settings} + missing = [key for key, value in values.items() if not value] + if missing: + return { + 'success': False, + 'status': 'blocked', + 'message': 'forge_oidc_template_not_configured', + 'missing': missing, + } + try: + flows = self._paged('/flows/instances/') + signing_keys = self._paged('/crypto/certificatekeypairs/', {'has_key': True}) + except RuntimeError: + return {'success': False, 'status': 'failed', 'message': 'forge_oidc_preflight_failed'} + + for field in ('AUTHENTIK_OIDC_AUTHORIZATION_FLOW', 'AUTHENTIK_OIDC_INVALIDATION_FLOW'): + if sum(1 for item in flows if _authentik_id(item) == values[field]) != 1: + return { + 'success': False, + 'status': 'blocked', + 'message': 'forge_oidc_flow_unavailable_or_ambiguous', + 'missing': [field], + } + signing_key_id = values['AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID'] + matching_keys = [item for item in signing_keys if _authentik_id(item) == signing_key_id] + if ( + len(matching_keys) != 1 + or _authentik_enum(matching_keys[0].get('key_type')) != 'rsa' + or matching_keys[0].get('private_key_available') is not True + ): + return { + 'success': False, + 'status': 'blocked', + 'message': 'forge_oidc_rsa_signing_key_unavailable', + 'missing': ['AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID'], + } + return {'success': True, 'status': 'ready', 'message': 'forge_oidc_static_prerequisites_ready'} + + def _forge_oidc_read_groups(self) -> list[dict[str, Any]]: + return self._paged('/core/groups/', _FORGE_OIDC_GROUP_READ_PARAMS) + + def _forge_oidc_read_group(self, group_id: str) -> Any: + return self._request( + f'/core/groups/{quote(group_id, safe="")}/', + {**_FORGE_OIDC_GROUP_READ_PARAMS, 'include_users': 'true'}, + ) + + def _forge_oidc_read_scope_mappings(self) -> list[dict[str, Any]]: + return self._paged('/propertymappings/provider/scope/', {'page_size': 100}) + + @staticmethod + def _forge_oidc_bound_mapping_ids() -> list[str] | None: + if settings.apis.get('AUTHENTIK_FORGE_OIDC_BINDING_VALID') is not True: + return None + mapping_ids = [str(settings.apis.get(key) or '').strip() for key in _FORGE_OIDC_SCOPE_MAPPING_IDS] + try: + ForgeOidcScopeMappingIdsV1.model_validate(dict(zip(('openid', 'email', 'profile'), mapping_ids, strict=True))) + except Exception: # noqa: BLE001 - invalid protected binding always blocks provider mutation. + return [] + return mapping_ids + + @staticmethod + def _forge_oidc_mapping_state(mappings: list[dict[str, Any]]) -> tuple[dict[str, str], dict[str, Any] | None, str | None]: + builtins: dict[str, str] = {} + for scope, managed, digest in ( + ('openid', FORGE_OIDC_OPENID_MAPPING_MANAGED, FORGE_OIDC_OPENID_MAPPING_EXPRESSION_SHA256), + ('email', FORGE_OIDC_EMAIL_MAPPING_MANAGED, FORGE_OIDC_EMAIL_MAPPING_EXPRESSION_SHA256), + ): + matches = [item for item in mappings if item.get('managed') == managed] + if len(matches) != 1: + return {}, None, 'forge_oidc_standard_scope_mapping_ambiguous' + mapping = matches[0] + mapping_id = _authentik_id(mapping) + if ( + not _forge_oidc_application_id(mapping_id) + or str(mapping.get('scope_name') or '') != scope + or _normalized_expression_sha256(mapping.get('expression')) != digest + ): + return {}, None, 'forge_oidc_standard_scope_mapping_mismatch' + builtins[scope] = mapping_id + + profiles = [item for item in mappings if str(item.get('name') or '') == FORGE_OIDC_PROFILE_MAPPING_NAME] + if len(profiles) > 1: + return {}, None, 'forge_oidc_profile_mapping_ambiguous' + profile = profiles[0] if profiles else None + if profile is not None and not _forge_oidc_scope_mapping_matches( + profile, + mapping_id=_authentik_id(profile), + scope='profile', + ): + return {}, None, 'forge_oidc_profile_mapping_mismatch' + return builtins, profile, None + + @staticmethod + def _forge_oidc_group_state(groups: list[dict[str, Any]]) -> tuple[dict[str, dict[str, Any]], str | None]: + resolved: dict[str, dict[str, Any]] = {} + for name in FORGE_OIDC_GROUPS: + matches = [item for item in groups if str(item.get('name') or '') == name] + if len(matches) > 1: + return {}, 'forge_oidc_group_ambiguous' + if matches: + group = matches[0] + if not _forge_oidc_group_contract_matches(group): + return {}, 'forge_oidc_group_conflict' + resolved[name] = group + return resolved, None + + def ensure_forge_oidc_prerequisites( + self, + *, + expected_client_secret: str | None = None, + ) -> dict[str, Any]: + """Create only the fixed empty Forge groups and exact profile scope map.""" + static = self._forge_oidc_static_preflight() + if static.get('success') is not True: + return static + + bound_ids = self._forge_oidc_bound_mapping_ids() + if bound_ids == []: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_binding_invalid'} + bound_provider_id = settings.apis.get('AUTHENTIK_FORGE_OIDC_PROVIDER_ID') + if bound_provider_id is not None and _forge_oidc_provider_id(bound_provider_id) is None: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_binding_invalid'} + + allow_existing_group_users = False + if bound_provider_id is not None: + if not expected_client_secret: + return { + 'success': False, + 'status': 'conflict', + 'message': 'forge_oidc_existing_client_secret_missing', + } + existing = self.inspect_forge_oidc_application( + expected_client_secret=expected_client_secret, + ) + if existing.get('success') is not True or existing.get('status') not in { + 'already_configured', + 'provider_orphan', + }: + return existing + if existing.get('providerId') != str(bound_provider_id) or existing.get('secretVerified') is not True: + return { + 'success': False, + 'status': 'conflict', + 'message': 'forge_oidc_bound_provider_mismatch', + } + allow_existing_group_users = existing.get('status') == 'already_configured' + + try: + groups = self._forge_oidc_read_groups() + mappings = self._forge_oidc_read_scope_mappings() + except RuntimeError: + return {'success': False, 'status': 'failed', 'message': 'forge_oidc_resource_preflight_failed'} + group_state, group_error = self._forge_oidc_group_state(groups) + if group_error: + return {'success': False, 'status': 'conflict', 'message': group_error} + for name, group in group_state.items(): + group_id = _authentik_id(group) + if _forge_oidc_application_id(group_id) is None: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_id_invalid'} + try: + observed = self._forge_oidc_read_group(group_id) + except RuntimeError: + return {'success': False, 'status': 'failed', 'message': 'forge_oidc_group_read_failed'} + if ( + not isinstance(observed, dict) + or _authentik_id(observed) != group_id + or str(observed.get('name') or '') != name + or not _forge_oidc_group_contract_matches( + observed, + require_empty_users=not allow_existing_group_users, + require_users=True, + ) + ): + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_conflict'} + group_state[name] = observed + builtin_ids, profile_mapping, mapping_error = self._forge_oidc_mapping_state(mappings) + if mapping_error: + return {'success': False, 'status': 'conflict', 'message': mapping_error} + + profile_id = _authentik_id(profile_mapping) if profile_mapping else '' + discovered_ids = [builtin_ids.get('openid', ''), builtin_ids.get('email', ''), profile_id] + if bound_ids is not None and ( + discovered_ids[0] != bound_ids[0] + or discovered_ids[1] != bound_ids[1] + or (profile_mapping is not None and discovered_ids[2] != bound_ids[2]) + or (profile_mapping is None and bound_ids[2]) + ): + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_bound_scope_mapping_mismatch'} + + # Create only absent fixed groups. A failed/unknown POST is reconciled by + # exact name and contract before any later request can retry it. + for name in FORGE_OIDC_GROUPS: + if name in group_state: + continue + try: + created = self._forge_oidc_write_request( + 'POST', + '/core/groups/', + { + 'name': name, + 'is_superuser': False, + 'attributes': {}, + 'parents': [], + 'users': [], + 'roles': [], + 'children': [], + }, + ) + except RuntimeError: + try: + after_unknown = self._forge_oidc_read_groups() + except RuntimeError: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_creation_unknown'} + matches = [item for item in after_unknown if str(item.get('name') or '') == name] + if len(matches) != 1: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_creation_unknown'} + recovered_id = _authentik_id(matches[0]) + if _forge_oidc_application_id(recovered_id) is None: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_creation_unknown'} + try: + recovered = self._forge_oidc_read_group(recovered_id) + except RuntimeError: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_creation_unknown'} + if ( + not isinstance(recovered, dict) + or _authentik_id(recovered) != recovered_id + or str(recovered.get('name') or '') != name + or not _forge_oidc_group_contract_matches(recovered, require_empty_users=True) + ): + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_creation_unknown'} + group_state[name] = recovered + continue + group_id = str(created.get('pk') or created.get('id') or '') if isinstance(created, dict) else '' + if _forge_oidc_application_id(group_id) is None: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_create_response_invalid'} + try: + observed = self._forge_oidc_read_group(group_id) + except RuntimeError: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_readback_failed'} + if ( + not isinstance(observed, dict) + or _authentik_id(observed) != group_id + or str(observed.get('name') or '') != name + or not _forge_oidc_group_contract_matches(observed, require_empty_users=True) + ): + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_readback_mismatch'} + group_state[name] = observed + + if profile_mapping is None: + if bound_ids is not None: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_bound_profile_mapping_missing'} + try: + created_mapping = self._forge_oidc_write_request( + 'POST', + '/propertymappings/provider/scope/', + { + 'name': FORGE_OIDC_PROFILE_MAPPING_NAME, + 'scope_name': 'profile', + 'expression': FORGE_OIDC_PROFILE_MAPPING_EXPRESSION, + }, + ) + except RuntimeError: + try: + after_unknown = self._forge_oidc_read_scope_mappings() + except RuntimeError: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_profile_mapping_creation_unknown'} + matches = [item for item in after_unknown if str(item.get('name') or '') == FORGE_OIDC_PROFILE_MAPPING_NAME] + if len(matches) != 1 or not _forge_oidc_scope_mapping_matches( + matches[0], mapping_id=_authentik_id(matches[0]), scope='profile' + ): + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_profile_mapping_creation_unknown'} + profile_mapping = matches[0] + else: + created_mapping_id = _authentik_id(created_mapping) + if not _forge_oidc_application_id(created_mapping_id): + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_profile_mapping_create_response_invalid'} + try: + observed_mapping = self._request( + f'/propertymappings/provider/scope/{quote(created_mapping_id, safe="")}/' + ) + mapping_list = self._forge_oidc_read_scope_mappings() + except RuntimeError: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_profile_mapping_readback_failed'} + matching_names = [item for item in mapping_list if str(item.get('name') or '') == FORGE_OIDC_PROFILE_MAPPING_NAME] + if ( + len(matching_names) != 1 + or _authentik_id(matching_names[0]) != created_mapping_id + or not _forge_oidc_scope_mapping_matches( + observed_mapping, + mapping_id=created_mapping_id, + scope='profile', + ) + ): + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_profile_mapping_readback_mismatch'} + profile_mapping = observed_mapping + + profile_id = _authentik_id(profile_mapping) if profile_mapping else '' + mapping_ids = { + 'openid': builtin_ids['openid'], + 'email': builtin_ids['email'], + 'profile': profile_id, + } + if bound_ids is not None and mapping_ids != dict(zip(('openid', 'email', 'profile'), bound_ids, strict=True)): + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_bound_scope_mapping_mismatch'} + try: + ForgeOidcScopeMappingIdsV1.model_validate(mapping_ids) + except Exception: # noqa: BLE001 - return only a fixed safe reason. + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_scope_mapping_id_invalid'} + return {'success': True, 'status': 'ready', 'scopeMappingIds': mapping_ids} + + def inspect_forge_oidc_application( + self, + *, + expected_client_secret: str | None = None, + ) -> dict[str, Any]: + """Read-only preflight for the one fixed Forge OIDC application.""" + static = self._forge_oidc_static_preflight() + if static.get('success') is not True: + return static + mapping_ids = self._forge_oidc_bound_mapping_ids() + if mapping_ids is None or mapping_ids == []: + return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_binding_required'} + signing_key_id = str(settings.apis.get('AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID') or '').strip() + bound_provider_id = settings.apis.get('AUTHENTIK_FORGE_OIDC_PROVIDER_ID') + if bound_provider_id is not None and _forge_oidc_provider_id(bound_provider_id) is None: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_binding_invalid'} + groups_have_users = False + try: + groups = self._forge_oidc_read_groups() + mappings = self._forge_oidc_read_scope_mappings() + group_state, group_error = self._forge_oidc_group_state(groups) + if group_error: + return {'success': False, 'status': 'conflict', 'message': group_error} + if len(group_state) != len(FORGE_OIDC_GROUPS): + return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_required_groups_missing'} + for name, group in group_state.items(): + group_id = _authentik_id(group) + if _forge_oidc_application_id(group_id) is None: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_id_invalid'} + observed_group = self._forge_oidc_read_group(group_id) + if ( + not isinstance(observed_group, dict) + or _authentik_id(observed_group) != group_id + or str(observed_group.get('name') or '') != name + or not _forge_oidc_group_contract_matches( + observed_group, + require_users=True, + ) + ): + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_conflict'} + if observed_group.get('users'): + groups_have_users = True + builtin_ids, profile_mapping, mapping_error = self._forge_oidc_mapping_state(mappings) + if mapping_error: + return {'success': False, 'status': 'conflict', 'message': mapping_error} + if profile_mapping is None: + return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_profile_mapping_missing'} + actual_mapping_ids = [builtin_ids['openid'], builtin_ids['email'], _authentik_id(profile_mapping)] + if actual_mapping_ids != mapping_ids: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_bound_scope_mapping_mismatch'} + for scope, mapping_id in zip(('openid', 'email', 'profile'), mapping_ids, strict=True): + mapping = self._request(f'/propertymappings/provider/scope/{mapping_id}/') + if not _forge_oidc_scope_mapping_matches( + mapping, + mapping_id=mapping_id, + scope=scope, + ): + return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_scope_mapping_mismatch'} + applications = self._paged('/core/applications/') + raw_providers = self._paged('/providers/oauth2/') + providers = [ + {key: value for key, value in item.items() if key != 'client_secret'} + for item in raw_providers + if isinstance(item, dict) + ] + raw_providers = [] + except RuntimeError: + return {'success': False, 'status': 'failed', 'message': 'forge_oidc_preflight_failed'} + + application_matches = [ + item for item in applications + if str(item.get('slug') or '') == FORGE_OIDC_CLIENT_ID + or str(item.get('name') or '') == FORGE_OIDC_APPLICATION_NAME + ] + provider_matches = [ + item for item in providers + if str(item.get('client_id') or '') == FORGE_OIDC_CLIENT_ID + or str(item.get('name') or '') == FORGE_OIDC_PROVIDER_NAME + ] + if len(application_matches) > 1 or len(provider_matches) > 1: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_duplicate_resources'} + if not application_matches and not provider_matches: + if groups_have_users: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_conflict'} + if bound_provider_id is not None: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_bound_provider_missing'} + return {'success': True, 'status': 'ready', 'message': 'forge_oidc_preflight_ready'} + + provider = provider_matches[0] if provider_matches else None + provider_id = _authentik_id(provider) if provider else '' + if provider and ( + _forge_oidc_provider_id(provider_id) is None + or not self._forge_oidc_provider_matches(provider, signing_key_id=signing_key_id) + or (bound_provider_id is not None and provider_id != str(bound_provider_id)) + ): + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_existing_contract_mismatch'} + + if not application_matches: + if not provider: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_application_provider_mismatch'} + if groups_have_users: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_conflict'} + secret_verified = expected_client_secret is not None and self._forge_oidc_provider_secret_matches( + provider_id, + expected_client_secret, + ) + if expected_client_secret is not None and not secret_verified: + return { + 'success': False, + 'status': 'conflict', + 'message': 'forge_oidc_existing_client_secret_mismatch', + } + return { + 'success': True, + 'status': 'provider_orphan', + 'message': 'forge_oidc_provider_recovery_available', + 'providerId': provider_id, + 'secretVerified': secret_verified, + } + + application = application_matches[0] + application_id = _authentik_id(application) + linked_provider_id = _authentik_id(application.get('provider') or application.get('provider_id')) + if ( + not _forge_oidc_application_id(application_id) + or str(application.get('name') or '') != FORGE_OIDC_APPLICATION_NAME + or str(application.get('slug') or '') != FORGE_OIDC_CLIENT_ID + or str(application.get('meta_launch_url') or application.get('launch_url') or '').rstrip('/') + != FORGE_OIDC_LAUNCH_URL + or str(application.get('meta_description') or '') != FORGE_OIDC_MANAGED_DESCRIPTION + or not provider + or linked_provider_id != provider_id + ): + return { + 'success': False, + 'status': 'conflict', + 'message': 'forge_oidc_existing_contract_mismatch', + } + secret_verified = expected_client_secret is not None and self._forge_oidc_provider_secret_matches( + provider_id, + expected_client_secret, + ) + if expected_client_secret is not None and not secret_verified: + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_existing_client_secret_mismatch'} + if groups_have_users and ( + bound_provider_id is None + or provider_id != str(bound_provider_id) + or not secret_verified + ): + return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_conflict'} + return { + 'success': True, + 'status': 'already_configured', + 'message': 'forge_oidc_already_configured', + 'applicationId': application_id, + 'providerId': provider_id, + 'secretVerified': secret_verified, + } + + def _forge_oidc_provider_secret_matches(self, provider_id: str, expected: str) -> bool: + """Compare provider detail secret without retaining, logging or returning it.""" + if not provider_id or not expected: + return False + config = self._config() + if not config: + return False + url = f'{config.url}/api/v3/providers/oauth2/{quote(provider_id, safe="")}/' + try: + with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + response = client.get( + url, + headers={ + 'Accept': 'application/json', + 'Authorization': f'Bearer {config.token}', + }, + ) + if response.status_code != 200: + self.set_error('forge_oidc_provider_secret_read_failed') + return False + try: + payload = response.json() + except ValueError: + self.set_error('forge_oidc_provider_secret_read_failed') + return False + actual = payload.get('client_secret') if isinstance(payload, dict) else None + matches = ( + isinstance(actual, str) + and actual.isascii() + and expected.isascii() + and hmac.compare_digest(actual, expected) + ) + # The response body is transient and never enters the connector + # cache, an error message, the audit stream, or an API result. + actual = None + payload = None + response = None + if not matches: + self.set_error('forge_oidc_existing_client_secret_mismatch') + return matches + except httpx.RequestError: + self.set_error('forge_oidc_provider_secret_read_failed') + return False + + @staticmethod + def _forge_oidc_provider_matches(provider: dict[str, Any], *, signing_key_id: str) -> bool: + expected_authorization_flow = str(settings.apis.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip() + expected_invalidation_flow = str(settings.apis.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip() + expected_mapping_ids = [str(settings.apis.get(key) or '').strip() for key in _FORGE_OIDC_SCOPE_MAPPING_IDS] + expected_provider_id = settings.apis.get('AUTHENTIK_FORGE_OIDC_PROVIDER_ID') + actual_mapping_values = provider.get('property_mappings') + actual_mapping_ids = ( + [_authentik_id(item) for item in actual_mapping_values] + if isinstance(actual_mapping_values, list) + else [] + ) + mappings_match = ( + len(expected_mapping_ids) == 3 + and len(set(expected_mapping_ids)) == 3 + and len(actual_mapping_ids) == 3 + and len(set(actual_mapping_ids)) == 3 + and set(actual_mapping_ids) == set(expected_mapping_ids) + ) + provider_id_valid = expected_provider_id is None or bool( + re.fullmatch(r'[1-9][0-9]{0,9}', str(expected_provider_id)) + ) + return ( + provider_id_valid + and (expected_provider_id is None or _authentik_id(provider) == str(expected_provider_id)) + and str(provider.get('name') or '') == FORGE_OIDC_PROVIDER_NAME + and str(provider.get('client_id') or '') == FORGE_OIDC_CLIENT_ID + and str(provider.get('client_type') or '') == 'confidential' + and _forge_oidc_redirect_is_exact(provider.get('redirect_uris')) + and str(provider.get('authorization_flow') or '') == expected_authorization_flow + and str(provider.get('invalidation_flow') or '') == expected_invalidation_flow + and mappings_match + and 'grant_types' not in provider + and 'redirect_uri_type' not in provider + and provider.get('include_claims_in_id_token') is True + and str(provider.get('sub_mode') or '') == 'hashed_user_id' + and str(provider.get('issuer_mode') or '') == 'per_provider' + and _authentik_id(provider.get('signing_key')) == signing_key_id + ) + + def _forge_oidc_discovery_matches(self) -> bool: + config = self._config() + if not config or config.url.rstrip('/') != _FORGE_OIDC_AUTHENTIK_URL or config.verify_tls is not True: + return False + try: + with httpx.Client(timeout=config.timeout_seconds, verify=True) as client: + response = client.get(FORGE_OIDC_DISCOVERY_URL, headers={'Accept': 'application/json'}) + if response.status_code != 200: + return False + metadata = response.json() + if not isinstance(metadata, dict): + return False + valid = ( + metadata.get('issuer') == FORGE_OIDC_ISSUER + and 'code' in metadata.get('response_types_supported', []) + and 'authorization_code' in metadata.get('grant_types_supported', []) + and {'openid', 'profile', 'email'}.issubset(set(metadata.get('scopes_supported', []))) + and 'RS256' in metadata.get('id_token_signing_alg_values_supported', []) + ) + metadata = None + response = None + return valid + except (httpx.RequestError, ValueError, TypeError): + return False + + @staticmethod + def _forge_oidc_result_from_observation( + observation: dict[str, Any], + *, + accepted_statuses: set[str], + response_provider_id: str | None = None, + response_application_id: str | None = None, + ) -> dict[str, Any] | None: + if ( + observation.get('success') is not True + or observation.get('secretVerified') is not True + or observation.get('status') not in accepted_statuses + ): + return None + provider_id = str(observation.get('providerId') or '') + if response_provider_id is not None and provider_id != response_provider_id: + return None + application_id = str(observation.get('applicationId') or '') + if response_application_id is not None and application_id != response_application_id: + return None + if _forge_oidc_provider_id(provider_id) is None: + return None + if observation.get('status') == 'already_configured' and not _forge_oidc_application_id(application_id): + return None + return { + 'success': True, + 'status': observation.get('status'), + 'message': 'forge_oidc_already_configured' + if observation.get('status') == 'already_configured' + else 'forge_oidc_provider_recovery_available', + 'applicationId': application_id or None, + 'providerId': provider_id, + } + + def ensure_forge_oidc_application( + self, + *, + client_secret: str, + persist_provider_id: Callable[[str], bool], + ) -> dict[str, Any]: + """Create or recover the fixed provider/application only after observed proof.""" + secret_valid = ( + isinstance(client_secret, str) + and 32 <= len(client_secret) <= 255 + and client_secret.isascii() + and client_secret.isprintable() + ) + if not secret_valid or not callable(persist_provider_id): + return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_client_secret_invalid'} + + preflight = self.inspect_forge_oidc_application(expected_client_secret=client_secret) + if preflight.get('success') is not True: + return preflight + if preflight.get('status') == 'already_configured': + provider_id = str(preflight.get('providerId') or '') + application_id = str(preflight.get('applicationId') or '') + if persist_provider_id(provider_id) is not True: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_binding_persist_failed'} + if not self._forge_oidc_discovery_matches(): + return {'success': False, 'status': 'failed', 'message': 'forge_oidc_discovery_contract_mismatch'} + return { + 'success': True, + 'status': 'already_configured', + 'message': 'forge_oidc_already_configured', + 'applicationId': application_id, + 'providerId': provider_id, + } + + if preflight.get('status') not in {'ready', 'provider_orphan'}: + return {'success': False, 'status': 'failed', 'message': 'forge_oidc_preflight_failed'} + + authorization_flow = str(settings.apis.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip() + invalidation_flow = str(settings.apis.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip() + scope_mapping_ids = [str(settings.apis.get(key) or '').strip() for key in _FORGE_OIDC_SCOPE_MAPPING_IDS] + signing_key_id = str(settings.apis.get('AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID') or '').strip() + provider_id = str(preflight.get('providerId') or '') if preflight.get('status') == 'provider_orphan' else '' + provider_response_id: str | None = None + application_missing = preflight.get('status') in {'ready', 'provider_orphan'} + + if preflight.get('status') == 'ready': + try: + provider_response = self._forge_oidc_write_request( + 'POST', + '/providers/oauth2/', + { + 'name': FORGE_OIDC_PROVIDER_NAME, + 'authorization_flow': authorization_flow, + 'invalidation_flow': invalidation_flow, + 'client_type': 'confidential', + 'client_id': FORGE_OIDC_CLIENT_ID, + 'client_secret': client_secret, + 'signing_key': signing_key_id, + 'redirect_uris': [{'matching_mode': 'strict', 'url': FORGE_OIDC_CALLBACK_URL}], + 'property_mappings': scope_mapping_ids, + 'include_claims_in_id_token': True, + 'sub_mode': 'hashed_user_id', + 'issuer_mode': 'per_provider', + }, + ) + provider_response_id = _authentik_id(provider_response) + except RuntimeError: + # The request may have committed remotely. Reconcile before any + # retry; a retry path can only continue with one exact provider + # whose confidential secret matches this AppOps-held secret. + try: + observed = self.inspect_forge_oidc_application(expected_client_secret=client_secret) + except Exception: # noqa: BLE001 - connector failures remain a safe partial result. + observed = {'success': False} + reconciled = self._forge_oidc_result_from_observation( + observed, + accepted_statuses={'provider_orphan', 'already_configured'}, + ) if isinstance(observed, dict) else None + if not reconciled: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_provider_creation_unknown'} + if observed.get('status') == 'already_configured': + provider_id = str(observed.get('providerId') or '') + if persist_provider_id(provider_id) is not True: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_binding_persist_failed'} + if not self._forge_oidc_discovery_matches(): + return {'success': False, 'status': 'failed', 'message': 'forge_oidc_discovery_contract_mismatch'} + return { + 'success': True, + 'status': 'already_configured', + 'message': 'forge_oidc_already_configured', + 'applicationId': observed.get('applicationId'), + 'providerId': provider_id, + } + provider_id = str(reconciled['providerId']) + + if not provider_id: + provider_id = provider_response_id or '' + if _forge_oidc_provider_id(provider_id) is None: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_provider_id_missing'} + + if provider_response_id is not None: + if _forge_oidc_provider_id(provider_response_id) is None: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_provider_response_invalid'} + try: + observed_provider = self.inspect_forge_oidc_application(expected_client_secret=client_secret) + except Exception: # noqa: BLE001 - no remote body or secret crosses the result boundary. + observed_provider = {'success': False} + reconciled = self._forge_oidc_result_from_observation( + observed_provider, + accepted_statuses={'provider_orphan', 'already_configured'}, + response_provider_id=provider_response_id, + ) if isinstance(observed_provider, dict) else None + if not reconciled: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_provider_readback_mismatch'} + provider_id = str(reconciled['providerId']) + if reconciled.get('status') == 'already_configured': + if persist_provider_id(provider_id) is not True: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_binding_persist_failed'} + if not self._forge_oidc_discovery_matches(): + return {'success': False, 'status': 'failed', 'message': 'forge_oidc_discovery_contract_mismatch'} + return { + 'success': True, + 'status': 'already_configured', + 'message': 'forge_oidc_already_configured', + 'applicationId': reconciled.get('applicationId'), + 'providerId': provider_id, + } + + if persist_provider_id(provider_id) is not True: + return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_binding_persist_failed'} + + app_request_id: str | None = None + if application_missing: + try: + application = self._forge_oidc_write_request( + 'POST', + '/core/applications/', + { + 'name': FORGE_OIDC_APPLICATION_NAME, + 'slug': FORGE_OIDC_CLIENT_ID, + 'provider': provider_id, + 'meta_launch_url': FORGE_OIDC_LAUNCH_URL, + 'meta_description': FORGE_OIDC_MANAGED_DESCRIPTION, + }, + ) + app_request_id = _authentik_id(application) + except RuntimeError: + try: + observed = self.inspect_forge_oidc_application(expected_client_secret=client_secret) + except Exception: # noqa: BLE001 - unknown outcome requires a read-only reconcile. + observed = {'success': False} + if isinstance(observed, dict) and observed.get('status') == 'already_configured': + reconciled = self._forge_oidc_result_from_observation( + observed, + accepted_statuses={'already_configured'}, + response_provider_id=provider_id, + ) + if reconciled and self._forge_oidc_discovery_matches(): + return { + 'success': True, + 'status': 'already_configured', + 'message': 'forge_oidc_already_configured', + 'applicationId': reconciled['applicationId'], + 'providerId': provider_id, + } + return { + 'success': False, + 'status': 'partial_failure', + 'message': 'forge_oidc_application_creation_unknown', + 'providerId': provider_id, + } + if _forge_oidc_application_id(app_request_id) is None: + return { + 'success': False, + 'status': 'partial_failure', + 'message': 'forge_oidc_application_response_invalid', + 'providerId': provider_id, + } + + try: + observed_pair = self.inspect_forge_oidc_application(expected_client_secret=client_secret) + except Exception: # noqa: BLE001 - successful writes are never trusted without observed readback. + observed_pair = {'success': False} + reconciled_pair = self._forge_oidc_result_from_observation( + observed_pair, + accepted_statuses={'already_configured'}, + response_provider_id=provider_id, + response_application_id=app_request_id, + ) if isinstance(observed_pair, dict) else None + if not reconciled_pair: + return { + 'success': False, + 'status': 'partial_failure', + 'message': 'forge_oidc_pair_readback_mismatch', + 'providerId': provider_id, + } + if not self._forge_oidc_discovery_matches(): + return { + 'success': False, + 'status': 'partial_failure', + 'message': 'forge_oidc_discovery_contract_mismatch', + 'providerId': provider_id, + } + created_now = app_request_id is not None + return { + 'success': True, + 'status': 'created' if created_now else 'already_configured', + 'message': 'forge_oidc_created' if created_now else 'forge_oidc_already_configured', + 'applicationId': reconciled_pair['applicationId'], + 'providerId': provider_id, + } + + def _forge_oidc_write_request( + self, + method: str, + path: str, + payload: dict[str, Any] | None = None, + ) -> Any: + """Use the connector identity without retaining remote error bodies.""" + config = self._config() + if not config: + raise RuntimeError('missing_config') + try: + with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + request_kwargs: dict[str, Any] = { + 'headers': {'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'}, + } + if payload is not None: + request_kwargs['json'] = payload + response = client.request(method, f'{config.url}/api/v3{path}', **request_kwargs) + except httpx.RequestError: + self.set_error('forge_oidc_request_failed') + raise RuntimeError('forge_oidc_request_failed') from None + if response.status_code in {401, 403}: + self.set_error('auth_failed') + raise RuntimeError('auth_failed') + if response.status_code >= 400: + self.set_error('forge_oidc_api_error') + raise RuntimeError('forge_oidc_api_error') + if not response.content: + return {} + try: + return response.json() + except ValueError: + self.set_error('forge_oidc_invalid_response') + raise RuntimeError('forge_oidc_invalid_response') from None + def remove_deployment_oidc_application( self, *, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2bb0a57..621cd30 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -289,6 +289,18 @@ class Settings: # The reviewed group mapping must emit this exact claim. The # enrollment schema only accepts its bounded role contract. "AUTHENTIK_OIDC_ROLE_CLAIM": _env_value("AUTHENTIK_OIDC_ROLE_CLAIM", None), + # Forge OIDC requires a pre-existing RSA keypair selected by ID; + # the key material remains managed inside Authentik. + "AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID": _env_value("AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID", None), + # These IDs are loaded only from the encrypted Forge OIDC binding. + # They deliberately have no environment or generic setup input. + "AUTHENTIK_FORGE_OIDC_BINDING_VALID": False, + "AUTHENTIK_FORGE_OIDC_PROVIDER_ID": None, + "AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING": None, + "AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING": None, + "AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING": None, + # The trusted Forge namespace owner is explicit and has no guess/default. + "FORGE_PRIVATE_PACKAGE_OWNER": _env_value("FORGE_PRIVATE_PACKAGE_OWNER", None), "JELLYFIN_URL": _env_value("JELLYFIN_URL", None), "JELLYFIN_API_KEY": _env_value("JELLYFIN_API_KEY", None), "WIDEFROG_URL": _env_value("WIDEFROG_URL", None), @@ -396,6 +408,13 @@ class Settings: "AUTHENTIK_OIDC_INVALIDATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_INVALIDATION_FLOW"]), "AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING"]), "AUTHENTIK_OIDC_ROLE_CLAIM": bool(self.apis["AUTHENTIK_OIDC_ROLE_CLAIM"]), + "AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID": bool(self.apis["AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID"]), + "AUTHENTIK_FORGE_OIDC_BINDING_VALID": bool(self.apis["AUTHENTIK_FORGE_OIDC_BINDING_VALID"]), + "AUTHENTIK_FORGE_OIDC_PROVIDER_ID": bool(self.apis["AUTHENTIK_FORGE_OIDC_PROVIDER_ID"]), + "AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING"]), + "AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING"]), + "AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING"]), + "FORGE_PRIVATE_PACKAGE_OWNER": bool(self.apis["FORGE_PRIVATE_PACKAGE_OWNER"]), "JELLYFIN_URL": bool(self.apis["JELLYFIN_URL"]), "WIDEFROG_URL": bool(self.apis["WIDEFROG_URL"]), "WIDEFROG_VERIFY_TLS": bool(self.apis["WIDEFROG_VERIFY_TLS"]), diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index fbaa0e5..37e42b6 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -2,13 +2,22 @@ from datetime import UTC, datetime from sqlalchemy import inspect, text from sqlmodel import SQLModel, select -from . import session as db_session -from .session import get_session + from ..core.config import Settings from ..services.app_classifier import classify_container from ..services.app_registry import category_label -from .models import App, AppContainerMapping, Connector, DiscoveredContainer, NotificationChannel, NotificationEvent, NotificationRule, Policy, WorkMeshPromotion - +from . import session as db_session +from .models import ( + App, + AppContainerMapping, + Connector, + DiscoveredContainer, + NotificationChannel, + NotificationEvent, + NotificationRule, + Policy, +) +from .session import get_session settings = Settings() @@ -939,4 +948,6 @@ def _seed_policies() -> list[Policy]: Policy(id="policy-authentik-force-sync", action="authentik.force_sync", target="authentik", voice="blocked", manual="blocked", risk="high", reason="Force sync is a mutating operation and blocked in Connector V1."), Policy(id="policy-authentik-mass-edit", action="authentik.mass_edit", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Bulk Authentik mutations are blocked."), Policy(id="policy-authentik-unknown-mutation", action="authentik.unknown_mutation", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Unknown Authentik mutations fail closed."), + Policy(id="policy-forge-oidc-provision", action="forge.oidc.provision", target="itworx-forge", voice="blocked", manual="allowed", risk="critical", reason="The fixed Forge OIDC client may be provisioned only by an authenticated manual operator route; voice and generic Authentik mutations remain blocked."), + Policy(id="policy-forge-secret-prepare", action="forge.secrets.prepare", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Preparing the fixed Forge bootstrap secret bundle is default-denied and requires an explicit exact-target manual operator policy; voice remains blocked."), ] diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 0557d12..4edbee4 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -28,6 +28,7 @@ from .db.session import engine from .schemas.deployment import DeploymentPlanV1 from .schemas.enrollment import EnrollmentPlanV1 from .schemas.forge_bootstrap import ForgeBootstrapPreflightV1 +from .schemas.forge_bootstrap_apply import ForgeBootstrapApplyV1 from .services import knowledge_service, mcp_operations, safety_center_service from .services.action_execution import mcp_execution_supported from .services.action_registry import ACTION_RISK, ACTIONS, action_capability @@ -696,6 +697,59 @@ def deployment_preflight(plan: dict[str, Any], idempotencyKey: str) -> dict[str, return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} +@appops_mcp.tool(name="forge.bootstrap.apply", annotations=_OPERATION_RUN_TOOL) +@_tracked("forge.bootstrap.apply") +def forge_bootstrap_apply(profile: dict[str, Any], idempotencyKey: str) -> dict[str, Any]: + """Apply one passed, exact-profile Forge first install through its fixed broker route.""" + if not _MCP_EXECUTION_LOCK.acquire(blocking=False): + raise RuntimeError("server_operation_capacity_busy") + try: + typed_profile = ForgeBootstrapApplyV1.model_validate(profile) + from .services.forge_bootstrap_apply import ( + ForgeBootstrapApplyService, + ) + + with Session(engine) as session: + return _json( + ForgeBootstrapApplyService().apply( + session, + typed_profile, + idempotency_key=idempotencyKey, + ) + ) + except ValueError as exc: + return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} + finally: + _MCP_EXECUTION_LOCK.release() + + +@appops_mcp.tool(name="forge.bootstrap.status", annotations=_READ_ONLY_TOOL) +@_tracked("forge.bootstrap.status") +def forge_bootstrap_status(jobId: str) -> dict[str, Any]: + """Reconcile one existing Forge first-install job using the signed read-only route.""" + from .services.forge_bootstrap_apply import ForgeBootstrapApplyService + + with Session(engine) as session: + result = ForgeBootstrapApplyService().status(session, jobId) + return _json(result or {"status": "not_found"}) + + +@appops_mcp.tool(name="forge.bootstrap.resume", annotations=_OPERATION_RUN_TOOL) +@_tracked("forge.bootstrap.resume") +def forge_bootstrap_resume(jobId: str) -> dict[str, Any]: + """Resume only a signed, pre-migration Forge database-credentials pause.""" + if not _MCP_EXECUTION_LOCK.acquire(blocking=False): + raise RuntimeError("server_operation_capacity_busy") + try: + from .services.forge_bootstrap_apply import ForgeBootstrapApplyService + + with Session(engine) as session: + result = ForgeBootstrapApplyService().resume(session, jobId) + return _json(result or {"status": "not_found"}) + finally: + _MCP_EXECUTION_LOCK.release() + + @appops_mcp.tool(name="deployment.capabilities", annotations=_READ_ONLY_TOOL) @_tracked("deployment.capabilities") def deployment_capabilities() -> dict[str, Any]: @@ -770,6 +824,11 @@ def deployment_apply(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any @_tracked("deployment.status") def deployment_status(jobId: str) -> dict[str, Any]: """Read durable deployment state and refresh a bounded broker request.""" + if jobId.startswith("forge-install-"): + return { + "status": "invalid", + "reason": "Use forge.bootstrap.status for Forge first-install jobs.", + } with Session(engine) as session: try: return _json(DeploymentControlPlane().refresh(session, jobId)) diff --git a/backend/app/schemas/forge_bootstrap_apply.py b/backend/app/schemas/forge_bootstrap_apply.py new file mode 100644 index 0000000..ccdd33e --- /dev/null +++ b/backend/app/schemas/forge_bootstrap_apply.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import re +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +_REVISION = re.compile(r"^[0-9a-f]{40}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +_PREFLIGHT_JOB = re.compile(r"^forge-preflight-[0-9a-f]{16}$") + + +class ForgeBootstrapApplyV1(BaseModel): + """Fixed first-install input; callers cannot choose runtime or secret inputs.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + api_version: Literal["itworx.forge-first-install/v1"] + revision: str = Field(min_length=40, max_length=40) + workspace_image_digest: str = Field(min_length=71, max_length=71) + frontdoor_image_digest: str = Field(min_length=71, max_length=71) + preflight_job_id: str = Field(min_length=32, max_length=32) + preflight_sha256: str = Field(min_length=64, max_length=64) + + @field_validator("revision") + @classmethod + def full_lowercase_revision(cls, value: str) -> str: + if not _REVISION.fullmatch(value): + raise ValueError("revision must be a full lowercase Git SHA") + return value + + @field_validator("workspace_image_digest", "frontdoor_image_digest") + @classmethod + def immutable_oci_digest(cls, value: str) -> str: + if not _DIGEST.fullmatch(value): + raise ValueError("image digest must be a lowercase sha256 OCI digest") + return value + + @field_validator("preflight_job_id") + @classmethod + def fixed_preflight_job_id(cls, value: str) -> str: + if not _PREFLIGHT_JOB.fullmatch(value): + raise ValueError("preflight_job_id must reference a fixed Forge preflight job") + return value + + @field_validator("preflight_sha256") + @classmethod + def fixed_preflight_digest(cls, value: str) -> str: + if not _SHA256.fullmatch(value): + raise ValueError("preflight_sha256 must be a lowercase SHA-256 digest") + return value diff --git a/backend/app/schemas/forge_oidc.py b/backend/app/schemas/forge_oidc.py new file mode 100644 index 0000000..348e066 --- /dev/null +++ b/backend/app/schemas/forge_oidc.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import re +from typing import Any, Literal +from uuid import RFC_4122, UUID + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, model_validator + +FORGE_OIDC_ACTION = "forge.oidc.provision" +FORGE_OIDC_TARGET = "itworx-forge" +FORGE_OIDC_CLIENT_ID = "itworx-forge" +FORGE_OIDC_APPLICATION_NAME = "ITWorx Forge" +FORGE_OIDC_PROVIDER_NAME = "ITWorx Forge OIDC" +FORGE_OIDC_LAUNCH_URL = "https://forge.example.com" +FORGE_OIDC_CALLBACK_URL = "https://forge.example.com/auth/callback" +FORGE_OIDC_ISSUER = "https://auth.example-host.com/application/o/itworx-forge/" +FORGE_OIDC_DISCOVERY_URL = ( + "https://auth.example-host.com/application/o/itworx-forge/.well-known/openid-configuration" +) +FORGE_OIDC_SECRET_REF = "ITWORX_FORGE_OIDC_CLIENT_SECRET" +FORGE_OIDC_BINDING_SECRET_REF = "AUTHENTIK_FORGE_OIDC_BINDING" +FORGE_OIDC_GROUPS = ("forge-viewers", "forge-operators", "forge-owners") +FORGE_OIDC_MANAGED_DESCRIPTION = "Managed by AppOps Forge OIDC v1." +FORGE_OIDC_PROFILE_MAPPING_NAME = "ITWorx Forge profile and groups claim v1" +FORGE_OIDC_PROFILE_MAPPING_EXPRESSION = '''allowed_groups = {"forge-viewers", "forge-operators", "forge-owners"} +return delete_none_values({ + "name": request.user.name, + "preferred_username": request.user.username, + "groups": sorted(group.name for group in request.user.ak_groups.all() if group.name in allowed_groups), +})''' +FORGE_OIDC_PROFILE_MAPPING_EXPRESSION_SHA256 = ( + "648b3f6dbf2212940309a581b239c9996562ca017a89be22bcfa93f9aa2c26ae" +) +FORGE_OIDC_OPENID_MAPPING_MANAGED = "goauthentik.io/providers/oauth2/scope-openid" +FORGE_OIDC_EMAIL_MAPPING_MANAGED = "goauthentik.io/providers/oauth2/scope-email" +FORGE_OIDC_OPENID_MAPPING_EXPRESSION_SHA256 = ( + "38e58da331cc5378d4f269389242aecb80adf30093c405524dc1ab5aab9d452f" +) +FORGE_OIDC_EMAIL_MAPPING_EXPRESSION_SHA256 = ( + "d1200eabeffc3b7f379e9c1636367b69befb0b1ac2b5d82b7be20e78667f2b94" +) + + +class ForgeOidcProvisionRequestV1(BaseModel): + """Empty, strict request contract; the target and resource fields are fixed.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class ForgeOidcScopeMappingIdsV1(BaseModel): + """The only three scopes attached to the fixed Forge provider.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + openid: UUID + email: UUID + profile: UUID + + @model_validator(mode="before") + @classmethod + def mapping_ids_are_canonical_rfc_uuids(cls, value: Any) -> Any: + if not isinstance(value, dict) or set(value) != {"openid", "email", "profile"}: + raise ValueError("forge_oidc_scope_mapping_ids_invalid") + for field in ("openid", "email", "profile"): + raw = value[field] + if not isinstance(raw, str): + # Pydantic converts ValueError into structured ValidationError output. + raise ValueError("forge_oidc_scope_mapping_id_invalid") # noqa: TRY004 + try: + parsed = UUID(raw) + except (TypeError, ValueError, AttributeError): + raise ValueError("forge_oidc_scope_mapping_id_invalid") from None + if ( + str(parsed) != raw + or parsed.version not in {1, 2, 3, 4, 5, 6, 7, 8} + or parsed.variant != RFC_4122 + ): + raise ValueError("forge_oidc_scope_mapping_id_invalid") + return value + + @model_validator(mode="after") + def mapping_ids_are_distinct(self) -> ForgeOidcScopeMappingIdsV1: + values = (self.openid, self.email, self.profile) + if len(set(values)) != len(values): + raise ValueError("forge_oidc_scope_mapping_ids_must_be_distinct") + return self + + +class ForgeOidcBindingV1(BaseModel): + """Protected, encrypted reference to the remote resources owned by Forge OIDC.""" + + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True) + + version: StrictInt + provider_id: StrictStr | None = Field(default=None, alias="providerId") + scope_mapping_ids: ForgeOidcScopeMappingIdsV1 = Field(alias="scopeMappingIds") + + @model_validator(mode="after") + def provider_id_is_positive_decimal(self) -> ForgeOidcBindingV1: + if self.version != 1: + raise ValueError("forge_oidc_binding_version_unsupported") + if self.provider_id is not None and not self.provider_id.isascii(): + raise ValueError("forge_oidc_provider_id_must_be_positive_decimal") + if self.provider_id is not None and ( + not re.fullmatch(r"[1-9][0-9]{0,9}", self.provider_id) + ): + raise ValueError("forge_oidc_provider_id_must_be_positive_decimal") + return self + + +class ForgeOidcProvisionResultV1(BaseModel): + """Safe result projection. It intentionally has no client-secret field.""" + + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True) + + success: bool + status: Literal["created", "already_configured", "blocked", "conflict", "failed", "partial_failure"] + message: str + client_id: Literal["itworx-forge"] = Field(default=FORGE_OIDC_CLIENT_ID, alias="clientId") + callback_url: Literal["https://forge.example.com/auth/callback"] = Field( + default=FORGE_OIDC_CALLBACK_URL, + alias="callbackUrl", + ) + issuer: Literal["https://auth.example-host.com/application/o/itworx-forge/"] = FORGE_OIDC_ISSUER + discovery_url: Literal[ + "https://auth.example-host.com/application/o/itworx-forge/.well-known/openid-configuration" + ] = Field(default=FORGE_OIDC_DISCOVERY_URL, alias="discoveryUrl") + groups: tuple[Literal["forge-viewers", "forge-operators", "forge-owners"], ...] = FORGE_OIDC_GROUPS + secret_ref: Literal["ITWORX_FORGE_OIDC_CLIENT_SECRET"] = Field( + default=FORGE_OIDC_SECRET_REF, + alias="secretRef", + ) + secret_created: bool = Field(default=False, alias="secretCreated") + application_id: str | None = Field(default=None, alias="applicationId") + provider_id: str | None = Field(default=None, alias="providerId") diff --git a/backend/app/schemas/forge_secret_delivery.py b/backend/app/schemas/forge_secret_delivery.py new file mode 100644 index 0000000..a3926c1 --- /dev/null +++ b/backend/app/schemas/forge_secret_delivery.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import re +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr + +from .forge_bootstrap import ForgeBootstrapPreflightV1 + +_SAFE_CODE = re.compile(r"^[a-z][a-z0-9_]{0,95}$") +_REVISION = re.compile(r"^[0-9a-f]{40}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_CONTROLLER_VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$") + + +class ForgeControllerReaderProvisionRequestV1(BaseModel): + """Empty request for the single fixed Forge controller-reader provisioner.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class ForgeControllerReaderProvisionResultV1(BaseModel): + """Safe response projection; the held reader token is never returned.""" + + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True) + + success: StrictBool + status: Literal["ready", "blocked", "conflict", "failed", "partial_failure"] + message: StrictStr = Field(pattern=_SAFE_CODE.pattern) + ready: StrictBool + secret_ref: Literal["ITWORX_FORGE_AUTHENTIK_READ_TOKEN"] = Field(alias="secretRef") + + +class ForgeSecretDeliveryRequestV1(BaseModel): + """Caller supplies only the existing exact-revision Forge profile.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + profile: ForgeBootstrapPreflightV1 + + +class ForgeSecretDeliveryControllerArtifactV1(BaseModel): + """Strict safe identity projection from the independently guarded controller.""" + + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True, strict=True) + + controller_version: StrictStr = Field(alias="controllerVersion", pattern=_CONTROLLER_VERSION.pattern) + source_repository: Literal["jens/unraid-appops-gateway"] = Field(alias="sourceRepository") + source_revision: StrictStr = Field(alias="sourceRevision", pattern=_REVISION.pattern) + source_tree: StrictStr = Field(alias="sourceTree", pattern=_REVISION.pattern) + artifact_sha256: StrictStr = Field(alias="artifactSha256", pattern=_SHA256.pattern) + + +class ForgeSecretDeliveryResultV1(BaseModel): + """Safe operator result; it contains no bundle material or token hashes.""" + + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True, strict=True) + + success: StrictBool + status: Literal["bundle_ready", "blocked", "conflict", "failed", "outcome_unknown"] + message: StrictStr = Field(pattern=_SAFE_CODE.pattern) + target: Literal["itworx-forge"] + revision: StrictStr = Field(pattern=_REVISION.pattern) + controller_artifact: ForgeSecretDeliveryControllerArtifactV1 | None = Field( + default=None, alias="controllerArtifact" + ) + secret_files_ready: StrictInt | None = Field(default=None, alias="secretFilesReady") + master_key_backup_verified: StrictBool | None = Field( + default=None, alias="masterKeyBackupVerified" + ) + apply_preflight: Literal["not_evaluated"] | None = Field(default=None, alias="applyPreflight") diff --git a/backend/app/services/action_registry.py b/backend/app/services/action_registry.py index c4900dd..f436b27 100644 --- a/backend/app/services/action_registry.py +++ b/backend/app/services/action_registry.py @@ -176,6 +176,8 @@ ACTIONS = [ 'authentik.force_sync', 'authentik.mass_edit', 'authentik.unknown_mutation', + 'forge.oidc.provision', + 'forge.secrets.prepare', 'tautulli.terminate_session', 'tautulli.delete_newsletter', 'tautulli.delete_notification_log', @@ -386,6 +388,8 @@ ACTION_RISK = { 'authentik.force_sync': 'high', 'authentik.mass_edit': 'critical', 'authentik.unknown_mutation': 'critical', + 'forge.oidc.provision': 'critical', + 'forge.secrets.prepare': 'critical', 'tautulli.terminate_session': 'high', 'tautulli.delete_newsletter': 'critical', 'tautulli.delete_notification_log': 'critical', @@ -531,6 +535,10 @@ def action_category(action: str) -> str: def action_default_target(action: str) -> str: + if action == 'forge.oidc.provision': + return 'itworx-forge' + if action == 'forge.secrets.prepare': + return 'itworx-forge' if action == 'controller.artifact.promote': return 'tower/deployment-controller' if action == 'controller.managed-storage.initialize': diff --git a/backend/app/services/deployment_broker.py b/backend/app/services/deployment_broker.py index 7bd4ada..b48ee91 100644 --- a/backend/app/services/deployment_broker.py +++ b/backend/app/services/deployment_broker.py @@ -8,6 +8,7 @@ runtime topology before it can mutate production. from __future__ import annotations +import base64 import hashlib import hmac import ipaddress @@ -27,6 +28,7 @@ from ..core.config import settings from ..core.redaction import redact_message from ..schemas.deployment import DeploymentPlanV1 from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1 +from ..schemas.forge_bootstrap_apply import ForgeBootstrapApplyV1 _REQUEST_ID = re.compile(r"^[0-9a-f]{64}$") _MAX_DELIVERY_STATUS_BYTES = 512 * 1024 @@ -35,6 +37,18 @@ _MAX_CONTROLLER_HEALTH_BYTES = 64 * 1024 _WORKMESH_REVISION = re.compile(r"^[0-9a-f]{40}$") _WORKMESH_PREFLIGHT_SHA256 = re.compile(r"^[0-9a-f]{64}$") _WORKMESH_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") +_FORGE_SECRET_DELIVERY_PATH = "/v1/appops/forge/bootstrap/secrets" +_MAX_FORGE_SECRET_DELIVERY_BODY_BYTES = 16 * 1024 +_MAX_FORGE_SECRET_DELIVERY_RESPONSE_BYTES = 64 * 1024 + + +def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate_json_key") + result[key] = value + return result class DeploymentBrokerClient: @@ -197,6 +211,8 @@ class DeploymentBrokerClient: value = decoded.get(field) if isinstance(value, str) and len(value) <= 256: rejected[field] = value + elif field == "noMutation" and isinstance(value, bool): + rejected[field] = value return rejected return {"status": str(decoded.get("status") or "ok"), **decoded} @@ -314,6 +330,184 @@ class DeploymentBrokerClient: payload={"profile": profile.model_dump(mode="json")}, ) + def forge_bootstrap_apply( + self, + profile: ForgeBootstrapApplyV1, + *, + idempotency_key: str, + secret: str, + ) -> dict[str, Any]: + """Submit only the fixed, preflight-bound Forge first-install profile.""" + result = self._request( + "POST", + "/v1/appops/forge/bootstrap/apply", + secret=secret, + payload={ + "profile": profile.model_dump(mode="json"), + "idempotencyKey": idempotency_key, + }, + error_response_fields=frozenset({ + "apiVersion", "request", "repository", "revision", "operation", + "workspaceImageDigest", "frontdoorImageDigest", "noMutation", + }), + ) + if result.get("status") == "preflight_changed": + result.pop("reason", None) + return result + + def forge_secret_delivery( + self, + profile: ForgeBootstrapPreflightV1, + *, + config_bytes: bytes, + oidc_client_secret: bytes, + authentik_read_token: bytes, + secret: str, + ) -> dict[str, Any]: + """Send the single fixed Forge credential bundle request. + + Any response after transmission is treated as an unknown outcome unless + it is a bounded, strict JSON object. Caller-side validation still owns + the complete success contract and never echoes this internal response. + """ + configuration_error = self.configuration_error() + if configuration_error is not None: + return { + "status": "missing_config", + "reason": "forge_secret_delivery_broker_configuration_invalid", + } + if ( + not isinstance(secret, str) + or len(secret) < 32 + or not secret.isascii() + or not secret.isprintable() + or not isinstance(config_bytes, bytes) + or not 1 <= len(config_bytes) <= 8192 + or not isinstance(oidc_client_secret, bytes) + or not 32 <= len(oidc_client_secret) <= 255 + or any(byte < 0x20 or byte > 0x7E for byte in oidc_client_secret) + or not isinstance(authentik_read_token, bytes) + or not 32 <= len(authentik_read_token) <= 256 + or any(byte < 0x21 or byte > 0x7E for byte in authentik_read_token) + ): + return {"status": "missing_secret", "reason": "forge_secret_delivery_inputs_invalid"} + + payload = { + "apiVersion": "itworx.forge-secret-delivery/v1", + "target": "itworx-forge", + "profile": profile.model_dump(mode="json"), + "configBase64": base64.b64encode(config_bytes).decode("ascii"), + "oidcClientSecretBase64": base64.b64encode(oidc_client_secret).decode("ascii"), + "authentikReadTokenBase64": base64.b64encode(authentik_read_token).decode("ascii"), + } + body = self._canonical_payload(payload) + payload.clear() + if len(body) > _MAX_FORGE_SECRET_DELIVERY_BODY_BYTES: + return {"status": "missing_secret", "reason": "forge_secret_delivery_request_too_large"} + + timestamp = str(int(time.time())) + nonce = uuid.uuid4().hex + path = _FORGE_SECRET_DELIVERY_PATH + headers = { + "Accept": "application/json", + "Accept-Encoding": "identity", + "Content-Type": "application/json", + "X-ITWorx-Deployment-Timestamp": timestamp, + "X-ITWorx-Deployment-Nonce": nonce, + "X-ITWorx-Deployment-Signature": self._signature( + secret, "POST", path, timestamp, nonce, body + ), + } + response_body = bytearray() + try: + with httpx.Client( + timeout=self.timeout_seconds, + verify=self._verify_option(), + follow_redirects=False, + ) as client, client.stream( + "POST", + f"{self.base_url}{path}", + content=body, + headers=headers, + ) as response: + if response.status_code != 200: + return { + "status": "outcome_unknown", + "reason": "forge_secret_delivery_controller_rejected", + } + content_length = response.headers.get("content-length") + if content_length is not None and ( + not content_length.isascii() + or not content_length.isdecimal() + or len(content_length) > 10 + or int(content_length) > _MAX_FORGE_SECRET_DELIVERY_RESPONSE_BYTES + ): + return { + "status": "outcome_unknown", + "reason": "forge_secret_delivery_response_invalid", + } + for chunk in response.iter_bytes(chunk_size=16 * 1024): + response_body.extend(chunk) + if len(response_body) > _MAX_FORGE_SECRET_DELIVERY_RESPONSE_BYTES: + response_body.clear() + return { + "status": "outcome_unknown", + "reason": "forge_secret_delivery_response_invalid", + } + except Exception: # noqa: BLE001 - exception text can contain held credentials. + response_body.clear() + return { + "status": "outcome_unknown", + "reason": "forge_secret_delivery_transport_unknown", + } + + try: + decoded = json.loads( + bytes(response_body).decode("utf-8"), + object_pairs_hook=_unique_json_object, + ) + except (UnicodeError, ValueError, TypeError): + response_body.clear() + return { + "status": "outcome_unknown", + "reason": "forge_secret_delivery_response_invalid", + } + response_body.clear() + if not isinstance(decoded, dict): + return { + "status": "outcome_unknown", + "reason": "forge_secret_delivery_response_invalid", + } + return {"status": "response", "response": decoded} + + def forge_bootstrap_resume(self, request_id: str, *, secret: str) -> dict[str, Any]: + """Resume only one recorded pre-migration credentials blocker.""" + if not _REQUEST_ID.fullmatch(request_id): + return {"status": "invalid_request_id"} + result = self._request( + "POST", + f"/v1/appops/forge/bootstrap/resume/{request_id}", + secret=secret, + payload={}, + error_response_fields=frozenset({ + "apiVersion", "request", "repository", "revision", "operation", + "workspaceImageDigest", "frontdoorImageDigest", "noMutation", + }), + ) + if result.get("status") == "preflight_changed": + result.pop("reason", None) + return result + + def forge_bootstrap_status(self, request_id: str, *, secret: str) -> dict[str, Any]: + """Read one fixed Forge first-install request; never submits mutations.""" + if not _REQUEST_ID.fullmatch(request_id): + return {"status": "invalid_request_id"} + return self._request( + "GET", + f"/v1/appops/forge/bootstrap/status/{request_id}", + secret=secret, + ) + def enrollment_capabilities(self, *, secret: str) -> dict[str, Any]: """Read the bootstrap-protected enrollment controller capability.""" return self._request( diff --git a/backend/app/services/forge_bootstrap_apply.py b/backend/app/services/forge_bootstrap_apply.py new file mode 100644 index 0000000..dc254b6 --- /dev/null +++ b/backend/app/services/forge_bootstrap_apply.py @@ -0,0 +1,923 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import re +from datetime import datetime +from typing import Any + +from sqlalchemy import update +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select + +from ..core.config import settings +from ..core.time import utc_now +from ..db.models import DeploymentJob, Policy +from ..schemas.enrollment import MANAGED_DOCKER_APPDATA_ROOT +from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1 +from ..schemas.forge_bootstrap_apply import ForgeBootstrapApplyV1 +from .audit_logger import log_event +from .deployment_broker import DeploymentBrokerClient +from .enrollment_plane import EnrollmentPlane +from .policy_engine import evaluate_policy +from .secret_store import get_secret + +_API_VERSION = "itworx.forge-first-install/v1" +_PREFLIGHT_API_VERSION = "itworx.forge-bootstrap-preflight/v1" +_APPLICATION_ID = "itworx-forge" +_REPOSITORY = "jens/itworx-forge" +_OPERATION = "forge_first_install" +_SECRET_REF = "FORGE_DEPLOYMENT_BROKER" +_FORGE_SOURCE_URL = "https://gitea.example.com/Jens/itworx-forge" +_FORGE_PRIVATE_PACKAGE_OWNER_SETTING = "FORGE_PRIVATE_PACKAGE_OWNER" +_FORGE_PRIVATE_PACKAGE_OWNER_PATTERN = re.compile(r"[a-z0-9][a-z0-9_-]{0,38}\Z") +_FORGE_OIDC_ISSUER = "https://auth.example-host.com/application/o/itworx-forge/" +_FORGE_OIDC_CLIENT_ID = "itworx-forge" +_FORGE_OIDC_CALLBACK = "https://forge.example.com/auth/callback" +_FORGE_OIDC_GROUPS = ["forge-owners", "forge-operators", "forge-viewers"] +_FORGE_OIDC_SCOPES = ["email", "openid", "profile"] +_FORGE_OIDC_SCOPE_MAPPING_EXPRESSION_SHA256 = { + "openid": "38e58da331cc5378d4f269389242aecb80adf30093c405524dc1ab5aab9d452f", + "email": "d1200eabeffc3b7f379e9c1636367b69befb0b1ac2b5d82b7be20e78667f2b94", + "profile": "648b3f6dbf2212940309a581b239c9996562ca017a89be22bcfa93f9aa2c26ae", +} +_FORGE_OIDC_PROFILE_MAPPING_NAME = "ITWorx Forge profile and groups claim v1" +_FORGE_OIDC_OPENID_MAPPING_MANAGED = "goauthentik.io/providers/oauth2/scope-openid" +_FORGE_OIDC_EMAIL_MAPPING_MANAGED = "goauthentik.io/providers/oauth2/scope-email" +_OIDC_ATTESTATION_FIELDS = { + "issuer", "audience", "clientId", "clientType", "callback", "applicationId", + "providerId", "clientSecretMatched", "authorizationFlowId", "invalidationFlowId", + "roleGroups", "scopeMappingIds", "scopeMappingExpressionSha256", + "profileMappingName", "standardMappingsManaged", "supportedScopes", + "authorizationCodeFlowSupported", "signingKeyId", "signingKeyType", + "signingKeyPrivateAvailable", "signingKeyFingerprintSha256", "signingKeyKid", + "tokenSigningAlgorithm", "jwksKid", +} +_AUTHENTIK_FORGE_OIDC_BINDING_VALID_SETTING = "AUTHENTIK_FORGE_OIDC_BINDING_VALID" +_AUTHENTIK_FORGE_OIDC_PROVIDER_ID_SETTING = "AUTHENTIK_FORGE_OIDC_PROVIDER_ID" +_AUTHENTIK_FORGE_OIDC_SCOPE_MAPPING_SETTINGS = { + "openid": "AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING", + "email": "AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING", + "profile": "AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING", +} +_AUTHENTIK_SIGNING_KEY_ID_SETTING = "AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID" +_AUTHENTIK_AUTHORIZATION_FLOW_SETTING = "AUTHENTIK_OIDC_AUTHORIZATION_FLOW" +_AUTHENTIK_INVALIDATION_FLOW_SETTING = "AUTHENTIK_OIDC_INVALIDATION_FLOW" +_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") +_HEALTHY_SERVICES = ["api", "dashboard", "itworx-forge", "postgres", "worker"] +_PROFILE_CHECKS = { + "deployment_apply_policy", + "controller_trust", + "managed_appdata_root_selection", + "exact_main", + "source_manifest", + "managed_appdata", + "proxy_network", + "resource_collisions", + "push_validation", + "image_registry", + "oidc", + "secret_delivery", +} +_FAILED_CODES = {"migration_failed", "service_start_failed", "healthcheck_failed"} +_ACTIVE_STATUSES = {"applying", "outcome_unknown", "needs_database_credentials"} +_TERMINAL_STATUSES = {"deployed", "failed", "preflight_changed"} + + +class ForgeBootstrapApplyError(ValueError): + """A fail-closed error in the fixed Forge first-install contract.""" + + +def _canonical_json(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _sha256(value: Any) -> str: + return hashlib.sha256(_canonical_json(value)).hexdigest() + + +def canonical_preflight_receipt(job: DeploymentJob) -> str: + """Hash the immutable preflight receipt as stored in AppOps, not caller data.""" + receipt = { + "apiVersion": _PREFLIGHT_API_VERSION, + "jobId": job.id, + "applicationId": job.application_id, + "repository": job.repository, + "revision": job.revision, + "jobStatus": job.status, + "planSha256": job.plan_sha256, + "profile": dict(job.plan_json or {}), + "preflight": dict(job.preflight_json or {}), + "evidence": dict(job.evidence_json or {}), + } + return _sha256(receipt) + + +def _idempotency_key(value: str) -> str: + normalized = value.strip() + if not _IDEMPOTENCY.fullmatch(normalized): + raise ForgeBootstrapApplyError("Idempotency-Key must contain 8 to 128 safe characters.") + return normalized + + +def _unknown_summary() -> str: + return "The fixed Forge controller result is unknown; reconcile its status before retrying." + + +def _preflight_ready( + session: Session, profile: ForgeBootstrapApplyV1 +) -> tuple[DeploymentJob, str]: + job = session.get(DeploymentJob, profile.preflight_job_id) + if ( + not job + or job.application_id != _APPLICATION_ID + or job.repository != _REPOSITORY + or job.revision != profile.revision + or job.status != "preflight_succeeded" + or not job.completed_at + or job.plan_sha256 != _sha256(dict(job.plan_json or {})) + ): + raise ForgeBootstrapApplyError( + "A matching passed Forge preflight receipt is required before first install." + ) + try: + stored_profile = ForgeBootstrapPreflightV1.model_validate(job.plan_json) + except ValueError as exc: + raise ForgeBootstrapApplyError("The stored Forge preflight receipt is invalid.") from exc + if ( + stored_profile.revision != profile.revision + or stored_profile.workspace_image_digest != profile.workspace_image_digest + or stored_profile.frontdoor_image_digest != profile.frontdoor_image_digest + ): + raise ForgeBootstrapApplyError( + "The stored Forge preflight receipt does not match this profile." + ) + + preflight = dict(job.preflight_json or {}) + if ( + set(preflight) != {"apiVersion", "status", "blockers", "unknowns", "checks"} + or preflight.get("apiVersion") != _PREFLIGHT_API_VERSION + or preflight.get("status") != "passed" + or preflight.get("blockers") != [] + or preflight.get("unknowns") != [] + ): + raise ForgeBootstrapApplyError( + "Forge first install requires a passed preflight with no unknown checks." + ) + checks = preflight.get("checks") + if not isinstance(checks, list) or len(checks) != len(_PROFILE_CHECKS): + raise ForgeBootstrapApplyError("The stored Forge preflight checks are incomplete.") + seen: set[str] = set() + for check in checks: + if ( + not isinstance(check, dict) + or set(check) != {"name", "status", "detail"} + or not isinstance(check.get("name"), str) + or check["name"] not in _PROFILE_CHECKS + or check["name"] in seen + or check.get("status") != "passed" + or not isinstance(check.get("detail"), str) + or len(check["detail"]) > 256 + ): + raise ForgeBootstrapApplyError( + "Forge first install requires every fixed preflight check to pass." + ) + seen.add(check["name"]) + if seen != _PROFILE_CHECKS: + raise ForgeBootstrapApplyError("The stored Forge preflight checks are incomplete.") + + evidence = dict(job.evidence_json or {}) + if ( + set(evidence) + != {"profile", "policyId", "workspaceImageDigest", "frontdoorImageDigest", "controller"} + or evidence.get("profile") != "forge-production-bootstrap" + or not isinstance(evidence.get("policyId"), str) + or evidence.get("workspaceImageDigest") != profile.workspace_image_digest + or evidence.get("frontdoorImageDigest") != profile.frontdoor_image_digest + or not isinstance(evidence.get("controller"), dict) + ): + raise ForgeBootstrapApplyError( + "The stored Forge preflight evidence does not match this profile." + ) + current_policy_id = _allowed_policy_id(session) + if current_policy_id is None or current_policy_id != evidence["policyId"]: + raise ForgeBootstrapApplyError( + "The exact-target Forge deployment policy changed after preflight." + ) + if not _attestations_ready(evidence["controller"], profile): + raise ForgeBootstrapApplyError( + "The stored Forge preflight lacks verified controller attestations." + ) + receipt_sha = canonical_preflight_receipt(job) + if profile.preflight_sha256 != receipt_sha: + raise ForgeBootstrapApplyError( + "The supplied preflight receipt digest does not match the stored preflight receipt." + ) + return job, receipt_sha + + +def _allowed_policy_id(session: Session) -> str | None: + policies = session.exec( + select(Policy).where( + (Policy.action == "deployment.apply") & (Policy.target == _APPLICATION_ID) + ) + ).all() + if len(policies) != 1: + return None + policy = policies[0] + decision = evaluate_policy( + session, action="deployment.apply", target=_APPLICATION_ID, source="manual" + ) + allowed = bool( + policy.manual == "allowed" + and policy.voice == "blocked" + and policy.risk == "high" + and decision.get("decision") == "allowed" + and decision.get("policy_id") == policy.id + ) + return policy.id if allowed else None + + +def _attestations_ready(controller: dict[str, Any], profile: ForgeBootstrapApplyV1) -> bool: + attestations = controller.get("attestations") + if not isinstance(attestations, dict) or set(attestations) != { + "push_validation", "image_registry", "oidc", "secret_delivery", + }: + return False + push = attestations.get("push_validation") + registry = attestations.get("image_registry") + oidc = attestations.get("oidc") + secret_delivery = attestations.get("secret_delivery") + private_owner = _protected_forge_private_package_owner() + return bool( + isinstance(push, dict) + and set(push) == {"context", "conclusion", "revision"} + and push == { + "context": "Managed validation / full (push)", + "conclusion": "success", + "revision": profile.revision, + } + and isinstance(registry, dict) + and private_owner is not None + and registry + == { + "owner": private_owner, + "ownerVisibility": "private", + "workspaceDigest": profile.workspace_image_digest, + "frontdoorDigest": profile.frontdoor_image_digest, + "workspacePullable": True, + "frontdoorPullable": True, + "workspaceSource": _FORGE_SOURCE_URL, + "workspaceRevision": profile.revision, + "frontdoorSource": _FORGE_SOURCE_URL, + "frontdoorRevision": profile.revision, + } + and forge_oidc_attestation_matches(oidc) + and isinstance(secret_delivery, dict) + and set(secret_delivery) == {"requiredFilesReady", "metadataSha256"} + and secret_delivery.get("requiredFilesReady") is True + and isinstance(secret_delivery.get("metadataSha256"), str) + and re.fullmatch(r"[0-9a-f]{64}", secret_delivery["metadataSha256"]) + ) + + +def _protected_forge_private_package_owner() -> str | None: + owner = settings.apis.get(_FORGE_PRIVATE_PACKAGE_OWNER_SETTING) + if not isinstance(owner, str) or _FORGE_PRIVATE_PACKAGE_OWNER_PATTERN.fullmatch(owner) is None: + return None + return owner + + +def forge_oidc_attestation_matches(value: object) -> bool: + """Validate the fixed controller proof against protected AppOps Authentik pins.""" + if not isinstance(value, dict) or set(value) != _OIDC_ATTESTATION_FIELDS: + return False + if settings.apis.get(_AUTHENTIK_FORGE_OIDC_BINDING_VALID_SETTING) is not True: + return False + provider_id = settings.apis.get(_AUTHENTIK_FORGE_OIDC_PROVIDER_ID_SETTING) + mapping_ids = { + scope: settings.apis.get(setting) + for scope, setting in _AUTHENTIK_FORGE_OIDC_SCOPE_MAPPING_SETTINGS.items() + } + signing_key_id = settings.apis.get(_AUTHENTIK_SIGNING_KEY_ID_SETTING) + authorization_flow = settings.apis.get(_AUTHENTIK_AUTHORIZATION_FLOW_SETTING) + invalidation_flow = settings.apis.get(_AUTHENTIK_INVALIDATION_FLOW_SETTING) + uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + if ( + not isinstance(provider_id, str) + or not re.fullmatch(r"[1-9][0-9]{0,19}", provider_id) + or any(not isinstance(item, str) or not re.fullmatch(uuid_pattern, item) for item in mapping_ids.values()) + or len(set(mapping_ids.values())) != len(_AUTHENTIK_FORGE_OIDC_SCOPE_MAPPING_SETTINGS) + or not isinstance(signing_key_id, str) + or not re.fullmatch(uuid_pattern, signing_key_id) + or not isinstance(authorization_flow, str) + or not authorization_flow.strip() + or not isinstance(invalidation_flow, str) + or not invalidation_flow.strip() + or value.get("providerId") != provider_id + or value.get("scopeMappingIds") != mapping_ids + or value.get("signingKeyId") != signing_key_id + or value.get("authorizationFlowId") != authorization_flow + or value.get("invalidationFlowId") != invalidation_flow + ): + return False + application_id = value.get("applicationId") + if ( + not isinstance(application_id, str) + or not re.fullmatch(uuid_pattern, application_id) + or value.get("providerId") != provider_id + ): + return False + fingerprint = value.get("signingKeyFingerprintSha256") + if not isinstance(fingerprint, str) or not re.fullmatch(r"[0-9a-f]{64}", fingerprint): + return False + jwks_kid = value.get("jwksKid") + keypair_kid = value.get("signingKeyKid") + return bool( + value.get("issuer") == _FORGE_OIDC_ISSUER + and value.get("audience") == _FORGE_OIDC_CLIENT_ID + and value.get("clientId") == _FORGE_OIDC_CLIENT_ID + and value.get("clientType") == "confidential" + and value.get("callback") == _FORGE_OIDC_CALLBACK + and value.get("clientSecretMatched") is True + and isinstance(value.get("authorizationFlowId"), str) + and isinstance(value.get("invalidationFlowId"), str) + and value.get("roleGroups") == _FORGE_OIDC_GROUPS + and value.get("profileMappingName") == _FORGE_OIDC_PROFILE_MAPPING_NAME + and isinstance(value.get("scopeMappingExpressionSha256"), dict) + and value.get("scopeMappingExpressionSha256") == _FORGE_OIDC_SCOPE_MAPPING_EXPRESSION_SHA256 + and value.get("standardMappingsManaged") is True + and value.get("supportedScopes") == _FORGE_OIDC_SCOPES + and value.get("authorizationCodeFlowSupported") is True + and value.get("signingKeyType") == "RSA" + and value.get("signingKeyPrivateAvailable") is True + and value.get("tokenSigningAlgorithm") == "RS256" + and _forge_oidc_kid_is_valid(keypair_kid) + and jwks_kid == keypair_kid + ) + + +def _forge_oidc_kid_is_valid(value: object) -> bool: + if not isinstance(value, str): + return False + if re.fullmatch(r"[0-9a-f]{32}", value): + return True + if re.fullmatch(r"[A-Za-z0-9_-]{86}", value) is None: + return False + try: + decoded = base64.b64decode(value + "==", altchars=b"-_", validate=True) + except (TypeError, ValueError): + return False + return ( + len(decoded) == 64 + and base64.urlsafe_b64encode(decoded).decode("ascii").rstrip("=") == value + ) + + +def _controller_secret(session: Session) -> str | None: + trust = EnrollmentPlane.get_trust_config(session) + if ( + trust is None + # The fixed Tower controller shares the existing enrollment identity + # with RepoPulse; changing global trust would invalidate that binding. + or trust.controller_identity != "unraid-controller" + or trust.trusted_appdata_root != MANAGED_DOCKER_APPDATA_ROOT + or trust.managed_appdata_backup_acknowledged is not True + ): + return None + try: + secret = get_secret(session, _SECRET_REF, touch=False) + except Exception: # noqa: BLE001 - never expose secret store errors. + return None + if ( + not isinstance(secret, str) + or len(secret) < 32 + or not secret.isascii() + or not secret.isprintable() + ): + return None + return secret + + +def _response_identity_valid( + result: dict[str, Any], *, request_id: str, profile: ForgeBootstrapApplyV1 +) -> bool: + return bool( + result.get("apiVersion") == _API_VERSION + and result.get("request") == request_id + and result.get("repository") == _REPOSITORY + and result.get("revision") == profile.revision + and result.get("operation") == _OPERATION + and result.get("workspaceImageDigest") == profile.workspace_image_digest + and result.get("frontdoorImageDigest") == profile.frontdoor_image_digest + ) + + +def _classify_controller_result( + result: object, + *, + request_id: str, + profile: ForgeBootstrapApplyV1, +) -> tuple[str, str, str, dict[str, Any]]: + """Return status/stage/summary and strictly projected controller evidence.""" + if not isinstance(result, dict) or not _response_identity_valid( + result, request_id=request_id, profile=profile + ): + return ( + "outcome_unknown", + "controller_response_unverified", + _unknown_summary(), + {}, + ) + status = result.get("status") + base_fields = { + "apiVersion", + "status", + "request", + "repository", + "revision", + "operation", + "workspaceImageDigest", + "frontdoorImageDigest", + } + identity = {key: result[key] for key in sorted(base_fields)} + if status in {"accepted", "running"} and set(result) == base_fields: + return ( + "applying", + "controller_accepted" if status == "accepted" else "controller_running", + "Forge first install is running on the fixed controller.", + identity, + ) + if status == "needs_database_credentials" and set(result) == base_fields: + return ( + "needs_database_credentials", + "controller_needs_database_credentials", + "Forge first install is paused before migration until fixed database credentials are provisioned.", + identity, + ) + if ( + status == "preflight_changed" + and set(result) == base_fields | {"noMutation"} + and result.get("noMutation") is True + ): + return ( + "preflight_changed", + "controller_preflight_changed", + "Controller rejected changed Forge first-install preflight before mutation.", + {**identity, "noMutation": True}, + ) + if status == "deployed" and set(result) == base_fields | {"receipt"}: + receipt = result.get("receipt") + if not isinstance(receipt, dict) or set(receipt) != { + "revision", + "workspaceImageDigest", + "frontdoorImageDigest", + "migration", + "health", + }: + return ( + "outcome_unknown", + "controller_receipt_unverified", + _unknown_summary(), + {}, + ) + migration = receipt.get("migration") + health = receipt.get("health") + if ( + receipt.get("revision") != profile.revision + or receipt.get("workspaceImageDigest") != profile.workspace_image_digest + or receipt.get("frontdoorImageDigest") != profile.frontdoor_image_digest + or not isinstance(migration, dict) + or set(migration) != {"service", "status", "result"} + or migration + != {"service": "migrate", "status": "completed", "result": "succeeded"} + or not isinstance(health, dict) + or set(health) != {"status", "services"} + or health.get("status") != "healthy" + or health.get("services") != _HEALTHY_SERVICES + ): + return ( + "outcome_unknown", + "controller_receipt_unverified", + _unknown_summary(), + {}, + ) + safe_receipt = { + "revision": profile.revision, + "workspaceImageDigest": profile.workspace_image_digest, + "frontdoorImageDigest": profile.frontdoor_image_digest, + "migration": dict(migration), + "health": {"status": "healthy", "services": list(_HEALTHY_SERVICES)}, + } + return ( + "deployed", + "controller_deployed", + "Forge first install completed with exact migration and health receipts.", + {**identity, "receipt": safe_receipt}, + ) + if status == "failed" and set(result) == base_fields | {"failure"}: + failure = result.get("failure") + if ( + isinstance(failure, dict) + and set(failure) == {"code", "preservedData"} + and failure.get("code") in _FAILED_CODES + and failure.get("preservedData") is True + ): + return ( + "failed", + "controller_failed_preserving_data", + "Controller reported a first-install failure and confirmed PostgreSQL data preservation.", + { + **identity, + "failure": { + "code": failure["code"], + "preservedData": True, + }, + }, + ) + return ( + "outcome_unknown", + "controller_response_unverified", + _unknown_summary(), + {}, + ) + + +def _serialize(job: DeploymentJob, *, replay: bool = False) -> dict[str, Any]: + return { + "id": job.id, + "applicationId": job.application_id, + "repository": job.repository, + "revision": job.revision, + "status": job.status, + "stage": job.stage, + "preflight": dict(job.preflight_json or {}), + "evidence": dict(job.evidence_json or {}), + "result": job.result_summary, + "request": job.broker_request_id, + "createdAt": job.created_at.isoformat(), + "updatedAt": job.updated_at.isoformat(), + "completedAt": job.completed_at.isoformat() if job.completed_at else None, + "idempotentReplay": replay, + } + + +class ForgeBootstrapApplyService: + """One fixed Forge first-install operation with durable, non-replaying apply.""" + + def __init__(self, *, broker: DeploymentBrokerClient | None = None): + self.broker = broker or DeploymentBrokerClient() + + @staticmethod + def request_id(profile: ForgeBootstrapApplyV1, idempotency_key: str) -> str: + normalized = _idempotency_key(idempotency_key) + payload = { + "repository": _REPOSITORY, + "operation": _OPERATION, + "profile": profile.model_dump(mode="json"), + "idempotencyKey": normalized, + } + return _sha256(payload) + + def apply( + self, + session: Session, + profile: ForgeBootstrapApplyV1, + *, + idempotency_key: str, + ) -> dict[str, Any]: + normalized_key = _idempotency_key(idempotency_key) + idempotency_digest = hashlib.sha256( + f"forge-first-install:{normalized_key}".encode() + ).hexdigest() + profile_json = profile.model_dump(mode="json") + plan_sha = _sha256(profile_json) + existing = session.exec( + select(DeploymentJob).where( + DeploymentJob.idempotency_sha256 == idempotency_digest + ) + ).first() + if existing: + if ( + existing.application_id != _APPLICATION_ID + or existing.repository != _REPOSITORY + ): + raise ForgeBootstrapApplyError( + "Idempotency-Key already belongs to a different application or repository." + ) + if existing.plan_sha256 != plan_sha: + raise ForgeBootstrapApplyError( + "Idempotency-Key was already used for a different Forge first-install profile." + ) + return self._refresh_job(session, existing, replay=True) + + preflight_job, receipt_sha = _preflight_ready(session, profile) + if _allowed_policy_id(session) is None: + raise ForgeBootstrapApplyError( + "An allowed exact-target deployment.apply policy is required for Forge first install." + ) + secret = _controller_secret(session) + if secret is None: + raise ForgeBootstrapApplyError( + "Stored managed-root trust and the fixed Forge deployment secret are required." + ) + + request_id = self.request_id(profile, normalized_key) + job_id = f"forge-install-{idempotency_digest}" + now = utc_now() + job = DeploymentJob( + id=job_id, + application_id=_APPLICATION_ID, + repository=_REPOSITORY, + revision=profile.revision, + plan_sha256=plan_sha, + idempotency_sha256=idempotency_digest, + status="applying", + stage="controller_request_pending", + plan_json=profile_json, + preflight_json={ + "apiVersion": _PREFLIGHT_API_VERSION, + "jobId": preflight_job.id, + "sha256": receipt_sha, + }, + evidence_json={"profile": "forge-first-install", "controller": {}}, + broker_request_id=request_id, + result_summary="Forge first install is awaiting the fixed controller response.", + created_at=now, + updated_at=now, + ) + session.add(job) + try: + # The durable request intent must exist before the only POST attempt. + session.commit() + session.refresh(job) + except IntegrityError: + session.rollback() + raced = session.get(DeploymentJob, job_id) + if raced and ( + raced.application_id != _APPLICATION_ID + or raced.repository != _REPOSITORY + ): + raise ForgeBootstrapApplyError( + "Idempotency-Key already belongs to a different application or repository." + ) from None + if raced and raced.plan_sha256 == plan_sha: + return self._refresh_job(session, raced, replay=True) + raise ForgeBootstrapApplyError( + "The Forge first-install request could not be durably claimed." + ) from None + + log_event( + session, + source="deployment-control-plane", + identity="operator", + action_id="deployment.apply", + target=_APPLICATION_ID, + target_type="deployment", + risk="high", + policy_decision="allowed", + reason="Submitted one fixed Forge first-install request after exact preflight validation.", + result="submitted", + execution_plan={ + "request": request_id, + "planSha256": plan_sha, + "preflightSha256": receipt_sha, + }, + redacted_metadata={"profile": "forge-first-install", "jobId": job_id}, + ) + try: + response = self.broker.forge_bootstrap_apply( + profile, + idempotency_key=normalized_key, + secret=secret, + ) + except Exception: # noqa: BLE001 - durable intent is reconciled with GET only. + response = {"status": "unreachable"} + return self._record_response( + session, + job, + response, + profile, + expected_updated_at=job.updated_at, + ) + + def status(self, session: Session, job_id: str) -> dict[str, Any] | None: + if not re.fullmatch(r"forge-install-[0-9a-f]{64}", job_id): + return None + job = session.get(DeploymentJob, job_id) + if ( + not job + or job.application_id != _APPLICATION_ID + or job.repository != _REPOSITORY + or not re.fullmatch(r"[0-9a-f]{64}", job.broker_request_id or "") + ): + return None + return self._refresh_job(session, job) + + def resume(self, session: Session, job_id: str) -> dict[str, Any] | None: + """Explicitly resume only after a signed read proves pre-migration pause.""" + if not re.fullmatch(r"forge-install-[0-9a-f]{64}", job_id): + return None + job = session.get(DeploymentJob, job_id) + if ( + not job + or job.application_id != _APPLICATION_ID + or job.repository != _REPOSITORY + or not re.fullmatch(r"[0-9a-f]{64}", job.broker_request_id or "") + ): + return None + + # A caller cannot turn an old pending/unknown record into another POST. + # First reconcile with the controller, then proceed only from its exact + # safe pre-migration credential state. + refreshed = self._refresh_job(session, job) + if refreshed.get("status") != "needs_database_credentials": + return refreshed + job = session.get(DeploymentJob, job_id) + if job is None: + return refreshed + session.refresh(job) + try: + profile = ForgeBootstrapApplyV1.model_validate(job.plan_json) + except ValueError: + return refreshed + preflight_receipt = dict(job.preflight_json or {}) + if ( + set(preflight_receipt) != {"apiVersion", "jobId", "sha256"} + or preflight_receipt.get("apiVersion") != _PREFLIGHT_API_VERSION + or preflight_receipt.get("jobId") != profile.preflight_job_id + or preflight_receipt.get("sha256") != profile.preflight_sha256 + ): + return refreshed + try: + _preflight_ready(session, profile) + except ForgeBootstrapApplyError: + return refreshed + secret = _controller_secret(session) + if not secret: + return refreshed + + expected_updated_at = job.updated_at + resume_pending = "Forge database credentials passed the signed pre-migration gate; resume is pending." + claimed_at = utc_now() + # Compare-and-swap the observed, signed pre-migration state. Concurrent + # callers can both reconcile it, but only one can persist the resume claim. + claim = session.exec( + update(DeploymentJob) + .where( + DeploymentJob.id == job.id, + DeploymentJob.application_id == _APPLICATION_ID, + DeploymentJob.repository == _REPOSITORY, + DeploymentJob.status == "needs_database_credentials", + DeploymentJob.stage == "controller_needs_database_credentials", + DeploymentJob.updated_at == expected_updated_at, + ) + .values( + status="applying", + stage="resume_request_pending", + result_summary=resume_pending, + updated_at=claimed_at, + ) + ) + if claim.rowcount != 1: + session.rollback() + current = session.get(DeploymentJob, job_id) + if current is None: + return refreshed + session.refresh(current) + return _serialize(current) + # The one-shot intent is durable before the only resume POST attempt. + session.commit() + session.refresh(job) + resume_intent_version = job.updated_at + log_event( + session, + source="deployment-control-plane", + identity="operator", + action_id="deployment.apply", + target=_APPLICATION_ID, + target_type="deployment", + risk="high", + policy_decision="allowed", + reason="Requested one explicit Forge first-install resume after signed pre-migration credential status.", + result="resume_submitted", + execution_plan={"request": job.broker_request_id, "jobId": job.id}, + redacted_metadata={"profile": "forge-first-install"}, + ) + try: + response = self.broker.forge_bootstrap_resume( + job.broker_request_id or "", secret=secret + ) + except Exception: # noqa: BLE001 - durable resume intent is reconciled with GET only. + response = {"status": "unreachable"} + return self._record_response( + session, + job, + response, + profile, + expected_updated_at=resume_intent_version, + resume_claimed=True, + ) + + def _refresh_job( + self, + session: Session, + job: DeploymentJob, + *, + replay: bool = False, + ) -> dict[str, Any]: + if job.status not in _ACTIVE_STATUSES: + return _serialize(job, replay=replay) + try: + profile = ForgeBootstrapApplyV1.model_validate(job.plan_json) + except ValueError: + return _serialize(job, replay=replay) + secret = _controller_secret(session) + expected_updated_at = job.updated_at + resume_claimed = job.stage.startswith("resume") + if not secret: + return self._record_response( + session, + job, + {"status": "unreachable"}, + profile, + replay=replay, + expected_updated_at=expected_updated_at, + resume_claimed=resume_claimed, + ) + try: + response = self.broker.forge_bootstrap_status( + job.broker_request_id or "", secret=secret + ) + except Exception: # noqa: BLE001 - reconciliation failures remain unknown. + response = {"status": "unreachable"} + return self._record_response( + session, + job, + response, + profile, + replay=replay, + expected_updated_at=expected_updated_at, + resume_claimed=resume_claimed, + ) + + @staticmethod + def _record_response( + session: Session, + job: DeploymentJob, + response: object, + profile: ForgeBootstrapApplyV1, + *, + replay: bool = False, + expected_updated_at: datetime | None = None, + resume_claimed: bool = False, + ) -> dict[str, Any]: + expected_updated_at = expected_updated_at or job.updated_at + status, stage, result, evidence = _classify_controller_result( + response, + request_id=job.broker_request_id or "", + profile=profile, + ) + resume_claimed = resume_claimed or job.stage.startswith("resume") + if resume_claimed: + if status == "needs_database_credentials": + status = "outcome_unknown" + stage = "resume_status_unresolved" + result = ( + "Resume was already claimed but the controller still reports its pre-migration pause; " + "reconcile by status without submitting another resume." + ) + else: + stage = f"resume_{stage}" + next_updated_at = utc_now() + next_completed_at = ( + utc_now() if status in _TERMINAL_STATUSES else job.completed_at + ) + values = { + "status": status, + "stage": stage, + "result_summary": result, + "evidence_json": {"profile": "forge-first-install", "controller": evidence}, + "updated_at": next_updated_at, + "completed_at": next_completed_at, + } + statement = update(DeploymentJob).where( + DeploymentJob.id == job.id, + DeploymentJob.updated_at == expected_updated_at, + ) + if status == "needs_database_credentials" and not resume_claimed: + # A status GET started after the local resume claim may still race a + # controller that has not processed the POST. It cannot reopen the gate. + statement = statement.where(DeploymentJob.stage.not_like("resume%")) + result_update = session.exec(statement.values(**values)) + if result_update.rowcount != 1: + session.rollback() + current = session.get(DeploymentJob, job.id) + if current is None: + return _serialize(job, replay=replay) + session.refresh(current) + return _serialize(current, replay=replay) + session.commit() + session.refresh(job) + return _serialize(job, replay=replay) diff --git a/backend/app/services/forge_bootstrap_preflight.py b/backend/app/services/forge_bootstrap_preflight.py index 1239ad2..dfbb791 100644 --- a/backend/app/services/forge_bootstrap_preflight.py +++ b/backend/app/services/forge_bootstrap_preflight.py @@ -14,6 +14,12 @@ from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1 from .audit_logger import log_event from .deployment_broker import DeploymentBrokerClient from .enrollment_plane import EnrollmentPlane +from .forge_bootstrap_apply import ( + _FORGE_SOURCE_URL, + _protected_forge_private_package_owner, + canonical_preflight_receipt, + forge_oidc_attestation_matches, +) from .policy_engine import evaluate_policy from .secret_store import get_secret, has_secret @@ -71,6 +77,21 @@ _STATUS_DETAILS = { "blocked": "A configured required push validation context rejected this exact commit.", "unknown": "Exact-commit push validation is not configured or cannot be verified.", }, + "image_registry": { + "passed": "The fixed private Forge image owner and both immutable image digests are authenticated and pullable.", + "blocked": "One or both immutable Forge image digests are unavailable from the registry.", + "unknown": "Registry publication or pull authorization could not be verified.", + }, + "oidc": { + "passed": "The fixed Forge OIDC provider, application, and role groups are ready.", + "blocked": "The Forge OIDC proof is missing, mismatched, or differs from the protected AppOps mapping and signing-key IDs.", + "unknown": "The fixed Forge OIDC configuration could not be verified.", + }, + "secret_delivery": { + "passed": "The fixed Forge secret files have the required safe metadata.", + "blocked": "One or more fixed Forge secret files are missing or unsafe.", + "unknown": "The fixed Forge secret-file metadata could not be verified.", + }, } @@ -114,10 +135,6 @@ def _checked_controller_result( raw = raw_checks.get(name) if not isinstance(raw, dict) or set(raw) != {"status"} or raw.get("status") not in {"passed", "blocked", "unknown"}: return None - # CI, registry access, OIDC and secret delivery are intentionally - # unknown until a configured, source-authenticated proof exists. - if name in {"image_registry", "oidc", "secret_delivery"} and raw["status"] != "unknown": - return None checks.append(_check(name, raw["status"])) statuses = [item["status"] for item in checks] expected_status = "preflight_blocked" if "blocked" in statuses else "preflight_unknown" if "unknown" in statuses else "preflight_ok" @@ -126,7 +143,7 @@ def _checked_controller_result( raw_evidence = result.get("evidence", {}) if not isinstance(raw_evidence, dict) or set(raw_evidence) - { "mainRevision", "sourceManifestSha256", "resourceCollisionCount", - "workspaceImagePresent", "frontdoorImagePresent", + "workspaceImagePresent", "frontdoorImagePresent", "attestations", }: return None evidence: dict[str, Any] = {} @@ -148,6 +165,84 @@ def _checked_controller_result( if not isinstance(value, bool): return None evidence[name] = value + attestations = raw_evidence.get("attestations") + oidc_rejected = False + validated_attestations: dict[str, dict[str, Any]] = {} + if attestations is not None: + if not isinstance(attestations, dict) or set(attestations) - { + "push_validation", "image_registry", "oidc", "secret_delivery", + }: + return None + push = attestations.get("push_validation") + if push is not None and ( + not isinstance(push, dict) + or set(push) != {"context", "conclusion", "revision"} + or push.get("context") != "Managed validation / full (push)" + or push.get("conclusion") != "success" + or push.get("revision") != profile.revision + ): + return None + if push is not None: + validated_attestations["push_validation"] = dict(push) + registry = attestations.get("image_registry") + private_owner = _protected_forge_private_package_owner() + if registry is not None and ( + not isinstance(registry, dict) + or private_owner is None + or registry.get("workspacePullable") is not True + or registry.get("frontdoorPullable") is not True + or registry != { + "owner": private_owner, + "ownerVisibility": "private", + "workspaceDigest": profile.workspace_image_digest, + "frontdoorDigest": profile.frontdoor_image_digest, + "workspacePullable": True, + "frontdoorPullable": True, + "workspaceSource": _FORGE_SOURCE_URL, + "workspaceRevision": profile.revision, + "frontdoorSource": _FORGE_SOURCE_URL, + "frontdoorRevision": profile.revision, + } + ): + return None + if registry is not None: + validated_attestations["image_registry"] = dict(registry) + oidc = attestations.get("oidc") + if oidc is not None: + if raw_checks["oidc"]["status"] != "passed": + return None + if forge_oidc_attestation_matches(oidc): + validated_attestations["oidc"] = dict(oidc) + else: + oidc_rejected = True + elif raw_checks["oidc"]["status"] == "passed": + oidc_rejected = True + secret_delivery = attestations.get("secret_delivery") + if secret_delivery is not None and ( + not isinstance(secret_delivery, dict) + or set(secret_delivery) != {"requiredFilesReady", "metadataSha256"} + or secret_delivery.get("requiredFilesReady") is not True + or not isinstance(secret_delivery.get("metadataSha256"), str) + or not re.fullmatch(r"^[0-9a-f]{64}$", secret_delivery["metadataSha256"]) + ): + return None + if secret_delivery is not None: + validated_attestations["secret_delivery"] = dict(secret_delivery) + if any( + raw_checks[name]["status"] == "passed" and + (not isinstance(attestations, dict) or name not in attestations) + for name in ("push_validation", "image_registry", "secret_delivery") + ): + return None + if raw_checks["oidc"]["status"] == "passed" and "oidc" not in validated_attestations: + oidc_rejected = True + if oidc_rejected: + for check in checks: + if check["name"] == "oidc": + check.update(_check("oidc", "blocked")) + break + if validated_attestations: + evidence["attestations"] = validated_attestations return checks, evidence @@ -260,6 +355,7 @@ class ForgeBootstrapPreflightService: }, evidence_json={ "profile": "forge-production-bootstrap", + "policyId": exact[0].id if policy_ready else None, "workspaceImageDigest": profile.workspace_image_digest, "frontdoorImageDigest": profile.frontdoor_image_digest, "controller": controller_evidence, @@ -302,6 +398,7 @@ class ForgeBootstrapPreflightService: "status": job.status, "stage": job.stage, "preflight": job.preflight_json, + "preflightSha256": canonical_preflight_receipt(job), "evidence": job.evidence_json, "result": job.result_summary, "brokerRequestId": job.broker_request_id, diff --git a/backend/app/services/forge_controller_reader.py b/backend/app/services/forge_controller_reader.py new file mode 100644 index 0000000..aad123b --- /dev/null +++ b/backend/app/services/forge_controller_reader.py @@ -0,0 +1,1681 @@ +from __future__ import annotations + +import base64 +import hmac +import json +import math +import re +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import parse_qs, quote, urlparse +from uuid import RFC_4122, UUID, uuid4 + +import httpx +from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric import rsa +from sqlmodel import Session + +from ..connectors.authentik import ( + AuthentikConnector, + _forge_oidc_group_contract_matches, + _forge_oidc_scope_mapping_matches, +) +from ..core.config import settings +from ..schemas.forge_oidc import ( + FORGE_OIDC_ACTION, + FORGE_OIDC_APPLICATION_NAME, + FORGE_OIDC_BINDING_SECRET_REF, + FORGE_OIDC_CLIENT_ID, + FORGE_OIDC_DISCOVERY_URL, + FORGE_OIDC_GROUPS, + FORGE_OIDC_ISSUER, + FORGE_OIDC_LAUNCH_URL, + FORGE_OIDC_MANAGED_DESCRIPTION, + FORGE_OIDC_SECRET_REF, + FORGE_OIDC_TARGET, + ForgeOidcBindingV1, + ForgeOidcScopeMappingIdsV1, +) +from .audit_logger import log_event +from .forge_oidc_provisioning import _exact_policy_allowed, _forge_oidc_process_lock +from .secret_store import get_secret, has_secret, secret_storage_status, set_secret +from .setup_service import _apply_forge_oidc_binding_to_runtime + +FORGE_READER_BINDING_REF = "AUTHENTIK_FORGE_READER_BINDING" +FORGE_READER_TOKEN_REF = "ITWORX_FORGE_AUTHENTIK_READ_TOKEN" +FORGE_READER_USERNAME = "itworx-forge-controller-reader" +FORGE_READER_NAME = "ITWorx Forge controller reader v1" +FORGE_READER_PATH = "service-accounts/itworx-forge" +FORGE_READER_ROLE = "ITWorx Forge controller reader v1" +FORGE_READER_TOKEN_ID = "itworx-forge-controller-reader-v1" +FORGE_READER_TOKEN_DESCRIPTION = "Managed by AppOps Forge controller reader v1." +FORGE_READER_OWNER_ATTRIBUTE = "appops_forge_controller_reader_v1" +_AUTHENTIK_BASE = "https://auth.example-host.com" +_JOURNAL_KEYS = { + "version", + "nonce", + "resourceContext", + "permissionManifest", + "userId", + "roleId", + "tokenId", + "phase", + "intent", + "permissionIndex", +} +_RESOURCE_KEYS = {"applicationId", "providerId", "scopeMappingIds", "groupIds", "signingKeyId"} +_PHASES = {"role", "permissions", "user", "membership", "token", "ready"} +_INTENTS = {None, "role", "permission", "user", "membership", "token"} +_TOKEN_PATTERN = re.compile(r"[\x21-\x7e]{32,256}\Z") +_USER_ID_PATTERN = re.compile(r"[1-9][0-9]{0,18}\Z") +_PROVIDER_ID_PATTERN = re.compile(r"[1-9][0-9]{0,9}\Z") +_GROUP_LIST_PARAMS = { + "include_users": "false", + "include_children": "true", + "include_parents": "true", + "include_inherited_roles": "true", + "page_size": 100, +} +_MAX_RESPONSE_BYTES = 256 * 1024 +_HTTP_TIMEOUT_MIN_SECONDS = 0.1 +_HTTP_TIMEOUT_MAX_SECONDS = 10.0 + + +class _ReaderFailure(RuntimeError): + def __init__(self, status: str, code: str): + super().__init__(code) + self.status = status + self.code = code + + +def _fail(code: str, *, status: str = "conflict") -> None: + raise _ReaderFailure(status, code) + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate_json_key") + result[key] = value + return result + + +def _uuid(value: Any, *, version4: bool = False) -> str | None: + if not isinstance(value, str): + return None + try: + parsed = UUID(value) + except (TypeError, ValueError, AttributeError): + return None + if ( + str(parsed) != value + or parsed.variant != RFC_4122 + or parsed.version not in range(1, 9) + or (version4 and parsed.version != 4) + ): + return None + return value + + +def _positive_id(value: Any, *, provider: bool = False) -> str | None: + if type(value) is int: + candidate = str(value) + elif isinstance(value, str): + candidate = value + else: + return None + pattern = _PROVIDER_ID_PATTERN if provider else _USER_ID_PATTERN + return candidate if pattern.fullmatch(candidate) else None + + +def _remote_id(value: Any) -> Any: + if isinstance(value, dict): + return value.get("pk", value.get("id")) + return value + + +def _json_items(payload: Any) -> tuple[list[dict[str, Any]], str | None]: + if isinstance(payload, list): + if any(not isinstance(item, dict) for item in payload): + _fail("forge_reader_remote_collection_invalid", status="failed") + return (payload, None) + if not isinstance(payload, dict): + _fail("forge_reader_remote_collection_invalid", status="failed") + collection_keys = [key for key in ("results", "data", "items") if key in payload] + if len(collection_keys) != 1: + _fail("forge_reader_remote_collection_invalid", status="failed") + items = payload[collection_keys[0]] + if not isinstance(items, list) or any(not isinstance(item, dict) for item in items): + _fail("forge_reader_remote_collection_invalid", status="failed") + + pagination = payload.get("pagination") + if "pagination" in payload and not isinstance(pagination, dict): + _fail("forge_reader_remote_collection_invalid", status="failed") + nested_next = pagination.get("next") if isinstance(pagination, dict) and "next" in pagination else None + direct_next = payload.get("next") if "next" in payload else None + if "next" in payload and isinstance(pagination, dict) and "next" in pagination and direct_next != nested_next: + _fail("forge_reader_remote_collection_invalid", status="failed") + next_value = direct_next if "next" in payload else nested_next + if next_value is None: + return items, None + if type(next_value) is int and next_value > 0: + next_value = str(next_value) + if not isinstance(next_value, str) or not next_value: + _fail("forge_reader_remote_collection_invalid", status="failed") + return items, next_value + + +def _bounded_timeout(value: Any) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + _fail("forge_reader_timeout_invalid", status="blocked") + try: + seconds = float(value) + except (OverflowError, ValueError): + _fail("forge_reader_timeout_invalid", status="blocked") + if not math.isfinite(seconds): + _fail("forge_reader_timeout_invalid", status="blocked") + return min(max(seconds, _HTTP_TIMEOUT_MIN_SECONDS), _HTTP_TIMEOUT_MAX_SECONDS) + + +def _http_json( + method: str, + url: str, + *, + token: str | None, + timeout: float, + params: dict[str, Any] | None = None, + payload: dict[str, Any] | None = None, +) -> Any: + headers = {"Accept": "application/json"} + if token is not None: + headers["Authorization"] = f"Bearer {token}" + try: + with httpx.Client(timeout=_bounded_timeout(timeout), verify=True, follow_redirects=False) as client: + with client.stream(method, url, params=params, json=payload, headers=headers) as response: + if response.status_code < 200 or response.status_code >= 300: + _fail("forge_reader_remote_request_failed", status="failed") + content_length = response.headers.get("content-length") + if content_length is not None: + if not content_length.isascii() or not content_length.isdecimal() or int(content_length) > _MAX_RESPONSE_BYTES: + _fail("forge_reader_remote_response_too_large", status="failed") + chunks: list[bytes] = [] + total_bytes = 0 + for chunk in response.iter_bytes(chunk_size=16 * 1024): + total_bytes += len(chunk) + if total_bytes > _MAX_RESPONSE_BYTES: + _fail("forge_reader_remote_response_too_large", status="failed") + chunks.append(chunk) + response_body = b"".join(chunks) + chunks.clear() + if not response_body: + response_body = None + return {} + try: + value = json.loads(response_body) + except (ValueError, TypeError, UnicodeError): + response_body = None + _fail("forge_reader_remote_response_invalid", status="failed") + response_body = None + return value + except _ReaderFailure: + raise + except (httpx.RequestError, OSError): + _fail("forge_reader_remote_unavailable", status="failed") + + +def _admin_json( + connector: Any, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + payload: dict[str, Any] | None = None, +) -> Any: + config = connector._config() + if ( + config is None + or str(getattr(config, "url", "")).rstrip("/") != _AUTHENTIK_BASE + or getattr(config, "verify_tls", None) is not True + or not isinstance(getattr(config, "token", None), str) + or not config.token + or not path.startswith("/") + or path.startswith("//") + ): + _fail("forge_reader_authentik_configuration_required", status="blocked") + return _http_json( + method, + f"{_AUTHENTIK_BASE}/api/v3{path}", + token=config.token, + timeout=config.timeout_seconds, + params=params, + payload=payload, + ) + + +def _reader_json(connector: Any, reader_token: str, path: str, *, params=None) -> Any: + config = connector._config() + if ( + config is None + or str(getattr(config, "url", "")).rstrip("/") != _AUTHENTIK_BASE + or getattr(config, "verify_tls", None) is not True + or not path.startswith("/") + or path.startswith("//") + ): + _fail("forge_reader_authentik_configuration_required", status="blocked") + return _http_json( + "GET", + f"{_AUTHENTIK_BASE}/api/v3{path}", + token=reader_token, + timeout=config.timeout_seconds, + params=params, + ) + + +def _public_json(url: str) -> Any: + parsed = urlparse(url) + if parsed.scheme != "https" or parsed.netloc != urlparse(_AUTHENTIK_BASE).netloc: + _fail("forge_reader_public_metadata_url_invalid", status="conflict") + return _http_json("GET", url, token=None, timeout=10.0) + + +def _paged( + request, + path: str, + params: dict[str, Any] | None = None, + *, + max_pages: int = 100, + max_items: int = 10000, +) -> list[dict[str, Any]]: + base_params = dict(params or {}) + if not isinstance(path, str) or not path.startswith("/") or path.startswith("//") or urlparse(path).path != path: + _fail("forge_reader_remote_pagination_invalid", status="failed") + if "page" in base_params: + _fail("forge_reader_remote_pagination_invalid", status="failed") + base_query = {key: [str(value)] for key, value in base_params.items()} + page_path = path + page_params: dict[str, Any] | None = dict(base_params) + items: list[dict[str, Any]] = [] + current_page = 1 + seen_pages = {current_page} + for _ in range(max_pages): + response = request(page_path, params=page_params) + page_items, next_value = _json_items(response) + items.extend(page_items) + if len(items) > max_items: + _fail("forge_reader_remote_collection_limit", status="failed") + if not next_value: + return items + if re.fullmatch(r"[1-9][0-9]*", next_value): + next_page = int(next_value) + else: + if "#" in next_value: + _fail("forge_reader_remote_pagination_invalid", status="failed") + parsed = urlparse(next_value) + if parsed.username is not None or parsed.password is not None or parsed.fragment: + _fail("forge_reader_remote_pagination_invalid", status="failed") + if parsed.scheme or parsed.netloc: + expected_host = urlparse(_AUTHENTIK_BASE) + if parsed.scheme != "https" or parsed.netloc != expected_host.netloc: + _fail("forge_reader_remote_pagination_invalid", status="failed") + elif next_value.startswith("//"): + _fail("forge_reader_remote_pagination_invalid", status="failed") + expected_paths = {path, f"/api/v3{path}"} + if parsed.path not in expected_paths: + _fail("forge_reader_remote_pagination_invalid", status="failed") + try: + query_params = parse_qs(parsed.query, keep_blank_values=True, strict_parsing=True) + except ValueError: + _fail("forge_reader_remote_pagination_invalid", status="failed") + if set(query_params) != set(base_query) | {"page"} or any( + query_params.get(key) != values for key, values in base_query.items() + ): + _fail("forge_reader_remote_pagination_invalid", status="failed") + page_values = query_params.get("page", []) + if len(page_values) != 1 or not re.fullmatch(r"[1-9][0-9]*", page_values[0]): + _fail("forge_reader_remote_pagination_invalid", status="failed") + next_page = int(page_values[0]) + if next_page < 1 or next_page > max_pages or next_page <= current_page or next_page in seen_pages: + _fail("forge_reader_remote_pagination_invalid", status="failed") + current_page = next_page + seen_pages.add(current_page) + page_path = path + page_params = {**base_params, "page": current_page} + _fail("forge_reader_remote_pagination_incomplete", status="failed") + + +def _manifest(context: dict[str, Any]) -> list[dict[str, str]]: + app_id = context["applicationId"] + provider_id = context["providerId"] + mappings = context["scopeMappingIds"] + groups = context["groupIds"] + key_id = context["signingKeyId"] + rows = [ + {"app": "authentik_core", "model": "application", "codename": "view_application", "objectPk": app_id}, + {"app": "authentik_providers_oauth2", "model": "oauth2provider", "codename": "view_oauth2provider", "objectPk": provider_id}, + *[ + { + "app": "authentik_providers_oauth2", + "model": "scopemapping", + "codename": "view_scopemapping", + "objectPk": mappings[scope], + } + for scope in ("openid", "email", "profile") + ], + *[ + { + "app": "authentik_core", + "model": "group", + "codename": "view_group", + "objectPk": groups[name], + } + for name in FORGE_OIDC_GROUPS + ], + {"app": "authentik_crypto", "model": "certificatekeypair", "codename": "view_certificatekeypair", "objectPk": key_id}, + { + "app": "authentik_crypto", + "model": "certificatekeypair", + "codename": "view_certificatekeypair_certificate", + "objectPk": key_id, + }, + ] + return sorted(rows, key=lambda row: (row["app"], row["model"], row["codename"], row["objectPk"])) + + +def _journal_bytes(journal: dict[str, Any]) -> str: + return json.dumps(journal, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + + +def _valid_journal(value: Any) -> bool: + if not isinstance(value, dict) or set(value) != _JOURNAL_KEYS: + return False + if type(value.get("version")) is not int or value["version"] != 1: + return False + if _uuid(value.get("nonce"), version4=True) is None: + return False + context = value.get("resourceContext") + if not isinstance(context, dict) or set(context) != _RESOURCE_KEYS: + return False + if ( + _uuid(context.get("applicationId")) is None + or not isinstance(context.get("providerId"), str) + or _positive_id(context.get("providerId"), provider=True) is None + or _uuid(context.get("signingKeyId")) is None + ): + return False + try: + mapping_ids = ForgeOidcScopeMappingIdsV1.model_validate(context.get("scopeMappingIds")) + except Exception: # noqa: BLE001 - journal is untrusted encrypted state. + return False + if not isinstance(context.get("groupIds"), dict) or set(context["groupIds"]) != set(FORGE_OIDC_GROUPS): + return False + if any(_uuid(context["groupIds"][name]) is None for name in FORGE_OIDC_GROUPS): + return False + if len({str(mapping_ids.openid), str(mapping_ids.email), str(mapping_ids.profile)}) != 3: + return False + if len(set(context["groupIds"].values())) != 3: + return False + if not isinstance(value.get("permissionManifest"), list) or len(value["permissionManifest"]) != 10: + return False + for row in value["permissionManifest"]: + if ( + not isinstance(row, dict) + or set(row) != {"app", "model", "codename", "objectPk"} + or any(not isinstance(row[key], str) or not row[key] for key in row) + ): + return False + user_id = value.get("userId") + if user_id is not None and (not isinstance(user_id, str) or _positive_id(user_id) is None): + return False + for key in ("roleId", "tokenId"): + if value.get(key) is not None and _uuid(value[key]) is None: + return False + if not isinstance(value.get("phase"), str) or value["phase"] not in _PHASES: + return False + if value.get("intent") is not None and ( + not isinstance(value.get("intent"), str) or value["intent"] not in _INTENTS + ): + return False + index = value.get("permissionIndex") + if index is not None and (type(index) is not int or not 0 <= index < 10): + return False + if (value.get("intent") == "permission") != (index is not None): + return False + phase = value["phase"] + intent = value["intent"] + role_id = value["roleId"] + token_id = value["tokenId"] + if phase == "role": + return ( + intent == "role" + and role_id is None + and user_id is None + and token_id is None + and index is None + ) + if phase == "permissions": + return ( + intent in {None, "permission"} + and role_id is not None + and user_id is None + and token_id is None + ) + if phase == "user": + return ( + intent == "user" + and role_id is not None + and token_id is None + and index is None + ) + if phase == "membership": + return ( + intent == "membership" + and role_id is not None + and user_id is not None + and token_id is None + and index is None + ) + if phase == "token": + return ( + intent == "token" + and role_id is not None + and user_id is not None + and index is None + ) + return ( + phase == "ready" + and intent is None + and role_id is not None + and user_id is not None + and token_id is not None + and index is None + ) + + +def _persist_journal(session: Session, journal: dict[str, Any]) -> bool: + if not _valid_journal(journal): + return False + raw = _journal_bytes(journal) + try: + set_secret( + session, + FORGE_READER_BINDING_REF, + raw, + used_by=["authentik:itworx-forge-controller-reader"], + metadata={"managedBy": "appops-forge-controller-reader/v1"}, + ) + stored = get_secret(session, FORGE_READER_BINDING_REF, touch=False) + except Exception: # noqa: BLE001 - never expose database or encrypted payload errors. + session.rollback() + return False + return isinstance(stored, str) and hmac.compare_digest(stored, raw) + + +def _load_or_create_journal( + session: Session, + context: dict[str, Any], + manifest: list[dict[str, str]], +) -> dict[str, Any]: + if has_secret(session, FORGE_READER_BINDING_REF): + raw = get_secret(session, FORGE_READER_BINDING_REF, touch=False) + if not isinstance(raw, str) or not raw: + _fail("forge_reader_binding_unreadable") + try: + journal = json.loads(raw, object_pairs_hook=_unique_object) + except (ValueError, TypeError): + _fail("forge_reader_binding_invalid") + if not _valid_journal(journal): + _fail("forge_reader_binding_invalid") + if not hmac.compare_digest(raw, _journal_bytes(journal)): + _fail("forge_reader_binding_not_canonical") + if journal["resourceContext"] != context or journal["permissionManifest"] != manifest: + _fail("forge_reader_binding_resource_mismatch") + return journal + if has_secret(session, FORGE_READER_TOKEN_REF): + _fail("forge_reader_token_without_binding") + journal = { + "version": 1, + "nonce": str(uuid4()), + "resourceContext": context, + "permissionManifest": manifest, + "userId": None, + "roleId": None, + "tokenId": None, + "phase": "role", + "intent": "role", + "permissionIndex": None, + } + if not _persist_journal(session, journal): + _fail("forge_reader_binding_persist_failed", status="partial_failure") + return journal + + +def _persist_token(session: Session, token: str) -> bool: + try: + set_secret( + session, + FORGE_READER_TOKEN_REF, + token, + used_by=["authentik:itworx-forge-controller-reader"], + metadata={"managedBy": "appops-forge-controller-reader/v1"}, + ) + stored = get_secret(session, FORGE_READER_TOKEN_REF, touch=False) + except Exception: # noqa: BLE001 - token material stays out of every error channel. + session.rollback() + return False + return isinstance(stored, str) and hmac.compare_digest(stored, token) + + +def _reader_secret(session: Session) -> str | None: + if not has_secret(session, FORGE_READER_TOKEN_REF): + return None + token = get_secret(session, FORGE_READER_TOKEN_REF, touch=False) + if not isinstance(token, str) or not _TOKEN_PATTERN.fullmatch(token): + _fail("forge_reader_token_unreadable") + return token + + +def _oidc_resource_context(session: Session, connector: Any) -> tuple[dict[str, Any], str]: + if not has_secret(session, FORGE_OIDC_BINDING_SECRET_REF): + _fail("forge_reader_oidc_binding_required", status="blocked") + if not _apply_forge_oidc_binding_to_runtime(session): + _fail("forge_reader_oidc_binding_invalid") + raw_binding = get_secret(session, FORGE_OIDC_BINDING_SECRET_REF, touch=False) + try: + decoded_binding = json.loads(raw_binding, object_pairs_hook=_unique_object) + binding = ForgeOidcBindingV1.model_validate(decoded_binding) + except Exception: # noqa: BLE001 - stored binding is untrusted. + _fail("forge_reader_oidc_binding_invalid") + provider_id = binding.provider_id + if not isinstance(provider_id, str) or _positive_id(provider_id, provider=True) is None: + _fail("forge_reader_oidc_provider_binding_required", status="blocked") + if settings.apis.get("AUTHENTIK_FORGE_OIDC_PROVIDER_ID") != provider_id: + _fail("forge_reader_oidc_provider_binding_mismatch") + oidc_secret = get_secret(session, FORGE_OIDC_SECRET_REF, touch=False) + if not isinstance(oidc_secret, str) or not 32 <= len(oidc_secret) <= 255 or not oidc_secret.isascii() or not oidc_secret.isprintable(): + _fail("forge_reader_oidc_client_secret_unavailable", status="conflict") + config = connector._config() + if ( + config is None + or str(getattr(config, "url", "")).rstrip("/") != _AUTHENTIK_BASE + or getattr(config, "verify_tls", None) is not True + ): + _fail("forge_reader_authentik_configuration_required", status="blocked") + + maps = { + "openid": str(binding.scope_mapping_ids.openid), + "email": str(binding.scope_mapping_ids.email), + "profile": str(binding.scope_mapping_ids.profile), + } + key_id = str(settings.apis.get("AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID") or "") + if _uuid(key_id) is None: + _fail("forge_reader_signing_key_binding_invalid", status="blocked") + + applications = _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/core/applications/", + {"page_size": 100}, + ) + app_matches = [ + item + for item in applications + if item.get("slug") == FORGE_OIDC_CLIENT_ID or item.get("name") == FORGE_OIDC_APPLICATION_NAME + ] + if len(app_matches) != 1: + _fail("forge_reader_oidc_application_missing_or_ambiguous") + application_id = _uuid(_remote_id(app_matches[0])) + if application_id is None: + _fail("forge_reader_oidc_application_id_invalid") + application = _admin_json(connector, "GET", f"/core/applications/{FORGE_OIDC_CLIENT_ID}/") + if ( + not isinstance(application, dict) + or _uuid(_remote_id(application)) != application_id + or application.get("name") != FORGE_OIDC_APPLICATION_NAME + or application.get("slug") != FORGE_OIDC_CLIENT_ID + or str(application.get("meta_launch_url") or application.get("launch_url") or "").rstrip("/") != FORGE_OIDC_LAUNCH_URL + or application.get("meta_description") != FORGE_OIDC_MANAGED_DESCRIPTION + or _positive_id(_remote_id(application.get("provider")), provider=True) != provider_id + ): + _fail("forge_reader_oidc_application_contract_mismatch") + application = None + + provider = _admin_json(connector, "GET", f"/providers/oauth2/{quote(provider_id, safe='')}/") + if not isinstance(provider, dict) or _positive_id(_remote_id(provider), provider=True) != provider_id: + _fail("forge_reader_oidc_provider_contract_mismatch") + if not AuthentikConnector._forge_oidc_provider_matches(provider, signing_key_id=key_id): + provider = None + _fail("forge_reader_oidc_provider_contract_mismatch") + actual_client_secret = provider.get("client_secret") + secret_matches = ( + isinstance(actual_client_secret, str) + and actual_client_secret.isascii() + and oidc_secret.isascii() + and hmac.compare_digest(actual_client_secret, oidc_secret) + ) + actual_client_secret = None + provider = None + if secret_matches is not True: + _fail("forge_reader_oidc_client_secret_mismatch") + + for scope, mapping_id in maps.items(): + mapping = _admin_json( + connector, + "GET", + f"/propertymappings/provider/scope/{quote(mapping_id, safe='')}/", + ) + if not _forge_oidc_scope_mapping_matches(mapping, mapping_id=mapping_id, scope=scope): + _fail("forge_reader_oidc_scope_mapping_mismatch") + mapping = None + + all_groups = _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/core/groups/", + _GROUP_LIST_PARAMS, + ) + group_ids: dict[str, str] = {} + for name in FORGE_OIDC_GROUPS: + matches = [item for item in all_groups if item.get("name") == name] + if len(matches) != 1: + _fail("forge_reader_oidc_group_missing_or_ambiguous") + group_id = _uuid(_remote_id(matches[0])) + if group_id is None: + _fail("forge_reader_oidc_group_id_invalid") + group = _admin_json( + connector, + "GET", + f"/core/groups/{quote(group_id, safe='')}/", + params={ + "include_users": "true", + "include_children": "true", + "include_parents": "true", + "include_inherited_roles": "true", + }, + ) + if ( + not isinstance(group, dict) + or _uuid(_remote_id(group)) != group_id + or group.get("name") != name + or not _forge_oidc_group_contract_matches(group, require_users=True) + or group.get("inherited_roles_obj") != [] + ): + _fail("forge_reader_oidc_group_contract_mismatch") + group_ids[name] = group_id + group = None + if len(set(group_ids.values())) != 3: + _fail("forge_reader_oidc_group_id_collision") + + signing_key = _admin_json( + connector, + "GET", + f"/crypto/certificatekeypairs/{quote(key_id, safe='')}/", + ) + if ( + not isinstance(signing_key, dict) + or _uuid(_remote_id(signing_key)) != key_id + or str(signing_key.get("key_type") or "").lower() != "rsa" + or signing_key.get("private_key_available") is not True + ): + _fail("forge_reader_signing_key_contract_mismatch") + signing_key = None + + context = { + "applicationId": application_id, + "providerId": provider_id, + "scopeMappingIds": maps, + "groupIds": group_ids, + "signingKeyId": key_id, + } + return context, oidc_secret + + +def _role_list(connector: Any, *, name: str | None = None, role_id: str | None = None) -> list[dict[str, Any]]: + if role_id is not None: + value = _admin_json(connector, "GET", f"/rbac/roles/{quote(role_id, safe='')}/") + return [value] if isinstance(value, dict) else [] + params: dict[str, Any] = {"page_size": 100} + if name is not None: + params["name"] = name + return _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/rbac/roles/", + params, + ) + + +def _role_users(connector: Any, role_id: str) -> list[dict[str, Any]]: + return _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/core/users/", + {"roles_by_pk": role_id, "page_size": 100}, + ) + + +def _role_object_permissions(connector: Any, role_id: str) -> list[dict[str, Any]]: + return _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/rbac/permissions/roles/", + {"uuid": role_id, "page_size": 100}, + ) + + +def _global_permissions(connector: Any, role_id: str) -> list[dict[str, Any]]: + return _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/rbac/permissions/", + {"role": role_id, "page_size": 100}, + ) + + +def _group_has_role(rows: list[dict[str, Any]], role_id: str) -> bool: + for group in rows: + direct = group.get("roles") + inherited = group.get("inherited_roles_obj") + if not isinstance(direct, list) or not isinstance(inherited, list): + _fail("forge_reader_group_role_readback_invalid", status="failed") + if any(str(_remote_id(item)) == role_id for item in direct): + return True + if any(str(_remote_id(item)) == role_id for item in inherited): + return True + return False + + +def _role_object_permission_keys(rows: list[dict[str, Any]]) -> set[tuple[str, str, str, str]]: + keys: set[tuple[str, str, str, str]] = set() + for row in rows: + if not isinstance(row, dict): + _fail("forge_reader_role_permission_readback_invalid", status="failed") + key = ( + str(row.get("app_label") or ""), + str(row.get("model") or ""), + str(row.get("codename") or ""), + str(row.get("object_pk") or ""), + ) + if not all(key) or key in keys: + _fail("forge_reader_role_permission_readback_conflict") + keys.add(key) + return keys + + +def _manifest_keys(rows: list[dict[str, Any]]) -> set[tuple[str, str, str, str]]: + return {(row["app"], row["model"], row["codename"], row["objectPk"]) for row in rows} + + +def _verify_permission_catalog(connector: Any, manifest: list[dict[str, Any]]) -> None: + for row in manifest: + items = _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/rbac/permissions/", + { + "content_type__app_label": row["app"], + "content_type__model": row["model"], + "codename": row["codename"], + "page_size": 100, + }, + ) + if ( + len(items) != 1 + or items[0].get("app_label") != row["app"] + or items[0].get("model") != row["model"] + or items[0].get("codename") != row["codename"] + ): + _fail("forge_reader_permission_catalog_mismatch") + + +def _journal_store( + session: Session, + journal: dict[str, Any], + *, + phase: str | None = None, + intent: str | None | object = ..., + permission_index: int | None | object = ..., + user_id: str | None | object = ..., + role_id: str | None | object = ..., + token_id: str | None | object = ..., +) -> None: + updated = dict(journal) + if phase is not None: + updated["phase"] = phase + if intent is not ...: + updated["intent"] = intent + if permission_index is not ...: + updated["permissionIndex"] = permission_index + if user_id is not ...: + updated["userId"] = user_id + if role_id is not ...: + updated["roleId"] = role_id + if token_id is not ...: + updated["tokenId"] = token_id + if not _persist_journal(session, updated): + _fail("forge_reader_binding_persist_failed", status="partial_failure") + journal.clear() + journal.update(updated) + + +def _prove_empty_role(connector: Any, role_id: str) -> None: + object_rows = _role_object_permissions(connector, role_id) + global_rows = _global_permissions(connector, role_id) + users = _role_users(connector, role_id) + groups = _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/core/groups/", + _GROUP_LIST_PARAMS, + ) + if object_rows or global_rows or users or _group_has_role(groups, role_id): + _fail("forge_reader_existing_role_not_empty") + + +def _resolve_role(session: Session, connector: Any, journal: dict[str, Any]) -> str: + role_id = journal["roleId"] + if role_id is None: + if journal["phase"] != "role" or journal["intent"] != "role": + _fail("forge_reader_role_binding_missing") + matches = _role_list(connector, name=FORGE_READER_ROLE) + matches = [row for row in matches if row.get("name") == FORGE_READER_ROLE] + if len(matches) > 1: + _fail("forge_reader_role_ambiguous") + if matches: + role_id = _uuid(_remote_id(matches[0])) + if role_id is None: + _fail("forge_reader_role_id_invalid") + _prove_empty_role(connector, role_id) + _journal_store(session, journal, phase="permissions", intent=None, permission_index=None, role_id=role_id) + return role_id + + # The protected role intent is durable before this only remote POST. + try: + _admin_json(connector, "POST", "/rbac/roles/", payload={"name": FORGE_READER_ROLE}) + except _ReaderFailure: + pass + matches = _role_list(connector, name=FORGE_READER_ROLE) + matches = [row for row in matches if row.get("name") == FORGE_READER_ROLE] + if len(matches) != 1: + _fail("forge_reader_role_creation_unconfirmed", status="partial_failure") + role_id = _uuid(_remote_id(matches[0])) + if role_id is None: + _fail("forge_reader_role_id_invalid", status="partial_failure") + _prove_empty_role(connector, role_id) + _journal_store(session, journal, phase="permissions", intent=None, permission_index=None, role_id=role_id) + return role_id + + details = _role_list(connector, role_id=role_id) + if len(details) != 1 or details[0].get("name") != FORGE_READER_ROLE or _uuid(_remote_id(details[0])) != role_id: + _fail("forge_reader_role_binding_mismatch") + return role_id + + +def _validate_existing_user( + user: dict[str, Any], + *, + journal: dict[str, Any], + role_id: str, +) -> str: + user_id = _positive_id(_remote_id(user)) + if user_id is None: + _fail("forge_reader_user_id_invalid") + if journal["userId"] is not None and journal["userId"] != user_id: + _fail("forge_reader_user_binding_mismatch") + attributes = user.get("attributes") + if ( + user.get("username") != FORGE_READER_USERNAME + or user.get("name") != FORGE_READER_NAME + or user.get("type") != "service_account" + or user.get("path") != FORGE_READER_PATH + or user.get("is_active") is not True + or user.get("is_superuser") is not False + or attributes != {FORGE_READER_OWNER_ATTRIBUTE: journal["nonce"]} + ): + _fail("forge_reader_existing_user_conflict") + groups = user.get("groups") + roles = user.get("roles") + if not isinstance(groups, list) or not isinstance(roles, list) or groups: + _fail("forge_reader_user_membership_conflict") + role_ids = {str(_remote_id(item)) for item in roles} + if journal["phase"] == "membership" and journal["intent"] == "membership": + allowed_role_states = (set(), {role_id}) + elif journal["phase"] in {"token", "ready"}: + allowed_role_states = ({role_id},) + else: + allowed_role_states = (set(),) + if role_ids not in allowed_role_states or len(roles) != len(role_ids): + _fail("forge_reader_user_role_conflict") + return user_id + + +def _user_matches_by_username(connector: Any) -> list[dict[str, Any]]: + users = _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/core/users/", + {"username": FORGE_READER_USERNAME, "include_groups": "true", "include_roles": "true", "page_size": 100}, + ) + return [user for user in users if user.get("username") == FORGE_READER_USERNAME] + + +def _ensure_role_permissions( + session: Session, + connector: Any, + journal: dict[str, Any], + manifest: list[dict[str, str]], +) -> str: + role_id = journal["roleId"] + if role_id is None: + _fail("forge_reader_role_binding_missing") + _verify_permission_catalog(connector, manifest) + required = _manifest_keys(manifest) + current = _role_object_permission_keys(_role_object_permissions(connector, role_id)) + globals_ = _global_permissions(connector, role_id) + if globals_: + _fail("forge_reader_global_permission_conflict") + if not current.issubset(required): + _fail("forge_reader_object_permission_conflict") + if journal["phase"] not in {"permissions"} and current != required: + _fail("forge_reader_permissions_incomplete") + if journal["phase"] == "permissions" and journal["intent"] not in {None, "permission"}: + _fail("forge_reader_journal_stage_conflict") + + current_list = current + members = _role_users(connector, role_id) + if journal["phase"] in {"permissions", "user"} and members: + _fail("forge_reader_role_has_other_users") + groups = _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/core/groups/", + _GROUP_LIST_PARAMS, + ) + if _group_has_role(groups, role_id): + _fail("forge_reader_role_assigned_to_group") + for index, row in enumerate(manifest): + key = (row["app"], row["model"], row["codename"], row["objectPk"]) + if key in current_list: + continue + if journal["phase"] != "permissions": + _journal_store(session, journal, phase="permissions", intent="permission", permission_index=index) + else: + _journal_store(session, journal, phase="permissions", intent="permission", permission_index=index) + model = f"{row['app']}.{row['model']}" + try: + _admin_json( + connector, + "POST", + f"/rbac/permissions/assigned_by_roles/{quote(role_id, safe='')}/assign/", + payload={"permissions": [row["codename"]], "model": model, "object_pk": row["objectPk"]}, + ) + except _ReaderFailure: + # A transport failure is reconciled by GET; this invocation never blindly repeats. + pass + current = _role_object_permission_keys(_role_object_permissions(connector, role_id)) + globals_ = _global_permissions(connector, role_id) + if globals_ or not current.issubset(required): + _fail("forge_reader_permission_assignment_conflict") + if key not in current: + _fail("forge_reader_permission_assignment_unconfirmed", status="partial_failure") + _journal_store(session, journal, phase="permissions", intent=None, permission_index=None) + current_list = current + final = _role_object_permission_keys(_role_object_permissions(connector, role_id)) + if final != required or _global_permissions(connector, role_id): + _fail("forge_reader_permissions_incomplete", status="partial_failure") + if journal["phase"] == "permissions": + _journal_store(session, journal, phase="user", intent="user", permission_index=None) + return role_id + + +def _user_detail(connector: Any, user_id: str) -> dict[str, Any]: + value = _admin_json( + connector, + "GET", + f"/core/users/{quote(user_id, safe='')}/", + params={"include_groups": "true", "include_roles": "true"}, + ) + if not isinstance(value, dict): + _fail("forge_reader_user_readback_invalid", status="failed") + return value + + +def _ensure_user(session: Session, connector: Any, journal: dict[str, Any], role_id: str) -> str: + matches = _user_matches_by_username(connector) + if len(matches) > 1: + _fail("forge_reader_user_ambiguous") + if matches: + user_id = _validate_existing_user( + matches[0], + journal=journal, + role_id=role_id, + ) + detail = _user_detail(connector, user_id) + observed_id = _validate_existing_user( + detail, + journal=journal, + role_id=role_id, + ) + if observed_id != user_id: + _fail("forge_reader_user_readback_mismatch") + if journal["userId"] is None: + if journal["phase"] != "user" or journal["intent"] != "user": + _fail("forge_reader_unbound_user_conflict") + _journal_store(session, journal, phase="membership", intent="membership", user_id=user_id) + elif journal["phase"] == "user": + _journal_store(session, journal, phase="membership", intent="membership", user_id=user_id) + return user_id + + if journal["userId"] is not None: + _fail("forge_reader_bound_user_missing") + if journal["phase"] != "user" or journal["intent"] != "user": + _fail("forge_reader_user_creation_not_authorized") + payload = { + "username": FORGE_READER_USERNAME, + "name": FORGE_READER_NAME, + "type": "service_account", + "path": FORGE_READER_PATH, + "attributes": {FORGE_READER_OWNER_ATTRIBUTE: journal["nonce"]}, + "groups": [], + "roles": [], + } + try: + _admin_json(connector, "POST", "/core/users/", payload=payload) + except _ReaderFailure: + pass + matches = _user_matches_by_username(connector) + if len(matches) != 1: + _fail("forge_reader_user_creation_unconfirmed", status="partial_failure") + user_id = _validate_existing_user(matches[0], journal=journal, role_id=role_id) + detail = _user_detail(connector, user_id) + if _validate_existing_user(detail, journal=journal, role_id=role_id) != user_id: + _fail("forge_reader_user_readback_mismatch", status="partial_failure") + _journal_store(session, journal, phase="membership", intent="membership", user_id=user_id) + return user_id + + +def _users_with_role(connector: Any, role_id: str) -> list[dict[str, Any]]: + return _role_users(connector, role_id) + + +def _user_roles(connector: Any, user_id: str) -> list[dict[str, Any]]: + return _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/rbac/roles/", + {"users": user_id, "inherited": "true", "page_size": 100}, + ) + + +def _ensure_membership(session: Session, connector: Any, journal: dict[str, Any], user_id: str, role_id: str) -> None: + all_groups = _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/core/groups/", + _GROUP_LIST_PARAMS, + ) + if _group_has_role(all_groups, role_id): + _fail("forge_reader_role_assigned_to_group") + members = _users_with_role(connector, role_id) + member_ids = [_positive_id(_remote_id(item)) for item in members] + if any(value is None for value in member_ids) or len(set(member_ids)) != len(member_ids): + _fail("forge_reader_role_user_readback_invalid") + if member_ids not in ([], [user_id]): + _fail("forge_reader_role_has_other_users") + detail = _user_detail(connector, user_id) + _validate_existing_user( + detail, + journal=journal, + role_id=role_id, + ) + roles = _user_roles(connector, user_id) + role_ids = [_uuid(_remote_id(item)) for item in roles] + if any(value is None for value in role_ids) or len(set(role_ids)) != len(role_ids): + _fail("forge_reader_inherited_role_readback_invalid") + if role_ids not in ([], [role_id]): + _fail("forge_reader_inherited_role_conflict") + has_role_membership = member_ids == [user_id] + user_has_role = role_ids == [role_id] + if has_role_membership != user_has_role: + _fail("forge_reader_role_membership_readback_conflict") + if has_role_membership: + if journal["phase"] == "membership": + _journal_store(session, journal, phase="token", intent="token") + return + if journal["phase"] != "membership" or journal["intent"] != "membership": + _fail("forge_reader_role_membership_not_authorized") + _journal_store(session, journal, phase="membership", intent="membership") + try: + _admin_json( + connector, + "POST", + f"/rbac/roles/{quote(role_id, safe='')}/add_user/", + payload={"pk": int(user_id)}, + ) + except _ReaderFailure: + pass + members = _users_with_role(connector, role_id) + member_ids = [_positive_id(_remote_id(item)) for item in members] + roles = _user_roles(connector, user_id) + role_ids = [_uuid(_remote_id(item)) for item in roles] + if member_ids != [user_id] or role_ids != [role_id]: + _fail("forge_reader_role_membership_unconfirmed", status="partial_failure") + _journal_store(session, journal, phase="token", intent="token") + + +def _token_metadata(connector: Any) -> list[dict[str, Any]]: + return _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/core/tokens/", + {"identifier": FORGE_READER_TOKEN_ID, "page_size": 100}, + ) + + +def _validate_token_metadata(token: dict[str, Any], *, user_id: str, journal: dict[str, Any]) -> str: + token_id = _uuid(_remote_id(token)) + token_user_id = _positive_id(_remote_id(token.get("user"))) + if ( + token_id is None + or token_user_id != user_id + or token.get("identifier") != FORGE_READER_TOKEN_ID + or token.get("intent") != "api" + or token.get("description") != FORGE_READER_TOKEN_DESCRIPTION + or token.get("expiring") is not False + ): + _fail("forge_reader_token_metadata_conflict") + if journal["tokenId"] is not None and journal["tokenId"] != token_id: + _fail("forge_reader_token_binding_mismatch") + return token_id + + +def _ensure_token( + session: Session, + connector: Any, + journal: dict[str, Any], + user_id: str, + admin_token: str, +) -> str: + existing_secret = _reader_secret(session) + rows = _token_metadata(connector) + rows = [row for row in rows if row.get("identifier") == FORGE_READER_TOKEN_ID] + if len(rows) > 1: + _fail("forge_reader_token_ambiguous") + if rows: + if journal["phase"] not in {"token", "ready"} or journal["intent"] != "token" and journal["phase"] != "ready": + _fail("forge_reader_unbound_token_conflict") + token_id = _validate_token_metadata(rows[0], user_id=user_id, journal=journal) + if journal["tokenId"] is None: + _journal_store(session, journal, phase="token", intent="token", token_id=token_id) + else: + if existing_secret is not None: + _fail("forge_reader_secret_without_remote_token") + if journal["phase"] != "token" or journal["intent"] != "token": + _fail("forge_reader_token_creation_not_authorized") + try: + _admin_json( + connector, + "POST", + "/core/tokens/", + payload={ + "identifier": FORGE_READER_TOKEN_ID, + "intent": "api", + "user": int(user_id), + "description": FORGE_READER_TOKEN_DESCRIPTION, + "expiring": False, + }, + ) + except _ReaderFailure: + pass + rows = _token_metadata(connector) + rows = [row for row in rows if row.get("identifier") == FORGE_READER_TOKEN_ID] + if len(rows) != 1: + _fail("forge_reader_token_creation_unconfirmed", status="partial_failure") + token_id = _validate_token_metadata(rows[0], user_id=user_id, journal=journal) + _journal_store(session, journal, phase="token", intent="token", token_id=token_id) + + if journal["tokenId"] is None: + _fail("forge_reader_token_binding_missing") + token = _admin_json( + connector, + "GET", + f"/core/tokens/{quote(FORGE_READER_TOKEN_ID, safe='')}/view_key/", + ) + raw_key = token.get("key") if isinstance(token, dict) else None + if not isinstance(raw_key, str) or not _TOKEN_PATTERN.fullmatch(raw_key): + token = None + raw_key = None + _fail("forge_reader_token_key_invalid", status="partial_failure") + if existing_secret is None: + if has_secret(session, FORGE_READER_TOKEN_REF): + raw_key = None + _fail("forge_reader_token_unreadable") + stored = _persist_token(session, raw_key) + if stored is not True: + raw_key = None + _fail("forge_reader_token_persist_failed", status="partial_failure") + elif not hmac.compare_digest(existing_secret, raw_key): + raw_key = None + _fail("forge_reader_original_token_mismatch") + raw_key = None + token = None + if existing_secret is None: + existing_secret = _reader_secret(session) + if not isinstance(existing_secret, str): + _fail("forge_reader_token_persist_failed", status="partial_failure") + if hmac.compare_digest(existing_secret, admin_token): + _fail("forge_reader_administrative_token_reuse") + return existing_secret + + +def _b64url_int(value: Any) -> int | None: + if not isinstance(value, str) or not value: + return None + try: + padded = value + "=" * (-len(value) % 4) + decoded = base64.urlsafe_b64decode(padded.encode("ascii")) + return int.from_bytes(decoded, "big") if decoded else None + except (ValueError, TypeError, UnicodeError): + return None + + +def _verify_public_reader( + connector: Any, + reader_token: str, + oidc_secret: str, + context: dict[str, Any], + role_id: str, + user_id: str, +) -> None: + self_response = _reader_json(connector, reader_token, "/core/users/me/") + reader = self_response.get("user") if isinstance(self_response, dict) else None + if ( + not isinstance(reader, dict) + or _positive_id(_remote_id(reader)) != user_id + or reader.get("username") != FORGE_READER_USERNAME + or reader.get("name") != FORGE_READER_NAME + or reader.get("is_active") is not True + or reader.get("is_superuser") is not False + or reader.get("type") != "service_account" + or reader.get("system_permissions") != [] + or reader.get("groups") != [] + ): + _fail("forge_reader_self_readback_mismatch") + roles = reader.get("roles") + if not isinstance(roles, list) or len(roles) != 1 or _uuid(_remote_id(roles[0])) != role_id: + _fail("forge_reader_self_role_mismatch") + + apps = _paged( + lambda path, params=None: _reader_json(connector, reader_token, path, params=params), + "/core/applications/", + {"page_size": 100, "search": FORGE_OIDC_CLIENT_ID}, + ) + app_matches = [ + app + for app in apps + if app.get("slug") == FORGE_OIDC_CLIENT_ID and _uuid(_remote_id(app)) == context["applicationId"] + ] + if len(app_matches) != 1: + _fail("forge_reader_application_visibility_mismatch") + application = _reader_json(connector, reader_token, f"/core/applications/{FORGE_OIDC_CLIENT_ID}/") + if ( + not isinstance(application, dict) + or _uuid(_remote_id(application)) != context["applicationId"] + or application.get("name") != FORGE_OIDC_APPLICATION_NAME + or application.get("slug") != FORGE_OIDC_CLIENT_ID + or str(application.get("meta_launch_url") or application.get("launch_url") or "").rstrip("/") != FORGE_OIDC_LAUNCH_URL + or application.get("meta_description") != FORGE_OIDC_MANAGED_DESCRIPTION + or _positive_id(_remote_id(application.get("provider")), provider=True) != context["providerId"] + ): + _fail("forge_reader_application_detail_mismatch") + application = None + + provider = _reader_json( + connector, + reader_token, + f"/providers/oauth2/{quote(context['providerId'], safe='')}/", + ) + if ( + not isinstance(provider, dict) + or not AuthentikConnector._forge_oidc_provider_matches(provider, signing_key_id=context["signingKeyId"]) + or _positive_id(_remote_id(provider), provider=True) != context["providerId"] + ): + _fail("forge_reader_provider_visibility_mismatch") + actual_secret = provider.get("client_secret") + secret_matches = ( + isinstance(actual_secret, str) + and actual_secret.isascii() + and oidc_secret.isascii() + and hmac.compare_digest(actual_secret, oidc_secret) + ) + actual_secret = None + provider = None + if secret_matches is not True: + _fail("forge_reader_provider_secret_readback_mismatch") + + for scope, mapping_id in context["scopeMappingIds"].items(): + mapping = _reader_json( + connector, + reader_token, + f"/propertymappings/provider/scope/{quote(mapping_id, safe='')}/", + ) + if not _forge_oidc_scope_mapping_matches(mapping, mapping_id=mapping_id, scope=scope): + _fail("forge_reader_scope_mapping_visibility_mismatch") + + visible_groups = _paged( + lambda path, params=None: _reader_json(connector, reader_token, path, params=params), + "/core/groups/", + _GROUP_LIST_PARAMS, + ) + visible_by_id = {_uuid(_remote_id(group)): group for group in visible_groups} + if len(visible_by_id) != 3 or set(visible_by_id) != set(context["groupIds"].values()): + _fail("forge_reader_group_visibility_mismatch") + for name in FORGE_OIDC_GROUPS: + group_id = context["groupIds"][name] + group = visible_by_id[group_id] + if ( + group.get("name") != name + or not _forge_oidc_group_contract_matches(group, require_users=True) + or group.get("inherited_roles_obj") != [] + ): + _fail("forge_reader_group_readback_mismatch") + + key_id = context["signingKeyId"] + key = _reader_json( + connector, + reader_token, + f"/crypto/certificatekeypairs/{quote(key_id, safe='')}/", + ) + if ( + not isinstance(key, dict) + or _uuid(_remote_id(key)) != key_id + or str(key.get("key_type") or "").lower() != "rsa" + or key.get("private_key_available") is not True + ): + _fail("forge_reader_signing_key_readback_mismatch") + cert_response = _reader_json( + connector, + reader_token, + f"/crypto/certificatekeypairs/{quote(key_id, safe='')}/view_certificate/", + ) + certificate_data = cert_response.get("data") if isinstance(cert_response, dict) else None + if not isinstance(certificate_data, str): + _fail("forge_reader_public_certificate_readback_invalid") + try: + certificate = x509.load_pem_x509_certificate(certificate_data.encode("ascii")) + public_key = certificate.public_key() + except (ValueError, TypeError, UnicodeError): + _fail("forge_reader_public_certificate_readback_invalid") + if not isinstance(public_key, rsa.RSAPublicKey): + _fail("forge_reader_signing_key_not_rsa") + numbers = public_key.public_numbers() + + discovery = _public_json(FORGE_OIDC_DISCOVERY_URL) + if ( + not isinstance(discovery, dict) + or discovery.get("issuer") != FORGE_OIDC_ISSUER + or "code" not in discovery.get("response_types_supported", []) + or "authorization_code" not in discovery.get("grant_types_supported", []) + or not {"openid", "email", "profile"}.issubset(set(discovery.get("scopes_supported", []))) + or "RS256" not in discovery.get("id_token_signing_alg_values_supported", []) + or discovery.get("jwks_uri") != f"{FORGE_OIDC_ISSUER}jwks/" + ): + _fail("forge_reader_discovery_contract_mismatch") + jwks_url = discovery["jwks_uri"] + jwks = _public_json(jwks_url) + keys = jwks.get("keys") if isinstance(jwks, dict) else None + if not isinstance(keys, list): + _fail("forge_reader_jwks_invalid") + matches = [ + item + for item in keys + if isinstance(item, dict) + and item.get("kty") == "RSA" + and item.get("alg") == "RS256" + and item.get("use") == "sig" + and _b64url_int(item.get("n")) == numbers.n + and _b64url_int(item.get("e")) == numbers.e + ] + if len(matches) != 1: + _fail("forge_reader_jwks_signing_key_mismatch") + certificate_data = None + public_key = None + certificate = None + jwks = None + discovery = None + + +@dataclass(frozen=True, slots=True, repr=False) +class _VerifiedForgeDeliveryInputs: + """Held delivery inputs; deliberately cannot print secret-bearing fields.""" + + context: dict[str, Any] + oidc_client_secret: bytes = field(repr=False) + authentik_read_token: bytes = field(repr=False) + + +def _verify_forge_delivery_inputs( + session: Session, + connector: Any, +) -> _VerifiedForgeDeliveryInputs: + """Revalidate the complete fixed Forge reader contract without writes.""" + if not has_secret(session, FORGE_OIDC_BINDING_SECRET_REF): + _fail("forge_reader_oidc_binding_required", status="blocked") + if not has_secret(session, FORGE_READER_BINDING_REF): + _fail("forge_reader_binding_required", status="blocked") + if not has_secret(session, FORGE_READER_TOKEN_REF): + _fail("forge_reader_token_required", status="blocked") + + raw_journal = get_secret(session, FORGE_READER_BINDING_REF, touch=False) + try: + journal = json.loads(raw_journal, object_pairs_hook=_unique_object) + except (TypeError, ValueError, UnicodeError): + _fail("forge_reader_binding_invalid") + if not _valid_journal(journal): + _fail("forge_reader_binding_invalid") + if not hmac.compare_digest(raw_journal, _journal_bytes(journal)): + _fail("forge_reader_binding_not_canonical") + if ( + journal["phase"] != "ready" + or journal["intent"] is not None + or journal["permissionIndex"] is not None + or not journal["userId"] + or not journal["roleId"] + or not journal["tokenId"] + ): + _fail("forge_reader_binding_not_ready", status="blocked") + + context, oidc_secret = _oidc_resource_context(session, connector) + manifest = _manifest(context) + if journal["resourceContext"] != context or journal["permissionManifest"] != manifest: + _fail("forge_reader_binding_resource_mismatch") + + role_id = journal["roleId"] + user_id = journal["userId"] + role_rows = _role_list(connector, role_id=role_id) + if ( + len(role_rows) != 1 + or role_rows[0].get("name") != FORGE_READER_ROLE + or _uuid(_remote_id(role_rows[0])) != role_id + ): + _fail("forge_reader_role_binding_mismatch") + + expected_permissions = _manifest_keys(manifest) + actual_permissions = _role_object_permission_keys( + _role_object_permissions(connector, role_id) + ) + if actual_permissions != expected_permissions: + _fail("forge_reader_permissions_incomplete") + if _global_permissions(connector, role_id): + _fail("forge_reader_global_permission_conflict") + + members = _role_users(connector, role_id) + member_ids = [_positive_id(_remote_id(item)) for item in members] + if member_ids != [user_id]: + _fail("forge_reader_role_membership_readback_conflict") + all_groups = _paged( + lambda path, params=None: _admin_json(connector, "GET", path, params=params), + "/core/groups/", + _GROUP_LIST_PARAMS, + ) + if _group_has_role(all_groups, role_id): + _fail("forge_reader_role_assigned_to_group") + + users = _user_matches_by_username(connector) + if len(users) != 1: + _fail("forge_reader_user_ambiguous") + user = _user_detail(connector, user_id) + if _validate_existing_user(user, journal=journal, role_id=role_id) != user_id: + _fail("forge_reader_user_readback_mismatch") + if _positive_id(_remote_id(users[0])) != user_id: + _fail("forge_reader_user_binding_mismatch") + user_roles = _user_roles(connector, user_id) + user_role_ids = [_uuid(_remote_id(item)) for item in user_roles] + if user_role_ids != [role_id]: + _fail("forge_reader_user_role_conflict") + + tokens = [ + item for item in _token_metadata(connector) + if item.get("identifier") == FORGE_READER_TOKEN_ID + ] + if len(tokens) != 1: + _fail("forge_reader_token_ambiguous") + if _validate_token_metadata(tokens[0], user_id=user_id, journal=journal) != journal["tokenId"]: + _fail("forge_reader_token_binding_mismatch") + + reader_token = _reader_secret(session) + if reader_token is None: + _fail("forge_reader_token_required", status="blocked") + admin_config = connector._config() + admin_token = getattr(admin_config, "token", None) if admin_config is not None else None + if isinstance(admin_token, str) and hmac.compare_digest(reader_token, admin_token): + _fail("forge_reader_administrative_token_reuse") + if ( + not isinstance(oidc_secret, str) + or not oidc_secret.isascii() + or not oidc_secret.isprintable() + or not reader_token.isascii() + ): + _fail("forge_reader_delivery_input_invalid") + + _verify_public_reader( + connector, + reader_token, + oidc_secret, + context, + role_id, + user_id, + ) + return _VerifiedForgeDeliveryInputs( + context=dict(context), + oidc_client_secret=oidc_secret.encode("ascii"), + authentik_read_token=reader_token.encode("ascii"), + ) + + +def _preflight_collisions( + connector: Any, + journal: dict[str, Any], + role_id: str | None, + existing_token: str | None, +) -> None: + users = _user_matches_by_username(connector) + if len(users) > 1: + _fail("forge_reader_user_ambiguous") + if users: + if role_id is None: + role_id = journal.get("roleId") + if role_id is None: + _fail("forge_reader_existing_user_conflict") + _validate_existing_user( + users[0], + journal=journal, + role_id=role_id, + ) + token_rows = _token_metadata(connector) + token_rows = [row for row in token_rows if row.get("identifier") == FORGE_READER_TOKEN_ID] + if len(token_rows) > 1: + _fail("forge_reader_token_ambiguous") + if token_rows and ( + journal["phase"] not in {"token", "ready"} + or (journal["intent"] != "token" and journal["phase"] != "ready") + ): + _fail("forge_reader_unbound_token_conflict") + if token_rows: + if journal["userId"] is None: + _fail("forge_reader_unbound_token_conflict") + if len(token_rows) != 1: + _fail("forge_reader_token_ambiguous") + token_id = _validate_token_metadata(token_rows[0], user_id=journal["userId"], journal=journal) + if journal["tokenId"] is not None and journal["tokenId"] != token_id: + _fail("forge_reader_token_binding_mismatch") + elif existing_token is not None or journal["tokenId"] is not None: + _fail("forge_reader_secret_without_remote_token") + + +def _provision_locked(session: Session, connector: Any) -> dict[str, Any]: + context, oidc_secret = _oidc_resource_context(session, connector) + manifest = _manifest(context) + journal = _load_or_create_journal(session, context, manifest) + existing_token = _reader_secret(session) + role_id = journal["roleId"] + _preflight_collisions(connector, journal, role_id, existing_token) + role_id = _resolve_role(session, connector, journal) + _ensure_role_permissions(session, connector, journal, manifest) + user_id = _ensure_user(session, connector, journal, role_id) + _ensure_membership(session, connector, journal, user_id, role_id) + admin_token = str(connector._config().token) + reader_token = _ensure_token(session, connector, journal, user_id, admin_token) + _verify_public_reader(connector, reader_token, oidc_secret, context, role_id, user_id) + _journal_store(session, journal, phase="ready", intent=None, permission_index=None, token_id=journal["tokenId"]) + return _result("ready", "forge_reader_ready", ready=True) + + +def _result(status: str, message: str, *, ready: bool = False) -> dict[str, Any]: + return { + "success": ready, + "status": status, + "message": message, + "ready": ready, + "secretRef": FORGE_READER_TOKEN_REF, + } + + +def _audit_result(session: Session, identity: str, outcome: dict[str, Any]) -> bool: + try: + log_event( + session, + source="manual", + identity=identity, + action_id=FORGE_OIDC_ACTION, + target=FORGE_OIDC_TARGET, + target_type="authentik_service_account", + risk="critical", + policy_decision="allowed" if outcome["status"] != "blocked" else "blocked", + reason=outcome["message"], + result=outcome["status"], + redacted_metadata={"reader": FORGE_READER_USERNAME, "secretRef": FORGE_READER_TOKEN_REF}, + ) + return True + except Exception: # noqa: BLE001 - audit failures cannot expose credentials or remote bodies. + session.rollback() + return False + + +def provision_forge_controller_reader( + session: Session, + *, + actor_identity: str, + authentik: Any | None = None, +) -> dict[str, Any]: + """Provision and verify the one fixed, read-only Forge Authentik identity.""" + identity = actor_identity.strip() if isinstance(actor_identity, str) else "" + if not identity: + outcome = _result("blocked", "forge_reader_operator_identity_required") + else: + try: + with _forge_oidc_process_lock(): + if not _exact_policy_allowed(session): + outcome = _result("blocked", "forge_reader_exact_target_policy_required") + elif secret_storage_status().get("productionReady") is not True: + outcome = _result("blocked", "forge_reader_secret_store_not_production_ready") + else: + if authentik is None: + from .setup_service import apply_stored_connector_settings + + apply_stored_connector_settings(session, {"authentik"}) + outcome = _provision_locked(session, authentik or AuthentikConnector()) + except _ReaderFailure as exc: + outcome = _result(exc.status, exc.code) + except Exception: # noqa: BLE001 - fail closed without remote details or credentials. + outcome = _result("failed", "forge_reader_provisioning_failed") + if not _audit_result(session, identity or "system", outcome) and outcome["ready"] is True: + return _result("partial_failure", "forge_reader_audit_persist_failed") + return outcome diff --git a/backend/app/services/forge_oidc_provisioning.py b/backend/app/services/forge_oidc_provisioning.py new file mode 100644 index 0000000..ad2c4d7 --- /dev/null +++ b/backend/app/services/forge_oidc_provisioning.py @@ -0,0 +1,834 @@ +from __future__ import annotations + +import json +import os +import secrets +import stat +import time +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from threading import Lock +from typing import Any + +from sqlmodel import Session, select + +from ..connectors.authentik import AuthentikConnector +from ..core.config import settings +from ..db.models import Policy +from ..schemas.forge_oidc import ( + FORGE_OIDC_ACTION, + FORGE_OIDC_BINDING_SECRET_REF, + FORGE_OIDC_CLIENT_ID, + FORGE_OIDC_SECRET_REF, + FORGE_OIDC_TARGET, + ForgeOidcBindingV1, + ForgeOidcProvisionResultV1, + ForgeOidcScopeMappingIdsV1, +) +from .audit_logger import log_event +from .policy_engine import evaluate_policy +from .secret_store import get_secret, has_secret, secret_storage_status, set_secret + +_SECRET_LOCK = Lock() +_PROVISION_LOCK_FILENAME = ".forge-oidc-provision.lock" +_PROVISION_LOCK_TIMEOUT_SECONDS = 30.0 +_PROVISION_LOCK_POLL_INTERVAL_SECONDS = 0.05 +_FORGE_OIDC_LOCK_ROOT = Path("/run/itworx-appops/forge-oidc-provision") + + +class _ProvisionLockUnavailable(RuntimeError): + pass + + +def _is_reparse_point(stat_result: os.stat_result) -> bool: + attributes = getattr(stat_result, "st_file_attributes", 0) + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return bool(attributes & reparse_flag) + + +def _windows_current_user_sid() -> bytes: + """Return the effective Windows token user SID without shelling out.""" + import ctypes + from ctypes import wintypes + + advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)] + advapi32.OpenProcessToken.restype = wintypes.BOOL + advapi32.GetTokenInformation.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ] + advapi32.GetTokenInformation.restype = wintypes.BOOL + advapi32.GetLengthSid.argtypes = [ctypes.c_void_p] + advapi32.GetLengthSid.restype = wintypes.DWORD + kernel32.GetCurrentProcess.argtypes = [] + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + token = wintypes.HANDLE() + if not advapi32.OpenProcessToken( + kernel32.GetCurrentProcess(), 0x0008, ctypes.byref(token) + ): + raise OSError(ctypes.get_last_error(), "OpenProcessToken failed") + try: + required = wintypes.DWORD() + advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(required)) + if not required.value: + raise OSError(ctypes.get_last_error(), "GetTokenInformation failed") + token_user = ctypes.create_string_buffer(required.value) + if not advapi32.GetTokenInformation( + token, 1, token_user, required.value, ctypes.byref(required) + ): + raise OSError(ctypes.get_last_error(), "GetTokenInformation failed") + sid = ctypes.cast(token_user, ctypes.POINTER(ctypes.c_void_p)).contents.value + sid_length = advapi32.GetLengthSid(ctypes.c_void_p(sid)) + if not sid or not sid_length: + raise OSError("invalid current-user SID") + return ctypes.string_at(sid, sid_length) + finally: + kernel32.CloseHandle(token) + + +def _windows_private_path(path: Path, *, require_current_owner: bool) -> None: + """Reject Windows paths whose owner/DACL lets an untrusted principal mutate them.""" + import ctypes + from ctypes import wintypes + + advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + advapi32.GetNamedSecurityInfoW.argtypes = [ + wintypes.LPWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ] + advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD + advapi32.GetLengthSid.argtypes = [ctypes.c_void_p] + advapi32.GetLengthSid.restype = wintypes.DWORD + advapi32.ConvertStringSidToSidW.argtypes = [wintypes.LPWSTR, ctypes.POINTER(ctypes.c_void_p)] + advapi32.ConvertStringSidToSidW.restype = wintypes.BOOL + advapi32.GetAclInformation.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + ] + advapi32.GetAclInformation.restype = wintypes.BOOL + advapi32.GetAce.argtypes = [ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)] + advapi32.GetAce.restype = wintypes.BOOL + kernel32.LocalFree.argtypes = [ctypes.c_void_p] + kernel32.LocalFree.restype = ctypes.c_void_p + owner = ctypes.c_void_p() + dacl = ctypes.c_void_p() + descriptor = ctypes.c_void_p() + # SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION + result = advapi32.GetNamedSecurityInfoW( + str(path), + 1, + 0x1 | 0x4, + ctypes.byref(owner), + None, + ctypes.byref(dacl), + None, + ctypes.byref(descriptor), + ) + if result != 0 or not owner.value or not dacl.value: + raise OSError(int(result or ctypes.get_last_error()), "security descriptor unavailable") + try: + current_sid = _windows_current_user_sid() + sid_size = advapi32.GetLengthSid(owner) + owner_sid = ctypes.string_at(owner.value, sid_size) + if require_current_owner and owner_sid != current_sid: + raise OSError("path owner is not the current user") + + # SYSTEM and local Administrators are trusted operating-system principals. + trusted_sids = {current_sid} + for sid_text in ( + "S-1-5-18", # LocalSystem + "S-1-5-32-544", # Builtin Administrators + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", # TrustedInstaller + ): + sid_buffer = ctypes.c_void_p() + if not advapi32.ConvertStringSidToSidW(sid_text, ctypes.byref(sid_buffer)): + raise OSError(ctypes.get_last_error(), "trusted SID unavailable") + try: + sid_size = advapi32.GetLengthSid(sid_buffer) + trusted_sids.add(ctypes.string_at(sid_buffer.value, sid_size)) + finally: + kernel32.LocalFree(sid_buffer) + + # Reject any explicit or inherited allow ACE outside the trusted set that + # can create/replace/delete this path or write to a lock file. + class _AclSizeInformation(ctypes.Structure): + _fields_ = [ + ("AceCount", wintypes.DWORD), + ("AclBytesInUse", wintypes.DWORD), + ("AclBytesFree", wintypes.DWORD), + ] + + acl_info = _AclSizeInformation() + if not advapi32.GetAclInformation( + dacl, + ctypes.byref(acl_info), + ctypes.sizeof(acl_info), + 2, + ): + raise OSError(ctypes.get_last_error(), "ACL unavailable") + + write_rights = ( + 0x00000002 # FILE_WRITE_DATA / FILE_ADD_FILE + | 0x00000004 # FILE_APPEND_DATA / FILE_ADD_SUBDIRECTORY + | 0x00000010 # FILE_WRITE_EA + | 0x00000040 # FILE_DELETE_CHILD + | 0x00000100 # FILE_WRITE_ATTRIBUTES + | 0x00010000 # DELETE + | 0x00040000 # WRITE_DAC + | 0x00080000 # WRITE_OWNER + | 0x10000000 # GENERIC_ALL + | 0x40000000 # GENERIC_WRITE + ) + for index in range(acl_info.AceCount): + ace = ctypes.c_void_p() + if not advapi32.GetAce(dacl, index, ctypes.byref(ace)): + raise OSError(ctypes.get_last_error(), "ACL entry unavailable") + header = ctypes.cast(ace, ctypes.POINTER(ctypes.c_ubyte * 2)).contents + ace_type = int(header[0]) + if ace_type != 0: # ACCESS_ALLOWED_ACE + if ace_type not in {1, 2, 3, 6, 7, 8, 10, 12, 13, 14}: + # Deny and audit entries do not grant rights. Unknown ACEs + # could grant access and therefore make this path untrusted. + raise OSError("unsupported ACL entry") + continue + mask = ctypes.cast( + ctypes.c_void_p(ace.value + 4), ctypes.POINTER(wintypes.DWORD) + ).contents.value + sid_address = ace.value + 8 + sid_size = advapi32.GetLengthSid(ctypes.c_void_p(sid_address)) + ace_sid = ctypes.string_at(sid_address, sid_size) + if mask & write_rights and ace_sid not in trusted_sids: + raise OSError("path ACL grants write access to an untrusted principal") + finally: + if descriptor.value: + kernel32.LocalFree(descriptor) + + +def _lock_root() -> Path: + # Linux containers share this private runtime path among their worker + # processes. Windows keeps the equivalent lock beside its protected data. + return Path(settings.data_dir) if os.name == "nt" else _FORGE_OIDC_LOCK_ROOT + + +def _validate_lock_data_dir(data_dir: Path) -> None: + if not data_dir.is_absolute(): + raise OSError("lock directory must be absolute") + + # Inspect each existing path component without following symlinks/reparse points. + components = [Path(data_dir.anchor)] + for part in data_dir.parts[1:]: + components.append(components[-1] / part) + private_components = {data_dir, data_dir.parent} + for index, component in enumerate(components): + try: + component_stat = os.lstat(component) + except FileNotFoundError: + if os.name == "nt" or component not in private_components: + raise OSError("lock directory path is missing") from None + try: + os.mkdir(component, 0o700) + except FileExistsError: + # Another worker may have created the same fixed private child; + # validate its metadata below before using it. + pass + component_stat = os.lstat(component) + if _is_reparse_point(component_stat) or not stat.S_ISDIR(component_stat.st_mode): + raise OSError("lock directory contains an unsafe path component") + is_private_component = component in private_components + if os.name == "nt": + _windows_private_path(component, require_current_owner=component == data_dir) + continue + + effective_uid = os.geteuid() + if component_stat.st_uid not in {effective_uid, 0}: + raise OSError("lock directory path has an untrusted owner") + mode = stat.S_IMODE(component_stat.st_mode) + if is_private_component: + if component_stat.st_uid != effective_uid or mode != 0o700: + raise OSError("lock directory is not private to its owner") + elif mode & 0o022 and not ( + component_stat.st_uid == 0 + and mode & stat.S_ISVTX + and mode & 0o002 + ): + # Root-owned sticky directories such as /tmp safely contain private, + # owner-checked children; other writable ancestors are not trusted. + raise OSError("lock directory has a writable untrusted ancestor") + + +def _validate_lock_file(lock_fd: int, lock_path: Path) -> None: + opened = os.fstat(lock_fd) + named = os.lstat(lock_path) + if ( + _is_reparse_point(named) + or not stat.S_ISREG(opened.st_mode) + or not stat.S_ISREG(named.st_mode) + or not os.path.samestat(opened, named) + or opened.st_nlink != 1 + or opened.st_size > 1 + ): + raise OSError("lock file is not a private regular file") + if os.name == "nt": + _windows_private_path(lock_path, require_current_owner=True) + return + if opened.st_uid != os.geteuid() or stat.S_IMODE(opened.st_mode) & 0o077: + raise OSError("lock file is not private to its owner") + + +@contextmanager +def _forge_oidc_process_lock() -> Iterator[None]: + """Serialize provisioning across workers in one AppOps runtime.""" + deadline = time.monotonic() + _PROVISION_LOCK_TIMEOUT_SECONDS + thread_locked = _SECRET_LOCK.acquire( + timeout=max(0.0, deadline - time.monotonic()) + ) + if not thread_locked: + raise _ProvisionLockUnavailable from None + lock_fd: int | None = None + locked = False + try: + try: + lock_root = _lock_root() + _validate_lock_data_dir(lock_root) + lock_path = lock_root / _PROVISION_LOCK_FILENAME + flags = os.O_RDWR + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + try: + lock_fd = os.open(str(lock_path), flags | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + lock_fd = os.open(str(lock_path), flags) + _validate_lock_file(lock_fd, lock_path) + if os.name == "nt": + import msvcrt + + if os.fstat(lock_fd).st_size == 0: + os.write(lock_fd, b"\0") + _validate_lock_file(lock_fd, lock_path) + while True: + os.lseek(lock_fd, 0, os.SEEK_SET) + try: + msvcrt.locking(lock_fd, msvcrt.LK_NBLCK, 1) + break + except OSError: + if time.monotonic() >= deadline: + raise TimeoutError("provision_lock_timeout") from None + time.sleep( + min( + _PROVISION_LOCK_POLL_INTERVAL_SECONDS, + max(0.0, deadline - time.monotonic()), + ) + ) + else: + import fcntl + + while True: + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise TimeoutError("provision_lock_timeout") from None + time.sleep( + min( + _PROVISION_LOCK_POLL_INTERVAL_SECONDS, + max(0.0, deadline - time.monotonic()), + ) + ) + locked = True + except Exception: # noqa: BLE001 - all lock setup errors fail closed. + # Keep platform-specific path/ACL errors internal and fail closed + # with the stable service error emitted by the caller. + if lock_fd is not None: + try: + os.close(lock_fd) + except OSError: + pass + lock_fd = None + raise _ProvisionLockUnavailable from None + + yield + finally: + if lock_fd is not None: + if locked: + try: + if os.name == "nt": + import msvcrt + + os.lseek(lock_fd, 0, os.SEEK_SET) + msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_fd, fcntl.LOCK_UN) + except OSError: + # Closing the descriptor also releases its OS lock. + pass + try: + os.close(lock_fd) + except OSError: + pass + if thread_locked: + _SECRET_LOCK.release() + + +def _new_client_secret() -> str: + return secrets.token_urlsafe(48) + + +def _result( + *, + status: str, + message: str, + success: bool = False, + secret_created: bool = False, + application_id: str | None = None, + provider_id: str | None = None, +) -> dict[str, Any]: + return ForgeOidcProvisionResultV1( + success=success, + status=status, + message=message, + secret_created=secret_created, + application_id=application_id, + provider_id=provider_id, + ).model_dump(mode="json", by_alias=True) + + +def _exact_policy_allowed(session: Session) -> bool: + rows = session.exec( + select(Policy).where( + (Policy.action == FORGE_OIDC_ACTION) + & (Policy.target == FORGE_OIDC_TARGET) + ) + ).all() + if len(rows) != 1: + return False + policy = rows[0] + if policy.manual != "allowed" or policy.voice != "blocked" or policy.risk != "critical": + return False + decision = evaluate_policy( + session, + action=FORGE_OIDC_ACTION, + target=FORGE_OIDC_TARGET, + source="manual", + ) + return ( + decision.get("decision") == "allowed" + and decision.get("scope_decision") == "allowed" + and decision.get("policy_id") == policy.id + ) + + +def _safe_connector_id(value: Any) -> str | None: + candidate = str(value or "") + if not candidate or len(candidate) > 128: + return None + if not all(character.isalnum() or character in "._:-" for character in candidate): + return None + return candidate + + +def _audit( + session: Session, + *, + identity: str, + decision: str, + reason: str, + result: str, + secret_created: bool = False, +) -> None: + log_event( + session, + source="manual", + identity=identity, + action_id=FORGE_OIDC_ACTION, + target=FORGE_OIDC_TARGET, + target_type="authentik_application", + risk="critical", + policy_decision=decision, + reason=reason, + result=result, + redacted_metadata={ + "clientId": FORGE_OIDC_CLIENT_ID, + "secretRef": FORGE_OIDC_SECRET_REF, + "secretCreated": secret_created, + }, + ) + + +def _blocked( + session: Session, + *, + identity: str, + message: str, + status: str = "blocked", + decision: str = "blocked", + secret_created: bool = False, +) -> dict[str, Any]: + _audit( + session, + identity=identity, + decision=decision, + reason=message, + result=status, + secret_created=secret_created, + ) + return _result(status=status, message=message, secret_created=secret_created) + + +def _existing_secret(session: Session) -> str | None: + if not has_secret(session, FORGE_OIDC_SECRET_REF): + return None + return get_secret(session, FORGE_OIDC_SECRET_REF, touch=False) + + +def _persist_forge_oidc_binding( + session: Session, + *, + scope_mapping_ids: Any, + provider_id: str | None = None, +) -> bool: + """Persist one validated mapping tuple while preserving a known provider ID.""" + from .setup_service import _apply_forge_oidc_binding_to_runtime + + try: + mappings = ForgeOidcScopeMappingIdsV1.model_validate(scope_mapping_ids) + except Exception: # noqa: BLE001 - invalid binding data is never serialized. + return False + + binding_exists = has_secret(session, FORGE_OIDC_BINDING_SECRET_REF) + binding_valid = _apply_forge_oidc_binding_to_runtime(session) + if binding_exists and not binding_valid: + return False + + current_ids = { + "openid": settings.apis.get("AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING"), + "email": settings.apis.get("AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING"), + "profile": settings.apis.get("AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING"), + } + new_ids = {key: str(getattr(mappings, key)) for key in ("openid", "email", "profile")} + if binding_valid and current_ids != new_ids: + return False + + current_provider_id = settings.apis.get("AUTHENTIK_FORGE_OIDC_PROVIDER_ID") if binding_valid else None + if current_provider_id is not None and provider_id is not None and current_provider_id != provider_id: + return False + selected_provider_id = provider_id if provider_id is not None else current_provider_id + try: + binding = ForgeOidcBindingV1( + version=1, + providerId=selected_provider_id, + scopeMappingIds=new_ids, + ) + set_secret( + session, + FORGE_OIDC_BINDING_SECRET_REF, + json.dumps(binding.model_dump(mode="json", by_alias=True), separators=(",", ":"), sort_keys=True), + used_by=["authentik:itworx-forge"], + metadata={"managedBy": "appops-forge-oidc-binding/v1"}, + ) + except Exception: # noqa: BLE001 - storage and serialization errors stay private. + session.rollback() + return False + return _apply_forge_oidc_binding_to_runtime(session) + + +def _connector_outcome( + session: Session, + *, + identity: str, + outcome: Any, + secret_created: bool, +) -> dict[str, Any]: + if not isinstance(outcome, dict): + status, message = "failed", "forge_oidc_connector_result_invalid" + success = False + else: + raw_status = outcome.get("status") + mappings = { + "created": ("created", "forge_oidc_created", True), + "already_configured": ("already_configured", "forge_oidc_already_configured", True), + "blocked": ("blocked", "forge_oidc_connector_blocked", False), + "conflict": ("conflict", "forge_oidc_resource_conflict", False), + "failed": ("failed", "forge_oidc_provisioning_failed", False), + "partial_failure": ("partial_failure", "forge_oidc_partial_failure", False), + } + status, message, success = mappings.get( + raw_status, + ("failed", "forge_oidc_connector_result_invalid", False), + ) + if outcome.get("success") is not True: + success = False + elif status not in {"created", "already_configured"}: + status, message, success = "failed", "forge_oidc_connector_result_invalid", False + + application_id = _safe_connector_id(outcome.get("applicationId")) if isinstance(outcome, dict) else None + provider_id = _safe_connector_id(outcome.get("providerId")) if isinstance(outcome, dict) else None + audit_result = "success" if success else status + _audit( + session, + identity=identity, + decision="allowed", + reason=message, + result=audit_result, + secret_created=secret_created, + ) + return _result( + status=status, + message=message, + success=success, + secret_created=secret_created, + application_id=application_id, + provider_id=provider_id, + ) + + +def provision_forge_oidc( + session: Session, + *, + actor_identity: str, + authentik: Any | None = None, +) -> dict[str, Any]: + """Provision only the fixed Forge OIDC client after exact-target governance.""" + identity = actor_identity.strip() if isinstance(actor_identity, str) else "" + if not identity: + return _blocked( + session, + identity="system", + message="forge_oidc_operator_identity_required", + ) + + try: + with _forge_oidc_process_lock(): + # Re-evaluate governance after entering the runtime lock so concurrent + # Forge provisioning attempts cannot interleave preflight and writes. + if not _exact_policy_allowed(session): + return _blocked( + session, + identity=identity, + message="forge_oidc_exact_target_policy_required", + ) + if secret_storage_status().get("productionReady") is not True: + return _blocked( + session, + identity=identity, + message="forge_oidc_secret_store_not_production_ready", + ) + return _provision_forge_oidc_locked( + session, + identity=identity, + authentik=authentik, + ) + except _ProvisionLockUnavailable: + return _blocked( + session, + identity=identity, + message="forge_oidc_provision_lock_unavailable", + status="failed", + ) + + +def _provision_forge_oidc_locked( + session: Session, + *, + identity: str, + authentik: Any | None, +) -> dict[str, Any]: + if authentik is None: + try: + from .setup_service import apply_stored_connector_settings + + apply_stored_connector_settings(session, {"authentik"}) + except Exception: # noqa: BLE001 - never expose connector or stored-secret errors. + return _blocked( + session, + identity=identity, + message="forge_oidc_connector_configuration_unavailable", + status="failed", + decision="allowed", + ) + connector = authentik or AuthentikConnector() + + # A present but unreadable or tampered binding must never be repaired from + # live discovery: its IDs are protected state, not caller-selected inputs. + from .setup_service import _apply_forge_oidc_binding_to_runtime + + binding_exists = has_secret(session, FORGE_OIDC_BINDING_SECRET_REF) + binding_valid = _apply_forge_oidc_binding_to_runtime(session) + if binding_exists and not binding_valid: + return _blocked( + session, + identity=identity, + message="forge_oidc_binding_invalid", + status="conflict", + decision="allowed", + ) + + secret_created = False + client_secret = _existing_secret(session) + if client_secret is None and has_secret(session, FORGE_OIDC_SECRET_REF): + return _blocked( + session, + identity=identity, + message="forge_oidc_existing_client_secret_unavailable", + status="conflict", + decision="allowed", + ) + if settings.apis.get("AUTHENTIK_FORGE_OIDC_PROVIDER_ID") is not None and client_secret is None: + return _blocked( + session, + identity=identity, + message="forge_oidc_existing_client_secret_missing", + status="conflict", + decision="allowed", + ) + + try: + prerequisites = connector.ensure_forge_oidc_prerequisites( + expected_client_secret=client_secret, + ) + except Exception: # noqa: BLE001 - remote connector errors are returned only as a safe status. + prerequisites = {"success": False, "status": "failed"} + if not isinstance(prerequisites, dict) or prerequisites.get("success") is not True: + return _connector_outcome( + session, + identity=identity, + outcome=prerequisites, + secret_created=False, + ) + + mapping_ids = prerequisites.get("scopeMappingIds") + try: + validated_mapping_ids = ForgeOidcScopeMappingIdsV1.model_validate(mapping_ids) + except Exception: # noqa: BLE001 - connector output is treated as untrusted. + return _connector_outcome( + session, + identity=identity, + outcome={"success": False, "status": "conflict", "message": "forge_oidc_scope_mapping_id_invalid"}, + secret_created=False, + ) + serialized_mapping_ids = { + key: str(getattr(validated_mapping_ids, key)) + for key in ("openid", "email", "profile") + } + if not _persist_forge_oidc_binding(session, scope_mapping_ids=serialized_mapping_ids): + return _connector_outcome( + session, + identity=identity, + outcome={ + "success": False, + "status": "partial_failure", + "message": "forge_oidc_binding_persist_failed", + }, + secret_created=False, + ) + + try: + preflight = connector.inspect_forge_oidc_application( + expected_client_secret=client_secret, + ) + except Exception: # noqa: BLE001 - remote connector errors are returned only as a safe status. + preflight = {"success": False, "status": "failed"} + if not isinstance(preflight, dict) or preflight.get("success") is not True: + return _connector_outcome( + session, + identity=identity, + outcome=preflight, + secret_created=False, + ) + if preflight.get("status") not in {"ready", "provider_orphan", "already_configured"}: + return _connector_outcome( + session, + identity=identity, + outcome={"success": False, "status": "failed"}, + secret_created=False, + ) + + if preflight.get("status") != "ready" and client_secret is None: + return _blocked( + session, + identity=identity, + message="forge_oidc_existing_client_secret_missing", + status="conflict", + decision="allowed", + ) + if client_secret is None: + client_secret = _new_client_secret() + if not 32 <= len(client_secret) <= 255 or not client_secret.isascii() or not client_secret.isprintable(): + return _blocked( + session, + identity=identity, + message="forge_oidc_client_secret_generation_failed", + status="failed", + decision="allowed", + ) + try: + set_secret( + session, + FORGE_OIDC_SECRET_REF, + client_secret, + used_by=["authentik:itworx-forge"], + metadata={"managedBy": "appops-forge-oidc/v1"}, + ) + except Exception: # noqa: BLE001 - secret-store errors are intentionally sanitized. + session.rollback() + return _blocked( + session, + identity=identity, + message="forge_oidc_client_secret_store_failed", + status="failed", + decision="allowed", + ) + secret_created = True + elif ( + not isinstance(client_secret, str) + or not 32 <= len(client_secret) <= 255 + or not client_secret.isascii() + or not client_secret.isprintable() + ): + return _blocked( + session, + identity=identity, + message="forge_oidc_existing_client_secret_unavailable", + status="conflict", + decision="allowed", + ) + + try: + outcome = connector.ensure_forge_oidc_application( + client_secret=client_secret, + persist_provider_id=lambda provider_id: _persist_forge_oidc_binding( + session, + scope_mapping_ids=serialized_mapping_ids, + provider_id=provider_id, + ), + ) + except Exception: # noqa: BLE001 - no connector response/body crosses the route boundary. + outcome = {"success": False, "status": "failed"} + + return _connector_outcome( + session, + identity=identity, + outcome=outcome, + secret_created=secret_created, + ) diff --git a/backend/app/services/forge_secret_delivery.py b/backend/app/services/forge_secret_delivery.py new file mode 100644 index 0000000..b75a9fb --- /dev/null +++ b/backend/app/services/forge_secret_delivery.py @@ -0,0 +1,599 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import re +from typing import Any +from uuid import RFC_4122, UUID + +from sqlmodel import Session, select + +from ..connectors.authentik import AuthentikConnector +from ..core.config import settings +from ..db.models import AppSetting, Policy +from ..schemas.enrollment import MANAGED_DOCKER_APPDATA_ROOT +from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1 +from ..schemas.forge_oidc import FORGE_OIDC_ISSUER, FORGE_OIDC_LAUNCH_URL +from ..schemas.forge_secret_delivery import ( + ForgeSecretDeliveryControllerArtifactV1, + ForgeSecretDeliveryResultV1, +) +from .audit_logger import log_event +from .deployment_broker import DeploymentBrokerClient +from .enrollment_plane import EnrollmentPlane +from .forge_oidc_provisioning import _forge_oidc_process_lock +from .policy_engine import evaluate_policy +from .secret_store import get_secret, has_secret, secret_storage_status + +_ACTION = "forge.secrets.prepare" +_TARGET = "itworx-forge" +_BROKER_SECRET_REF = "FORGE_DEPLOYMENT_BROKER" +_FORGE_OIDC_BINDING_REF = "AUTHENTIK_FORGE_OIDC_BINDING" +_FORGE_OIDC_CLIENT_SECRET_REF = "ITWORX_FORGE_OIDC_CLIENT_SECRET" +_FORGE_READER_BINDING_REF = "AUTHENTIK_FORGE_READER_BINDING" +_FORGE_READER_TOKEN_REF = "ITWORX_FORGE_AUTHENTIK_READ_TOKEN" +_FLOW = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z") +_PROVIDER_ID = re.compile(r"[1-9][0-9]{0,9}\Z") +_UUID = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\Z" +) +_IDEMPOTENCY = re.compile(r"[A-Za-z0-9._:-]{8,128}\Z") +_CONFIG_KEYS = { + "schema", + "issuer", + "audience", + "client_id", + "authentik_api_url", + "launch_url", + "authorization_flow", + "invalidation_flow", + "authentik_openid_mapping_id", + "authentik_email_mapping_id", + "authentik_profile_mapping_id", + "authentik_provider_id", + "authentik_signing_key_id", + "private_package_owner", +} +_RESPONSE_KEYS = { + "apiVersion", + "target", + "status", + "profile", + "controllerArtifact", + "secretFilesReady", + "masterKeyBackupVerified", + "applyPreflight", +} +_ARTIFACT_KEYS = { + "controllerVersion", + "sourceRepository", + "sourceRevision", + "sourceTree", + "artifactSha256", +} + + +def _exact_secret_prepare_policy_allowed(session: Session) -> str | None: + rows = session.exec(select(Policy).where(Policy.action == _ACTION)).all() + if len(rows) != 1: + return None + policy = rows[0] + if ( + policy.target != _TARGET + or policy.manual != "allowed" + or policy.voice != "blocked" + or policy.risk != "critical" + ): + return None + decision = evaluate_policy( + session, + action=_ACTION, + target=_TARGET, + source="manual", + ) + if ( + decision.get("decision") != "allowed" + or decision.get("scope_decision") != "allowed" + or decision.get("policy_id") != policy.id + ): + return None + return policy.id + + +def _secret_store_ready() -> bool: + try: + status = secret_storage_status() + except Exception: # noqa: BLE001 - store diagnostics never enter delivery errors. + return False + return isinstance(status, dict) and status.get("productionReady") is True + + +def _canonical_uuid(value: Any) -> bool: + if not isinstance(value, str) or _UUID.fullmatch(value) is None: + return False + try: + parsed = UUID(value) + except (TypeError, ValueError, AttributeError): + return False + return str(parsed) == value and parsed.variant == RFC_4122 and parsed.version in range(1, 9) + + +def _protected_config(context: dict[str, Any]) -> bytes | None: + if not isinstance(context, dict) or set(context) != { + "applicationId", "providerId", "scopeMappingIds", "groupIds", "signingKeyId" + }: + return None + provider_id = context.get("providerId") + signing_key_id = context.get("signingKeyId") + mappings = context.get("scopeMappingIds") + if ( + _PROVIDER_ID.fullmatch(provider_id or "") is None + or not _canonical_uuid(signing_key_id) + or not isinstance(mappings, dict) + or set(mappings) != {"openid", "email", "profile"} + or any(not _canonical_uuid(item) for item in mappings.values()) + or len(set(mappings.values())) != 3 + ): + return None + if not _canonical_uuid(context.get("applicationId")): + return None + owner = _protected_forge_private_package_owner() + if owner is None: + return None + authorization_flow = settings.apis.get("AUTHENTIK_OIDC_AUTHORIZATION_FLOW") + invalidation_flow = settings.apis.get("AUTHENTIK_OIDC_INVALIDATION_FLOW") + if ( + not isinstance(authorization_flow, str) + or _FLOW.fullmatch(authorization_flow) is None + or not isinstance(invalidation_flow, str) + or _FLOW.fullmatch(invalidation_flow) is None + ): + return None + values = { + "schema": "itworx.forge-first-install-config/v4", + "issuer": FORGE_OIDC_ISSUER, + "audience": "itworx-forge", + "client_id": "itworx-forge", + "authentik_api_url": "https://auth.example-host.com", + "launch_url": FORGE_OIDC_LAUNCH_URL, + "authorization_flow": authorization_flow, + "invalidation_flow": invalidation_flow, + "authentik_openid_mapping_id": mappings["openid"], + "authentik_email_mapping_id": mappings["email"], + "authentik_profile_mapping_id": mappings["profile"], + "authentik_provider_id": provider_id, + "authentik_signing_key_id": signing_key_id, + "private_package_owner": owner, + } + if set(values) != _CONFIG_KEYS or any( + not isinstance(value, str) or not value or len(value) > 512 + for value in values.values() + ): + return None + encoded = json.dumps(values, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return encoded if len(encoded) <= 8192 else None + + +def _checked_controller_response( + value: object, + *, + profile: ForgeBootstrapPreflightV1, +) -> ForgeSecretDeliveryControllerArtifactV1 | None: + if not isinstance(value, dict) or set(value) != _RESPONSE_KEYS: + return None + if ( + value.get("apiVersion") != "itworx.forge-secret-delivery/v1" + or value.get("target") != _TARGET + or value.get("status") != "bundle_ready" + or value.get("profile") != profile.model_dump(mode="json") + or type(value.get("secretFilesReady")) is not int + or value.get("secretFilesReady") != 7 + or value.get("masterKeyBackupVerified") is not True + or value.get("applyPreflight") != "not_evaluated" + ): + return None + artifact = value.get("controllerArtifact") + if not isinstance(artifact, dict) or set(artifact) != _ARTIFACT_KEYS: + return None + try: + return ForgeSecretDeliveryControllerArtifactV1.model_validate(artifact) + except Exception: # noqa: BLE001 - controller response data is untrusted. + return None + + +def _result( + *, + profile: ForgeBootstrapPreflightV1, + status: str, + message: str, + artifact: ForgeSecretDeliveryControllerArtifactV1 | None = None, +) -> dict[str, Any]: + ready = status == "bundle_ready" + payload = ForgeSecretDeliveryResultV1( + success=ready, + status=status, + message=message, + target=_TARGET, + revision=profile.revision, + controllerArtifact=artifact, + secretFilesReady=7 if ready else None, + masterKeyBackupVerified=True if ready else None, + applyPreflight="not_evaluated" if ready else None, + ) + return payload.model_dump(mode="json", by_alias=True, exclude_none=True) + + +def _audit( + session: Session, + *, + identity: str, + decision: str, + result: dict[str, Any], + policy_id: str | None, + idempotency_key: str, +) -> bool: + try: + artifact = result.get("controllerArtifact") + log_event( + session, + source="manual", + identity=identity, + action_id=_ACTION, + target=_TARGET, + target_type="forge_secret_delivery", + risk="critical", + policy_decision=decision, + reason=result["message"], + result=result["status"], + after_state={ + "status": result["status"], + "revision": result["revision"], + "secretFilesReady": result.get("secretFilesReady"), + "masterKeyBackupVerified": result.get("masterKeyBackupVerified"), + "applyPreflight": result.get("applyPreflight"), + "controllerArtifact": artifact, + }, + redacted_metadata={ + "policyId": policy_id, + "revision": result["revision"], + "idempotencyKeySha256": hashlib.sha256(idempotency_key.encode("ascii")).hexdigest(), + "secretRefs": [ + _BROKER_SECRET_REF, + _FORGE_OIDC_CLIENT_SECRET_REF, + _FORGE_READER_TOKEN_REF, + ], + }, + ) + except Exception: # noqa: BLE001 - an audit exception may carry held secrets. + try: + session.rollback() + except Exception: # noqa: BLE001 - keep DB error detail out of the response. + pass + return False + return True + + +def _protected_forge_private_package_owner() -> str | None: + from .forge_bootstrap_apply import ( + _protected_forge_private_package_owner as protected_owner, + ) + + return protected_owner() + + +def _fixed_authentik_connector(session: Session) -> AuthentikConnector | None: + """Use only already-loaded, fixed Authentik runtime configuration. + + The generic setup loader also migrates legacy plaintext connector secrets. + Secret delivery must not trigger that write as a side effect, so this path + verifies the existing runtime against protected/read-only inputs instead. + """ + try: + legacy_secret = session.get(AppSetting, "connector:authentik:secret") + if legacy_secret is not None: + if legacy_secret.is_secret is not True or not isinstance(legacy_secret.value_json, dict): + return None + legacy_value = legacy_secret.value_json.get("value") + if isinstance(legacy_value, str) and legacy_value.strip(): + return None + if legacy_secret.value_json not in ( + {}, + {"secretRef": "AUTHENTIK_TOKEN", "hasSecret": True}, + {"secretRef": "AUTHENTIK_TOKEN", "hasSecret": True, "migrated": True}, + ): + return None + + connector_config = session.get(AppSetting, "connector:authentik:config") + if connector_config is not None: + if connector_config.is_secret is not False or not isinstance(connector_config.value_json, dict): + return None + if not set(connector_config.value_json).issubset( + {"url", "verifyTls", "outpostUrl", "expectEmbeddedOutpost"} + ): + return None + + connector = AuthentikConnector() + config = connector._config() + if ( + config is None + or config.url.rstrip("/") != "https://auth.example-host.com" + or config.verify_tls is not True + or not isinstance(config.token, str) + or not config.token + ): + return None + + stored_admin_token = get_secret(session, "AUTHENTIK_TOKEN", touch=False) + if not isinstance(stored_admin_token, str) or not stored_admin_token: + return None + normalized = stored_admin_token.strip() + if normalized.lower().startswith("bearer "): + normalized = normalized[7:].strip() + if not hmac.compare_digest(normalized, config.token): + return None + + if connector_config is not None: + stored_config = connector_config.value_json + runtime_values = { + "url": config.url, + "verifyTls": config.verify_tls, + "outpostUrl": config.outpost_url, + "expectEmbeddedOutpost": config.expect_embedded_outpost, + } + if any(stored_config.get(key) != runtime_values[key] for key in stored_config): + return None + return connector + except Exception: # noqa: BLE001 - config/store failures remain a fixed denial. + return None + + +def prepare_forge_secret_delivery( + session: Session, + *, + profile: ForgeBootstrapPreflightV1, + idempotency_key: str, + actor_identity: str, +) -> dict[str, Any]: + """Prepare only the fixed Forge credential bundle through the trusted broker.""" + identity = actor_identity.strip() if isinstance(actor_identity, str) else "" + request_key = idempotency_key.strip() if isinstance(idempotency_key, str) else "" + if not identity: + return _result( + profile=profile, + status="blocked", + message="forge_secret_operator_identity_required", + ) + if _IDEMPOTENCY.fullmatch(request_key) is None: + outcome = _result( + profile=profile, + status="blocked", + message="forge_secret_idempotency_key_invalid", + ) + _audit( + session, + identity=identity, + decision="blocked", + result=outcome, + policy_id=None, + idempotency_key="invalid-key", + ) + return outcome + + policy_id = _exact_secret_prepare_policy_allowed(session) + if policy_id is None: + outcome = _result( + profile=profile, + status="blocked", + message="forge_secret_exact_target_policy_required", + ) + _audit( + session, + identity=identity, + decision="blocked", + result=outcome, + policy_id=None, + idempotency_key=request_key, + ) + return outcome + + if not _secret_store_ready(): + outcome = _result( + profile=profile, + status="blocked", + message="forge_secret_store_not_production_ready", + ) + audit_ok = _audit( + session, + identity=identity, + decision="allowed", + result=outcome, + policy_id=policy_id, + idempotency_key=request_key, + ) + return outcome if audit_ok else _result( + profile=profile, status="failed", message="forge_secret_audit_persist_failed" + ) + + attempted_delivery = False + try: + with _forge_oidc_process_lock(): + if _exact_secret_prepare_policy_allowed(session) != policy_id: + outcome = _result( + profile=profile, + status="blocked", + message="forge_secret_exact_target_policy_required", + ) + elif not _secret_store_ready(): + outcome = _result( + profile=profile, + status="blocked", + message="forge_secret_store_not_production_ready", + ) + else: + trust = EnrollmentPlane.get_trust_config(session) + if ( + trust is None + or trust.trusted_appdata_root != MANAGED_DOCKER_APPDATA_ROOT + or trust.managed_appdata_backup_acknowledged is not True + ): + outcome = _result( + profile=profile, + status="blocked", + message="forge_secret_controller_trust_required", + ) + elif not all( + has_secret(session, reference) + for reference in ( + _FORGE_OIDC_BINDING_REF, + _FORGE_OIDC_CLIENT_SECRET_REF, + _FORGE_READER_BINDING_REF, + _FORGE_READER_TOKEN_REF, + ) + ): + outcome = _result( + profile=profile, + status="blocked", + message="forge_secret_verified_reader_required", + ) + else: + from .forge_bootstrap_apply import _controller_secret + from .forge_controller_reader import ( + _ReaderFailure, + _verify_forge_delivery_inputs, + ) + + broker_secret = _controller_secret(session) + if not isinstance(broker_secret, str) or not broker_secret: + outcome = _result( + profile=profile, + status="blocked", + message="forge_secret_controller_trust_required", + ) + else: + try: + connector = _fixed_authentik_connector(session) + if connector is None: + outcome = _result( + profile=profile, + status="blocked", + message="forge_secret_authentik_configuration_required", + ) + connector = None + if connector is None: + verified = None + else: + verified = _verify_forge_delivery_inputs(session, connector) + if verified is None: + pass + else: + config_bytes = _protected_config(verified.context) + if config_bytes is None: + outcome = _result( + profile=profile, + status="conflict", + message="forge_secret_protected_config_invalid", + ) + elif ( + verified.oidc_client_secret in config_bytes + or verified.authentik_read_token in config_bytes + ): + outcome = _result( + profile=profile, + status="conflict", + message="forge_secret_config_contains_secret", + ) + else: + attempted_delivery = True + broker_result = DeploymentBrokerClient().forge_secret_delivery( + profile, + config_bytes=config_bytes, + oidc_client_secret=verified.oidc_client_secret, + authentik_read_token=verified.authentik_read_token, + secret=broker_secret, + ) + if ( + isinstance(broker_result, dict) + and broker_result.get("status") in {"missing_config", "missing_secret"} + ): + outcome = _result( + profile=profile, + status="blocked", + message="forge_secret_controller_delivery_unavailable", + ) + attempted_delivery = False + elif ( + isinstance(broker_result, dict) + and broker_result.get("status") == "response" + ): + artifact = _checked_controller_response( + broker_result.get("response"), + profile=profile, + ) + if artifact is None: + outcome = _result( + profile=profile, + status="outcome_unknown", + message="forge_secret_controller_outcome_unknown", + ) + else: + outcome = _result( + profile=profile, + status="bundle_ready", + message="forge_secret_bundle_ready", + artifact=artifact, + ) + else: + outcome = _result( + profile=profile, + status="outcome_unknown", + message="forge_secret_controller_outcome_unknown", + ) + except _ReaderFailure as exc: + outcome = _result( + profile=profile, + status="blocked" if exc.status == "blocked" else "conflict", + message="forge_secret_reader_proof_invalid", + ) + except Exception: # noqa: BLE001 - never surface connector or held input details. + outcome = _result( + profile=profile, + status=("outcome_unknown" if attempted_delivery else "failed"), + message=( + "forge_secret_controller_outcome_unknown" + if attempted_delivery + else "forge_secret_input_verification_failed" + ), + ) + except Exception: # noqa: BLE001 - lock and DB errors are fixed safe codes. + outcome = _result( + profile=profile, + status="outcome_unknown" if attempted_delivery else "failed", + message=( + "forge_secret_controller_outcome_unknown" + if attempted_delivery + else "forge_secret_preparation_unavailable" + ), + ) + + audit_ok = _audit( + session, + identity=identity, + decision="allowed", + result=outcome, + policy_id=policy_id, + idempotency_key=request_key, + ) + if not audit_ok: + if attempted_delivery or outcome["status"] == "bundle_ready": + return _result( + profile=profile, + status="outcome_unknown", + message="forge_secret_delivery_audit_persist_failed", + ) + return _result( + profile=profile, + status="failed", + message="forge_secret_audit_persist_failed", + ) + return outcome diff --git a/backend/app/services/policy_engine.py b/backend/app/services/policy_engine.py index 7f128f8..d693ee4 100644 --- a/backend/app/services/policy_engine.py +++ b/backend/app/services/policy_engine.py @@ -164,6 +164,29 @@ def evaluate_policy( "scope_decision": "allowed" if allowed else None, } + if action == "forge.oidc.provision": + # This is the only manual Authentik mutation exposed by AppOps. Requiring + # exactly one row for the action prevents a wildcard or duplicate row + # from becoming an alternate authorization path. + policies = session.exec(select(Policy).where(Policy.action == action)).all() + policy = policies[0] if len(policies) == 1 else None + allowed = bool( + source == "manual" + and target == "itworx-forge" + and policy is not None + and policy.target == "itworx-forge" + and policy.manual == "allowed" + and policy.voice == "blocked" + and policy.risk == "critical" + ) + return { + "decision": "allowed" if allowed else "blocked", + "reason": "Forge OIDC provisioning requires one exact-target manual policy; voice is always blocked.", + "risk": "critical", + "policy_id": policy.id if allowed and policy is not None else None, + "scope_decision": "allowed" if allowed else None, + } + if action in _DEFAULT_DECISIONS: reason = _DEFAULT_REASONS.get(action, 'Action uses default governance policy.') return { diff --git a/backend/app/services/setup_service.py b/backend/app/services/setup_service.py index c776297..54d846f 100644 --- a/backend/app/services/setup_service.py +++ b/backend/app/services/setup_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import Any from sqlmodel import Session, select @@ -11,6 +12,7 @@ from ..core.redaction import redact_message from ..core.time import utc_now from ..core.network_security import validate_connector_url from ..db.models import AppContainerMapping, AppSetting, AuditEvent, Connector, DiscoveryRun, Secret +from ..schemas.forge_oidc import FORGE_OIDC_BINDING_SECRET_REF, ForgeOidcBindingV1 from .audit_logger import log_event from .secret_store import empty_secret_status, get_secret, has_secret, secret_status, secret_storage_status, set_secret from . import safety_center_service @@ -219,6 +221,61 @@ def _apply_connector_config_to_runtime(session: Session, connector_id: str) -> N settings.apis[secret_env] = stored_secret elif secret_row and secret_row.value_json.get('value'): settings.apis[secret_env] = secret_row.value_json.get('value') + if connector_id == 'authentik': + _apply_forge_oidc_binding_to_runtime(session) + + +def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError('duplicate_json_key') + result[key] = value + return result + + +def _apply_forge_oidc_binding_to_runtime(session: Session) -> bool: + """Load protected Forge resource IDs atomically and fail closed on bad state. + + This fixed secret reference is intentionally absent from connector setup + fields and response schemas. Generic Authentik settings can only update the + Authentik URL/TLS/token inputs, never these remote resource bindings. + """ + internal_keys = ( + 'AUTHENTIK_FORGE_OIDC_PROVIDER_ID', + 'AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING', + 'AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING', + 'AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING', + ) + settings.apis.update({key: None for key in internal_keys}) + settings.apis['AUTHENTIK_FORGE_OIDC_BINDING_VALID'] = False + try: + raw = get_secret(session, FORGE_OIDC_BINDING_SECRET_REF, touch=False) + if not isinstance(raw, str) or not raw: + return False + payload = json.loads(raw, object_pairs_hook=_unique_json_object) + if ( + not isinstance(payload, dict) + or set(payload) != {'version', 'providerId', 'scopeMappingIds'} + or not isinstance(payload.get('scopeMappingIds'), dict) + or set(payload['scopeMappingIds']) != {'openid', 'email', 'profile'} + ): + return False + binding = ForgeOidcBindingV1.model_validate(payload) + except Exception: # noqa: BLE001 - binding failures never expose encrypted-store contents. + return False + + # Only publish values after the whole versioned record and distinct UUIDs + # have passed validation. Keep the stored JSON out of runtime settings. + values = { + 'AUTHENTIK_FORGE_OIDC_PROVIDER_ID': binding.provider_id, + 'AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING': str(binding.scope_mapping_ids.openid), + 'AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING': str(binding.scope_mapping_ids.email), + 'AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING': str(binding.scope_mapping_ids.profile), + } + settings.apis.update(values) + settings.apis['AUTHENTIK_FORGE_OIDC_BINDING_VALID'] = True + return True def apply_stored_connector_settings(session: Session, connector_ids: set[str] | None = None) -> None: diff --git a/backend/tests/test_controller_artifact_bootstrap.py b/backend/tests/test_controller_artifact_bootstrap.py index 99f58e3..98828cb 100644 --- a/backend/tests/test_controller_artifact_bootstrap.py +++ b/backend/tests/test_controller_artifact_bootstrap.py @@ -28,6 +28,7 @@ REVISION = "a" * 40 TREE = "b" * 40 CALLER_ID = "c" * 64 MODERN_HELPER_ID = "f" * 64 +RETIRED_HELPER_ID = bootstrap.RETIRED_FAILED_MODERN_HELPER_ID HOST_HOSTNAME = "Tower" @@ -99,7 +100,7 @@ bash "$work/upgrade.sh" \\ ''' -def _legacy_helper_spec() -> dict[str, Any]: +def _legacy_helper_spec(revision: str = REVISION) -> dict[str, Any]: tree = ast.parse(LEGACY_HELPER_SOURCE_FIXTURE) function = next( node @@ -121,7 +122,7 @@ def _legacy_helper_spec() -> dict[str, Any]: compile(ast.Module(body=[function], type_ignores=[]), "legacy-promoter", "exec"), namespace, ) - return namespace["helper_container_spec"](revision=REVISION, image_id=CONTROLLER_IMAGE_ID) + return namespace["helper_container_spec"](revision=revision, image_id=CONTROLLER_IMAGE_ID) def _image(labels: dict[str, str]) -> dict[str, Any]: @@ -409,14 +410,43 @@ class FakeDocker: self.existing_child = existing_child self.created: list[dict[str, Any]] = [] self.started: list[str] = [] + self.removed: list[str] = [] self.inspected: list[str] = [] self.host_name = HOST_HOSTNAME self.log_bytes = b"controller_v2_ready revision=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + self.named_inspect_override: dict[str, Any] | None = None + self.named_inspect_override_at = 0 + self.retired_id_inspect_override: dict[str, Any] | None = None + self.appops_inspect_count = 0 + self.appops_inspect_override: dict[str, Any] | None = None + self.appops_inspect_override_at = 0 + self._inspect_counts: dict[str, int] = {} def inspect_container(self, reference: str) -> dict[str, Any] | None: self.inspected.append(reference) + self._inspect_counts[reference] = self._inspect_counts.get(reference, 0) + 1 if reference == "itworx-controller-artifact-promoter-modern": + if ( + self.named_inspect_override is not None + and self._inspect_counts[reference] == self.named_inspect_override_at + ): + return self.named_inspect_override return self.existing_child + if ( + reference == RETIRED_HELPER_ID + and self.retired_id_inspect_override is not None + ): + return self.retired_id_inspect_override + if ( + reference == RETIRED_HELPER_ID + and self.existing_child is not None + and self.existing_child.get("Id") == RETIRED_HELPER_ID + ): + return self.existing_child + if reference == APPOPS_CONTAINER_ID: + self.appops_inspect_count += 1 + if self.appops_inspect_count == self.appops_inspect_override_at: + return self.appops_inspect_override return self.containers.get(reference) def daemon_name(self) -> str: @@ -444,8 +474,15 @@ class FakeDocker: source_revision = self.created[-1]["Labels"]["itworx.source.revision"] self.containers["gitea-deploy-control"] = _modern_controller_inspect(source_revision) + def remove_retired_failed_container(self, identifier: str) -> None: + assert identifier == RETIRED_HELPER_ID + assert self.existing_child is not None + assert self.existing_child["Id"] == RETIRED_HELPER_ID + self.removed.append(identifier) + self.existing_child = None + def container_logs(self, identifier: str, *, tail: int, max_bytes: int) -> bytes: - assert identifier == MODERN_HELPER_ID + assert identifier in {MODERN_HELPER_ID, RETIRED_HELPER_ID} assert tail <= 40 assert max_bytes <= 8192 return self.log_bytes[:max_bytes] @@ -458,9 +495,13 @@ class FakeProtectedGit: self.helper_source = helper_source self.blobs_read: list[tuple[str, str]] = [] self.main_checks = 0 + self.fail_current_main_at: int | None = None + self.retired_tree_valid = True def verify_current_main(self, revision: str, tree: str) -> None: self.main_checks += 1 + if self.fail_current_main_at == self.main_checks: + raise bootstrap.BootstrapError("controller_promoter_source_not_current_main") if (revision, tree) != (self.revision, self.tree): raise bootstrap.BootstrapError("controller_promoter_source_not_current_main") @@ -468,9 +509,18 @@ class FakeProtectedGit: if (revision, tree) != (LEGACY_REVISION, LEGACY_TREE): raise bootstrap.BootstrapError("controller_promoter_legacy_provenance_invalid") + def verify_retired_modern_revision(self, revision: str, tree: str) -> None: + if (revision, tree) != ( + bootstrap.RETIRED_FAILED_MODERN_REVISION, + bootstrap.RETIRED_FAILED_MODERN_TREE, + ) or not self.retired_tree_valid: + raise bootstrap.BootstrapError( + "controller_promoter_retired_helper_provenance_invalid" + ) + def read_blob(self, revision: str, path: str) -> bytes: self.blobs_read.append((revision, path)) - if revision != self.revision or path != "backend/app/services/controller_artifact_helper_spec.py": + if revision not in {self.revision, bootstrap.RETIRED_FAILED_MODERN_REVISION} or path != "backend/app/services/controller_artifact_helper_spec.py": raise bootstrap.BootstrapError("controller_promoter_source_blob_invalid") return self.helper_source.encode("utf-8") @@ -518,7 +568,10 @@ def _run_bootstrap( *, health: dict[str, Any] | None = None, initial_health: dict[str, Any] | None = None, + health_sequence: list[dict[str, Any]] | None = None, hostname: str = HOST_HOSTNAME, + source_revision: str = REVISION, + source_tree: str = TREE, ): source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( encoding="utf-8" @@ -530,15 +583,15 @@ def _run_bootstrap( version="2.1.12", artifact_sha256=bootstrap.LEGACY_CONTROLLER_ARTIFACT_SHA256, ) - health_reads = [initial, health or _health()] + health_reads = health_sequence or [initial, health or _health()] def read_health() -> dict[str, Any]: return health_reads.pop(0) if health_reads else health or _health() return bootstrap.run_bootstrap( "jens/unraid-appops-gateway", - REVISION, - TREE, + source_revision, + source_tree, docker=docker, protected_git=git, hostname=hostname, @@ -549,6 +602,54 @@ def _run_bootstrap( ) +def _retired_failed_child(helper_source: str) -> dict[str, Any]: + spec = bootstrap.render_modern_helper_spec( + helper_source, + revision=bootstrap.RETIRED_FAILED_MODERN_REVISION, + image_id=CONTROLLER_IMAGE_ID, + current_controller_id=CONTROLLER_CONTAINER_ID, + current_controller_revision=CONTROLLER_REVISION, + current_controller_tree=CONTROLLER_TREE, + current_controller_version="2.1.12", + ) + child = _inspect_from_spec( + "itworx-controller-artifact-promoter-modern", + RETIRED_HELPER_ID, + CONTROLLER_IMAGE_ID, + spec, + state={"Running": False, "ExitCode": 1, "Status": "exited"}, + ) + child["Config"]["Volumes"] = None + child["Mounts"] = [ + {"Type": "bind", "Source": "/mnt/cache", "Destination": "/mnt/cache", "RW": True, "Propagation": "rprivate"}, + {"Type": "bind", "Source": "/mnt/user", "Destination": "/mnt/user", "RW": True, "Propagation": "rprivate"}, + {"Type": "bind", "Source": "/var/lib/docker", "Destination": "/var/lib/docker", "RW": False, "Propagation": "rslave"}, + {"Type": "bind", "Source": "/srv/example/appdata/gitea/git/repositories", "Destination": "/gitea-repositories", "RW": False, "Propagation": "rprivate"}, + {"Type": "bind", "Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock", "RW": True, "Propagation": "rprivate"}, + ] + child["HostConfig"].update( + { + "Mounts": None, + "CapAdd": None, + "CapDrop": None, + "Devices": None, + "DeviceRequests": None, + "PortBindings": None, + "VolumesFrom": None, + "PidMode": "", + "UTSMode": "", + "UsernsMode": "", + "CgroupParent": "", + "IpcMode": "private", + "CgroupnsMode": "private", + "Runtime": "runc", + "AutoRemove": False, + "PublishAllPorts": False, + } + ) + return child + + def test_initial_controller_runtime_accepts_exact_current_compose_container(): docker = FakeDocker(_legacy_helper_spec()) base_image_labels = bootstrap._inspect_controller_base_image(docker) @@ -876,6 +977,187 @@ def test_running_modern_child_returns_unknown_without_duplicate_or_stop(): assert docker.started == [] +def test_exact_failed_historical_child_is_removed_before_current_helper_launch(): + helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( + encoding="utf-8" + ) + legacy_health = _health( + revision=CONTROLLER_REVISION, + tree=CONTROLLER_TREE, + version="2.1.12", + artifact_sha256=bootstrap.LEGACY_CONTROLLER_ARTIFACT_SHA256, + ) + failed_child = _retired_failed_child(helper_source) + docker = FakeDocker(_legacy_helper_spec(), existing_child=failed_child) + git = FakeProtectedGit(helper_source) + + result = _run_bootstrap( + docker, + git, + health_sequence=[legacy_health, legacy_health, legacy_health, _health()], + ) + + assert result["status"] == "promoted" + assert docker.removed == [RETIRED_HELPER_ID] + assert docker.created and docker.started == [MODERN_HELPER_ID] + assert RETIRED_HELPER_ID in docker.inspected + assert docker.appops_inspect_count == 2 + assert git.blobs_read.count( + (bootstrap.RETIRED_FAILED_MODERN_REVISION, bootstrap.MODERN_HELPER_SOURCE_PATH) + ) == 1 + + +@pytest.mark.parametrize( + "mutation", + [ + "running", + "successful-exit", + "boolean-exit", + "other-exit", + "wrong-id", + "config-volumes", + "extra-host-mount", + "cap-add", + "device-request", + "docker-root-writable", + "socket-bind-changed", + "legacy-controller-stopped", + "current-main-changed", + "second-name-inspect-changed", + "second-id-inspect-changed", + "appops-stopped-before-delete", + "historical-tree-proof-failed", + ], +) +def test_retired_helper_cleanup_fails_closed_on_any_identity_or_state_drift(mutation: str): + helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( + encoding="utf-8" + ) + failed_child = _retired_failed_child(helper_source) + docker = FakeDocker(_legacy_helper_spec(), existing_child=failed_child) + git = FakeProtectedGit(helper_source) + if mutation == "running": + failed_child["State"].update({"Running": True, "Status": "running"}) + elif mutation == "successful-exit": + failed_child["State"]["ExitCode"] = 0 + elif mutation == "boolean-exit": + failed_child["State"]["ExitCode"] = True + elif mutation == "other-exit": + failed_child["State"]["ExitCode"] = 2 + elif mutation == "wrong-id": + failed_child["Id"] = "9" * 64 + elif mutation == "config-volumes": + failed_child["Config"]["Volumes"] = {"/extra": {}} + elif mutation == "extra-host-mount": + failed_child["HostConfig"]["Mounts"] = [{"Type": "volume"}] + elif mutation == "cap-add": + failed_child["HostConfig"]["CapAdd"] = ["SYS_ADMIN"] + elif mutation == "device-request": + failed_child["HostConfig"]["DeviceRequests"] = [{"Capabilities": [["gpu"]]}] + elif mutation == "docker-root-writable": + failed_child["HostConfig"]["Binds"][2] = "/var/lib/docker:/var/lib/docker:rw" + failed_child["Mounts"][2]["RW"] = True + elif mutation == "socket-bind-changed": + failed_child["HostConfig"]["Binds"][4] = "/tmp/docker.sock:/var/run/docker.sock" + failed_child["Mounts"][4]["Source"] = "/tmp/docker.sock" + elif mutation == "legacy-controller-stopped": + docker.containers["gitea-deploy-control"]["State"]["Running"] = False + elif mutation == "current-main-changed": + git.fail_current_main_at = 2 + elif mutation == "second-name-inspect-changed": + docker.named_inspect_override = json.loads(json.dumps(failed_child)) + docker.named_inspect_override["Config"]["Cmd"].append("unexpected") + docker.named_inspect_override_at = 2 + elif mutation == "second-id-inspect-changed": + docker.retired_id_inspect_override = json.loads(json.dumps(failed_child)) + docker.retired_id_inspect_override["State"]["ExitCode"] = 0 + elif mutation == "appops-stopped-before-delete": + docker.appops_inspect_override = json.loads( + json.dumps(docker.containers[APPOPS_CONTAINER_ID]) + ) + docker.appops_inspect_override["State"]["Running"] = False + docker.appops_inspect_override_at = 2 + elif mutation == "historical-tree-proof-failed": + git.retired_tree_valid = False + + legacy_health = _health( + revision=CONTROLLER_REVISION, + tree=CONTROLLER_TREE, + version="2.1.12", + artifact_sha256=bootstrap.LEGACY_CONTROLLER_ARTIFACT_SHA256, + ) + with pytest.raises(bootstrap.BootstrapError): + _run_bootstrap( + docker, + git, + health_sequence=[legacy_health, legacy_health, legacy_health], + ) + + assert docker.removed == [] + assert docker.created == [] + assert docker.started == [] + + +def test_retired_helper_remove_uses_non_force_non_volume_docker_delete(): + client = bootstrap.DockerSocketClient() + calls: list[tuple[str, str]] = [] + + def request(method: str, path: str, **_kwargs: Any) -> tuple[int, bytes]: + calls.append((method, path)) + return 204, b"" + + client._request = request # type: ignore[method-assign] + client.remove_retired_failed_container(RETIRED_HELPER_ID) + + assert calls == [ + ("DELETE", f"/containers/{RETIRED_HELPER_ID}?force=false&v=false") + ] + + +@pytest.mark.parametrize("status", [404, 409]) +def test_retired_helper_delete_refuses_unexpected_docker_status(status: int): + client = bootstrap.DockerSocketClient() + + def request(_method: str, _path: str, **_kwargs: Any) -> tuple[int, bytes]: + return status, b"" + + client._request = request # type: ignore[method-assign] + with pytest.raises(bootstrap.BootstrapError): + client.remove_retired_failed_container(RETIRED_HELPER_ID) + + +def test_same_failed_source_revision_is_never_retried_or_removed(): + helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( + encoding="utf-8" + ) + docker = FakeDocker( + _legacy_helper_spec(bootstrap.RETIRED_FAILED_MODERN_REVISION), + existing_child=_retired_failed_child(helper_source), + ) + git = FakeProtectedGit(helper_source) + git.revision = bootstrap.RETIRED_FAILED_MODERN_REVISION + git.tree = bootstrap.RETIRED_FAILED_MODERN_TREE + legacy_health = _health( + revision=CONTROLLER_REVISION, + tree=CONTROLLER_TREE, + version="2.1.12", + artifact_sha256=bootstrap.LEGACY_CONTROLLER_ARTIFACT_SHA256, + ) + + result = _run_bootstrap( + docker, + git, + health_sequence=[legacy_health], + source_revision=git.revision, + source_tree=git.tree, + ) + + assert result["status"] == "failed" + assert result["exitCode"] == 1 + assert docker.removed == [] + assert docker.created == [] + + def test_completed_modern_child_with_wrong_artifact_is_not_relaunched(): helper_source = (ROOT / "backend/app/services/controller_artifact_helper_spec.py").read_text( encoding="utf-8" diff --git a/backend/tests/test_controller_hub_release_volume.py b/backend/tests/test_controller_hub_release_volume.py index 7ddede5..1a94df1 100644 --- a/backend/tests/test_controller_hub_release_volume.py +++ b/backend/tests/test_controller_hub_release_volume.py @@ -23,6 +23,18 @@ spec.loader.exec_module(module) REVISION = "a" * 40 IMAGE_ID = "sha256:" + "b" * 64 ORIGINAL_COMPOSE = b"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: immutable-controller\n" +HISTORICAL_CONFIG_ROOT_RELEASE_FILES = ( + "controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py", + "deploy.py", "managed_appdata_paths.py", "prepare_managed_appdata_root.py", "runner_fleet.py", + "runner_capacity_controller.py", "runner_capacity_policy.py", "runner_fleet_upgrade_v2.py", + "runner_profiles.py", "runner_admission_policy.py", "runner_admission_controller.py", + "runner_resource_envelope.py", "runner_fleet_specialize.py", "runner_statistics_binding.py", + "status_server.py", "repopulse_adoption_store.py", "repopulse_registry_restore.py", + "repopulse_adoption_executor.py", "repopulse_adoption_effects.py", + "repopulse_adoption_storage_effects.py", "repopulse_target_attestation.py", + "stage_repopulse_target_attestation.py", "repopulse_adoption_cli.py", + "stage_repopulse_maintenance_environment.py", "run_repopulse_adoption.py", +) def volume(): @@ -58,6 +70,214 @@ def test_only_the_exact_local_volume_is_accepted(): module.validate_volume(volume()) +def backup_volume(): + return { + "Name": "itworx-forge-master-key-backup", + "Driver": "local", + "Scope": "local", + "Options": None, + "Labels": { + "itworx.managed-by": "deployment-controller", + "itworx.purpose": "forge-master-key-backup", + }, + "Mountpoint": "/var/lib/docker/volumes/itworx-forge-master-key-backup/_data", + } + + +def test_backup_volume_identity_is_exact_and_never_repairs_existing_metadata(): + module.validate_backup_volume(backup_volume()) + bad = backup_volume() + bad["Labels"]["itworx.purpose"] = "foreign" + with pytest.raises(module.VolumeTransitionError): + module.validate_backup_volume(bad) + + +def test_promotion_preparer_creates_only_the_fixed_backup_volume_if_absent(monkeypatch, tmp_path): + commands = [] + present = False + + def run(arguments, **_kwargs): + nonlocal present + commands.append(arguments) + if arguments == ["docker", "info", "--format", "{{json .DockerRootDir}}"]: + return b'"/var/lib/docker"' + if arguments == ["docker", "volume", "ls", "--format", "{{.Name}}"]: + return b"" if not present else b"itworx-forge-master-key-backup\n" + if arguments[:3] == ["docker", "volume", "create"]: + present = True + return b"itworx-forge-master-key-backup\n" + if arguments == ["docker", "volume", "inspect", "itworx-forge-master-key-backup"]: + return json.dumps([backup_volume()]).encode() + raise AssertionError(arguments) + + directory_calls = [] + monkeypatch.setattr(module, "_run", run) + monkeypatch.setattr(module.os, "close", lambda _descriptor: None) + + def directory(path, anchor): + directory_calls.append((path, anchor)) + return 23 + + monkeypatch.setattr(module, "_directory", directory) + module.ensure_backup_volume(create=True) + + create = [arguments for arguments in commands if arguments[:3] == ["docker", "volume", "create"]] + assert create == [[ + "docker", "volume", "create", "--driver", "local", + "--label", "itworx.managed-by=deployment-controller", + "--label", "itworx.purpose=forge-master-key-backup", + "itworx-forge-master-key-backup", + ]] + assert directory_calls == [ + (module.DOCKER_ROOT / "volumes", module.DOCKER_ROOT), + (module.BACKUP_MOUNTPOINT, module.DOCKER_ROOT), + ] + + +def test_existing_backup_volume_is_validated_without_relabel_or_recreation(monkeypatch, tmp_path): + commands = [] + + def run(arguments, **_kwargs): + commands.append(arguments) + if arguments == ["docker", "info", "--format", "{{json .DockerRootDir}}"]: + return b'"/var/lib/docker"' + if arguments == ["docker", "volume", "ls", "--format", "{{.Name}}"]: + return b"itworx-forge-master-key-backup\n" + if arguments == ["docker", "volume", "inspect", "itworx-forge-master-key-backup"]: + item = backup_volume() + item["CreatedAt"] = "2000-01-01T00:00:00Z" + return json.dumps([item]).encode() + raise AssertionError(arguments) + + monkeypatch.setattr(module, "_run", run) + monkeypatch.setattr(module.os, "close", lambda _descriptor: None) + monkeypatch.setattr( + module, "_directory", + lambda _path, _anchor: 23, + ) + module.ensure_backup_volume(create=False) + assert not any(arguments[:3] == ["docker", "volume", "create"] for arguments in commands) + + +def test_missing_backup_volume_is_not_created_outside_promotion(monkeypatch, tmp_path): + commands = [] + + def run(arguments, **_kwargs): + commands.append(arguments) + if arguments == ["docker", "info", "--format", "{{json .DockerRootDir}}"]: + return b'"/var/lib/docker"' + if arguments == ["docker", "volume", "ls", "--format", "{{.Name}}"]: + return b"" + raise AssertionError(arguments) + + monkeypatch.setattr(module, "_run", run) + monkeypatch.setattr(module.os, "close", lambda _descriptor: None) + monkeypatch.setattr( + module, "_directory", + lambda _path, _anchor: 23, + ) + with pytest.raises(module.VolumeTransitionError, match="backup_volume_missing"): + module.ensure_backup_volume(create=False) + assert not any(arguments[:3] == ["docker", "volume", "create"] for arguments in commands) + + +def _prepare_promotion_doubles(monkeypatch, tmp_path, *, config_valid=True): + events = [] + controller_id = "c" * 64 + current = { + "Id": controller_id, + "Mounts": [{ + "Destination": module.EXECUTABLE_TARGET, + "Source": module.CONTROLLER_DIR.as_posix(), + }], + } + previous_identity = { + "sourceRevision": "b" * 40, + "sourceTree": "c" * 40, + "controllerVersion": "2.1.0", + } + config_tree = ({}, {"": 0o755}) + monkeypatch.setattr(module, "_validate_private_stage", lambda _stage: events.append("stage_validated")) + monkeypatch.setattr(module, "ensure_volume", lambda **_kwargs: None) + monkeypatch.setattr( + module, "_controller_image_inspect", + lambda _image_id: (events.append("controller_inspected") or current), + ) + monkeypatch.setattr( + module, "_snapshot_attested_controller", + lambda *_args, **_kwargs: ({}, {"": 0o755}, previous_identity), + ) + monkeypatch.setattr(module, "_validate_previous_private_tls", lambda _stage: None) + monkeypatch.setattr(module, "_release_tree", lambda *_args, **_kwargs: ({}, {"": 0o755})) + monkeypatch.setattr(module, "_config_tree", lambda *_args, **_kwargs: config_tree) + monkeypatch.setattr(module, "_read_config_leaf", lambda: config_tree) + monkeypatch.setattr(module, "_prepare_merged_tree", lambda *_args, **_kwargs: ({}, {"": 0o755})) + + def validate_staged(*_args, **_kwargs): + events.append("config_validated") + if not config_valid: + raise module.VolumeTransitionError("staged_config_invalid") + + monkeypatch.setattr(module, "_validate_staged_configuration", validate_staged) + monkeypatch.setattr(module, "_install_release_leaf", lambda *_args, **_kwargs: None) + monkeypatch.setattr(module, "_read_root_compose", lambda: (ORIGINAL_COMPOSE, 0o644)) + monkeypatch.setattr(module, "_mirror_snapshot_tar", lambda: b"mirror") + monkeypatch.setattr( + module, "rendered_model", + lambda *_args, **_kwargs: (events.append("compose_rendered") or b"rendered-compose"), + ) + monkeypatch.setattr( + module, "_verify_compose_image", + lambda *_args, **_kwargs: events.append("compose_verified"), + ) + monkeypatch.setattr( + module, "ensure_backup_volume", + lambda **_kwargs: events.append("backup_volume_prepared"), + raising=False, + ) + monkeypatch.setattr(module, "_release_tar_bytes", lambda *_args, **_kwargs: b"tar") + backup = tmp_path / "promotion-backup" + backup.mkdir(mode=0o700) + backup_fd = 23 + monkeypatch.setattr(module, "_create_backup", lambda: (backup, backup_fd)) + monkeypatch.setattr(module, "_write_backup", lambda *_args, **_kwargs: None) + monkeypatch.setattr(module.os, "close", lambda _descriptor: None) + return events + + +def test_promotion_prepares_backup_volume_after_source_stage_controller_and_config_checks( + monkeypatch, tmp_path +): + events = _prepare_promotion_doubles(monkeypatch, tmp_path) + module.prepare_promotion( + tmp_path / "stage", + source_revision="d" * 40, + source_tree="e" * 40, + controller_version="2.2.0", + image_id=IMAGE_ID, + ) + assert events.index("stage_validated") < events.index("config_validated") + assert events.index("config_validated") < events.index("backup_volume_prepared") + assert events.index("compose_rendered") < events.index("backup_volume_prepared") + assert events.count("compose_verified") == 2 + assert events.index("compose_verified") < events.index("backup_volume_prepared") + + +def test_promotion_does_not_prepare_backup_volume_when_staged_configuration_fails( + monkeypatch, tmp_path +): + events = _prepare_promotion_doubles(monkeypatch, tmp_path, config_valid=False) + with pytest.raises(module.VolumeTransitionError, match="staged_config_invalid"): + module.prepare_promotion( + tmp_path / "stage", + source_revision="d" * 40, + source_tree="e" * 40, + controller_version="2.2.0", + image_id=IMAGE_ID, + ) + assert "backup_volume_prepared" not in events + + def writer_inventory(inherited_labels): identifier = "c" * 64 spec = module._writer_spec(IMAGE_ID, REVISION, kind="config") @@ -230,18 +450,28 @@ def test_compose_uses_protected_code_and_two_config_views(compose): "target": module.TARGET, "volume": {"nocopy": True}, } in volumes + assert { + "type": "volume", + "source": "itworx-forge-master-key-backup", + "target": "/forge-master-key-backup", + "volume": {"nocopy": True}, + } in volumes assert { "type": "bind", "source": module._expected_config_path().as_posix(), "target": module.CONFIG_TARGET, "read_only": True, } in volumes - assert len(volumes) == 4 + assert len(volumes) == 5 volumes.remove(next(item for item in volumes if item["target"] == module.EXECUTABLE_TARGET)) volumes.remove(next(item for item in volumes if item["target"] == module.TARGET)) + volumes.remove(next(item for item in volumes if item["target"] == "/forge-master-key-backup")) volumes.remove(next(item for item in volumes if item["target"] == module.CONFIG_TARGET)) assert rendered["volumes"] == { - module.VOLUME: {"name": module.VOLUME, "external": True} + module.VOLUME: {"name": module.VOLUME, "external": True}, + "itworx-forge-master-key-backup": { + "name": "itworx-forge-master-key-backup", "external": True + }, } assert rendered["services"]["runner"] == baseline["services"]["runner"] assert service["environment"] == baseline["services"]["deploy-control"]["environment"] @@ -257,6 +487,46 @@ def test_compose_uses_protected_code_and_two_config_views(compose): assert calls[0][1] == ORIGINAL_COMPOSE +def test_compose_allows_only_the_fixed_managed_appdata_volume_mount(compose): + baseline, _calls = compose + mount = { + "type": "volume", + "source": module.MANAGED_APPDATA_VOLUME, + "target": module.MANAGED_APPDATA_MOUNTPOINT.as_posix(), + } + baseline["services"][module.SERVICE]["volumes"].append(mount) + baseline["volumes"] = { + module.MANAGED_APPDATA_VOLUME: { + "name": module.MANAGED_APPDATA_VOLUME, + "external": True, + } + } + + rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)) + assert mount in rendered["services"][module.SERVICE]["volumes"] + assert rendered["volumes"][module.MANAGED_APPDATA_VOLUME] == { + "name": module.MANAGED_APPDATA_VOLUME, + "external": True, + } + + +def test_compose_rejects_a_managed_appdata_target_alias(compose): + baseline, _calls = compose + baseline["services"][module.SERVICE]["volumes"].append({ + "type": "volume", + "source": module.MANAGED_APPDATA_VOLUME, + "target": "/var/lib/docker/volumes/foreign/_data", + }) + baseline["volumes"] = { + module.MANAGED_APPDATA_VOLUME: { + "name": module.MANAGED_APPDATA_VOLUME, + "external": True, + } + } + with pytest.raises(module.VolumeTransitionError, match="managed_appdata"): + module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID) + + def test_source_relative_controller_bind_uses_original_compose_directory(monkeypatch): def run(arguments, *, data=None, **kwargs): if arguments[:3] == ["docker", "image", "inspect"]: @@ -274,7 +544,14 @@ def test_source_relative_controller_bind_uses_original_compose_directory(monkeyp assert next(m["source"] for m in mounts if m["target"] == module.EXECUTABLE_TARGET) == module._mount_source_for_revision(REVISION) -@pytest.mark.parametrize("damage", ["target_collision", "foreign_volume", "graph_rw", "relative_bind", "env_file"]) +@pytest.mark.parametrize( + "damage", + [ + "target_collision", "foreign_volume", "graph_rw", "relative_bind", "env_file", + "backup_target_collision", "backup_volume_alias", "backup_target_child_alias", + "backup_definition_collision", + ], +) def test_compose_rejects_unsafe_or_unpinned_activation_inputs(compose, damage): baseline, _ = compose if damage == "target_collision": @@ -289,12 +566,65 @@ def test_compose_rejects_unsafe_or_unpinned_activation_inputs(compose, damage): baseline["services"]["deploy-control"]["volumes"][0]["read_only"] = False elif damage == "relative_bind": baseline["services"]["runner"]["volumes"][0]["source"] = "./unsafe" - else: + elif damage == "env_file": baseline["services"]["deploy-control"]["env_file"] = [".env"] + elif damage == "backup_target_collision": + baseline["services"]["deploy-control"]["volumes"].append( + {"type": "bind", "source": "/foreign", "target": "/forge-master-key-backup"} + ) + elif damage == "backup_volume_alias": + baseline["services"]["runner"]["volumes"].append( + { + "type": "volume", "source": "itworx-forge-master-key-backup", + "target": "/foreign-backup", + } + ) + elif damage == "backup_target_child_alias": + baseline["services"]["runner"]["volumes"].append( + {"type": "bind", "source": "/foreign", "target": "/forge-master-key-backup/state"} + ) + else: + baseline["volumes"] = { + "itworx-forge-master-key-backup": {"name": "foreign", "external": True} + } with pytest.raises(module.VolumeTransitionError): module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID) +def test_pre_promotion_accepts_the_healthy_controller_without_the_new_mount(compose): + baseline, _calls = compose + reference = "registry.example/controller:stable" + baseline["services"][module.SERVICE]["image"] = reference + current = { + "Image": IMAGE_ID, + "Config": { + "Image": reference, + "Labels": { + "com.docker.compose.project": "gitea-deploy-runners", + "com.docker.compose.service": module.SERVICE, + }, + }, + "State": {"Health": {"Status": "healthy"}}, + "Mounts": [ + { + "Type": "volume", "Name": module.VOLUME, + "Source": module.MOUNTPOINT.as_posix(), "Destination": module.TARGET, + "RW": True, + } + ], + } + original = f"name: gitea-deploy-runners\nservices:\n deploy-control:\n image: {reference}\n".encode() + rendered = json.loads( + module.rendered_model(original, REVISION, IMAGE_ID, current_container=current) + ) + assert not any(mount.get("Destination") == "/forge-master-key-backup" for mount in current["Mounts"]) + assert any( + mount.get("target") == "/forge-master-key-backup" + and mount.get("source") == "itworx-forge-master-key-backup" + for mount in rendered["services"][module.SERVICE]["volumes"] + ) + + def test_compose_tag_replacement_cannot_change_controller_startup_image(compose, monkeypatch): _baseline, calls = compose original_run = module._run @@ -385,6 +715,184 @@ def test_config_snapshot_has_exact_allowlist_and_broker_token(): assert config_dirs == directories +def _forge_secret_entries(): + return { + name: (f'fixture:{index}'.encode(), module.FORGE_SECRET_MODE) + for index, name in enumerate(sorted(module.FORGE_SECRET_OWNERS)) + } + + +def test_complete_forge_secret_bundle_has_exact_export_modes_and_owners(): + entries = { + 'projects.json': (b'{}', 0o600), + 'broker-token': (b'fixture-token', 0o600), + 'gitea-validation.json': (b'{}', 0o600), + 'gitea-validation-token': (b'fixture-validation-token', 0o600), + } | _forge_secret_entries() + directories = {'': 0o755, module.FORGE_SECRET_DIRECTORY: 0o700} + + config, config_dirs = module._config_tree(entries, directories) + archive_bytes = module._release_tar_bytes(REVISION, (config, config_dirs)) + restored, restored_dirs = module._read_tar_tree(archive_bytes) + + assert restored == config + assert restored_dirs == config_dirs + with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode='r:') as archive: + for name, owner in module.FORGE_SECRET_OWNERS.items(): + member = archive.getmember(name) + assert (member.uid, member.gid) == owner + assert member.mode == module.FORGE_SECRET_MODE + assert archive.getmember('broker-token').uid == 0 + assert archive.getmember('broker-token').gid == 0 + + +@pytest.mark.parametrize('damage', ['partial', 'unknown', 'wrong_mode', 'nested']) +def test_forge_secret_snapshot_requires_the_complete_exact_private_tree(damage): + entries = { + 'projects.json': (b'{}', 0o600), + 'broker-token': (b'fixture-token', 0o600), + 'gitea-validation.json': (b'{}', 0o600), + 'gitea-validation-token': (b'fixture-validation-token', 0o600), + } | _forge_secret_entries() + directories = {'': 0o755, module.FORGE_SECRET_DIRECTORY: 0o700} + if damage == 'partial': + entries.pop(min(module.FORGE_SECRET_OWNERS)) + elif damage == 'unknown': + entries[f'{module.FORGE_SECRET_DIRECTORY}/unexpected.secret'] = (b'x', 0o400) + elif damage == 'wrong_mode': + name = min(module.FORGE_SECRET_OWNERS) + entries[name] = (entries[name][0], 0o600) + else: + directories[f'{module.FORGE_SECRET_DIRECTORY}/nested'] = 0o700 + + with pytest.raises(module.VolumeTransitionError): + module._config_tree(entries, directories) + + +def test_forge_secret_tar_owner_drift_is_rejected(): + output = io.BytesIO() + with tarfile.open(fileobj=output, mode='w', format=tarfile.USTAR_FORMAT) as archive: + member = tarfile.TarInfo( + f'{module.FORGE_SECRET_DIRECTORY}/runtime_database_url.secret' + ) + member.size = 1 + member.mode = module.FORGE_SECRET_MODE + member.uid = member.gid = 0 + archive.addfile(member, io.BytesIO(b'x')) + + with pytest.raises(module.VolumeTransitionError, match='entry_unsafe'): + module._read_tar_tree(output.getvalue()) + + +def test_config_writer_uses_archive_mode_to_preserve_forge_leaf_owners(monkeypatch): + tree = ( + { + 'projects.json': (b'{}', 0o600), + 'broker-token': (b'fixture-token', 0o600), + 'gitea-validation.json': (b'{}', 0o600), + 'gitea-validation-token': (b'fixture-validation-token', 0o600), + } | _forge_secret_entries(), + {'': 0o755, module.FORGE_SECRET_DIRECTORY: 0o700}, + ) + writer_id = 'f' * 64 + commands = [] + copy_payloads = [] + + def run(arguments, *, data=None, **_kwargs): + commands.append(arguments) + if arguments == ['docker', 'create']: + return writer_id.encode() + if arguments[:2] == ['docker', 'wait']: + return b'0' + if arguments[:2] == ['docker', 'cp']: + copy_payloads.append(data) + return b'' + + monkeypatch.setattr(module, '_read_partial_config_leaf', lambda: None) + monkeypatch.setattr(module, 'ensure_volume', lambda **_kwargs: None) + monkeypatch.setattr(module, '_release_tar_bytes', lambda *_args, **_kwargs: b'archive') + monkeypatch.setattr(module, '_remove_stale_writer', lambda *_args, **_kwargs: None) + monkeypatch.setattr(module, '_writer_spec', lambda *_args, **_kwargs: ['docker', 'create']) + monkeypatch.setattr(module, '_inspect_writer', lambda *_args, **_kwargs: {}) + monkeypatch.setattr(module, '_read_config_leaf', lambda: tree) + monkeypatch.setattr(module, '_run', run) + + module._install_config_tree(tree, IMAGE_ID) + + assert [command for command in commands if command[:2] == ['docker', 'cp']] == [[ + 'docker', 'cp', '-a', '-', f'{writer_id}:{module.TARGET}/{module.CONFIG_DIR}', + ]] + assert copy_payloads == [b'archive'] + + +def test_partial_forge_bundle_fails_during_promotion_preparation_before_stop( + monkeypatch, tmp_path +): + valid_config_tree = module._config_tree + events = _prepare_promotion_doubles(monkeypatch, tmp_path) + monkeypatch.setattr(module, '_config_tree', valid_config_tree) + partial = { + 'projects.json': (b'{}', 0o600), + 'broker-token': (b'fixture-token', 0o600), + 'gitea-validation.json': (b'{}', 0o600), + 'gitea-validation-token': (b'fixture-validation-token', 0o600), + min(module.FORGE_SECRET_OWNERS): (b'fixture', module.FORGE_SECRET_MODE), + } + directories = {'': 0o755, module.FORGE_SECRET_DIRECTORY: 0o700} + identity = { + 'sourceRevision': 'b' * 40, + 'sourceTree': 'c' * 40, + 'controllerVersion': '2.1.0', + } + monkeypatch.setattr( + module, '_snapshot_attested_controller', + lambda *_args, **_kwargs: (partial, directories, identity), + ) + + with pytest.raises(module.VolumeTransitionError, match='forge_secret_bundle_incomplete'): + module.prepare_promotion( + tmp_path / 'stage', + source_revision='d' * 40, + source_tree='e' * 40, + controller_version='2.2.0', + image_id=IMAGE_ID, + ) + + assert 'backup_volume_prepared' not in events + + +@pytest.mark.skipif(os.name == 'nt' or not hasattr(os, 'geteuid') or os.geteuid() != 0, + reason='fixed Forge ownership roundtrip requires Linux root') +def test_forge_secret_filesystem_capture_and_private_stage_keep_exact_owner(tmp_path): + config_root = tmp_path / 'config' + config_root.mkdir(mode=0o700) + secret_root = config_root / module.FORGE_SECRET_DIRECTORY + secret_root.mkdir(mode=0o700) + for index, (relative, owner) in enumerate(sorted(module.FORGE_SECRET_OWNERS.items())): + path = config_root / relative + path.write_bytes(f'fixture:{index}'.encode()) + os.chown(path, *owner) + path.chmod(module.FORGE_SECRET_MODE) + + descriptor = os.open(config_root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + entries, directories = module._read_tree_fd(descriptor, allow_forge_secrets=True) + finally: + os.close(descriptor) + config, config_dirs = module._config_tree(entries, directories, allow_partial=True) + payload = module._release_tar_bytes(REVISION, (config, config_dirs)) + roundtrip, roundtrip_dirs = module._read_tar_tree(payload) + assert roundtrip == config + assert roundtrip_dirs == config_dirs + + staged = tmp_path / 'private-stage' + module._write_private_tree(staged, config, config_dirs) + for relative, owner in module.FORGE_SECRET_OWNERS.items(): + info = (staged / relative).stat(follow_symlinks=False) + assert (info.st_uid, info.st_gid) == owner + assert stat.S_IMODE(info.st_mode) == module.FORGE_SECRET_MODE + + def test_unknown_config_file_is_rejected(): entries = { "projects.json": (b"{}", 0o600), @@ -655,6 +1163,154 @@ def test_installer_release_order_matches_renderer_contract(): assert module.RELEASE_FILES[:3] == ( "controller_paths.py", "mcp_hub_release_store.py", "hub_permission_preparation.py" ) + assert module.RELEASE_FILES[module.RELEASE_FILES.index("forge_first_install.py") + 1] == ( + "forge_database_credentials.py" + ) + helper_index = module.RELEASE_FILES.index("forge_database_credentials.py") + 1 + assert module.RELEASE_FILES[helper_index : helper_index + 2] == ( + "forge_backup_paths.py", "forge_secret_bundle.py" + ) + assert module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER == tuple( + name for name in module.RELEASE_FILES + if name not in {"forge_backup_paths.py", "forge_secret_bundle.py"} + ) + assert module.LEGACY_RELEASE_FILES_WITHOUT_ROLE_HELPER == tuple( + name for name in module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER + if name != "forge_database_credentials.py" + ) + installer = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8") + module_list = installer.split("module_files=(", 1)[1].split(")", 1)[0].split() + module_helper_index = module_list.index("forge_database_credentials.py") + 1 + assert module_list[module_helper_index : module_helper_index + 2] == [ + "forge_backup_paths.py", "forge_secret_bundle.py" + ] + source_list = installer.split("source_paths=(", 1)[1].split(")", 1)[0].split() + source_helper_index = source_list.index("ops/gitea-deploy-broker/forge_database_credentials.py") + 1 + assert source_list[source_helper_index : source_helper_index + 2] == [ + "ops/gitea-deploy-broker/forge_backup_paths.py", + "ops/gitea-deploy-broker/forge_secret_bundle.py", + ] + + +def test_current_release_digest_binds_the_role_password_helper(): + entries = {name: (f"source:{name}".encode(), 0o644) for name in module.RELEASE_FILES} + expected = module._artifact_digest(entries) + assert module._matching_release_files(entries, expected) == module.RELEASE_FILES + + entries["forge_database_credentials.py"] = (b"tampered helper", 0o644) + with pytest.raises(module.VolumeTransitionError, match="release_identity_invalid"): + module._matching_release_files(entries, expected) + + +def test_previous_role_helper_release_digest_remains_recognized_and_bound(): + files = module.LEGACY_RELEASE_FILES_WITH_ROLE_HELPER + entries = {name: (f"source:{name}".encode(), 0o644) for name in files} + expected = module._artifact_digest(entries, files) + assert module._matching_release_files(entries, expected) == files + + entries["forge_database_credentials.py"] = (b"tampered role helper", 0o644) + with pytest.raises(module.VolumeTransitionError, match="release_identity_invalid"): + module._matching_release_files(entries, expected) + + +def test_historical_config_root_manifest_order_and_digest_remain_exact(): + files = HISTORICAL_CONFIG_ROOT_RELEASE_FILES + assert module.LEGACY_RELEASE_FILES_WITH_CONFIG_ROOT == files + entries = {name: (f"source:{name}".encode(), 0o644) for name in files} + expected = module._artifact_digest(entries, files) + assert module._matching_release_files(entries, expected) == files + + entries["status_server.py"] = (b"tampered historical file", 0o644) + with pytest.raises(module.VolumeTransitionError, match="release_identity_invalid"): + module._matching_release_files(entries, expected) + + +def test_activated_controller_must_have_the_exact_backup_mount(monkeypatch): + mounts = [ + { + "Type": "bind", "Source": module._expected_revision_path(REVISION).as_posix(), + "Destination": module.EXECUTABLE_TARGET, "RW": False, + }, + { + "Type": "volume", "Name": module.VOLUME, + "Source": module.MOUNTPOINT.as_posix(), "Destination": module.TARGET, "RW": True, + }, + { + "Type": "bind", "Source": module._expected_config_path().as_posix(), + "Destination": module.CONFIG_TARGET, "RW": False, + }, + { + "Type": "bind", "Source": module.DOCKER_ROOT.as_posix(), + "Destination": module.DOCKER_ROOT.as_posix(), "RW": False, + }, + { + "Type": "volume", "Name": "itworx-forge-master-key-backup", + "Source": "/var/lib/docker/volumes/itworx-forge-master-key-backup/_data", + "Destination": "/forge-master-key-backup", "RW": True, + }, + ] + item = {"Mounts": mounts} + monkeypatch.setattr(module, "_controller_image_inspect", lambda _image_id: item) + monkeypatch.setattr(module, "_read_release_leaf", lambda _revision: ({}, {"": 0o755})) + monkeypatch.setattr(module, "_release_identity", lambda _entries: {"sourceRevision": REVISION}) + monkeypatch.setattr(module, "_read_config_leaf", lambda: ({}, {"": 0o755})) + + module._verify_active_mount(REVISION, IMAGE_ID) + mounts.pop() + with pytest.raises(module.VolumeTransitionError, match="backup_mount"): + module._verify_active_mount(REVISION, IMAGE_ID) + + +@pytest.mark.parametrize( + "damage", + ["backup_readonly", "backup_name", "backup_source", "backup_alias", "docker_root_rw"], +) +def test_activated_controller_rejects_backup_or_docker_root_mount_drift( + monkeypatch, damage +): + mounts = [ + { + "Type": "bind", "Source": module._expected_revision_path(REVISION).as_posix(), + "Destination": module.EXECUTABLE_TARGET, "RW": False, + }, + { + "Type": "volume", "Name": module.VOLUME, + "Source": module.MOUNTPOINT.as_posix(), "Destination": module.TARGET, "RW": True, + }, + { + "Type": "bind", "Source": module._expected_config_path().as_posix(), + "Destination": module.CONFIG_TARGET, "RW": False, + }, + { + "Type": "bind", "Source": module.DOCKER_ROOT.as_posix(), + "Destination": module.DOCKER_ROOT.as_posix(), "RW": False, + }, + { + "Type": "volume", "Name": module.BACKUP_VOLUME, + "Source": module.BACKUP_MOUNTPOINT.as_posix(), + "Destination": module.BACKUP_TARGET, "RW": True, + }, + ] + if damage == "backup_readonly": + mounts[-1]["RW"] = False + elif damage == "backup_name": + mounts[-1]["Name"] = "foreign-volume" + elif damage == "backup_source": + mounts[-1]["Source"] = "/var/lib/docker/volumes/foreign/_data" + elif damage == "backup_alias": + mounts.append({ + "Type": "bind", "Source": "/foreign", "Destination": f"{module.BACKUP_TARGET}/child", + "RW": True, + }) + else: + mounts[-2]["RW"] = True + monkeypatch.setattr(module, "_controller_image_inspect", lambda _image_id: {"Mounts": mounts}) + monkeypatch.setattr(module, "_read_release_leaf", lambda _revision: ({}, {"": 0o755})) + monkeypatch.setattr(module, "_release_identity", lambda _entries: {"sourceRevision": REVISION}) + monkeypatch.setattr(module, "_read_config_leaf", lambda: ({}, {"": 0o755})) + + with pytest.raises(module.VolumeTransitionError): + module._verify_active_mount(REVISION, IMAGE_ID) @pytest.mark.skipif(os.name == "nt", reason="dirfd swap test requires POSIX openat") @@ -943,6 +1599,10 @@ def test_capture_program_is_fixed_descriptor_based_and_compiles(): @pytest.mark.parametrize("legacy_config", [True, False]) def test_capture_archive_roundtrips_through_a_nonseekable_pipe(legacy_config): source = module._controller_capture_source(config_root_in_code=legacy_config) + assert '__FORGE_' not in source + assert 'runtime_database_url.secret' in source + assert '/forge-master-key-backup' not in source + compile(source, 'controller-capture', 'exec') statements = ast.parse(source).body start = next(i for i, node in enumerate(statements) if isinstance(node, ast.Assign) @@ -958,6 +1618,7 @@ def test_capture_archive_roundtrips_through_a_nonseekable_pipe(legacy_config): "import io,sys,tarfile\n" "records={'deploy.py':(b'fixture source',0o644),'secrets/key':(b'fixture secret',0o600)}\n" "dirs={'secrets':0o700}\n" + "forge_secret_owners={}\n" ) result = subprocess.run([sys.executable, "-c", fixture + archive_program], capture_output=True, timeout=10, check=False) @@ -971,8 +1632,13 @@ def test_capture_archive_roundtrips_through_a_nonseekable_pipe(legacy_config): assert all(member.uid == member.gid == 0 for member in archive.getmembers()) -def test_prior_release_capture_must_match_protected_git_tree(monkeypatch): - files = module.LEGACY_RELEASE_FILES_2_1_5 +@pytest.mark.parametrize("files", [ + module.LEGACY_RELEASE_FILES_WITHOUT_ROLE_HELPER, + module.LEGACY_RELEASE_FILES_2_1_5, + module.LEGACY_RELEASE_FILES_WITH_CONFIG_ROOT, + module.LEGACY_RELEASE_FILES_BEFORE_FORGE, +]) +def test_prior_release_capture_must_match_protected_git_tree(monkeypatch, files): entries = {name: (f"source:{name}".encode(), 0o644) for name in files} source_revision = "32a6eece0e3e1276946abdc04196c819daf02e2a" source_tree = "e0d7e80b79504b964d968e51e0d21c288cac8f0d" diff --git a/backend/tests/test_controller_root_procedure_contract.py b/backend/tests/test_controller_root_procedure_contract.py index 8f0665d..aaf3b10 100644 --- a/backend/tests/test_controller_root_procedure_contract.py +++ b/backend/tests/test_controller_root_procedure_contract.py @@ -31,6 +31,10 @@ def test_windows_wrapper_archives_every_file_required_by_root_installer(): ) assert vendor_sources == required_vendor_sources assert "scripts/prepare_controller_hub_release_volume.py" in archived.group(1) + assert "ops/gitea-deploy-broker/forge_first_install.py" in required_paths + assert "ops/gitea-deploy-broker/forge_database_credentials.py" in required_paths + assert "ops/gitea-deploy-broker/forge_backup_paths.py" in required_paths + assert "ops/gitea-deploy-broker/forge_secret_bundle.py" in required_paths assert "scripts/upgrade_deployment_controller.sh" in archived.group(1) assert "scripts/vendor" in archived.group(1) assert "scripts/vendor/pyyaml-LICENSE" in required.group(1) @@ -39,6 +43,28 @@ def test_windows_wrapper_archives_every_file_required_by_root_installer(): assert '"${work}/vendor/yaml/${vendor_file}"' in installer assert ('[[ -d "${script_dir}/vendor" && ! -L "${script_dir}/vendor" && ' '-d "${script_dir}/vendor/yaml" && ! -L "${script_dir}/vendor/yaml" ]]') in installer + assert "forge_first_install.py" in installer.split("module_files=(", 1)[1].split(")", 1)[0] + assert "forge_database_credentials.py" in installer.split("module_files=(", 1)[1].split(")", 1)[0] + assert "forge_backup_paths.py" in installer.split("module_files=(", 1)[1].split(")", 1)[0] + assert "forge_secret_bundle.py" in installer.split("module_files=(", 1)[1].split(")", 1)[0] + + +def test_forge_first_install_module_is_bound_into_controller_release_and_config(): + renderer = (ROOT / "scripts/prepare_controller_artifact.py").read_text(encoding="utf-8") + preparer = (ROOT / "scripts/prepare_controller_hub_release_volume.py").read_text(encoding="utf-8") + + assert '"forge_first_install.py"' in renderer.split("_ARTIFACT_FILES = (", 1)[1].split(")", 1)[0] + release_files = preparer.split("RELEASE_FILES = (", 1)[1].split(")", 1)[0] + legacy_role_release_files = preparer.split( + "LEGACY_RELEASE_FILES_WITH_ROLE_HELPER = (", 1 + )[1].split(")", 1)[0] + assert "'forge_first_install.py'" in legacy_role_release_files + assert "'forge_database_credentials.py'" in legacy_role_release_files + assert "*LEGACY_RELEASE_FILES_WITH_ROLE_HELPER" in release_files + assert "'forge_backup_paths.py'" in release_files + assert "'forge_secret_bundle.py'" in release_files + assert "'forge-first-install.json'" in preparer.split("CONFIG_FILES = {", 1)[1].split("}", 1)[0] + assert "'forge-bootstrap-secrets'" in preparer.split("CONFIG_SECRET_DIRS = {", 1)[1].split("}", 1)[0] def test_windows_wrapper_supplies_private_archive_and_exact_image_identity(): diff --git a/backend/tests/test_controller_state_reconciliation.py b/backend/tests/test_controller_state_reconciliation.py index 11d775e..5ff396b 100644 --- a/backend/tests/test_controller_state_reconciliation.py +++ b/backend/tests/test_controller_state_reconciliation.py @@ -192,6 +192,8 @@ def test_upgrade_prepares_before_stop_and_reconciles_between_stop_and_activation activate = 'python3 "${work}/${PREPARER_NAME}" activate --backup "$backup"' assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in script + assert 'lock_path="${ROOT}/promotion.lock"' in script + assert 'lock_path="${VOLUME_ROOT}/promotion.lock"' not in script assert script.index(prepare) < script.index(stop) assert script.index(stop) < script.index(reconcile) assert script.index(reconcile) < script.index(activate) diff --git a/backend/tests/test_deploy_broker.py b/backend/tests/test_deploy_broker.py index 06dbe0f..063f288 100644 --- a/backend/tests/test_deploy_broker.py +++ b/backend/tests/test_deploy_broker.py @@ -7001,6 +7001,12 @@ def test_controller_release_identity_is_content_bound(status_module, tmp_path, m renderer = importlib.util.module_from_spec(renderer_spec) renderer_spec.loader.exec_module(renderer) assert renderer._ARTIFACT_FILES == status_module._CONTROLLER_ARTIFACT_FILES + forge_first_install_index = status_module._CONTROLLER_ARTIFACT_FILES.index( + "forge_first_install.py" + ) + assert status_module._CONTROLLER_ARTIFACT_FILES[forge_first_install_index + 1] == ( + "forge_database_credentials.py" + ) artifact_root = tmp_path / "controller" artifact_root.mkdir() for filename in status_module._CONTROLLER_ARTIFACT_FILES: diff --git a/backend/tests/test_forge_backup_paths.py b/backend/tests/test_forge_backup_paths.py new file mode 100644 index 0000000..6ad89dd --- /dev/null +++ b/backend/tests/test_forge_backup_paths.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = ROOT / "ops/gitea-deploy-broker/forge_backup_paths.py" +if MODULE_PATH.is_file(): + spec = importlib.util.spec_from_file_location("forge_backup_paths_test", MODULE_PATH) + assert spec is not None and spec.loader is not None + paths = importlib.util.module_from_spec(spec) + spec.loader.exec_module(paths) +else: + paths = None + + +def _paths(): + if paths is None: + pytest.fail("fixed Forge backup path verifier has not been implemented", pytrace=False) + return paths + + +def volume_metadata(): + return { + "Name": "itworx-forge-master-key-backup", + "Driver": "local", + "Scope": "local", + "Options": None, + "Labels": { + "itworx.managed-by": "deployment-controller", + "itworx.purpose": "forge-master-key-backup", + }, + "Mountpoint": "/var/lib/docker/volumes/itworx-forge-master-key-backup/_data", + } + + +def controller_mounts(): + return [ + { + "Type": "volume", + "Name": "itworx-forge-master-key-backup", + "Driver": "local", + "Source": "/var/lib/docker/volumes/itworx-forge-master-key-backup/_data", + "Destination": "/forge-master-key-backup", + "RW": True, + }, + { + "Type": "bind", + "Name": "", + "Source": "/var/lib/docker", + "Destination": "/var/lib/docker", + "RW": False, + }, + { + "Type": "volume", + "Name": "itworx-managed-appdata", + "Driver": "local", + "Source": "/var/lib/docker/volumes/itworx-managed-appdata/_data", + "Destination": "/var/lib/docker/volumes/itworx-managed-appdata/_data", + "RW": True, + }, + { + "Type": "volume", + "Name": "itworx-controller-hub-releases", + "Driver": "local", + "Source": "/var/lib/docker/volumes/itworx-controller-hub-releases/_data", + "Destination": "/controller-hub-releases", + "RW": True, + }, + { + "Type": "bind", + "Name": "", + "Source": "/var/lib/docker/volumes/itworx-controller-hub-releases/_data/controller-config", + "Destination": "/controller-config", + "RW": False, + }, + ] + + +def mountinfo(*, backup_options="rw,nosuid,nodev", docker_options="ro,relatime"): + base = [ + "35 25 8:1 / / rw,relatime - ext4 /dev/sda1 rw,relatime", + f"41 35 8:2 / /var/lib/docker {docker_options} - ext4 /dev/sda2 ro,relatime", + f"42 35 8:3 / /forge-master-key-backup {backup_options} - ext4 /dev/sda3 rw,relatime", + ] + base.extend( + f"{50 + index} 41 8:{index + 4} / /var/lib/docker/containers/c{index}/mount{index} " + f"{'ro' if index % 2 else 'rw'},relatime - ext4 /dev/sdb{index} rw,relatime" + for index in range(35) + ) + return "\n".join(base) + + +def test_exact_local_backup_volume_metadata_is_accepted(): + module = _paths() + assert module.validate_volume_metadata(volume_metadata(), "/var/lib/docker") is None + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("Name", "foreign"), + ("Driver", "nfs"), + ("Scope", "global"), + ("Options", {"device": "/mnt/share"}), + ("Options", []), + ("Labels", {"itworx.managed-by": "deployment-controller"}), + ("Labels", { + "itworx.managed-by": "deployment-controller", + "itworx.purpose": "managed-appdata", + }), + ("Mountpoint", "/mnt/foreign"), + ], +) +def test_backup_volume_metadata_drift_is_rejected(field, value): + module = _paths() + item = volume_metadata() + item[field] = value + with pytest.raises(module.UnsafeForgeBackupVolume): + module.validate_volume_metadata(item, "/var/lib/docker") + + +def test_backup_volume_metadata_requires_the_fixed_docker_root(): + module = _paths() + with pytest.raises(module.UnsafeForgeBackupVolume): + module.validate_volume_metadata(volume_metadata(), "/mnt/docker") + + +def test_mount_contract_allows_only_the_fixed_rw_backup_and_ro_docker_root(): + module = _paths() + assert module.validate_controller_mounts( + controller_mounts(), + controller_id_before="a" * 64, + controller_id_after="a" * 64, + ) is None + + +def test_mount_contract_allows_managed_appdata_to_be_absent(): + module = _paths() + mounts = controller_mounts() + mounts.pop(2) + assert module.validate_controller_mounts( + mounts, + controller_id_before="a" * 64, + controller_id_after="a" * 64, + ) is None + + +def test_mount_contract_allows_only_the_exact_managed_appdata_nested_volume(): + module = _paths() + mounts = controller_mounts() + module.validate_controller_mounts( + mounts, + controller_id_before="a" * 64, + controller_id_after="a" * 64, + ) + mounts[-3]["Destination"] = "/var/lib/docker/volumes/foreign/_data" + with pytest.raises(module.UnsafeForgeBackupVolume): + module.validate_controller_mounts( + mounts, + controller_id_before="a" * 64, + controller_id_after="a" * 64, + ) + + +@pytest.mark.parametrize( + "damage", + [ + "controller_changed", + "backup_rw_disabled", + "backup_name_changed", + "backup_source_changed", + "backup_destination_changed", + "backup_driver_changed", + "duplicate_backup_mount", + "backup_source_alias", + "backup_target_child_alias", + "backup_target_parent_alias", + "docker_root_rw", + "docker_root_child_mount", + "managed_appdata_duplicate", + "managed_appdata_source_changed", + "managed_appdata_driver_changed", + "managed_appdata_target_alias", + "managed_appdata_name_alias", + "root_exposure", + ], +) +def test_mount_contract_rejects_aliases_and_mount_drift(damage): + module = _paths() + mounts = controller_mounts() + before = after = "a" * 64 + if damage == "controller_changed": + after = "b" * 64 + elif damage == "backup_rw_disabled": + mounts[0]["RW"] = False + elif damage == "backup_name_changed": + mounts[0]["Name"] = "foreign" + elif damage == "backup_source_changed": + mounts[0]["Source"] = "/mnt/foreign" + elif damage == "backup_destination_changed": + mounts[0]["Destination"] = "/backup-copy" + elif damage == "backup_driver_changed": + mounts[0]["Driver"] = "nfs" + elif damage == "duplicate_backup_mount": + mounts.append(dict(mounts[0])) + elif damage == "backup_source_alias": + mounts.append({ + "Type": "bind", "Source": volume_metadata()["Mountpoint"], + "Destination": "/backup-copy", "RW": False, + }) + elif damage == "backup_target_child_alias": + mounts.append({ + "Type": "bind", "Source": "/foreign", "Destination": "/forge-master-key-backup/state", + "RW": True, + }) + elif damage == "backup_target_parent_alias": + mounts.append({ + "Type": "bind", "Source": "/foreign", "Destination": "/", + "RW": True, + }) + elif damage == "docker_root_rw": + mounts[1]["RW"] = True + elif damage == "docker_root_child_mount": + mounts.append({ + "Type": "bind", "Source": "/var/lib/docker/volumes/other", + "Destination": "/var/lib/docker/volumes/other", "RW": True, + }) + elif damage == "managed_appdata_duplicate": + mounts.append(dict(mounts[2])) + elif damage == "managed_appdata_source_changed": + mounts[2]["Source"] = "/var/lib/docker/volumes/foreign/_data" + elif damage == "managed_appdata_driver_changed": + mounts[2]["Driver"] = "nfs" + elif damage == "managed_appdata_target_alias": + mounts[2]["Destination"] = "/appdata-copy" + elif damage == "managed_appdata_name_alias": + mounts[2].update({ + "Source": "/var/lib/docker/volumes/foreign/_data", + "Destination": "/appdata-copy", + }) + else: + mounts.append({ + "Type": "bind", "Source": "/", "Destination": "/", "RW": True, + }) + with pytest.raises(module.UnsafeForgeBackupVolume): + module.validate_controller_mounts( + mounts, controller_id_before=before, controller_id_after=after + ) + + +def test_proc_mountinfo_requires_the_exact_rw_backup_and_ro_docker_mounts(): + module = _paths() + assert module._validate_mountinfo_contract(mountinfo()) is None + for unsafe in ( + mountinfo(backup_options="ro,nosuid,nodev"), + mountinfo(docker_options="rw,relatime"), + mountinfo().replace("/forge-master-key-backup", "/backup-copy"), + mountinfo() + "\n43 35 8:4 / /forge-master-key-backup rw - ext4 /dev/sda4 rw", + ): + with pytest.raises(module.UnsafeForgeBackupVolume): + module._validate_mountinfo_contract(unsafe) + + +@pytest.mark.skipif(os.name == "nt", reason="directory inode checks require POSIX descriptors") +def test_directory_identity_binds_backup_mount_to_the_fixed_docker_volume_source(tmp_path): + module = _paths() + source = tmp_path / "source" + mounted = tmp_path / "mounted" + source.mkdir() + mounted.mkdir() + source_fd = os.open(source, os.O_RDONLY) + mounted_fd = os.open(mounted, os.O_RDONLY) + same_fd = os.open(source, os.O_RDONLY) + try: + module._require_same_directory(source_fd, same_fd) + with pytest.raises(module.UnsafeForgeBackupVolume, match="mount_source_mismatch"): + module._require_same_directory(source_fd, mounted_fd) + finally: + os.close(source_fd) + os.close(mounted_fd) + os.close(same_fd) + + +@pytest.mark.skipif( + os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0, + reason="directory ownership checks require root on POSIX", +) +def test_fixed_directory_walk_rejects_symlinks_and_unsafe_modes(tmp_path): + module = _paths() + volume_root = tmp_path / "volume" + volume_root.mkdir(mode=0o755) + volume_root.chmod(0o755) + fd = module._open_directory_no_symlinks(volume_root, anchor=tmp_path) + os.close(fd) + + linked = tmp_path / "linked" + linked.symlink_to(volume_root, target_is_directory=True) + with pytest.raises(module.UnsafeForgeBackupVolume): + module._open_directory_no_symlinks(linked, anchor=tmp_path) + + volume_root.chmod(0o777) + with pytest.raises(module.UnsafeForgeBackupVolume): + module._open_directory_no_symlinks(volume_root, anchor=tmp_path) + + +@pytest.mark.skipif( + os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0, + reason="directory ACL checks require root on POSIX", +) +def test_fixed_directory_walk_rejects_access_acl(tmp_path, monkeypatch): + module = _paths() + volume_root = tmp_path / "volume" + volume_root.mkdir(mode=0o755) + volume_root.chmod(0o755) + monkeypatch.setattr(module.os, "listxattr", lambda _fd: ["system.posix_acl_access"]) + with pytest.raises(module.UnsafeForgeBackupVolume): + module._open_directory_no_symlinks(volume_root, anchor=tmp_path) + + +def test_read_only_identity_check_fails_closed_without_creating_or_repairing_paths(tmp_path, monkeypatch): + module = _paths() + sentinel = tmp_path / "unchanged" + sentinel.write_bytes(b"keep") + bad = volume_metadata() + bad["Labels"]["itworx.purpose"] = "foreign-purpose" + monkeypatch.setattr(module, "_read_volume_snapshot", lambda: (bad, "/var/lib/docker")) + monkeypatch.setattr( + module, + "_open_fixed_backup_root", + lambda: pytest.fail("unsafe volume metadata must fail before opening the data root"), + ) + with pytest.raises(module.UnsafeForgeBackupVolume): + module.validate_forge_backup_volume() + assert sentinel.read_bytes() == b"keep" + assert sorted(path.name for path in tmp_path.iterdir()) == ["unchanged"] diff --git a/backend/tests/test_forge_bootstrap_apply.py b/backend/tests/test_forge_bootstrap_apply.py new file mode 100644 index 0000000..e474fa3 --- /dev/null +++ b/backend/tests/test_forge_bootstrap_apply.py @@ -0,0 +1,1048 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import threading + +import pytest +from sqlmodel import Session, SQLModel, create_engine, select + +from backend.app.core.config import settings +from backend.app.core.time import utc_now +from backend.app.db.models import DeploymentJob, Policy +from backend.app.schemas.enrollment import EnrollmentTrustConfigV1 +from backend.app.schemas.forge_bootstrap_apply import ForgeBootstrapApplyV1 +from backend.app.services.enrollment_plane import EnrollmentPlane +from backend.app.services.forge_bootstrap_apply import ( + ForgeBootstrapApplyError, + ForgeBootstrapApplyService, + canonical_preflight_receipt, + forge_oidc_attestation_matches, +) +from backend.app.services.secret_store import set_secret + +_REVISION = "a" * 40 +_WORKSPACE_DIGEST = "sha256:" + "b" * 64 +_FRONTDOOR_DIGEST = "sha256:" + "c" * 64 +_PREFLIGHT_ID = "forge-preflight-0123456789abcdef" +_SERVICE_HEALTH = ["api", "dashboard", "itworx-forge", "postgres", "worker"] +_OIDC_OPENID_MAPPING_ID = "01234567-89ab-4cde-8fab-0123456789ab" +_OIDC_EMAIL_MAPPING_ID = "21234567-89ab-4cde-8fab-0123456789ab" +_OIDC_PROFILE_MAPPING_ID = "31234567-89ab-4cde-8fab-0123456789ab" +_OIDC_PROVIDER_ID = "456" +_OIDC_SIGNING_KEY_ID = "11234567-89ab-4cde-8fab-0123456789ab" +_OIDC_FINGERPRINT = "ab" * 32 +_OIDC_KEY_KID = base64.urlsafe_b64encode(bytes(range(64))).decode("ascii").rstrip("=") +_OIDC_MAPPING_IDS = { + "openid": _OIDC_OPENID_MAPPING_ID, + "email": _OIDC_EMAIL_MAPPING_ID, + "profile": _OIDC_PROFILE_MAPPING_ID, +} +_OIDC_MAPPING_EXPRESSION_SHA256 = { + "openid": "38e58da331cc5378d4f269389242aecb80adf30093c405524dc1ab5aab9d452f", + "email": "d1200eabeffc3b7f379e9c1636367b69befb0b1ac2b5d82b7be20e78667f2b94", + "profile": "648b3f6dbf2212940309a581b239c9996562ca017a89be22bcfa93f9aa2c26ae", +} +_FORGE_PRIVATE_OWNER = "itworx-forge-private" +_FORGE_SOURCE_URL = "https://gitea.example.com/Jens/itworx-forge" + + +@pytest.fixture(autouse=True) +def _forge_oidc_protected_pins(monkeypatch): + apis = dict(settings.apis) + apis["AUTHENTIK_FORGE_OIDC_BINDING_VALID"] = True + apis["AUTHENTIK_FORGE_OIDC_PROVIDER_ID"] = _OIDC_PROVIDER_ID + apis["AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING"] = _OIDC_OPENID_MAPPING_ID + apis["AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING"] = _OIDC_EMAIL_MAPPING_ID + apis["AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING"] = _OIDC_PROFILE_MAPPING_ID + apis["AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID"] = _OIDC_SIGNING_KEY_ID + apis["AUTHENTIK_OIDC_AUTHORIZATION_FLOW"] = "default-provider-authorization-implicit-consent" + apis["AUTHENTIK_OIDC_INVALIDATION_FLOW"] = "default-provider-invalidation-flow" + apis["FORGE_PRIVATE_PACKAGE_OWNER"] = _FORGE_PRIVATE_OWNER + monkeypatch.setattr(settings, "apis", apis) + + +def _oidc_attestation(): + return { + "issuer": "https://auth.example-host.com/application/o/itworx-forge/", + "audience": "itworx-forge", + "clientId": "itworx-forge", + "clientType": "confidential", + "callback": "https://forge.example.com/auth/callback", + "applicationId": "41234567-89ab-4cde-8fab-0123456789ab", + "providerId": _OIDC_PROVIDER_ID, + "clientSecretMatched": True, + "authorizationFlowId": "default-provider-authorization-implicit-consent", + "invalidationFlowId": "default-provider-invalidation-flow", + "roleGroups": ["forge-owners", "forge-operators", "forge-viewers"], + "scopeMappingIds": _OIDC_MAPPING_IDS.copy(), + "scopeMappingExpressionSha256": _OIDC_MAPPING_EXPRESSION_SHA256.copy(), + "profileMappingName": "ITWorx Forge profile and groups claim v1", + "standardMappingsManaged": True, + "supportedScopes": ["email", "openid", "profile"], + "authorizationCodeFlowSupported": True, + "signingKeyId": _OIDC_SIGNING_KEY_ID, + "signingKeyType": "RSA", + "signingKeyPrivateAvailable": True, + "signingKeyFingerprintSha256": _OIDC_FINGERPRINT, + "signingKeyKid": _OIDC_KEY_KID, + "tokenSigningAlgorithm": "RS256", + "jwksKid": _OIDC_KEY_KID, + } + + +def _profile(**overrides): + value = { + "api_version": "itworx.forge-first-install/v1", + "revision": _REVISION, + "workspace_image_digest": _WORKSPACE_DIGEST, + "frontdoor_image_digest": _FRONTDOOR_DIGEST, + "preflight_job_id": _PREFLIGHT_ID, + "preflight_sha256": "d" * 64, + } + value.update(overrides) + return ForgeBootstrapApplyV1.model_validate(value) + + +def _session(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'forge-first-install.db'}") + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def _passed_preflight(session): + profile = { + "api_version": "itworx.forge-bootstrap-preflight/v1", + "revision": _REVISION, + "workspace_image_digest": _WORKSPACE_DIGEST, + "frontdoor_image_digest": _FRONTDOOR_DIGEST, + } + check_names = { + "deployment_apply_policy", + "controller_trust", + "managed_appdata_root_selection", + "exact_main", + "source_manifest", + "managed_appdata", + "proxy_network", + "resource_collisions", + "push_validation", + "image_registry", + "oidc", + "secret_delivery", + } + job = DeploymentJob( + id=_PREFLIGHT_ID, + application_id="itworx-forge", + repository="jens/itworx-forge", + revision=_REVISION, + plan_sha256=hashlib.sha256( + json.dumps(profile, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest(), + idempotency_sha256="e" * 64, + status="preflight_succeeded", + stage="preflight_complete", + completed_at=utc_now(), + plan_json=profile, + preflight_json={ + "apiVersion": "itworx.forge-bootstrap-preflight/v1", + "status": "passed", + "blockers": [], + "unknowns": [], + "checks": [{"name": name, "status": "passed", "detail": "passed"} for name in sorted(check_names)], + }, + evidence_json={ + "profile": "forge-production-bootstrap", + "policyId": "forge-apply-policy", + "workspaceImageDigest": _WORKSPACE_DIGEST, + "frontdoorImageDigest": _FRONTDOOR_DIGEST, + "controller": { + "mainRevision": _REVISION, + "attestations": { + "push_validation": { + "context": "Managed validation / full (push)", + "conclusion": "success", + "revision": _REVISION, + }, + "image_registry": { + "owner": _FORGE_PRIVATE_OWNER, + "ownerVisibility": "private", + "workspaceDigest": _WORKSPACE_DIGEST, + "frontdoorDigest": _FRONTDOOR_DIGEST, + "workspacePullable": True, + "frontdoorPullable": True, + "workspaceSource": _FORGE_SOURCE_URL, + "workspaceRevision": _REVISION, + "frontdoorSource": _FORGE_SOURCE_URL, + "frontdoorRevision": _REVISION, + }, + "oidc": _oidc_attestation(), + "secret_delivery": { + "requiredFilesReady": True, + "metadataSha256": "f" * 64, + }, + }, + }, + }, + ) + session.add(job) + session.commit() + return job + + +def _trust(session, *, controller_identity="unraid-controller"): + EnrollmentPlane.set_trust_config( + session, + EnrollmentTrustConfigV1( + controller_identity=controller_identity, + bootstrap_secret_ref="CONTROLLER_BOOTSTRAP", + trusted_appdata_root="/var/lib/docker/volumes/itworx-managed-appdata/_data", + managed_appdata_backup_acknowledged=True, + approved_networks=("itworx-mcp-hub-npm",), + ), + ) + set_secret(session, "FORGE_DEPLOYMENT_BROKER", "f" * 40, commit=True) + + +def _policy(session, *, manual="allowed"): + session.add( + Policy( + id="forge-apply-policy", + action="deployment.apply", + target="itworx-forge", + voice="blocked", + manual=manual, + risk="high", + reason="explicit exact target", + ) + ) + session.commit() + + +def _success(request_id): + return { + "apiVersion": "itworx.forge-first-install/v1", + "status": "deployed", + "request": request_id, + "repository": "jens/itworx-forge", + "revision": _REVISION, + "operation": "forge_first_install", + "workspaceImageDigest": _WORKSPACE_DIGEST, + "frontdoorImageDigest": _FRONTDOOR_DIGEST, + "receipt": { + "revision": _REVISION, + "workspaceImageDigest": _WORKSPACE_DIGEST, + "frontdoorImageDigest": _FRONTDOOR_DIGEST, + "migration": {"service": "migrate", "status": "completed", "result": "succeeded"}, + "health": {"status": "healthy", "services": _SERVICE_HEALTH}, + }, + } + + +def _needs_credentials(request_id): + return { + "apiVersion": "itworx.forge-first-install/v1", + "status": "needs_database_credentials", + "request": request_id, + "repository": "jens/itworx-forge", + "revision": _REVISION, + "operation": "forge_first_install", + "workspaceImageDigest": _WORKSPACE_DIGEST, + "frontdoorImageDigest": _FRONTDOOR_DIGEST, + } + + +class _Broker: + def __init__(self, response=None, status_response=None): + self.response = response + self.status_response = status_response + self.apply_calls = [] + self.status_calls = [] + self.resume_calls = [] + self.session = None + + def forge_bootstrap_apply(self, profile, *, idempotency_key, secret): + assert self.session is not None + request_id = ForgeBootstrapApplyService.request_id(profile, idempotency_key) + rows = self.session.exec( + select(DeploymentJob).where(DeploymentJob.broker_request_id == request_id) + ).all() + assert len(rows) == 1 + assert rows[0].status == "applying" + self.apply_calls.append((profile.model_dump(mode="json"), idempotency_key, secret)) + if isinstance(self.response, Exception): + raise self.response + return self.response(request_id) if callable(self.response) else self.response + + def forge_bootstrap_status(self, request_id, *, secret): + self.status_calls.append((request_id, secret)) + if isinstance(self.status_response, Exception): + raise self.status_response + return self.status_response(request_id) if callable(self.status_response) else self.status_response + + def forge_bootstrap_resume(self, request_id, *, secret): + rows = self.session.exec( + select(DeploymentJob).where(DeploymentJob.broker_request_id == request_id) + ).all() + assert len(rows) == 1 + assert rows[0].status == "applying" + assert rows[0].stage == "resume_request_pending" + self.resume_calls.append((request_id, secret)) + if isinstance(self.response, Exception): + raise self.response + return self.response(request_id) if callable(self.response) else self.response + + +def _service(session, broker): + broker.session = session + return ForgeBootstrapApplyService(broker=broker) + + +def test_apply_profile_rejects_any_caller_selected_runtime_input(): + _profile() + for field, value in ( + ("target", "other-host"), + ("endpoint", "https://attacker.invalid"), + ("secret_ref", "OTHER_SECRET"), + ("command", "docker compose up"), + ("host_path", "/etc"), + ): + with pytest.raises(ValueError): + _profile(**{field: value}) + + +def test_apply_rejects_preflight_hash_mismatch_before_controller_call(tmp_path): + with _session(tmp_path) as session: + job = _passed_preflight(session) + _trust(session) + _policy(session) + broker = _Broker(response=lambda request_id: _success(request_id)) + profile = _profile(preflight_sha256="0" * 64) + + with pytest.raises(ForgeBootstrapApplyError, match="preflight receipt"): + _service(session, broker).apply(session, profile, idempotency_key="forge-first-0001") + + assert broker.apply_calls == [] + assert session.get(DeploymentJob, job.id) is not None + assert len(session.exec(select(DeploymentJob).where(DeploymentJob.id.startswith("forge-install-"))).all()) == 0 + + +def test_blocked_or_unknown_stored_preflight_cannot_submit_apply(tmp_path): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + preflight.preflight_json = { + **preflight.preflight_json, + "unknowns": ["image_registry"], + } + session.add(preflight) + session.commit() + _trust(session) + _policy(session) + broker = _Broker(response=lambda request_id: _success(request_id)) + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + + with pytest.raises(ForgeBootstrapApplyError, match="no unknown checks"): + _service(session, broker).apply(session, profile, idempotency_key="forge-first-0007") + + assert broker.apply_calls == [] + + +def test_preflight_policy_binding_must_still_match_at_apply(tmp_path): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + _trust(session) + _policy(session) + session.delete(session.get(Policy, "forge-apply-policy")) + session.add( + Policy( + id="changed-forge-apply-policy", + action="deployment.apply", + target="itworx-forge", + voice="blocked", + manual="allowed", + risk="high", + ) + ) + session.commit() + broker = _Broker(response=lambda request_id: _success(request_id)) + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + + with pytest.raises(ForgeBootstrapApplyError, match="policy changed after preflight"): + _service(session, broker).apply(session, profile, idempotency_key="forge-first-0008") + + assert broker.apply_calls == [] + + +def test_stored_managed_root_trust_and_fixed_secret_are_required(tmp_path): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + _policy(session) + broker = _Broker(response=lambda request_id: _success(request_id)) + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + + with pytest.raises(ForgeBootstrapApplyError, match="managed-root trust"): + _service(session, broker).apply(session, profile, idempotency_key="forge-first-0010") + + assert broker.apply_calls == [] + + +@pytest.mark.parametrize("controller_identity", ["tower-deployment-controller", "other-controller"]) +def test_foreign_controller_identity_blocks_before_install_intent(tmp_path, controller_identity): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + _trust(session, controller_identity=controller_identity) + _policy(session) + broker = _Broker(response=lambda request_id: _success(request_id)) + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + + with pytest.raises(ForgeBootstrapApplyError, match="managed-root trust"): + _service(session, broker).apply( + session, profile, idempotency_key="forge-controller-identity-0001" + ) + + assert broker.apply_calls == [] + assert session.exec(select(DeploymentJob)).all() == [preflight] + + +@pytest.mark.parametrize( + ("owner", "visibility"), + [("another-owner", "private"), (_FORGE_PRIVATE_OWNER, "public")], +) +def test_apply_rejects_registry_attestation_without_fixed_private_owner( + tmp_path, owner, visibility +): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + evidence = json.loads(json.dumps(preflight.evidence_json)) + evidence["controller"]["attestations"]["image_registry"]["owner"] = owner + evidence["controller"]["attestations"]["image_registry"]["ownerVisibility"] = visibility + preflight.evidence_json = evidence + session.add(preflight) + session.commit() + _trust(session) + _policy(session) + broker = _Broker(response=lambda request_id: _success(request_id)) + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + + with pytest.raises(ForgeBootstrapApplyError, match="verified controller attestations"): + _service(session, broker).apply( + session, profile, idempotency_key="forge-first-private-owner-0001" + ) + + assert broker.apply_calls == [] + + +def test_apply_persists_before_post_and_only_marks_deployed_with_full_receipt(tmp_path): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + _trust(session) + _policy(session) + broker = _Broker(response=lambda request_id: _success(request_id)) + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + + result = _service(session, broker).apply( + session, profile, idempotency_key="forge-first-0002" + ) + + assert result["status"] == "deployed" + assert len(broker.apply_calls) == 1 + request_id = result["request"] + assert len(request_id) == 64 + row = session.get(DeploymentJob, result["id"]) + assert row is not None and row.broker_request_id == request_id + assert row.evidence_json["controller"]["receipt"]["migration"]["result"] == "succeeded" + + +def test_wrong_digest_or_incomplete_health_receipt_never_marks_deployed(tmp_path): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + _trust(session) + _policy(session) + response = _success("will-be-replaced") + response["receipt"]["health"]["services"] = ["api", "dashboard", "itworx-forge", "postgres"] + broker = _Broker(response=lambda request_id: {**response, "request": request_id}) + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + + result = _service(session, broker).apply( + session, profile, idempotency_key="forge-first-0009" + ) + + assert result["status"] == "outcome_unknown" + assert result["evidence"]["controller"] == {} + + +def test_same_idempotency_key_cannot_be_rebound_to_a_different_profile(tmp_path): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + _trust(session) + _policy(session) + broker = _Broker(response=lambda request_id: _success(request_id)) + service = _service(session, broker) + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + service.apply(session, profile, idempotency_key="forge-first-0011") + + with pytest.raises(ForgeBootstrapApplyError, match="different Forge first-install profile"): + service.apply( + session, + _profile(preflight_sha256="e" * 64), + idempotency_key="forge-first-0011", + ) + + assert len(broker.apply_calls) == 1 + + +@pytest.mark.parametrize( + ("application_id", "repository"), + ( + ("other-application", "jens/itworx-forge"), + ("itworx-forge", "other/repository"), + ), +) +def test_idempotency_replay_rejects_existing_job_for_another_target( + tmp_path, application_id, repository +): + with _session(tmp_path) as session: + profile = _profile() + key = "forge-crossjob-0001" + idempotency_digest = hashlib.sha256( + f"forge-first-install:{key}".encode() + ).hexdigest() + plan = profile.model_dump(mode="json") + session.add( + DeploymentJob( + id="unrelated-deployment-job", + application_id=application_id, + repository=repository, + revision=_REVISION, + plan_sha256=hashlib.sha256( + json.dumps(plan, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest(), + idempotency_sha256=idempotency_digest, + status="failed", + stage="complete", + plan_json=plan, + ) + ) + session.commit() + broker = _Broker() + + with pytest.raises(ForgeBootstrapApplyError, match="different application or repository"): + _service(session, broker).apply(session, profile, idempotency_key=key) + + assert broker.apply_calls == [] + assert broker.status_calls == [] + + +def test_integrity_error_replay_rejects_raced_job_for_another_target(tmp_path, monkeypatch): + from sqlalchemy.exc import IntegrityError + + engine = create_engine(f"sqlite:///{tmp_path / 'forge-integrity-replay.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + preflight = _passed_preflight(session) + _trust(session) + _policy(session) + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + key = "forge-integrity-race-0001" + idempotency_digest = hashlib.sha256( + f"forge-first-install:{key}".encode() + ).hexdigest() + job_id = f"forge-install-{idempotency_digest}" + plan = profile.model_dump(mode="json") + broker = _Broker() + original_commit = session.commit + + def insert_foreign_race_then_conflict(): + session.rollback() + with Session(engine) as competing_session: + competing_session.add( + DeploymentJob( + id=job_id, + application_id="itworx-forge", + repository="other/repository", + revision=_REVISION, + plan_sha256=hashlib.sha256( + json.dumps(plan, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest(), + idempotency_sha256=idempotency_digest, + status="failed", + stage="complete", + plan_json=plan, + ) + ) + competing_session.commit() + raise IntegrityError("simulated concurrent insert", {}, RuntimeError("duplicate")) + + monkeypatch.setattr(session, "commit", insert_foreign_race_then_conflict) + with pytest.raises(ForgeBootstrapApplyError, match="different application or repository"): + _service(session, broker).apply(session, profile, idempotency_key=key) + + assert broker.apply_calls == [] + assert broker.status_calls == [] + monkeypatch.setattr(session, "commit", original_commit) + + +def test_broker_uses_fixed_forge_apply_resume_and_status_routes(monkeypatch): + from backend.app.services.deployment_broker import DeploymentBrokerClient + + client = DeploymentBrokerClient(base_url="https://controller.example", verify_tls=True) + calls = [] + + def request(method, path, **kwargs): + calls.append((method, path, kwargs)) + return {"status": "accepted"} + + monkeypatch.setattr(client, "_request", request) + profile = _profile() + client.forge_bootstrap_apply( + profile, idempotency_key="forge-broker-0001", secret="s" * 40 + ) + client.forge_bootstrap_status("a" * 64, secret="s" * 40) + client.forge_bootstrap_resume("a" * 64, secret="s" * 40) + + assert [(method, path) for method, path, _kwargs in calls] == [ + ("POST", "/v1/appops/forge/bootstrap/apply"), + ("GET", "/v1/appops/forge/bootstrap/status/" + "a" * 64), + ("POST", "/v1/appops/forge/bootstrap/resume/" + "a" * 64), + ] + assert calls[0][2]["payload"] == { + "profile": profile.model_dump(mode="json"), + "idempotencyKey": "forge-broker-0001", + } + assert calls[2][2]["payload"] == {} + + +def test_unknown_apply_outcome_reconciles_by_get_without_resubmitting(tmp_path): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + _trust(session) + _policy(session) + broker = _Broker(response={"status": "unreachable"}) + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + service = _service(session, broker) + + first = service.apply(session, profile, idempotency_key="forge-first-0003") + assert first["status"] == "outcome_unknown" + request_id = first["request"] + broker.status_response = lambda _request_id: _success(request_id) + + reconciled = service.apply(session, profile, idempotency_key="forge-first-0003") + + assert reconciled["status"] == "deployed" + assert len(broker.apply_calls) == 1 + assert broker.status_calls == [(request_id, "f" * 40)] + + +def test_preflight_changed_is_recorded_as_no_mutation(tmp_path): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + _trust(session) + _policy(session) + broker = _Broker() + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + broker.response = lambda request_id: { + "apiVersion": "itworx.forge-first-install/v1", + "status": "preflight_changed", + "request": request_id, + "repository": "jens/itworx-forge", + "revision": _REVISION, + "operation": "forge_first_install", + "workspaceImageDigest": _WORKSPACE_DIGEST, + "frontdoorImageDigest": _FRONTDOOR_DIGEST, + "noMutation": True, + } + + result = _service(session, broker).apply( + session, profile, idempotency_key="forge-first-0004" + ) + + assert result["status"] == "preflight_changed" + assert result["evidence"]["controller"]["noMutation"] is True + assert result["result"] == "Controller rejected changed Forge first-install preflight before mutation." + + +def test_database_credentials_state_requires_explicit_resume_and_never_reposts_apply(tmp_path): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + _trust(session) + _policy(session) + broker = _Broker() + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + service = _service(session, broker) + broker.response = lambda request_id: _needs_credentials(request_id) + + first = service.apply(session, profile, idempotency_key="forge-first-0005") + assert first["status"] == "needs_database_credentials" + request_id = first["request"] + assert len(broker.apply_calls) == 1 + + # Replaying apply only asks the durable read-only status endpoint. + broker.status_response = lambda request: _needs_credentials(request) + replay = service.apply(session, profile, idempotency_key="forge-first-0005") + assert replay["status"] == "needs_database_credentials" + assert len(broker.apply_calls) == 1 + + # Only the explicit resume call may submit the controller's one-shot resume. + broker.response = lambda request: _success(request) + resumed = service.resume(session, first["id"]) + assert resumed["status"] == "deployed" + assert len(broker.apply_calls) == 1 + assert broker.resume_calls == [(request_id, "f" * 40)] + + +def test_concurrent_explicit_resume_claims_only_once(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'forge-concurrent-resume.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + preflight = _passed_preflight(session) + _trust(session) + _policy(session) + initial_broker = _Broker(response=lambda request_id: _needs_credentials(request_id)) + initial_broker.session = session + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + initial = ForgeBootstrapApplyService(broker=initial_broker).apply( + session, profile, idempotency_key="forge-concurrent-resume-0001" + ) + assert initial["status"] == "needs_database_credentials" + + status_barrier = threading.Barrier(2) + + class ConcurrentBroker: + def __init__(self): + self.status_calls = [] + self.resume_calls = [] + self.lock = threading.Lock() + + def forge_bootstrap_status(self, request_id, *, secret): + with self.lock: + self.status_calls.append((request_id, secret)) + status_barrier.wait(timeout=10) + return _needs_credentials(request_id) + + def forge_bootstrap_resume(self, request_id, *, secret): + with self.lock: + self.resume_calls.append((request_id, secret)) + return { + "apiVersion": "itworx.forge-first-install/v1", + "status": "accepted", + "request": request_id, + "repository": "jens/itworx-forge", + "revision": _REVISION, + "operation": "forge_first_install", + "workspaceImageDigest": _WORKSPACE_DIGEST, + "frontdoorImageDigest": _FRONTDOOR_DIGEST, + } + + broker = ConcurrentBroker() + service = ForgeBootstrapApplyService(broker=broker) + results = [] + errors = [] + + def resume_in_own_session(): + try: + with Session(engine) as session: + results.append(service.resume(session, initial["id"])) + except Exception as exc: # noqa: BLE001 - surface thread failures in the assertion below. + errors.append(exc) + + threads = [threading.Thread(target=resume_in_own_session) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=20) + + assert all(not thread.is_alive() for thread in threads) + assert errors == [] + assert len(broker.status_calls) == 2 + assert len(broker.resume_calls) == 1 + assert all(result["status"] == "applying" for result in results) + + +def test_stale_needs_credentials_get_cannot_reopen_claimed_resume(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'forge-stale-resume-status.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + preflight = _passed_preflight(session) + _trust(session) + _policy(session) + initial_broker = _Broker(response=lambda request_id: _needs_credentials(request_id)) + initial_broker.session = session + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + initial = ForgeBootstrapApplyService(broker=initial_broker).apply( + session, profile, idempotency_key="forge-stale-resume-0001" + ) + assert initial["status"] == "needs_database_credentials" + + post_started = threading.Event() + release_post = threading.Event() + + class DelayedResumeBroker: + def __init__(self): + self.status_calls = [] + self.resume_calls = [] + + def forge_bootstrap_status(self, request_id, *, secret): + self.status_calls.append((request_id, secret)) + return _needs_credentials(request_id) + + def forge_bootstrap_resume(self, request_id, *, secret): + self.resume_calls.append((request_id, secret)) + post_started.set() + assert release_post.wait(timeout=10) + return { + "apiVersion": "itworx.forge-first-install/v1", + "status": "accepted", + "request": request_id, + "repository": "jens/itworx-forge", + "revision": _REVISION, + "operation": "forge_first_install", + "workspaceImageDigest": _WORKSPACE_DIGEST, + "frontdoorImageDigest": _FRONTDOOR_DIGEST, + } + + broker = DelayedResumeBroker() + service = ForgeBootstrapApplyService(broker=broker) + first_result = [] + first_errors = [] + + def first_resume(): + try: + with Session(engine) as session: + first_result.append(service.resume(session, initial["id"])) + except Exception as exc: # noqa: BLE001 - surface thread failures in the assertion below. + first_errors.append(exc) + + first_thread = threading.Thread(target=first_resume) + first_thread.start() + assert post_started.wait(timeout=10) + + # The controller has not processed the first POST yet and still answers + # needs_database_credentials. That stale observation cannot reset the + # persisted local claim or authorize another resume POST. + with Session(engine) as session: + second_result = service.resume(session, initial["id"]) + assert second_result["status"] == "outcome_unknown" + assert second_result["stage"] == "resume_status_unresolved" + + release_post.set() + first_thread.join(timeout=15) + assert not first_thread.is_alive() + assert first_errors == [] + assert first_result[0]["status"] == "outcome_unknown" + assert len(broker.status_calls) == 2 + assert len(broker.resume_calls) == 1 + + +def test_resume_refuses_without_signed_pre_migration_credentials_state(tmp_path): + with _session(tmp_path) as session: + preflight = _passed_preflight(session) + _trust(session) + _policy(session) + broker = _Broker(response=lambda request: _success(request)) + profile = _profile(preflight_sha256=canonical_preflight_receipt(preflight)) + service = _service(session, broker) + first = service.apply(session, profile, idempotency_key="forge-first-0006") + assert first["status"] == "deployed" + + result = service.resume(session, first["id"]) + + assert result["status"] == "deployed" + assert broker.resume_calls == [] + + +def test_preflight_serialization_exposes_server_computed_receipt_digest(tmp_path): + from backend.app.services.forge_bootstrap_preflight import ( + ForgeBootstrapPreflightService, + ) + + with _session(tmp_path) as session: + job = _passed_preflight(session) + serialized = ForgeBootstrapPreflightService.serialize_job(job) + + assert serialized["preflightSha256"] == canonical_preflight_receipt(job) + + +def test_controller_passed_checks_require_profile_bound_attestations(monkeypatch): + from backend.app.schemas.forge_bootstrap import ForgeBootstrapPreflightV1 + from backend.app.services.forge_bootstrap_apply import _attestations_ready + from backend.app.services.forge_bootstrap_preflight import ( + _checked_controller_result, + ) + + profile = ForgeBootstrapPreflightV1.model_validate( + { + "api_version": "itworx.forge-bootstrap-preflight/v1", + "revision": _REVISION, + "workspace_image_digest": _WORKSPACE_DIGEST, + "frontdoor_image_digest": _FRONTDOOR_DIGEST, + } + ) + checks = { + name: {"status": "passed"} + for name in ( + "exact_main", "source_manifest", "managed_appdata", "proxy_network", + "resource_collisions", "push_validation", "image_registry", "oidc", "secret_delivery", + ) + } + result = { + "apiVersion": "itworx.forge-bootstrap-preflight/v1", + "status": "preflight_ok", + "revision": _REVISION, + "workspaceImageDigest": _WORKSPACE_DIGEST, + "frontdoorImageDigest": _FRONTDOOR_DIGEST, + "checks": checks, + "evidence": {}, + } + assert _checked_controller_result(result, profile) is None + + result["evidence"] = { + "attestations": { + "push_validation": { + "context": "Managed validation / full (push)", + "conclusion": "success", + "revision": _REVISION, + }, + "image_registry": { + "owner": _FORGE_PRIVATE_OWNER, + "ownerVisibility": "private", + "workspaceDigest": _WORKSPACE_DIGEST, + "frontdoorDigest": _FRONTDOOR_DIGEST, + "workspacePullable": True, + "frontdoorPullable": True, + "workspaceSource": _FORGE_SOURCE_URL, + "workspaceRevision": _REVISION, + "frontdoorSource": _FORGE_SOURCE_URL, + "frontdoorRevision": _REVISION, + }, + "oidc": _oidc_attestation(), + "secret_delivery": {"requiredFilesReady": True, "metadataSha256": "f" * 64}, + } + } + parsed = _checked_controller_result(result, profile) + assert parsed is not None + assert parsed[1]["attestations"]["image_registry"]["workspaceDigest"] == _WORKSPACE_DIGEST + assert parsed[1]["attestations"]["oidc"] == _oidc_attestation() + assert _attestations_ready(parsed[1], _profile()) + + for protected_owner in (None, "Jens", "owner.with.dot", "a" * 40): + monkeypatch.setitem(settings.apis, "FORGE_PRIVATE_PACKAGE_OWNER", protected_owner) + assert not _attestations_ready(parsed[1], _profile()) + monkeypatch.setitem(settings.apis, "FORGE_PRIVATE_PACKAGE_OWNER", _FORGE_PRIVATE_OWNER) + + # A positive controller OIDC status is downgraded unless the signed proof + # matches the independently configured AppOps mapping and signing-key IDs. + result["evidence"]["attestations"]["oidc"]["scopeMappingIds"]["profile"] = "41234567-89ab-4cde-8fab-0123456789ab" + parsed = _checked_controller_result(result, profile) + assert parsed is not None + assert next(item for item in parsed[0] if item["name"] == "oidc")["status"] == "blocked" + assert "oidc" not in parsed[1].get("attestations", {}) + result["evidence"]["attestations"]["oidc"] = _oidc_attestation() + + monkeypatch.setitem(settings.apis, "AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING", None) + parsed = _checked_controller_result(result, profile) + assert parsed is not None + assert next(item for item in parsed[0] if item["name"] == "oidc")["status"] == "blocked" + monkeypatch.setitem(settings.apis, "AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING", _OIDC_PROFILE_MAPPING_ID) + monkeypatch.setitem(settings.apis, "AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID", None) + assert not _attestations_ready( + {"attestations": {"oidc": _oidc_attestation()}}, _profile() + ) + monkeypatch.setitem(settings.apis, "AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID", _OIDC_SIGNING_KEY_ID) + + for field, value in ( + ("clientSecretMatched", False), + ("scopeMappingExpressionSha256", {**_OIDC_MAPPING_EXPRESSION_SHA256, "profile": "0" * 64}), + ("signingKeyPrivateAvailable", False), + ("jwksKid", "attacker-kid"), + ): + original = _oidc_attestation() + result["evidence"]["attestations"]["oidc"] = {**original, field: value} + parsed = _checked_controller_result(result, profile) + assert parsed is not None + assert next(item for item in parsed[0] if item["name"] == "oidc")["status"] == "blocked" + result["evidence"]["attestations"]["oidc"] = _oidc_attestation() + + result["evidence"]["attestations"]["image_registry"]["ownerVisibility"] = "public" + assert _checked_controller_result(result, profile) is None + result["evidence"]["attestations"]["image_registry"]["ownerVisibility"] = "private" + result["evidence"]["attestations"]["image_registry"]["owner"] = "unreviewed-owner" + assert _checked_controller_result(result, profile) is None + result["evidence"]["attestations"]["image_registry"]["owner"] = _FORGE_PRIVATE_OWNER + result["evidence"]["attestations"]["image_registry"]["workspaceSource"] = "https://attacker.invalid/repo" + assert _checked_controller_result(result, profile) is None + result["evidence"]["attestations"]["image_registry"]["workspaceSource"] = _FORGE_SOURCE_URL + result["evidence"]["attestations"]["image_registry"]["frontdoorRevision"] = "d" * 40 + assert _checked_controller_result(result, profile) is None + result["evidence"]["attestations"]["image_registry"]["frontdoorRevision"] = _REVISION + result["evidence"]["attestations"]["image_registry"]["workspaceDigest"] = _FRONTDOOR_DIGEST + assert _checked_controller_result(result, profile) is None + result["evidence"]["attestations"]["image_registry"]["workspaceDigest"] = _WORKSPACE_DIGEST + result["evidence"]["attestations"]["secret_delivery"]["metadataSha256"] = "not-a-digest" + assert _checked_controller_result(result, profile) is None + + +def test_controller_oidc_attestation_accepts_authentik_legacy_kid(): + legacy_kid = "8cf5f32a6c6d9a876fd0284980a25ce0" + attestation = { + **_oidc_attestation(), + "signingKeyKid": legacy_kid, + "jwksKid": legacy_kid, + } + + assert forge_oidc_attestation_matches(attestation) + + +def test_controller_oidc_attestation_binds_exact_scope_mappings_and_discovery(): + valid = _oidc_attestation() + assert forge_oidc_attestation_matches(valid) + for invalid in ( + {**valid, "mappingId": _OIDC_PROFILE_MAPPING_ID}, + {**valid, "mappingName": "legacy claim"}, + {**valid, "mappingScope": "groups"}, + {**valid, "mappingExpressionSha256": "0" * 64}, + {**valid, "scopeMappingIds": {"openid": _OIDC_OPENID_MAPPING_ID}}, + { + **valid, + "scopeMappingIds": { + "openid": _OIDC_EMAIL_MAPPING_ID, + "email": _OIDC_OPENID_MAPPING_ID, + "profile": _OIDC_PROFILE_MAPPING_ID, + }, + }, + {**valid, "scopeMappingIds": {**_OIDC_MAPPING_IDS, "profile": _OIDC_EMAIL_MAPPING_ID}}, + {**valid, "scopeMappingIds": {**_OIDC_MAPPING_IDS, "email": _OIDC_OPENID_MAPPING_ID}}, + {**valid, "supportedScopes": ["email", "groups", "openid", "profile"]}, + {**valid, "supportedScopes": ["email", "openid"]}, + {**valid, "authorizationCodeFlowSupported": 1}, + {**valid, "standardMappingsManaged": 1}, + {**valid, "profileMappingName": "stock profile"}, + {**valid, "applicationId": "123"}, + {**valid, "providerId": "457"}, + ): + assert not forge_oidc_attestation_matches(invalid) + + +def test_controller_oidc_attestation_requires_current_protected_scope_binding(monkeypatch): + attestation = _oidc_attestation() + assert forge_oidc_attestation_matches(attestation) + monkeypatch.setitem(settings.apis, "AUTHENTIK_FORGE_OIDC_BINDING_VALID", 1) + assert not forge_oidc_attestation_matches(attestation) + monkeypatch.setitem(settings.apis, "AUTHENTIK_FORGE_OIDC_BINDING_VALID", True) + monkeypatch.setitem(settings.apis, "AUTHENTIK_FORGE_OIDC_PROVIDER_ID", "0456") + assert not forge_oidc_attestation_matches(attestation) + monkeypatch.setitem(settings.apis, "AUTHENTIK_FORGE_OIDC_PROVIDER_ID", _OIDC_PROVIDER_ID) + monkeypatch.setitem(settings.apis, "AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING", _OIDC_OPENID_MAPPING_ID) + assert not forge_oidc_attestation_matches(attestation) + + +def test_controller_oidc_attestation_requires_canonical_sha512_kid(): + assert forge_oidc_attestation_matches(_oidc_attestation()) + noncanonical_kid = "A" * 85 + "B" + assert not forge_oidc_attestation_matches( + { + **_oidc_attestation(), + "signingKeyKid": noncanonical_kid, + "jwksKid": noncanonical_kid, + } + ) diff --git a/backend/tests/test_forge_bootstrap_controller.py b/backend/tests/test_forge_bootstrap_controller.py index e43d34f..029fe2e 100644 --- a/backend/tests/test_forge_bootstrap_controller.py +++ b/backend/tests/test_forge_bootstrap_controller.py @@ -177,6 +177,11 @@ def test_forge_host_preflight_compares_exact_main_and_reports_fixed_runtime_chec monkeypatch.setattr(Path, "lstat", lstat_with_fixed_service_owners) monkeypatch.setattr(status_module, "_forge_gitea_main_revision", lambda: revision) monkeypatch.setattr(status_module, "_forge_push_validation", lambda _revision: "unknown") + monkeypatch.setattr( + status_module, + "_load_forge_bootstrap_config", + lambda _path: {"private_package_owner": "itworx-forge-private"}, + ) monkeypatch.setattr( status_module, "FORGE_COMPOSE_SHA256", hashlib.sha256(manifest).hexdigest() ) @@ -217,13 +222,14 @@ def test_forge_host_preflight_compares_exact_main_and_reports_fixed_runtime_chec "frontdoor_image_digest": "sha256:" + "c" * 64, } result = status_module._forge_bootstrap_runtime_checks(profile) - assert result["status"] == "preflight_unknown" + assert result["status"] == "preflight_blocked" assert result["checks"]["exact_main"] == {"status": "passed"} assert result["checks"]["source_manifest"] == {"status": "passed"} assert result["checks"]["managed_appdata"] == {"status": "passed"} assert result["checks"]["proxy_network"] == {"status": "passed"} assert result["checks"]["resource_collisions"] == {"status": "passed"} assert result["checks"]["push_validation"] == {"status": "unknown"} + assert result["checks"]["secret_delivery"] == {"status": "blocked"} assert result["evidence"]["mainRevision"] == revision assert result["evidence"]["sourceManifestSha256"] == hashlib.sha256(manifest).hexdigest() assert result["evidence"]["workspaceImagePresent"] is True diff --git a/backend/tests/test_forge_bootstrap_preflight.py b/backend/tests/test_forge_bootstrap_preflight.py index 15ba3b9..9524221 100644 --- a/backend/tests/test_forge_bootstrap_preflight.py +++ b/backend/tests/test_forge_bootstrap_preflight.py @@ -2,6 +2,7 @@ from __future__ import annotations from sqlmodel import Session, SQLModel, create_engine +from backend.app.core.config import settings from backend.app.db.models import DeploymentJob from backend.app.schemas.enrollment import EnrollmentTrustConfigV1 from backend.app.schemas.forge_bootstrap import ForgeBootstrapPreflightV1 @@ -10,6 +11,9 @@ from backend.app.services.forge_bootstrap_preflight import ( ForgeBootstrapPreflightService, ) +_PRIVATE_OWNER = "itworx-forge-private" +_SOURCE_URL = "https://gitea.example.com/Jens/itworx-forge" + def _profile(**overrides): value = { @@ -58,6 +62,67 @@ def test_forge_profile_is_strict_and_cannot_select_runtime_inputs(): raise AssertionError(f"invalid {field} must be rejected") +def test_preflight_registry_attestation_binds_protected_owner_and_both_image_labels(monkeypatch): + from backend.app.services.forge_bootstrap_preflight import ( + _checked_controller_result, + ) + + profile = _profile() + checks = { + name: {"status": "unknown"} + for name in ( + "exact_main", "source_manifest", "managed_appdata", "proxy_network", + "resource_collisions", "push_validation", "image_registry", "oidc", + "secret_delivery", + ) + } + checks["image_registry"] = {"status": "passed"} + registry = { + "owner": _PRIVATE_OWNER, + "ownerVisibility": "private", + "workspaceDigest": profile.workspace_image_digest, + "frontdoorDigest": profile.frontdoor_image_digest, + "workspacePullable": True, + "frontdoorPullable": True, + "workspaceSource": _SOURCE_URL, + "workspaceRevision": profile.revision, + "frontdoorSource": _SOURCE_URL, + "frontdoorRevision": profile.revision, + } + result = { + "apiVersion": "itworx.forge-bootstrap-preflight/v1", + "status": "preflight_unknown", + "revision": profile.revision, + "workspaceImageDigest": profile.workspace_image_digest, + "frontdoorImageDigest": profile.frontdoor_image_digest, + "checks": checks, + "evidence": {"attestations": {"image_registry": registry}}, + } + monkeypatch.setitem(settings.apis, "FORGE_PRIVATE_PACKAGE_OWNER", _PRIVATE_OWNER) + accepted = _checked_controller_result(result, profile) + assert accepted is not None + assert accepted[1]["attestations"]["image_registry"] == registry + + for protected_owner in (None, "Jens", "owner.with.dot", "a" * 40): + monkeypatch.setitem(settings.apis, "FORGE_PRIVATE_PACKAGE_OWNER", protected_owner) + assert _checked_controller_result(result, profile) is None + monkeypatch.setitem(settings.apis, "FORGE_PRIVATE_PACKAGE_OWNER", _PRIVATE_OWNER) + + for field, invalid in ( + ("owner", "another-owner"), + ("workspaceSource", "https://attacker.invalid/repo"), + ("frontdoorRevision", "d" * 40), + ("workspaceDigest", profile.frontdoor_image_digest), + ("workspacePullable", 1), + ("frontdoorPullable", 1), + ("workspacePullable", "true"), + ("frontdoorPullable", None), + ): + bad = {**registry, field: invalid} + result["evidence"]["attestations"]["image_registry"] = bad + assert _checked_controller_result(result, profile) is None + + class _Broker: def __init__(self, result): self.result = result diff --git a/backend/tests/test_forge_controller_reader.py b/backend/tests/test_forge_controller_reader.py new file mode 100644 index 0000000..06036b2 --- /dev/null +++ b/backend/tests/test_forge_controller_reader.py @@ -0,0 +1,1065 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + + +class _RecordingAuthentik: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + def _request(self, path, params=None): + self.calls.append(("GET", path)) + raise AssertionError("a governance rejection must not contact Authentik") + + def _write_request(self, method, path, payload=None): + self.calls.append((method, path)) + raise AssertionError("a governance rejection must not contact Authentik") + + +def _session(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine, Session(engine) + + +@contextmanager +def _unlocked(): + yield + + +def test_exact_policy_rejection_precedes_every_remote_reader_request(monkeypatch): + from backend.app.services import forge_controller_reader as reader + + engine, session = _session() + connector = _RecordingAuthentik() + lock_entered = False + + @contextmanager + def recording_lock(): + nonlocal lock_entered + lock_entered = True + yield + + monkeypatch.setattr(reader, "_forge_oidc_process_lock", recording_lock) + monkeypatch.setattr(reader, "_exact_policy_allowed", lambda _session: False) + monkeypatch.setattr( + reader, + "secret_storage_status", + lambda: {"productionReady": True}, + ) + + try: + result = reader.provision_forge_controller_reader( + session, + actor_identity="operator@example.test", + authentik=connector, + ) + finally: + session.close() + engine.dispose() + + assert lock_entered is True + assert result["status"] == "blocked" + assert result["ready"] is False + assert connector.calls == [] + + +APP_ID = "f7212687-a3d0-4eaa-b209-427f4043f870" +PROVIDER_ID = "31" +MAPPING_IDS = { + "openid": "75a34fd6-cc81-40b8-a8c3-2745af9567ab", + "email": "312810bb-11da-47d8-97d4-f2155348eaa2", + "profile": "7aa8c5f8-5e8e-4a1b-9c11-3a4f0a731cff", +} +GROUP_IDS = { + "forge-viewers": "01e9b457-75be-4c43-a276-5ef49eb691f7", + "forge-operators": "6a238dab-1f85-4fce-b20d-c987a9d2924f", + "forge-owners": "a3430246-574b-4e40-9217-59c12b7babca", +} +KEY_ID = "f2a77680-c53b-4aef-a739-25b8c2477027" +ROLE_ID = "08bd36a8-bfc3-4e7f-91b6-cd34ac2e3e54" +OIDC_SECRET = "fixed-oidc-client-secret-for-reader-tests" +ADMIN_TOKEN = "authentik-admin-token-fixture-never-issued-to-reader" +READER_TOKEN = "authentik-reader-token-fixture-0123456789-abcdef" + + +def _group(name): + from backend.app.schemas.forge_oidc import FORGE_OIDC_GROUPS + + return { + "pk": GROUP_IDS[name], + "num_pk": 14 + FORGE_OIDC_GROUPS.index(name), + "name": name, + "is_superuser": False, + "attributes": {}, + "parents": [], + "children": [], + "roles": [], + "inherited_roles_obj": [], + "users": [], + } + + +def _provider(): + from backend.app.schemas.forge_oidc import FORGE_OIDC_CALLBACK_URL + + return { + "pk": int(PROVIDER_ID), + "name": "ITWorx Forge OIDC", + "client_id": "itworx-forge", + "client_type": "confidential", + "client_secret": OIDC_SECRET, + "redirect_uris": [{"matching_mode": "strict", "url": FORGE_OIDC_CALLBACK_URL}], + "authorization_flow": "forge-authorization-flow", + "invalidation_flow": "forge-invalidation-flow", + "property_mappings": list(MAPPING_IDS.values()), + "include_claims_in_id_token": True, + "sub_mode": "hashed_user_id", + "issuer_mode": "per_provider", + "signing_key": KEY_ID, + } + + +def _mappings(): + from backend.app.schemas.forge_oidc import ( + FORGE_OIDC_EMAIL_MAPPING_EXPRESSION_SHA256, + FORGE_OIDC_EMAIL_MAPPING_MANAGED, + FORGE_OIDC_OPENID_MAPPING_EXPRESSION_SHA256, + FORGE_OIDC_OPENID_MAPPING_MANAGED, + FORGE_OIDC_PROFILE_MAPPING_EXPRESSION, + FORGE_OIDC_PROFILE_MAPPING_NAME, + ) + + return { + "openid": { + "pk": MAPPING_IDS["openid"], + "managed": FORGE_OIDC_OPENID_MAPPING_MANAGED, + "scope_name": "openid", + "expression": "# This scope is required by the OpenID-spec, and must as such exist in authentik.\n# The scope by itself does not grant any information\nreturn {}", + "known_sha256": FORGE_OIDC_OPENID_MAPPING_EXPRESSION_SHA256, + }, + "email": { + "pk": MAPPING_IDS["email"], + "managed": FORGE_OIDC_EMAIL_MAPPING_MANAGED, + "scope_name": "email", + "expression": 'return {\n "email": request.user.email,\n "email_verified": False\n}', + "known_sha256": FORGE_OIDC_EMAIL_MAPPING_EXPRESSION_SHA256, + }, + "profile": { + "pk": MAPPING_IDS["profile"], + "name": FORGE_OIDC_PROFILE_MAPPING_NAME, + "scope_name": "profile", + "expression": FORGE_OIDC_PROFILE_MAPPING_EXPRESSION, + }, + } + + +class _ReaderRemote: + def __init__(self): + from backend.app.schemas.forge_oidc import ( + FORGE_OIDC_APPLICATION_NAME, + FORGE_OIDC_LAUNCH_URL, + FORGE_OIDC_MANAGED_DESCRIPTION, + ) + + self.calls = [] + self.mutations = [] + self.roles = [] + self.users = [] + self.tokens = [] + self.object_permissions = [] + self.catalog_rows = {} + self.raise_after_permission_post = False + self.raise_after_token_post = False + self.fail_membership_readback_once = False + self.raise_after_membership_post = False + self.application = { + "pk": APP_ID, + "name": FORGE_OIDC_APPLICATION_NAME, + "slug": "itworx-forge", + "meta_launch_url": FORGE_OIDC_LAUNCH_URL, + "meta_description": FORGE_OIDC_MANAGED_DESCRIPTION, + "provider": int(PROVIDER_ID), + } + self.unrelated_application = { + "pk": "0bf8a20a-9438-4dcb-93e1-46e4a6d96090", + "name": "Baseline-visible unrelated app", + "slug": "unrelated-baseline-visible", + "meta_launch_url": "https://unrelated.example.test", + "meta_description": "Unrelated baseline-visible application", + "provider": None, + } + self.provider = _provider() + self.mappings = _mappings() + self.groups = [_group(name) for name in GROUP_IDS] + self.admin_token = ADMIN_TOKEN + self.oidc_secret = OIDC_SECRET + self.reader_token = READER_TOKEN + + self.private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.now(timezone.utc).replace(tzinfo=None) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "reader-test")]) + certificate = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(self.private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(days=10)) + .sign(self.private_key, hashes.SHA256()) + ) + self.certificate_pem = certificate.public_bytes(serialization.Encoding.PEM).decode("ascii") + public_numbers = self.private_key.public_key().public_numbers() + self.jwks_key = { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "test-signing-kid", + "n": self._base64url(public_numbers.n), + "e": self._base64url(public_numbers.e), + } + self.discovery = { + "issuer": "https://auth.example-host.com/application/o/itworx-forge/", + "response_types_supported": ["code", "id_token"], + "grant_types_supported": ["authorization_code"], + "scopes_supported": ["openid", "email", "profile"], + "id_token_signing_alg_values_supported": ["RS256"], + "jwks_uri": "https://auth.example-host.com/application/o/itworx-forge/jwks/", + } + + @staticmethod + def _base64url(value): + import base64 + + length = (value.bit_length() + 7) // 8 + return base64.urlsafe_b64encode(value.to_bytes(length, "big")).decode("ascii").rstrip("=") + + @staticmethod + def _collection(items): + return {"results": deepcopy(items), "pagination": {"next": None}} + + def _role_users(self, role_id): + return [ + user + for user in self.users + if any(str(item.get("pk") if isinstance(item, dict) else item) == role_id for item in user["roles"]) + ] + + def _groups(self): + groups = deepcopy(self.groups) + for group in groups: + group["roles"] = list(group.get("roles", [])) + group["inherited_roles_obj"] = list(group.get("inherited_roles_obj", [])) + return groups + + def admin(self, _connector, method, path, *, params=None, payload=None): + params = dict(params or {}) + self.calls.append((method, path, params, deepcopy(payload))) + if method == "POST": + self.mutations.append((method, path, deepcopy(payload))) + if path == "/rbac/roles/": + role = {"pk": ROLE_ID, "name": payload["name"]} + self.roles.append(role) + return deepcopy(role) + if path.startswith("/rbac/permissions/assigned_by_roles/") and path.endswith("/assign/"): + assert set(payload) == {"permissions", "model", "object_pk"} + assert payload["permissions"] and payload["model"] and payload["object_pk"] + codename = payload["permissions"][0] + app_label, model = payload["model"].split(".", maxsplit=1) + row = {"app_label": app_label, "model": model, "codename": codename, "object_pk": str(payload["object_pk"])} + self.object_permissions.append(row) + if self.raise_after_permission_post: + self.raise_after_permission_post = False + from backend.app.services.forge_controller_reader import ( + _ReaderFailure, + ) + + raise _ReaderFailure("failed", "simulated_transport_timeout") + return {"results": [{"id": 1}]} + if path == "/core/users/": + user = { + "pk": 101, + "username": payload["username"], + "name": payload["name"], + "type": payload["type"], + "path": payload["path"], + "attributes": deepcopy(payload["attributes"]), + "is_active": True, + "is_superuser": False, + "groups": [], + "roles": [], + } + self.users.append(user) + return deepcopy(user) + if path.endswith("/add_user/"): + role_id = path.split("/")[-3] + user_id = str(payload["pk"]) + user = next(user for user in self.users if str(user["pk"]) == user_id) + user["roles"] = [role_id] + if self.raise_after_membership_post: + self.raise_after_membership_post = False + self.fail_membership_readback_once = True + from backend.app.services.forge_controller_reader import ( + _ReaderFailure, + ) + + raise _ReaderFailure("failed", "simulated_membership_post_timeout") + return {} + if path == "/core/tokens/": + token = { + "pk": str(uuid4()), + "identifier": payload["identifier"], + "intent": payload["intent"], + "user": payload["user"], + "description": payload["description"], + "expiring": payload["expiring"], + } + self.tokens.append(token) + if self.raise_after_token_post: + self.raise_after_token_post = False + from backend.app.services.forge_controller_reader import ( + _ReaderFailure, + ) + + raise _ReaderFailure("failed", "simulated_transport_timeout") + return deepcopy(token) + raise AssertionError(f"unexpected remote POST path: {path}") + + if path == "/core/applications/": + return self._collection([self.application]) + if path == "/core/applications/itworx-forge/": + return deepcopy(self.application) + if path == f"/providers/oauth2/{PROVIDER_ID}/": + return deepcopy(self.provider) + for scope, mapping_id in MAPPING_IDS.items(): + if path == f"/propertymappings/provider/scope/{mapping_id}/": + return deepcopy(self.mappings[scope]) + if path == "/core/groups/": + return self._collection(self._groups()) + for name, group_id in GROUP_IDS.items(): + if path == f"/core/groups/{group_id}/": + return deepcopy(next(group for group in self._groups() if group["name"] == name)) + if path == f"/crypto/certificatekeypairs/{KEY_ID}/": + return {"pk": KEY_ID, "key_type": "rsa", "private_key_available": True, "name": "forge-signing"} + if path == "/rbac/roles/": + if "name" in params: + return self._collection([role for role in self.roles if role["name"] == params["name"]]) + if "users" in params: + role_ids = { + str(role["pk"]) + for role in self.roles + if any(str(_role_pk) == str(role["pk"]) for user in self._role_users(str(role["pk"])) for _role_pk in user["roles"]) + } + return self._collection([role for role in self.roles if str(role["pk"]) in role_ids]) + return self._collection(self.roles) + if path == f"/rbac/roles/{ROLE_ID}/": + role = next((role for role in self.roles if role["pk"] == ROLE_ID), None) + return deepcopy(role) if role else {} + if path == "/rbac/permissions/roles/": + return self._collection(self.object_permissions) + if path == "/rbac/permissions/": + if "role" in params: + return self._collection([]) + app_label = params["content_type__app_label"] + model = params["content_type__model"] + codename = params["codename"] + row = {"app_label": app_label, "model": model, "codename": codename, "name": codename} + return self._collection([row]) + if path == "/core/users/": + if "username" in params: + return self._collection([user for user in self.users if user["username"] == params["username"]]) + if "roles_by_pk" in params: + if self.fail_membership_readback_once and self.users: + self.fail_membership_readback_once = False + from backend.app.services.forge_controller_reader import ( + _ReaderFailure, + ) + + raise _ReaderFailure("failed", "simulated_membership_readback_interruption") + return self._collection(self._role_users(str(params["roles_by_pk"]))) + return self._collection(self.users) + if path.startswith("/core/users/") and path.endswith("/"): + user_id = path.strip("/").split("/")[-1] + user = next((user for user in self.users if str(user["pk"]) == user_id), None) + return deepcopy(user) if user else {} + if path == "/core/tokens/": + return self._collection([token for token in self.tokens if token["identifier"] == params.get("identifier")]) + if path == "/core/tokens/itworx-forge-controller-reader-v1/view_key/": + return {"key": self.reader_token} + raise AssertionError(f"unexpected remote GET path: {path}") + + def reader(self, _connector, token, path, *, params=None): + self.calls.append(("READER", path, dict(params or {}), token)) + if token != self.reader_token or token == self.admin_token: + raise AssertionError("reader verification used the administrative token") + if path == "/core/users/me/": + user = deepcopy(self.users[0]) + user["system_permissions"] = [] + user["roles"] = [{"pk": ROLE_ID, "name": "ITWorx Forge controller reader v1"}] + return {"user": user} + if path == "/core/applications/": + apps = ( + [self.application] + if params.get("search") == "itworx-forge" + else [self.application, self.unrelated_application] + ) + return self._collection(apps) + if path == "/core/applications/itworx-forge/": + return deepcopy(self.application) + if path == f"/providers/oauth2/{PROVIDER_ID}/": + return deepcopy(self.provider) + for scope, mapping_id in MAPPING_IDS.items(): + if path == f"/propertymappings/provider/scope/{mapping_id}/": + return deepcopy(self.mappings[scope]) + if path == "/core/groups/": + return self._collection(self._groups()) + if path == f"/crypto/certificatekeypairs/{KEY_ID}/": + return {"pk": KEY_ID, "key_type": "rsa", "private_key_available": True, "name": "forge-signing"} + if path == f"/crypto/certificatekeypairs/{KEY_ID}/view_certificate/": + return {"data": self.certificate_pem} + raise AssertionError(f"unexpected reader GET path: {path}") + + +def _setup(monkeypatch, tmp_path, *, remote=None): + from backend.app.core.config import settings + from backend.app.schemas.forge_oidc import ( + FORGE_OIDC_BINDING_SECRET_REF, + FORGE_OIDC_SECRET_REF, + ) + from backend.app.services import forge_controller_reader as reader + from backend.app.services.secret_store import set_secret + + engine = create_engine( + f"sqlite:///{tmp_path / 'forge_reader_test.db'}", + connect_args={"check_same_thread": False}, + ) + SQLModel.metadata.create_all(engine) + session = Session(engine) + monkeypatch.setattr(settings, "environment", "production") + monkeypatch.setattr(settings, "secret_key", "test-production-secret-key-material-0123456789") + monkeypatch.setattr(settings, "secret_key_source", "env") + for key, value in { + "AUTHENTIK_URL": "https://auth.example-host.com", + "AUTHENTIK_TOKEN": ADMIN_TOKEN, + "AUTHENTIK_VERIFY_TLS": True, + "AUTHENTIK_TIMEOUT_SECONDS": "1", + "AUTHENTIK_OIDC_AUTHORIZATION_FLOW": "forge-authorization-flow", + "AUTHENTIK_OIDC_INVALIDATION_FLOW": "forge-invalidation-flow", + "AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID": KEY_ID, + "AUTHENTIK_FORGE_OIDC_PROVIDER_ID": PROVIDER_ID, + "AUTHENTIK_FORGE_OIDC_BINDING_VALID": True, + "AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING": MAPPING_IDS["openid"], + "AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING": MAPPING_IDS["email"], + "AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING": MAPPING_IDS["profile"], + }.items(): + monkeypatch.setitem(settings.apis, key, value) + set_secret( + session, + FORGE_OIDC_BINDING_SECRET_REF, + json.dumps({ + "version": 1, + "providerId": PROVIDER_ID, + "scopeMappingIds": MAPPING_IDS, + }, separators=(",", ":"), sort_keys=True), + ) + set_secret(session, FORGE_OIDC_SECRET_REF, OIDC_SECRET) + remote = remote or _ReaderRemote() + connector = SimpleNamespace( + _config=lambda: SimpleNamespace( + url="https://auth.example-host.com", + verify_tls=True, + token=ADMIN_TOKEN, + timeout_seconds=1, + ) + ) + monkeypatch.setattr(reader, "_forge_oidc_process_lock", _unlocked) + monkeypatch.setattr(reader, "_exact_policy_allowed", lambda _session: True) + monkeypatch.setattr(reader, "secret_storage_status", lambda: {"productionReady": True}) + monkeypatch.setattr(reader, "_admin_json", remote.admin) + monkeypatch.setattr(reader, "_reader_json", remote.reader) + monkeypatch.setattr( + reader, + "_public_json", + lambda url: deepcopy(remote.discovery) if url.endswith(".well-known/openid-configuration") else {"keys": [deepcopy(remote.jwks_key)]}, + ) + return engine, session, connector, remote, reader + + +def _provision(session, connector, reader): + return reader.provision_forge_controller_reader( + session, + actor_identity="operator@example.test", + authentik=connector, + ) + + +def test_default_reader_connector_loads_only_after_locked_governance_and_store_checks( + monkeypatch, + tmp_path, +): + from backend.app.services import forge_controller_reader as reader + from backend.app.services import setup_service + + engine, session, _connector, remote, _reader = _setup(monkeypatch, tmp_path) + events = [] + loader_calls = [] + + @contextmanager + def recording_lock(): + events.append("lock-enter") + yield + events.append("lock-exit") + + def policy_check(_session): + events.append("policy") + return True + + def store_status(): + events.append("store") + return {"productionReady": True} + + original_loader = setup_service.apply_stored_connector_settings + + def tracked_loader(current_session, connector_ids=None): + events.append("loader") + loader_calls.append(connector_ids) + return original_loader(current_session, connector_ids) + + class FakeAuthentikConnector: + pass + + connector_seen = [] + + def provision_locked(_session, connector): + events.append("provision") + connector_seen.append(connector) + return reader._result("ready", "forge_reader_ready", ready=True) + + monkeypatch.setattr(reader, "_exact_policy_allowed", policy_check) + monkeypatch.setattr(reader, "secret_storage_status", store_status) + monkeypatch.setattr(reader, "_forge_oidc_process_lock", recording_lock) + monkeypatch.setattr(setup_service, "apply_stored_connector_settings", tracked_loader) + monkeypatch.setattr(reader, "AuthentikConnector", FakeAuthentikConnector) + monkeypatch.setattr(reader, "_provision_locked", provision_locked) + try: + result = reader.provision_forge_controller_reader( + session, + actor_identity="operator@example.test", + ) + assert result["status"] == "ready" + assert events == [ + "lock-enter", + "policy", + "store", + "loader", + "provision", + "lock-exit", + ] + assert loader_calls == [{"authentik"}] + assert len(connector_seen) == 1 + assert isinstance(connector_seen[0], FakeAuthentikConnector) + assert remote.calls == [] + assert remote.mutations == [] + finally: + session.close() + engine.dispose() + + +def test_default_reader_connector_is_not_loaded_when_store_is_not_production_ready( + monkeypatch, + tmp_path, +): + from backend.app.services import forge_controller_reader as reader + from backend.app.services import setup_service + + engine, session, _connector, remote, _reader = _setup(monkeypatch, tmp_path) + loader_calls = [] + original_loader = setup_service.apply_stored_connector_settings + + def tracked_loader(current_session, connector_ids=None): + loader_calls.append(connector_ids) + return original_loader(current_session, connector_ids) + + monkeypatch.setattr(setup_service, "apply_stored_connector_settings", tracked_loader) + monkeypatch.setattr(reader, "_exact_policy_allowed", lambda _session: True) + monkeypatch.setattr(reader, "secret_storage_status", lambda: {"productionReady": False}) + try: + result = reader.provision_forge_controller_reader( + session, + actor_identity="operator@example.test", + ) + assert result["status"] == "blocked" + assert result["message"] == "forge_reader_secret_store_not_production_ready" + assert loader_calls == [] + assert remote.calls == [] + assert remote.mutations == [] + finally: + session.close() + engine.dispose() + + +def test_fixed_reader_creates_exact_object_permission_set_and_reconciles_ready_retry(monkeypatch, tmp_path): + from backend.app.db.models import AuditEvent, Secret + from backend.app.services.secret_store import get_secret + + engine, session, connector, remote, reader = _setup(monkeypatch, tmp_path) + try: + result = _provision(session, connector, reader) + assert result == { + "success": True, + "status": "ready", + "message": "forge_reader_ready", + "ready": True, + "secretRef": "ITWORX_FORGE_AUTHENTIK_READ_TOKEN", + } + assert len(remote.mutations) == 14 + assignments = [item for item in remote.mutations if "/assign/" in item[1]] + assert len(assignments) == 10 + assert all( + set(payload) == {"permissions", "model", "object_pk"} + and len(payload["permissions"]) == 1 + and payload["model"] + and payload["object_pk"] + for _, _, payload in assignments + ) + assert len(remote.object_permissions) == 10 + assert remote.tokens[0]["intent"] == "api" + assert remote.tokens[0]["expiring"] is False + assert remote.users[0]["username"] == "itworx-forge-controller-reader" + assert remote.users[0]["type"] == "service_account" + assert remote.users[0]["groups"] == [] + assert "password" not in next(payload for method, path, payload in remote.mutations if method == "POST" and path == "/core/users/") + reader_calls = [call for call in remote.calls if call[0] == "READER"] + assert reader_calls and {call[3] for call in reader_calls} == {READER_TOKEN} + assert all(call[3] != ADMIN_TOKEN for call in reader_calls) + application_lists = [call for call in reader_calls if call[1] == "/core/applications/"] + assert application_lists and all(call[2].get("search") == "itworx-forge" for call in application_lists) + assert not any( + call[1] == "/core/applications/unrelated-baseline-visible/" + for call in reader_calls + ) + assert ("GET", "/core/applications/itworx-forge/") in { + (call[0], call[1]) for call in remote.calls if call[0] == "GET" + } + assert ("READER", "/core/applications/itworx-forge/") in { + (call[0], call[1]) for call in reader_calls + } + token = get_secret(session, reader.FORGE_READER_TOKEN_REF, touch=False) + assert token == READER_TOKEN + binding = json.loads(get_secret(session, reader.FORGE_READER_BINDING_REF, touch=False)) + first_nonce = binding["nonce"] + assert binding["phase"] == "ready" + assert binding["userId"] == "101" + assert binding["roleId"] == ROLE_ID + assert binding["tokenId"] == remote.tokens[0]["pk"] + assert all(token not in row.encrypted_value for row in session.exec(select(Secret)).all()) + assert OIDC_SECRET not in json.dumps(result) + assert ADMIN_TOKEN not in json.dumps(result) + assert READER_TOKEN not in json.dumps(result) + audit = session.exec(select(AuditEvent)).all() + assert audit and all(READER_TOKEN not in json.dumps(row.redacted_metadata or {}) for row in audit) + + mutation_count = len(remote.mutations) + token_count = len(remote.tokens) + retry = _provision(session, connector, reader) + next_binding = json.loads(get_secret(session, reader.FORGE_READER_BINDING_REF, touch=False)) + assert retry["ready"] is True, retry + assert len(remote.mutations) == mutation_count + assert len(remote.tokens) == token_count == 1 + assert next_binding["nonce"] == first_nonce + assert get_secret(session, reader.FORGE_READER_TOKEN_REF, touch=False) == READER_TOKEN + finally: + session.close() + engine.dispose() + + +def test_unknown_permission_post_is_reconciled_by_get_without_replaying_it(monkeypatch, tmp_path): + engine, session, connector, remote, reader = _setup(monkeypatch, tmp_path) + remote.raise_after_permission_post = True + try: + result = _provision(session, connector, reader) + assert result["ready"] is True + assignments = [item for item in remote.mutations if "/assign/" in item[1]] + assert len(assignments) == 10 + assert len(remote.object_permissions) == 10 + finally: + session.close() + engine.dispose() + + +def test_journal_persistence_failure_prevents_first_remote_mutation(monkeypatch, tmp_path): + engine, session, connector, remote, reader = _setup(monkeypatch, tmp_path) + monkeypatch.setattr(reader, "_persist_journal", lambda *_args: False) + try: + result = _provision(session, connector, reader) + assert result["status"] == "partial_failure" + assert result["ready"] is False + assert not remote.mutations + finally: + session.close() + engine.dispose() + + +def test_existing_unowned_user_is_left_untouched_before_any_write(monkeypatch, tmp_path): + engine, session, connector, remote, reader = _setup(monkeypatch, tmp_path) + remote.users.append({ + "pk": 555, + "username": "itworx-forge-controller-reader", + "name": "unrelated account", + "type": "service_account", + "path": "service-accounts/itworx-forge", + "attributes": {}, + "is_active": True, + "is_superuser": False, + "groups": [], + "roles": [], + }) + try: + result = _provision(session, connector, reader) + assert result["status"] == "conflict" + assert result["ready"] is False + assert not remote.mutations + assert remote.users[0]["name"] == "unrelated account" + finally: + session.close() + engine.dispose() + + +@pytest.mark.parametrize("bad_version", [True, 1.0, 2]) +def test_nonliteral_or_unsupported_journal_version_conflicts_before_mutations(monkeypatch, tmp_path, bad_version): + from backend.app.services.secret_store import set_secret + + engine, session, connector, remote, reader = _setup(monkeypatch, tmp_path) + journal = { + "version": bad_version, + "nonce": str(uuid4()), + "resourceContext": {}, + "permissionManifest": [], + "userId": None, + "roleId": None, + "tokenId": None, + "phase": "role", + "intent": "role", + "permissionIndex": None, + } + set_secret(session, reader.FORGE_READER_BINDING_REF, json.dumps(journal, separators=(",", ":"), sort_keys=True)) + try: + result = _provision(session, connector, reader) + assert result["status"] == "conflict" + assert not remote.mutations + finally: + session.close() + engine.dispose() + + +def test_original_token_is_recovered_after_token_store_failure_without_rotation(monkeypatch, tmp_path): + engine, session, connector, remote, reader = _setup(monkeypatch, tmp_path) + persist_token = reader._persist_token + monkeypatch.setattr(reader, "_persist_token", lambda *_args: False) + try: + result = _provision(session, connector, reader) + assert result["status"] == "partial_failure" + assert result["ready"] is False + assert len(remote.tokens) == 1 + token_posts = sum(path == "/core/tokens/" for method, path, _payload in remote.mutations if method == "POST") + assert token_posts == 1 + + monkeypatch.setattr(reader, "_persist_token", persist_token) + retry = _provision(session, connector, reader) + token_posts_after_retry = sum(path == "/core/tokens/" for method, path, _payload in remote.mutations if method == "POST") + assert retry["ready"] is True, retry + assert len(remote.tokens) == 1 + assert token_posts_after_retry == 1 + finally: + session.close() + engine.dispose() + + +def test_membership_intent_recovers_when_interrupted_before_assignment_post(monkeypatch, tmp_path): + from backend.app.services.secret_store import get_secret + + engine, session, connector, remote, reader = _setup(monkeypatch, tmp_path) + remote.fail_membership_readback_once = True + try: + interrupted = _provision(session, connector, reader) + binding = json.loads(get_secret(session, reader.FORGE_READER_BINDING_REF, touch=False)) + membership_posts = [ + row for row in remote.mutations if row[1].endswith("/add_user/") + ] + assert interrupted["ready"] is False + assert binding["phase"] == "membership" + assert binding["intent"] == "membership" + assert binding["userId"] == "101" + assert remote.users[0]["roles"] == [] + assert membership_posts == [] + + recovered = _provision(session, connector, reader) + membership_posts = [ + row for row in remote.mutations if row[1].endswith("/add_user/") + ] + assert recovered["ready"] is True, recovered + assert len(remote.users) == 1 + assert remote.users[0]["roles"] == [ROLE_ID] + assert len(remote.roles) == 1 + assert len(membership_posts) == 1 + assert all(not group["roles"] for group in remote.groups) + finally: + session.close() + engine.dispose() + + +def test_membership_intent_recovers_when_post_applies_before_transport_loss(monkeypatch, tmp_path): + from backend.app.services.secret_store import get_secret + + engine, session, connector, remote, reader = _setup(monkeypatch, tmp_path) + remote.raise_after_membership_post = True + try: + interrupted = _provision(session, connector, reader) + binding = json.loads(get_secret(session, reader.FORGE_READER_BINDING_REF, touch=False)) + membership_posts = [ + row for row in remote.mutations if row[1].endswith("/add_user/") + ] + assert interrupted["ready"] is False + assert binding["phase"] == "membership" + assert binding["intent"] == "membership" + assert remote.users[0]["roles"] == [ROLE_ID] + assert len(membership_posts) == 1 + assert remote.tokens == [] + + recovered = _provision(session, connector, reader) + membership_posts = [ + row for row in remote.mutations if row[1].endswith("/add_user/") + ] + assert recovered["ready"] is True, recovered + assert len(remote.users) == 1 + assert remote.users[0]["roles"] == [ROLE_ID] + assert len(remote.roles) == 1 + assert len(membership_posts) == 1 + assert len(remote.tokens) == 1 + assert all(not group["roles"] for group in remote.groups) + finally: + session.close() + engine.dispose() + + +def test_collection_with_malformed_rows_fails_closed(): + from backend.app.services import forge_controller_reader as reader + + with pytest.raises(reader._ReaderFailure) as failure: + reader._json_items({"results": [{"pk": "valid"}, "not-an-object"], "pagination": {"next": None}}) + + assert failure.value.code == "forge_reader_remote_collection_invalid" + + +def test_secret_delivery_verifier_requires_ready_reader_and_performs_gets_only( + monkeypatch, tmp_path +): + from backend.app.services.secret_store import get_secret + + engine, session, connector, remote, reader = _setup(monkeypatch, tmp_path) + try: + assert _provision(session, connector, reader)["ready"] is True + before_mutations = deepcopy(remote.mutations) + remote.calls.clear() + + verified = reader._verify_forge_delivery_inputs(session, connector) + + assert verified.oidc_client_secret == OIDC_SECRET.encode("ascii") + assert verified.authentik_read_token == READER_TOKEN.encode("ascii") + assert verified.context == { + "applicationId": APP_ID, + "providerId": PROVIDER_ID, + "scopeMappingIds": MAPPING_IDS, + "groupIds": GROUP_IDS, + "signingKeyId": KEY_ID, + } + assert OIDC_SECRET not in repr(verified) + assert READER_TOKEN not in repr(verified) + assert remote.mutations == before_mutations + assert remote.calls + assert all(call[0] in {"GET", "READER"} for call in remote.calls) + assert get_secret(session, reader.FORGE_READER_TOKEN_REF, touch=False) == READER_TOKEN + finally: + session.close() + engine.dispose() + + +def test_secret_delivery_verifier_rejects_partial_journal_before_remote_reads( + monkeypatch, tmp_path +): + from backend.app.services.secret_store import get_secret, set_secret + + engine, session, connector, remote, reader = _setup(monkeypatch, tmp_path) + try: + assert _provision(session, connector, reader)["ready"] is True + journal = json.loads(get_secret(session, reader.FORGE_READER_BINDING_REF, touch=False)) + journal["phase"] = "token" + journal["intent"] = "token" + set_secret( + session, + reader.FORGE_READER_BINDING_REF, + json.dumps(journal, separators=(",", ":"), sort_keys=True), + ) + before_mutations = deepcopy(remote.mutations) + remote.calls.clear() + + with pytest.raises(reader._ReaderFailure) as failure: + reader._verify_forge_delivery_inputs(session, connector) + + assert failure.value.code == "forge_reader_binding_not_ready" + assert remote.calls == [] + assert remote.mutations == before_mutations + finally: + session.close() + engine.dispose() + + +@pytest.mark.parametrize("drift", ["extra_permission", "token_expiring", "user_privileged"]) +def test_secret_delivery_verifier_rejects_reader_privilege_or_token_drift_without_writes( + monkeypatch, tmp_path, drift +): + engine, session, connector, remote, reader = _setup(monkeypatch, tmp_path) + try: + assert _provision(session, connector, reader)["ready"] is True + if drift == "extra_permission": + remote.object_permissions.append( + { + "app_label": "authentik_core", + "model": "user", + "codename": "view_user", + "object_pk": "101", + } + ) + elif drift == "token_expiring": + remote.tokens[0]["expiring"] = True + else: + remote.users[0]["is_superuser"] = True + before_mutations = deepcopy(remote.mutations) + remote.calls.clear() + + with pytest.raises(reader._ReaderFailure): + reader._verify_forge_delivery_inputs(session, connector) + + assert remote.mutations == before_mutations + assert remote.calls + assert all(call[0] in {"GET", "READER"} for call in remote.calls) + finally: + session.close() + engine.dispose() + + +@pytest.mark.parametrize( + "next_value", + [ + "https://auth.example-host.com/api/v3/core/users/?page=2&page_size=100", + "https://auth.example-host.com/api/v3/core/applications/?page=2&page_size=99", + "https://user@auth.example-host.com/api/v3/core/applications/?page=2&page_size=100", + ], +) +def test_pagination_rejects_endpoint_filter_or_authority_drift(next_value): + from backend.app.services import forge_controller_reader as reader + + requests = [] + + def request(path, *, params=None): + requests.append((path, params)) + return {"results": [{"pk": APP_ID}], "pagination": {"next": next_value}} + + with pytest.raises(reader._ReaderFailure) as failure: + reader._paged(request, "/core/applications/", {"page_size": 100}) + + assert failure.value.code == "forge_reader_remote_pagination_invalid" + assert len(requests) == 1 + + +@pytest.mark.parametrize("second_next", ["1", "2"]) +def test_pagination_rejects_backward_or_repeated_pages(second_next): + from backend.app.services import forge_controller_reader as reader + + requests = [] + + def request(path, *, params=None): + requests.append((path, params)) + next_value = "2" if len(requests) == 1 else second_next + return {"results": [], "pagination": {"next": next_value}} + + with pytest.raises(reader._ReaderFailure) as failure: + reader._paged(request, "/core/applications/", {"page_size": 100}) + + assert failure.value.code == "forge_reader_remote_pagination_invalid" + assert requests == [ + ("/core/applications/", {"page_size": 100}), + ("/core/applications/", {"page_size": 100, "page": 2}), + ] + + +def test_http_json_clamps_timeout_and_rejects_oversized_stream_without_echoing_body(monkeypatch): + from backend.app.services import forge_controller_reader as reader + + secret_text = "never-echo-this-reader-token" + observed = {} + + class _Response: + status_code = 200 + headers = {} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def iter_bytes(self, *, chunk_size): + assert chunk_size == 16 * 1024 + body = b"x" * (reader._MAX_RESPONSE_BYTES + 1) + secret_text.encode() + for index in range(0, len(body), chunk_size): + yield body[index : index + chunk_size] + + class _Client: + def __init__(self, *, timeout, verify, follow_redirects): + observed["timeout"] = timeout + assert verify is True + assert follow_redirects is False + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def stream(self, method, url, **kwargs): + assert method == "GET" + assert url == "https://auth.example-host.com/test" + assert kwargs["headers"] == {"Accept": "application/json"} + return _Response() + + monkeypatch.setattr(reader.httpx, "Client", _Client) + with pytest.raises(reader._ReaderFailure) as failure: + reader._http_json( + "GET", + "https://auth.example-host.com/test", + token=None, + timeout=9999, + ) + + assert observed["timeout"] == reader._HTTP_TIMEOUT_MAX_SECONDS + assert failure.value.code == "forge_reader_remote_response_too_large" + assert secret_text not in str(failure.value) diff --git a/backend/tests/test_forge_database_credentials.py b/backend/tests/test_forge_database_credentials.py new file mode 100644 index 0000000..ed80230 --- /dev/null +++ b/backend/tests/test_forge_database_credentials.py @@ -0,0 +1,487 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import sys +import threading +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BROKER = ROOT / "ops" / "gitea-deploy-broker" +if str(BROKER) not in sys.path: + sys.path.insert(0, str(BROKER)) + + +@pytest.fixture +def credentials_module(): + spec = importlib.util.spec_from_file_location( + "forge_database_credentials_test", + BROKER / "forge_database_credentials.py", + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _secrets(module): + return { + module.RUNTIME_ROLE: "runtime-secret-SENTINEL-0123456789abcdef", + module.MIGRATOR_ROLE: "migrator-secret-SENTINEL-fedcba9876543210", + } + + +def _uri(role: str, password: str) -> bytes: + return f"postgresql://{role}:{password}@postgres:5432/forge".encode("ascii") + + +def _install_fake_docker( + module, + tmp_path: Path, + secrets: dict[str, str], + *, + behavior: str = "success", +): + marker = tmp_path / "fake-docker-state.txt" + script = tmp_path / "fake-docker" + secret_literals = json.dumps(secrets) + marker_literal = json.dumps(str(marker)) + script.write_text( + f"#!{sys.executable}\n" + "import json, os, sys, termios, time\n" + f"expected = {secret_literals}\n" + f"marker = {marker_literal}\n" + f"behavior = {behavior!r}\n" + "args = sys.argv[1:]\n" + "try:\n" + " role_arg = args[args.index('-c') + 1]\n" + " role = role_arg.split(' ', 1)[1]\n" + "except Exception:\n" + " open(marker, 'a').write('bad-command\\n')\n" + " raise SystemExit(90)\n" + f"expected_args = ['exec', '-i', '-t', '--user', '70:70', '--env', 'LANG=C', '--env', 'LC_ALL=C', '--env', 'PGPASSFILE=/dev/null', '--env', 'PGSERVICEFILE=/dev/null', '--env', 'PGPASSWORD=', '--env', 'PGOPTIONS=', 'itworx-forge-postgres-1', '/bin/busybox', 'timeout', '-s', 'TERM', '-k', '{module.REMOTE_KILL_AFTER_SECONDS}', '{module.REMOTE_TIMEOUT_SECONDS}', 'psql', '-X', '-w', '-v', 'ON_ERROR_STOP=1', '-h', '/var/run/postgresql', '-p', '5432', '-U', 'forge_bootstrap', '-d', 'forge', '-c', role_arg]\n" + "if args != expected_args or role not in expected:\n" + " open(marker, 'a').write('bad-command\\n')\n" + " raise SystemExit(91)\n" + "secret = expected[role]\n" + "if any(secret in value for value in sys.argv + list(os.environ.values())):\n" + " open(marker, 'a').write('secret-in-command-or-env\\n')\n" + " raise SystemExit(92)\n" + "if termios.tcgetattr(0)[3] & (termios.ECHO | termios.ECHONL):\n" + " open(marker, 'a').write('echo-enabled\\n')\n" + " raise SystemExit(93)\n" + "if behavior == 'exit-before-prompt':\n" + " raise SystemExit(7)\n" + "if behavior == 'timeout-before-prompt':\n" + " open(marker + '.pid', 'w').write(str(os.getpid()))\n" + " time.sleep(30)\n" + " raise SystemExit(0)\n" + "if behavior == 'output-flood':\n" + " os.write(2, b'x' * 65536)\n" + " time.sleep(30)\n" + " raise SystemExit(0)\n" + "if behavior == 'prompts-then-exit':\n" + " os.write(2, f'Enter new password for user \\\"{role}\\\": Enter it again: '.encode())\n" + " raise SystemExit(0)\n" + "os.write(2, f'Enter new password for user \\\"{role}\\\": '.encode())\n" + "first = sys.stdin.readline().rstrip('\\r\\n')\n" + "if first != secret:\n" + " open(marker, 'a').write('first-mismatch\\n')\n" + " raise SystemExit(94)\n" + "if behavior == 'echo-received':\n" + " os.write(2, first.encode() + b'\\n')\n" + "if behavior == 'exit-after-first':\n" + " raise SystemExit(8)\n" + "os.write(2, b'Enter it again: ')\n" + "second = sys.stdin.readline().rstrip('\\r\\n')\n" + "if second != secret:\n" + " open(marker, 'a').write('second-mismatch\\n')\n" + " raise SystemExit(95)\n" + "open(marker, 'a').write(role + '=matched-noecho\\n')\n" + "raise SystemExit(0)\n", + encoding="utf-8", + ) + script.chmod(0o700) + module._DOCKER_CLI = str(script) + module._CHILD_PATH = str(tmp_path) + return marker + + +def _install_uri_reader(monkeypatch, module, secrets): + uri_by_name = { + "runtime_database_url": _uri(module.RUNTIME_ROLE, secrets[module.RUNTIME_ROLE]), + "migrator_database_url": _uri(module.MIGRATOR_ROLE, secrets[module.MIGRATOR_ROLE]), + } + reads = [] + + def read(_root, name, role): + reads.append((name, role)) + return uri_by_name.get(name) + + monkeypatch.setattr( + module, + "read_database_uri", + read, + ) + return reads + + +def _journal(initial): + states = dict(initial) + calls: list[tuple[str, str]] = [] + + def role_state(role): + return states[role] + + def transition(name, allowed, destination): + def apply(role): + if states[role] not in allowed: + raise RuntimeError("compare-and-swap rejected") + states[role] = destination + calls.append((name, role)) + return True + + return apply + + return states, calls, { + "role_state": role_state, + "persist_role_intent": transition("intent", {"pending", "retryable"}, "intent"), + "persist_role_retryable": transition("retryable", {"intent"}, "retryable"), + "persist_role_unknown": transition("unknown", {"intent"}, "unknown"), + "persist_role_complete": transition("complete", {"intent"}, "completed"), + } + + +def _run(module, secret_root, journal): + return module.set_forge_database_role_passwords(secret_root, **journal) + + +def test_fixed_roles_are_set_through_hidden_prompts_without_secret_output( + credentials_module, monkeypatch, tmp_path +): + module = credentials_module + secrets = _secrets(module) + reads = _install_uri_reader(monkeypatch, module, secrets) + marker = _install_fake_docker(module, tmp_path, secrets, behavior="echo-received") + states, calls, journal = _journal( + {module.RUNTIME_ROLE: "pending", module.MIGRATOR_ROLE: "pending"} + ) + + result = _run(module, tmp_path, journal) + + assert result == { + "status": "completed", + "completedRoles": [module.RUNTIME_ROLE, module.MIGRATOR_ROLE], + "activeRole": None, + } + assert states == { + module.RUNTIME_ROLE: "completed", + module.MIGRATOR_ROLE: "completed", + } + assert calls == [ + ("intent", module.RUNTIME_ROLE), + ("complete", module.RUNTIME_ROLE), + ("intent", module.MIGRATOR_ROLE), + ("complete", module.MIGRATOR_ROLE), + ] + assert reads == [ + ("runtime_database_url", module.RUNTIME_ROLE), + ("migrator_database_url", module.MIGRATOR_ROLE), + ] + assert marker.read_text(encoding="utf-8").splitlines() == [ + f"{module.RUNTIME_ROLE}=matched-noecho", + f"{module.MIGRATOR_ROLE}=matched-noecho", + ] + serialized = json.dumps(result) + assert all(secret not in serialized for secret in secrets.values()) + + +def test_completed_role_is_skipped_and_uncertain_role_is_never_replayed( + credentials_module, monkeypatch, tmp_path +): + module = credentials_module + secrets = _secrets(module) + _install_uri_reader(monkeypatch, module, secrets) + marker = _install_fake_docker(module, tmp_path, secrets) + states, calls, journal = _journal( + {module.RUNTIME_ROLE: "completed", module.MIGRATOR_ROLE: "intent"} + ) + + result = _run(module, tmp_path, journal) + + assert result["status"] == "outcome_unknown" + assert result["completedRoles"] == [module.RUNTIME_ROLE] + assert result["activeRole"] == module.MIGRATOR_ROLE + assert calls == [] + assert not marker.exists() + + +def test_migrator_cannot_be_journaled_complete_before_runtime_role( + credentials_module, tmp_path +): + module = credentials_module + states, calls, journal = _journal( + {module.RUNTIME_ROLE: "pending", module.MIGRATOR_ROLE: "completed"} + ) + + result = _run(module, tmp_path, journal) + + assert result["status"] == "blocked" + assert result["reason"] == "role_journal_order_invalid" + assert result["completedRoles"] == [] + assert calls == [] + + +@pytest.mark.parametrize("behavior", ["exit-before-prompt", "timeout-before-prompt", "output-flood"]) +def test_no_input_failures_are_bounded_reaped_and_retryable( + credentials_module, monkeypatch, tmp_path, behavior +): + module = credentials_module + secrets = _secrets(module) + _install_uri_reader(monkeypatch, module, secrets) + states, calls, journal = _journal( + {module.RUNTIME_ROLE: "pending", module.MIGRATOR_ROLE: "pending"} + ) + monkeypatch.setattr(module, "PROMPT_TIMEOUT_SECONDS", 0.15) + monkeypatch.setattr(module, "ROLE_TIMEOUT_SECONDS", 0.6) + monkeypatch.setattr(module, "REMOTE_TIMEOUT_SECONDS", 1) + monkeypatch.setattr(module, "REMOTE_KILL_AFTER_SECONDS", 1) + monkeypatch.setattr(module, "REMOTE_TERMINATION_MARGIN_SECONDS", 0.1) + monkeypatch.setattr(module, "MAX_TRANSCRIPT_BYTES", 128) + marker = _install_fake_docker(module, tmp_path, secrets, behavior=behavior) + started = __import__("time").monotonic() + + result = _run(module, tmp_path, journal) + + elapsed = __import__("time").monotonic() - started + assert result["status"] == "failed" + assert result["reason"] == "psql_failed_before_password_input" + assert result["activeRole"] == module.RUNTIME_ROLE + assert states[module.RUNTIME_ROLE] == "retryable" + assert states[module.MIGRATOR_ROLE] == "pending" + assert calls == [ + ("intent", module.RUNTIME_ROLE), + ("retryable", module.RUNTIME_ROLE), + ] + assert elapsed < 3 + if behavior == "timeout-before-prompt": + assert elapsed >= 1.8 + child_pid = int(Path(str(marker) + ".pid").read_text(encoding="utf-8")) + with pytest.raises(ProcessLookupError): + os.kill(child_pid, 0) + assert not marker.exists() or not any( + value in marker.read_text(encoding="utf-8") + for value in secrets.values() + ) + + +def test_any_password_input_without_success_becomes_unknown( + credentials_module, monkeypatch, tmp_path +): + module = credentials_module + secrets = _secrets(module) + _install_uri_reader(monkeypatch, module, secrets) + states, calls, journal = _journal( + {module.RUNTIME_ROLE: "pending", module.MIGRATOR_ROLE: "pending"} + ) + monkeypatch.setattr(module, "PROMPT_TIMEOUT_SECONDS", 0.15) + monkeypatch.setattr(module, "REMOTE_TIMEOUT_SECONDS", 1) + monkeypatch.setattr(module, "REMOTE_KILL_AFTER_SECONDS", 1) + monkeypatch.setattr(module, "REMOTE_TERMINATION_MARGIN_SECONDS", 0.1) + marker = _install_fake_docker(module, tmp_path, secrets, behavior="exit-after-first") + + result = _run(module, tmp_path, journal) + + assert result["status"] == "outcome_unknown" + assert result["reason"] == "psql_result_uncertain" + assert states[module.RUNTIME_ROLE] == "unknown" + assert states[module.MIGRATOR_ROLE] == "pending" + assert calls == [ + ("intent", module.RUNTIME_ROLE), + ("unknown", module.RUNTIME_ROLE), + ] + assert not marker.exists() or not any( + value in marker.read_text(encoding="utf-8") + for value in secrets.values() + ) + + +def test_invalid_fixed_uri_blocks_before_any_intent_or_process( + credentials_module, monkeypatch, tmp_path +): + module = credentials_module + monkeypatch.setattr(module, "read_database_uri", lambda *_args: None) + secrets = _secrets(module) + marker = _install_fake_docker(module, tmp_path, secrets) + states, calls, journal = _journal( + {module.RUNTIME_ROLE: "pending", module.MIGRATOR_ROLE: "pending"} + ) + + result = _run(module, tmp_path, journal) + + assert result["status"] == "blocked" + assert result["reason"] == "database_uri_unavailable" + assert states == {module.RUNTIME_ROLE: "pending", module.MIGRATOR_ROLE: "pending"} + assert calls == [] + assert not marker.exists() + + +def test_concurrent_call_stops_on_durable_intent_without_starting_a_second_child( + credentials_module, monkeypatch, tmp_path +): + module = credentials_module + secrets = _secrets(module) + _install_uri_reader(monkeypatch, module, secrets) + states, calls, journal = _journal( + {module.RUNTIME_ROLE: "pending", module.MIGRATOR_ROLE: "pending"} + ) + entered = threading.Event() + release = threading.Event() + child_calls = [] + + def hold_child(role, _password): + child_calls.append(role) + entered.set() + assert release.wait(timeout=2) + return "failed", False + + monkeypatch.setattr(module, "_run_password_command", hold_child) + first_result = [] + + thread = threading.Thread( + target=lambda: first_result.append(_run(module, tmp_path, journal)), + daemon=True, + ) + thread.start() + assert entered.wait(timeout=1) + + second_result = _run(module, tmp_path, journal) + release.set() + thread.join(timeout=2) + + assert not thread.is_alive() + assert second_result["status"] == "outcome_unknown" + assert second_result["reason"] == "role_requires_login_reconciliation" + assert first_result[0]["status"] == "failed" + assert child_calls == [module.RUNTIME_ROLE] + assert states[module.RUNTIME_ROLE] == "retryable" + assert calls == [ + ("intent", module.RUNTIME_ROLE), + ("retryable", module.RUNTIME_ROLE), + ] + + +@pytest.mark.parametrize("claim_result", [False, None]) +def test_failed_durable_intent_claim_never_starts_child( + credentials_module, monkeypatch, tmp_path, claim_result +): + module = credentials_module + secrets = _secrets(module) + _install_uri_reader(monkeypatch, module, secrets) + marker = _install_fake_docker(module, tmp_path, secrets) + states, calls, journal = _journal( + {module.RUNTIME_ROLE: "pending", module.MIGRATOR_ROLE: "pending"} + ) + journal["persist_role_intent"] = lambda _role: claim_result + + result = _run(module, tmp_path, journal) + + assert result["status"] == "outcome_unknown" + assert result["reason"] == "role_intent_persistence_failed" + assert states[module.RUNTIME_ROLE] == "pending" + assert calls == [] + assert not marker.exists() + + +def test_exited_child_prompt_output_is_drained_without_sending_password( + credentials_module, monkeypatch, tmp_path +): + module = credentials_module + secrets = _secrets(module) + _install_uri_reader(monkeypatch, module, secrets) + _install_fake_docker(module, tmp_path, secrets, behavior="prompts-then-exit") + states, _calls, journal = _journal( + {module.RUNTIME_ROLE: "pending", module.MIGRATOR_ROLE: "pending"} + ) + child = [] + writes = [] + original_popen = module.subprocess.Popen + original_select = module.select.select + original_write_password = module._write_password_line + + def capture_popen(*args, **kwargs): + process = original_popen(*args, **kwargs) + child.append(process) + return process + + def select_after_exit(*args, **kwargs): + child[0].wait(timeout=2) + return original_select(*args, **kwargs) + + def record_write(*args, **kwargs): + writes.append(True) + return original_write_password(*args, **kwargs) + + monkeypatch.setattr(module.subprocess, "Popen", capture_popen) + monkeypatch.setattr(module.select, "select", select_after_exit) + monkeypatch.setattr(module, "_write_password_line", record_write) + + result = _run(module, tmp_path, journal) + + assert result["status"] != "completed" + assert writes == [] + assert states[module.RUNTIME_ROLE] != "completed" + assert states[module.RUNTIME_ROLE] == "retryable" + + +def test_zero_length_pty_write_fails_closed(credentials_module, monkeypatch): + module = credentials_module + monkeypatch.setattr(module.os, "write", lambda *_args: 0) + monkeypatch.setattr( + module.select, + "select", + lambda _readers, writers, _errors, _timeout: ([], writers, []), + ) + + with pytest.raises(OSError): + module._write_password_line(123, bytearray(b"bounded-secret")) + + +def test_pty_write_backpressure_expires_within_fixed_budget( + credentials_module, monkeypatch +): + module = credentials_module + ticks = iter(range(0, 10)) + monkeypatch.setattr(module.time, "monotonic", lambda: float(next(ticks))) + monkeypatch.setattr( + module.select, + "select", + lambda _readers, _writers, _errors, _timeout: ([], [], []), + ) + monkeypatch.setattr(module, "PTY_WRITE_TIMEOUT_SECONDS", 2.0) + + with pytest.raises(TimeoutError): + module._write_password_line(123, bytearray(b"bounded-secret")) + + +def test_uri_password_decoder_rejects_malformed_or_unbounded_values(credentials_module): + module = credentials_module + role = module.RUNTIME_ROLE + + assert module._password_from_uri(_uri(role, "x" * 31), role) is None + assert module._password_from_uri(_uri(role, "x" * 257), role) is None + assert module._password_from_uri( + f"postgresql://{role}:bad%Q0value-with-more-than-32-characters@postgres:5432/forge".encode(), + role, + ) is None + + decoded = module._password_from_uri( + f"postgresql://{role}:encoded%2Dpassword%2D0123456789abcdef@postgres:5432/forge".encode(), + role, + ) + assert decoded == bytearray(b"encoded-password-0123456789abcdef") + decoded[:] = b"\0" * len(decoded) diff --git a/backend/tests/test_forge_first_install_controller.py b/backend/tests/test_forge_first_install_controller.py new file mode 100644 index 0000000..146577b --- /dev/null +++ b/backend/tests/test_forge_first_install_controller.py @@ -0,0 +1,2061 @@ +from __future__ import annotations + +import base64 +import hashlib +import importlib.util +import io +import json +import os +import subprocess +import sys +import threading +import types +from email.message import Message +from pathlib import Path +from urllib.error import HTTPError +from urllib.parse import urlparse + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BROKER = ROOT / "ops" / "gitea-deploy-broker" +_FORGE_PRIVATE_OWNER = "itworx-forge-private" +_FORGE_SOURCE_URL = "https://gitea.example.com/Jens/itworx-forge" + + +def _load(path: Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def controller_module(): + return _load(BROKER / "forge_first_install.py", "forge_first_install_controller_test") + + +@pytest.fixture +def status_module(): + if "fcntl" not in sys.modules: + fcntl = types.ModuleType("fcntl") + fcntl.LOCK_EX = 2 + fcntl.LOCK_UN = 8 + fcntl.flock = lambda *_args: None + sys.modules["fcntl"] = fcntl + return _load(BROKER / "status_server.py", "forge_first_install_status_test") + + +def _profile(module): + return { + "api_version": module.API_VERSION, + "revision": "a" * 40, + "workspace_image_digest": "sha256:" + "b" * 64, + "frontdoor_image_digest": "sha256:" + "c" * 64, + "preflight_job_id": "forge-preflight-" + "d" * 16, + "preflight_sha256": "e" * 64, + } + + +def _passed_preflight(profile): + checks = { + name: {"status": "passed"} + for name in ( + "exact_main", "source_manifest", "managed_appdata", "proxy_network", + "resource_collisions", "push_validation", "image_registry", "oidc", + "secret_delivery", + ) + } + return { + "apiVersion": "itworx.forge-bootstrap-preflight/v1", + "status": "preflight_ok", + "revision": profile["revision"], + "workspaceImageDigest": profile["workspace_image_digest"], + "frontdoorImageDigest": profile["frontdoor_image_digest"], + "checks": checks, + "evidence": {}, + } + + +def _forge_oidc_test_certificate(*, elliptic_curve: bool = False, rsa_key_size: int = 2048): + from datetime import datetime, timedelta, timezone + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec, rsa + from cryptography.x509.oid import NameOID + + if elliptic_curve: + private_key = ec.generate_private_key(ec.SECP256R1()) + else: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=rsa_key_size) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "forge-oidc-test")]) + now = datetime.now(timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before((now - timedelta(minutes=1)).replace(tzinfo=None)) + .not_valid_after((now + timedelta(days=1)).replace(tzinfo=None)) + .sign(private_key, hashes.SHA256()) + ) + certificate_der = certificate.public_bytes(serialization.Encoding.DER) + certificate_pem = certificate.public_bytes(serialization.Encoding.PEM).decode("ascii") + public_numbers = private_key.public_key().public_numbers() + + def encode(value: int) -> str: + raw = value.to_bytes((value.bit_length() + 7) // 8, "big") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + jwk = None + if not elliptic_curve: + jwk = { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "8cf5f32a6c6d9a876fd0284980a25ce0", + "n": encode(public_numbers.n), + "e": encode(public_numbers.e), + } + return { + "data": certificate_pem, + "fingerprint": hashlib.sha256(certificate_der).hexdigest(), + "jwk": jwk, + } + + +def _controller(module, tmp_path, profile, *, command_runner=None): + controller = module.ForgeFirstInstallController( + state_root=tmp_path / "state", + secret_root=tmp_path / "secrets", + config_path=tmp_path / "forge-first-install.json", + repository_path=tmp_path / "itworx-forge.git", + compose_sha256="f" * 64, + preflight_check=lambda _profile: _passed_preflight(profile), + resume_check=lambda _profile: _passed_preflight(profile), + command_runner=command_runner, + ) + controller.remember_preflight(profile, _passed_preflight(profile)) + return controller + + +def _forge_config(**overrides): + config = { + "schema": "itworx.forge-first-install-config/v4", + "issuer": "https://auth.example-host.com/application/o/itworx-forge/", + "audience": "itworx-forge", + "client_id": "itworx-forge", + "authentik_api_url": "https://auth.example-host.com", + "launch_url": "https://forge.example.com", + "authorization_flow": "forge-authz", + "invalidation_flow": "forge-logout", + "authentik_openid_mapping_id": "01234567-89ab-4cde-8fab-0123456789ab", + "authentik_email_mapping_id": "21234567-89ab-4cde-8fab-0123456789ab", + "authentik_profile_mapping_id": "31234567-89ab-4cde-8fab-0123456789ab", + "authentik_provider_id": "7", + "authentik_signing_key_id": "11234567-89ab-4cde-8fab-0123456789ab", + "private_package_owner": _FORGE_PRIVATE_OWNER, + } + config.update(overrides) + return config + + +def _write_protected_config(path, value): + path.write_text(json.dumps(value), encoding="utf-8") + if os.name != "nt": + path.chmod(0o600) + + +_FORGE_ROLE_NAMES = ("forge_runtime", "forge_migrator") + + +def _credential_resume_record( + module, controller, profile, key, *, status="running", + phase="resume_intent_persisted", roles=None, +): + request = module.request_id(profile, key) + record = { + "schema": module.STATE_SCHEMA, + "kind": "request", + "request": request, + "repository": module.REPOSITORY, + "operation": module.OPERATION, + "profile": profile, + "status": status, + "phase": phase, + "mutationStarted": True, + "migrationStarted": False, + "migrationCompleted": False, + "failureCode": None, + "credentialRoles": roles or {role: "pending" for role in _FORGE_ROLE_NAMES}, + "resumeGeneration": 1, + "resumeClaimed": status == "running", + "resumeAttempts": 1, + "createdAt": 1, + "updatedAt": 1, + } + controller._write_record(record) + return request, record + + +def _install_successful_role_helper(monkeypatch, before_send=None): + calls = [] + helper = types.ModuleType("forge_database_credentials") + + def set_passwords(secret_root, *, role_state, persist_role_intent, + persist_role_retryable, persist_role_unknown, + persist_role_complete): + calls.append((secret_root, "called")) + completed = [] + for role in _FORGE_ROLE_NAMES: + state = role_state(role) + if state == "completed": + completed.append(role) + continue + if state not in {"pending", "retryable"} or persist_role_intent(role) is not True: + return {"status": "outcome_unknown", "completedRoles": completed, + "activeRole": role, "reason": "role_intent_persistence_failed"} + calls.append((secret_root, role)) + if before_send is not None: + before_send(role) + if persist_role_complete(role) is not True: + persist_role_unknown(role) + return {"status": "outcome_unknown", "completedRoles": completed, + "activeRole": role, "reason": "role_completion_persistence_failed"} + completed.append(role) + return {"status": "completed", "completedRoles": completed, "activeRole": None} + + helper.set_forge_database_role_passwords = set_passwords + monkeypatch.setitem(sys.modules, "forge_database_credentials", helper) + return calls + + +def _prepare_credential_resume(controller_module, controller, tmp_path, profile): + _write_protected_config(controller.config_path, _forge_config()) + controller.resume_check = lambda _profile: _passed_preflight(profile) + controller._database_roles_are_safe = lambda: True + controller._wait_health = lambda *_args, **_kwargs: True + controller._existing_source = lambda _request: tmp_path + controller._compose_config_valid = lambda *_args, **_kwargs: True + return controller._compose_command(tmp_path) + + +def test_controller_config_v4_requires_safe_private_owner_and_distinct_authentik_ids(controller_module, tmp_path): + config_path = tmp_path / "forge-first-install.json" + config = _forge_config() + _write_protected_config(config_path, config) + + loaded = controller_module.load_config(config_path) + assert loaded is not None + assert loaded["private_package_owner"] == _FORGE_PRIVATE_OWNER + + invalid_configs = [ + {**config, "schema": "itworx.forge-first-install-config/v3"}, + {key: value for key, value in config.items() if key != "private_package_owner"}, + {**config, "private_package_owner": "Jens"}, + {**config, "private_package_owner": "owner.with.dot"}, + {**config, "private_package_owner": "_owner"}, + {**config, "private_package_owner": "a" * 40}, + {**config, "authentik_provider_id": "0"}, + {**config, "authentik_provider_id": "07"}, + {**config, "authentik_provider_id": "provider"}, + {**config, "authentik_provider_id": "1" * 11}, + {**config, "authentik_provider_id": 7}, + {**config, "authentik_email_mapping_id": config["authentik_openid_mapping_id"]}, + {key: value for key, value in config.items() if key != "authentik_profile_mapping_id"}, + ] + for invalid in invalid_configs: + _write_protected_config(config_path, invalid) + assert controller_module.load_config(config_path) is None + assert controller_module.load_config_bytes(json.dumps(invalid).encode("utf-8")) is None + + +def test_held_config_matches_file_validation_without_filesystem_access(controller_module, tmp_path, monkeypatch): + config = _forge_config() + config_path = tmp_path / "forge-first-install.json" + _write_protected_config(config_path, config) + expected = controller_module.load_config(config_path) + assert expected is not None + + def unexpected_read(*_args, **_kwargs): + raise AssertionError("held configuration validation must not read a file") + + monkeypatch.setattr(controller_module, "_read_json", unexpected_read) + assert controller_module.load_config_bytes(json.dumps(config, indent=2).encode("utf-8")) == expected + + +@pytest.mark.parametrize("raw", [ + b"", b"\xff", b"[]", b"null", b"{\"schema\":\"a\",\"schema\":\"b\"}", + b" " * 8193, b"[" * 2000 + b"]" * 2000, "{}", bytearray(b"{}"), +]) +def test_held_config_rejects_malformed_duplicate_oversized_or_nonbyte_input(controller_module, raw): + assert controller_module.load_config_bytes(raw) is None + + +def test_compose_and_pulls_use_only_the_protected_private_package_owner(controller_module, tmp_path): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + config_path = tmp_path / "protected-config.json" + _write_protected_config(config_path, _forge_config()) + config = controller_module.load_config(config_path) + assert config is not None + environment = controller._compose_environment(profile, config) + assert environment["FORGE_PRIVATE_PACKAGE_OWNER"] == _FORGE_PRIVATE_OWNER + assert controller._expected_image(profile, "api", _FORGE_PRIVATE_OWNER) == ( + f"gitea.example.com/{_FORGE_PRIVATE_OWNER}/itworx-forge@{profile['workspace_image_digest']}" + ) + assert controller._expected_image(profile, "itworx-forge", _FORGE_PRIVATE_OWNER) == ( + f"gitea.example.com/{_FORGE_PRIVATE_OWNER}/itworx-forge-frontdoor@{profile['frontdoor_image_digest']}" + ) + + commands = [] + controller._command_ok = lambda command, **_kwargs: commands.append(command) or True + assert controller._pull_images(profile, _FORGE_PRIVATE_OWNER) + assert commands == [ + ["docker", "pull", f"gitea.example.com/{_FORGE_PRIVATE_OWNER}/itworx-forge@{profile['workspace_image_digest']}"], + ["docker", "pull", f"gitea.example.com/{_FORGE_PRIVATE_OWNER}/itworx-forge-frontdoor@{profile['frontdoor_image_digest']}"], + ] + assert not controller._pull_images(profile, "Jens") + + +def test_request_id_uses_the_shared_canonical_identity(controller_module): + profile = _profile(controller_module) + request = controller_module.request_id(profile, " forge-bootstrap-2026 ") + payload = { + "repository": "jens/itworx-forge", + "operation": "forge_first_install", + "profile": profile, + "idempotencyKey": "forge-bootstrap-2026", + } + expected = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + assert request == expected + + +def test_apply_persists_intent_before_worker_and_replay_never_starts_another( + controller_module, tmp_path +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + worker_entered = threading.Event() + release_worker = threading.Event() + observed = [] + + def hold_worker(record): + saved = controller._read_record(record["request"]) + observed.append((record["request"], saved["status"] if saved else None)) + worker_entered.set() + release_worker.wait(timeout=3) + + controller._execute = hold_worker + try: + accepted = controller.apply(profile, "forge-first-install-2026") + assert accepted["status"] == "accepted" + assert worker_entered.wait(timeout=2) + request = accepted["request"] + assert observed == [(request, "accepted")] + path = controller._record_path(request) + assert path.is_file() + replay = controller.apply(profile, "forge-first-install-2026") + assert replay["request"] == request + assert replay["status"] == "accepted" + assert len(observed) == 1 + finally: + release_worker.set() + thread = controller._threads.get(accepted["request"]) + if thread is not None: + thread.join(timeout=2) + + +@pytest.mark.parametrize("gate", ["image_registry", "oidc", "secret_delivery"]) +def test_apply_rechecks_security_gates_before_any_service_mutation( + controller_module, tmp_path, gate +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + current = _passed_preflight(profile) + current["checks"][gate]["status"] = "blocked" + controller.preflight_check = lambda _profile: current + prepared = [] + pulls = [] + actions = [] + controller._prepare_source = lambda *_args, **_kwargs: prepared.append(True) + controller._pull_images = lambda *_args, **_kwargs: pulls.append(True) + controller._compose_action = lambda *_args, **_kwargs: actions.append(True) + + accepted = controller.apply(profile, f"forge-gate-block-{gate}-0001") + thread = controller._threads[accepted["request"]] + thread.join(timeout=2) + + assert controller.status(accepted["request"])["status"] == "preflight_changed" + assert prepared == [] + assert pulls == [] + assert actions == [] + + +def test_resume_is_durable_and_one_shot_only_from_database_credential_pause( + controller_module, tmp_path +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + request = controller_module.request_id(profile, "forge-first-install-resume") + record = { + "schema": controller_module.STATE_SCHEMA, + "kind": "request", + "request": request, + "repository": controller_module.REPOSITORY, + "operation": controller_module.OPERATION, + "profile": profile, + "status": "needs_database_credentials", + "phase": "awaiting_external_role_passwords", + "mutationStarted": True, + "migrationStarted": False, + "migrationCompleted": False, + "failureCode": None, + "createdAt": 1, + "updatedAt": 1, + } + controller._write_record(record) + worker_entered = threading.Event() + release_worker = threading.Event() + calls = [] + + def hold_resume(saved): + calls.append(saved["request"]) + worker_entered.set() + release_worker.wait(timeout=3) + + controller._execute_after_database_credentials = hold_resume + try: + response = controller.resume(request) + assert response["status"] == "running" + assert worker_entered.wait(timeout=2) + persisted = controller._read_record(request) + assert persisted["resumeClaimed"] is True + assert persisted["resumeAttempts"] == 1 + assert controller.resume(request)["status"] == "running" + assert calls == [request] + finally: + release_worker.set() + thread = controller._threads.get(request) + if thread is not None: + thread.join(timeout=2) + + +def test_resume_refuses_migration_started_or_uncertain_records(controller_module, tmp_path): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + request = controller_module.request_id(profile, "forge-first-install-uncertain") + controller._write_record( + { + "schema": controller_module.STATE_SCHEMA, + "kind": "request", + "request": request, + "repository": controller_module.REPOSITORY, + "operation": controller_module.OPERATION, + "profile": profile, + "status": "needs_database_credentials", + "phase": "awaiting_external_role_passwords", + "mutationStarted": True, + "migrationStarted": True, + "migrationCompleted": False, + "failureCode": None, + "createdAt": 1, + "updatedAt": 1, + } + ) + result = controller.resume(request) + assert result is not None + assert result["status"] == "outcome_unknown" + assert result["request"] == request + assert not controller._threads + + +def test_resume_rejects_negative_attempt_counter_without_starting_a_worker( + controller_module, tmp_path +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, + controller, + profile, + "forge-resume-negative-attempt-counter", + status="needs_database_credentials", + phase="awaiting_external_role_passwords", + ) + record["resumeGeneration"] = 0 + record["resumeAttempts"] = -1 + record["resumeClaimed"] = False + controller._write_record(record) + + response = controller.resume(request) + + saved = controller._read_record(request) + assert response is not None and response["status"] == "outcome_unknown" + assert not controller._threads + assert saved is not None + assert saved["resumeAttempts"] == -1 + assert saved["phase"] == "credential_journal_invalid" + assert saved["migrationStarted"] is False + + +def test_resume_prerequisites_run_before_any_role_login_or_password_helper( + controller_module, tmp_path, monkeypatch +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + _prepare_credential_resume(controller_module, controller, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, controller, profile, "forge-resume-gate-before-roles" + ) + events = [] + blocked = _passed_preflight(profile) + blocked["checks"]["push_validation"]["status"] = "blocked" + controller.resume_check = lambda _profile: events.append("resume_gate") or blocked + controller._test_database_uri = lambda _name, role: events.append(("login", role)) or False + mutations = [] + helper_calls = _install_successful_role_helper( + monkeypatch, before_send=lambda role: mutations.append(role) + ) + migrations = [] + controller._run_migration = lambda *_args: migrations.append(True) + + controller._execute_after_database_credentials(record) + + assert events == ["resume_gate"] + assert helper_calls == [] + assert mutations == [] + assert migrations == [] + saved = controller._read_record(request) + assert saved is not None and saved["migrationStarted"] is False + + +def test_resume_skips_password_changes_for_already_working_roles( + controller_module, tmp_path, monkeypatch +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + _prepare_credential_resume(controller_module, controller, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, controller, profile, "forge-resume-known-role-logins" + ) + login_events = [] + controller._test_database_uri = lambda _name, role: login_events.append(role) or True + helper_calls = _install_successful_role_helper(monkeypatch) + migrations = [] + controller._run_migration = lambda saved, *_args: migrations.append( + (saved["credentialRoles"].copy(), tuple(login_events)) + ) + + controller._execute_after_database_credentials(record) + + assert helper_calls == [] + assert login_events == [*_FORGE_ROLE_NAMES, *_FORGE_ROLE_NAMES] + assert migrations == [({role: "completed" for role in _FORGE_ROLE_NAMES}, tuple(login_events))] + saved = controller._read_record(request) + assert saved is not None and saved["migrationStarted"] is True + + +def test_resume_sets_only_pending_roles_then_proves_both_logins_before_migration( + controller_module, tmp_path, monkeypatch +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + _prepare_credential_resume(controller_module, controller, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, controller, profile, "forge-resume-set-then-prove" + ) + events = [] + changed_roles = [] + helper_calls = _install_successful_role_helper( + monkeypatch, + before_send=lambda role: (changed_roles.append(role), events.append(("set", role))), + ) + + def login(_name, role): + valid = all(item in changed_roles for item in _FORGE_ROLE_NAMES) + events.append(("login", role, valid)) + return valid + + controller._test_database_uri = login + + def migrate(saved, *_args): + events.append(("migration", saved["credentialRoles"].copy())) + + controller._run_migration = migrate + controller._execute_after_database_credentials(record) + + assert helper_calls == [ + (controller.secret_root, "called"), + (controller.secret_root, "forge_runtime"), + (controller.secret_root, "forge_migrator"), + ] + assert events == [ + ("login", "forge_runtime", False), + ("login", "forge_migrator", False), + ("set", "forge_runtime"), + ("set", "forge_migrator"), + ("login", "forge_runtime", True), + ("login", "forge_migrator", True), + ("migration", {role: "completed" for role in _FORGE_ROLE_NAMES}), + ] + saved = controller._read_record(request) + assert saved is not None and saved["migrationStarted"] is True + + +def test_resume_never_replays_intent_role_when_independent_login_fails( + controller_module, tmp_path, monkeypatch +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + _prepare_credential_resume(controller_module, controller, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, + controller, + profile, + "forge-resume-intent-requires-login", + roles={"forge_runtime": "completed", "forge_migrator": "intent"}, + ) + controller._test_database_uri = lambda _name, role: role == "forge_runtime" + helper_calls = _install_successful_role_helper(monkeypatch) + migrations = [] + controller._run_migration = lambda *_args: migrations.append(True) + + controller._execute_after_database_credentials(record) + + saved = controller._read_record(request) + assert helper_calls == [] + assert migrations == [] + assert saved is not None + assert saved["status"] == "outcome_unknown" + assert saved["phase"] in {"resume_intent_persisted", "role_credentials"} + assert saved["credentialRoles"] == {"forge_runtime": "completed", "forge_migrator": "unknown"} + assert saved["migrationStarted"] is False + + +def test_retry_after_second_role_zero_input_failure_preserves_first_role_completion( + controller_module, tmp_path, monkeypatch +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + _prepare_credential_resume(controller_module, controller, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, controller, profile, "forge-resume-partial-role-retry" + ) + changed_roles = [] + helper_calls = [] + helper = types.ModuleType("forge_database_credentials") + + def set_passwords(secret_root, *, role_state, persist_role_intent, + persist_role_retryable, persist_role_unknown, + persist_role_complete): + helper_calls.append((role_state("forge_runtime"), role_state("forge_migrator"))) + completed = [] + for role in _FORGE_ROLE_NAMES: + state = role_state(role) + if state == "completed": + completed.append(role) + continue + assert state in {"pending", "retryable"} + assert persist_role_intent(role) is True + if role == "forge_migrator" and len(helper_calls) == 1: + assert persist_role_retryable(role) is True + return {"status": "failed", "completedRoles": completed, + "activeRole": role, "reason": "psql_failed_before_password_input"} + changed_roles.append(role) + assert persist_role_complete(role) is True + completed.append(role) + return {"status": "completed", "completedRoles": completed, "activeRole": None} + + helper.set_forge_database_role_passwords = set_passwords + monkeypatch.setitem(sys.modules, "forge_database_credentials", helper) + controller._test_database_uri = lambda _name, role: role in changed_roles + migrations = [] + controller._run_migration = lambda *_args: migrations.append(True) + + controller._execute_after_database_credentials(record) + first = controller._read_record(request) + assert first is not None + assert first["status"] == "needs_database_credentials" + assert first["phase"] == "awaiting_external_role_passwords" + assert first["credentialRoles"] == {"forge_runtime": "completed", "forge_migrator": "retryable"} + assert first["resumeClaimed"] is False + assert migrations == [] + + response = controller.resume(request) + assert response is not None and response["status"] == "running" + thread = controller._threads[request] + thread.join(timeout=3) + assert not thread.is_alive() + + final = controller._read_record(request) + assert final is not None + assert helper_calls == [("pending", "pending"), ("completed", "retryable")] + assert changed_roles == ["forge_runtime", "forge_migrator"] + assert final["credentialRoles"] == {"forge_runtime": "completed", "forge_migrator": "completed"} + assert migrations == [True] + + +def test_explicit_resume_reconciles_only_a_crashed_credentials_phase( + controller_module, tmp_path +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, + controller, + profile, + "forge-resume-crashed-credentials-only", + status="outcome_unknown", + phase="role_credentials", + roles={"forge_runtime": "intent", "forge_migrator": "completed"}, + ) + record["resumeClaimed"] = True + controller._write_record(record) + entered = threading.Event() + release = threading.Event() + calls = [] + + def hold_resume(saved): + calls.append(saved["resumeGeneration"]) + entered.set() + release.wait(timeout=3) + + controller._execute_after_database_credentials = hold_resume + try: + response = controller.resume(request) + assert response is not None and response["status"] == "running" + assert entered.wait(timeout=2) + persisted = controller._read_record(request) + assert persisted is not None + assert persisted["resumeGeneration"] == 2 + assert persisted["resumeAttempts"] == 2 + assert persisted["phase"] == "resume_intent_persisted" + assert controller.resume(request)["status"] == "running" + assert calls == [2] + finally: + release.set() + thread = controller._threads.get(request) + if thread is not None: + thread.join(timeout=2) + + +def test_old_generation_callback_cannot_overwrite_new_role_journal( + controller_module, tmp_path +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + request, _record = _credential_resume_record( + controller_module, controller, profile, "forge-resume-old-callback" + ) + callbacks = controller._credential_role_callbacks(request, profile, 1) + assert callbacks["role_state"]("forge_runtime") == "pending" + newer = controller._read_record(request) + assert newer is not None + newer["resumeGeneration"] = 2 + newer["credentialRoles"]["forge_runtime"] = "completed" + controller._write_record(newer) + + assert callbacks["persist_role_intent"]("forge_runtime") is False + saved = controller._read_record(request) + assert saved is not None + assert saved["resumeGeneration"] == 2 + assert saved["credentialRoles"]["forge_runtime"] == "completed" + + +@pytest.mark.parametrize( + ("stored_generation", "stored_phase", "expected_generation"), + [ + (2, "resume_intent_persisted", 1), + (2, "role_credentials", 2), + ], +) +def test_resume_phase_advance_rejects_stale_generation_or_phase( + controller_module, + tmp_path, + stored_generation, + stored_phase, + expected_generation, +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, + controller, + profile, + f"forge-resume-stale-phase-advance-{stored_generation}-{stored_phase}", + phase=stored_phase, + ) + record["resumeGeneration"] = stored_generation + controller._write_record(record) + + advanced = controller._advance_resume_to_credentials( + request, profile, expected_generation + ) + + saved = controller._read_record(request) + assert advanced is None + assert saved is not None + assert saved["resumeGeneration"] == stored_generation + assert saved["phase"] == stored_phase + + +def test_stale_generation_cannot_claim_migration(controller_module, tmp_path): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, + controller, + profile, + "forge-resume-stale-migration-claim", + phase="role_credentials", + roles={role: "completed" for role in _FORGE_ROLE_NAMES}, + ) + record["resumeGeneration"] = 2 + controller._write_record(record) + + claim = controller._claim_resume_migration(request, profile, 1) + + saved = controller._read_record(request) + assert claim is None + assert saved is not None + assert saved["resumeGeneration"] == 2 + assert saved["phase"] == "role_credentials" + assert saved["migrationStarted"] is False + + +def test_migration_claim_requires_both_roles_complete(controller_module, tmp_path): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, + controller, + profile, + "forge-resume-incomplete-migration-claim", + phase="role_credentials", + roles={"forge_runtime": "completed", "forge_migrator": "retryable"}, + ) + controller._write_record(record) + + claim = controller._claim_resume_migration(request, profile, 1) + + saved = controller._read_record(request) + assert claim is None + assert saved is not None + assert saved["phase"] == "role_credentials" + assert saved["migrationStarted"] is False + + +def test_status_reconciliation_preserves_a_completed_role_journal(controller_module, tmp_path): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, controller, profile, "forge-resume-status-read-preserves-completion" + ) + record["credentialRoles"] = {role: "completed" for role in _FORGE_ROLE_NAMES} + controller._write_record(record) + + response = controller.status(request) + + saved = controller._read_record(request) + assert response is not None and response["status"] == "outcome_unknown" + assert saved is not None + assert saved["phase"] in {"resume_intent_persisted", "role_credentials"} + assert saved["credentialRoles"] == {role: "completed" for role in _FORGE_ROLE_NAMES} + assert saved["migrationStarted"] is False + + +def test_failed_role_journal_completion_blocks_migration(controller_module, tmp_path, monkeypatch): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + _prepare_credential_resume(controller_module, controller, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, controller, profile, "forge-resume-journal-write-failure" + ) + helper_calls = _install_successful_role_helper(monkeypatch) + controller._test_database_uri = lambda _name, _role: False + migrations = [] + controller._run_migration = lambda *_args: migrations.append(True) + original_write = controller._write_record + + def fail_completed_journal(saved): + roles = saved.get("credentialRoles", {}) + if "completed" in roles.values(): + raise OSError("private journal failure") + original_write(saved) + + controller._write_record = fail_completed_journal + + controller._execute_after_database_credentials(record) + + saved = controller._read_record(request) + assert helper_calls + assert migrations == [] + assert saved is not None + assert saved["status"] == "outcome_unknown" + assert saved["migrationStarted"] is False + assert "private journal failure" not in json.dumps(controller._response(saved)) + + +def test_final_login_failure_stops_before_migration(controller_module, tmp_path, monkeypatch): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + _prepare_credential_resume(controller_module, controller, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, controller, profile, "forge-resume-final-login-failure" + ) + changed_roles = [] + helper_calls = _install_successful_role_helper( + monkeypatch, before_send=lambda role: changed_roles.append(role) + ) + login_calls = [] + + def login(_name, role): + login_calls.append(role) + return bool(changed_roles) and role != "forge_migrator" + + controller._test_database_uri = login + migrations = [] + controller._run_migration = lambda *_args: migrations.append(True) + + controller._execute_after_database_credentials(record) + + saved = controller._read_record(request) + assert helper_calls + assert login_calls == [*_FORGE_ROLE_NAMES, *_FORGE_ROLE_NAMES] + assert migrations == [] + assert saved is not None + assert saved["status"] == "outcome_unknown" + assert saved["credentialRoles"] == {"forge_runtime": "completed", "forge_migrator": "unknown"} + assert saved["migrationStarted"] is False + + +def test_final_resume_gate_failure_preserves_completed_credentials_and_skips_migration( + controller_module, tmp_path, monkeypatch +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + _prepare_credential_resume(controller_module, controller, tmp_path, profile) + request, record = _credential_resume_record( + controller_module, controller, profile, "forge-resume-final-gate-failure" + ) + changed_roles = [] + helper_calls = _install_successful_role_helper( + monkeypatch, before_send=lambda role: changed_roles.append(role) + ) + controller._test_database_uri = lambda _name, role: role in changed_roles + gate_calls = [] + + def resume_gate(_profile): + gate_calls.append(True) + result = _passed_preflight(profile) + if len(gate_calls) == 2: + result["checks"]["exact_main"]["status"] = "blocked" + return result + + controller.resume_check = resume_gate + migrations = [] + controller._run_migration = lambda *_args: migrations.append(True) + + controller._execute_after_database_credentials(record) + + saved = controller._read_record(request) + assert len(gate_calls) == 2 + assert helper_calls + assert migrations == [] + assert saved is not None + assert saved["status"] == "outcome_unknown" + assert saved["credentialRoles"] == {role: "completed" for role in _FORGE_ROLE_NAMES} + assert saved["migrationStarted"] is False + + +def test_get_at_credential_pause_never_probes_database_or_loads_password_helper( + controller_module, tmp_path, monkeypatch +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + request = controller_module.request_id(profile, "forge-resume-get-no-remote-writes") + record = { + "schema": controller_module.STATE_SCHEMA, + "kind": "request", + "request": request, + "repository": controller_module.REPOSITORY, + "operation": controller_module.OPERATION, + "profile": profile, + "status": "needs_database_credentials", + "phase": "awaiting_external_role_passwords", + "mutationStarted": True, + "migrationStarted": False, + "migrationCompleted": False, + "credentialRoles": {role: "pending" for role in _FORGE_ROLE_NAMES}, + "resumeGeneration": 0, + "resumeAttempts": 0, + "resumeClaimed": False, + "failureCode": None, + "createdAt": 1, + "updatedAt": 1, + } + controller._write_record(record) + controller._test_database_uri = lambda *_args: pytest.fail("GET must not run a login probe") + helper = types.ModuleType("forge_database_credentials") + helper.set_forge_database_role_passwords = lambda *_args, **_kwargs: pytest.fail( + "GET must not load or call the password helper" + ) + monkeypatch.setitem(sys.modules, "forge_database_credentials", helper) + + response = controller.status(request) + + assert response is not None and response["status"] == "needs_database_credentials" + saved = controller._read_record(request) + assert saved is not None + assert saved["credentialRoles"] == {role: "pending" for role in _FORGE_ROLE_NAMES} + assert saved["resumeGeneration"] == 0 + + +def test_runtime_verification_requires_worker_health_not_just_running(controller_module, tmp_path): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + service_names = ( + "itworx-forge", "postgres", "migrate", "api", "worker", "dashboard" + ) + containers = dict(zip(service_names, controller_module.CONTAINER_NAMES)) + inspected = {} + for service, name in containers.items(): + if service == "migrate": + continue + inspected[name] = { + "Config": { + "Image": controller._expected_image(profile, service, _FORGE_PRIVATE_OWNER), + "Labels": { + "com.docker.compose.project": controller_module.PROJECT_NAME, + "com.docker.compose.service": service, + **( + { + "org.opencontainers.image.source": _FORGE_SOURCE_URL, + "org.opencontainers.image.revision": profile["revision"], + } + if service in {"itworx-forge", "api", "worker", "dashboard"} + else {} + ), + }, + }, + "State": {"Running": True, "Health": {"Status": "healthy"}}, + } + inspected[containers["worker"]]["State"].pop("Health") + controller._inspect_container = lambda name: inspected.get(name) + assert not controller._all_services_verified( + profile, private_package_owner=_FORGE_PRIVATE_OWNER, migration_completed=True + ) + inspected[containers["worker"]]["State"]["Health"] = {"Status": "healthy"} + assert controller._all_services_verified( + profile, private_package_owner=_FORGE_PRIVATE_OWNER, migration_completed=True + ) + inspected[containers["api"]]["Config"]["Labels"]["org.opencontainers.image.source"] = ( + "https://example.invalid/copied-image" + ) + assert not controller._all_services_verified( + profile, private_package_owner=_FORGE_PRIVATE_OWNER, migration_completed=True + ) + inspected[containers["api"]]["Config"]["Labels"]["org.opencontainers.image.source"] = _FORGE_SOURCE_URL + inspected[containers["api"]]["Config"]["Labels"]["org.opencontainers.image.revision"] = "d" * 40 + assert not controller._all_services_verified( + profile, private_package_owner=_FORGE_PRIVATE_OWNER, migration_completed=True + ) + + +def test_worker_start_waits_for_health_before_starting_later_services( + controller_module, tmp_path, monkeypatch +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + services = ("itworx-forge", "postgres", "migrate", "api", "worker", "dashboard") + containers = dict(zip(services, controller_module.CONTAINER_NAMES)) + worker_states = iter(("starting", "healthy")) + worker_health_seen = [] + actions = [] + + def inspect(name): + service = next(service for service, container in containers.items() if container == name) + health_status = next(worker_states) if service == "worker" else "healthy" + if service == "worker": + worker_health_seen.append(health_status) + image = ( + f"gitea.example.com/{_FORGE_PRIVATE_OWNER}/itworx-forge-frontdoor@{profile['frontdoor_image_digest']}" + if service == "itworx-forge" + else f"gitea.example.com/{_FORGE_PRIVATE_OWNER}/itworx-forge@{profile['workspace_image_digest']}" + ) + return { + "Config": { + "Image": image, + "Labels": { + "com.docker.compose.project": controller_module.PROJECT_NAME, + "com.docker.compose.service": service, + **( + { + "org.opencontainers.image.source": _FORGE_SOURCE_URL, + "org.opencontainers.image.revision": profile["revision"], + } + if service in {"itworx-forge", "api", "worker", "dashboard"} + else {} + ), + }, + }, + "State": {"Running": True, "Status": "running", "Health": {"Status": health_status}}, + } + + def compose_action(_compose, action, _source, _environment): + if action == ["up", "--detach", "--no-deps", "dashboard"]: + assert worker_health_seen == ["starting", "healthy"] + actions.append(action) + return True + + controller._inspect_container = inspect + controller._compose_action = compose_action + controller._all_services_verified = lambda *_args, **_kwargs: True + monkeypatch.setattr( + controller_module, + "time", + types.SimpleNamespace(monotonic=lambda: 0.0, sleep=lambda _seconds: None, time=lambda: 1), + ) + request = controller_module.request_id(profile, "forge-worker-health-starting-0001") + record = { + "schema": controller_module.STATE_SCHEMA, + "kind": "request", + "request": request, + "repository": controller_module.REPOSITORY, + "operation": controller_module.OPERATION, + "profile": profile, + "status": "running", + "phase": "migration_intent", + "mutationStarted": True, + "migrationStarted": False, + "migrationCompleted": False, + "failureCode": None, + "createdAt": 1, + "updatedAt": 1, + } + controller._write_record(record) + + controller._run_migration( + record, ["docker", "compose"], tmp_path, + {"FORGE_PRIVATE_PACKAGE_OWNER": _FORGE_PRIVATE_OWNER}, + ) + + assert worker_health_seen == ["starting", "healthy"] + assert actions[-1] == ["up", "--detach", "--no-deps", "itworx-forge"] + assert controller.status(request)["status"] == "deployed" + + +def test_worker_without_healthcheck_stops_startup_before_later_services( + controller_module, tmp_path, monkeypatch +): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + services = ("itworx-forge", "postgres", "migrate", "api", "worker", "dashboard") + containers = dict(zip(services, controller_module.CONTAINER_NAMES)) + actions = [] + + def inspect_worker(name): + assert name == containers["worker"] + return { + "Config": { + "Image": f"gitea.example.com/{_FORGE_PRIVATE_OWNER}/itworx-forge@{profile['workspace_image_digest']}", + "Labels": { + "com.docker.compose.project": controller_module.PROJECT_NAME, + "com.docker.compose.service": "worker", + "org.opencontainers.image.source": _FORGE_SOURCE_URL, + "org.opencontainers.image.revision": profile["revision"], + }, + }, + "State": {"Running": True, "Status": "running"}, + } + + actual_wait_health = controller._wait_health + + def wait_health(container, *, expected_image, timeout_seconds, expected_revision=None): + if container == containers["worker"]: + return actual_wait_health( + container, + expected_image=expected_image, + expected_revision=expected_revision, + timeout_seconds=0.5, + ) + return True + + ticks = iter((0.0, 0.0, 1.0)) + controller._inspect_container = inspect_worker + controller._wait_health = wait_health + controller._compose_action = lambda _compose, action, _source, _environment: actions.append(action) or True + controller._all_services_verified = lambda *_args, **_kwargs: True + monkeypatch.setattr( + controller_module, + "time", + types.SimpleNamespace(monotonic=lambda: next(ticks), sleep=lambda _seconds: None, time=lambda: 1), + ) + request = controller_module.request_id(profile, "forge-worker-no-health-0001") + record = { + "schema": controller_module.STATE_SCHEMA, + "kind": "request", + "request": request, + "repository": controller_module.REPOSITORY, + "operation": controller_module.OPERATION, + "profile": profile, + "status": "running", + "phase": "migration_intent", + "mutationStarted": True, + "migrationStarted": False, + "migrationCompleted": False, + "failureCode": None, + "createdAt": 1, + "updatedAt": 1, + } + controller._write_record(record) + + controller._run_migration( + record, ["docker", "compose"], tmp_path, + {"FORGE_PRIVATE_PACKAGE_OWNER": _FORGE_PRIVATE_OWNER}, + ) + + assert actions == [ + ["run", "--rm", "--no-deps", "--pull", "never", "migrate"], + ["up", "--detach", "--no-deps", "api"], + ["up", "--detach", "--no-deps", "worker"], + ] + assert controller.status(request)["status"] == "outcome_unknown" + + +def test_migration_and_each_service_activation_use_no_deps(controller_module, tmp_path): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + actions = [] + controller._compose_action = lambda _compose, action, _source, _environment: actions.append(action) or True + controller._wait_health = lambda *_args, **_kwargs: True + controller._wait_running = lambda *_args, **_kwargs: True + controller._all_services_verified = lambda *_args, **_kwargs: True + request = controller_module.request_id(profile, "forge-first-install-compose") + record = { + "schema": controller_module.STATE_SCHEMA, + "kind": "request", + "request": request, + "repository": controller_module.REPOSITORY, + "operation": controller_module.OPERATION, + "profile": profile, + "status": "running", + "phase": "migration_intent", + "mutationStarted": True, + "migrationStarted": False, + "migrationCompleted": False, + "failureCode": None, + "createdAt": 1, + "updatedAt": 1, + } + controller._write_record(record) + controller._run_migration( + record, ["docker", "compose"], tmp_path, + {"FORGE_PRIVATE_PACKAGE_OWNER": _FORGE_PRIVATE_OWNER}, + ) + assert actions[0] == ["run", "--rm", "--no-deps", "--pull", "never", "migrate"] + assert actions[1:] == [ + ["up", "--detach", "--no-deps", service] + for service in ("api", "worker", "dashboard", "itworx-forge") + ] + assert controller.status(request)["receipt"]["migration"]["result"] == "succeeded" + + +def test_migration_does_not_start_without_a_safe_protected_owner(controller_module, tmp_path): + profile = _profile(controller_module) + controller = _controller(controller_module, tmp_path, profile) + actions = [] + controller._compose_action = lambda _compose, action, _source, _environment: actions.append(action) or True + request = controller_module.request_id(profile, "forge-private-owner-required-0001") + record = { + "schema": controller_module.STATE_SCHEMA, + "kind": "request", + "request": request, + "repository": controller_module.REPOSITORY, + "operation": controller_module.OPERATION, + "profile": profile, + "status": "running", + "phase": "credentials_verified", + "mutationStarted": True, + "migrationStarted": False, + "migrationCompleted": False, + "failureCode": None, + "createdAt": 1, + "updatedAt": 1, + } + controller._write_record(record) + + controller._run_migration(record, ["docker", "compose"], tmp_path, {}) + + current = controller.status(request) + assert actions == [] + assert current["status"] == "failed" + assert current["failure"] == {"code": "service_start_failed", "preservedData": True} + saved = controller._read_record(request) + assert saved is not None and saved["migrationStarted"] is False + + +def test_database_uri_password_is_supplied_on_stdin_not_argv_or_environment( + controller_module, tmp_path, monkeypatch +): + profile = _profile(controller_module) + captured = {} + + def runner(arguments, **kwargs): + captured["arguments"] = arguments + captured.update(kwargs) + return subprocess.CompletedProcess( + arguments, 0, stdout=b"forge_runtime|forge\n", stderr=b"" + ) + + controller = _controller(controller_module, tmp_path, profile, command_runner=runner) + password = b"not-a-real-secret-value" + uri = b"postgresql://forge_runtime:not-a-real-secret-value@postgres:5432/forge" + monkeypatch.setattr(controller_module, "read_database_uri", lambda *_args: uri) + assert controller._test_database_uri("runtime_database_url", "forge_runtime") + assert password not in " ".join(captured["arguments"]).encode() + assert password in captured["input"] + assert "env" not in captured + assert captured["capture_output"] is True + + +class _Response: + def __init__(self, body=b"", status=200, headers=None): + self.body = body + self.status = status + self.headers = headers or {} + self.read_sizes = [] + + def read(self, size=-1): + self.read_sizes.append(size) + return self.body if size < 0 else self.body[:size] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + +class _StaticOpener: + def __init__(self, outcome): + self.outcome = outcome + self.requests = [] + + def open(self, request, timeout=None): + self.requests.append((request, timeout)) + if isinstance(self.outcome, Exception): + raise self.outcome + return self.outcome + + +def _registry_artifact(labels): + config = json.dumps( + {"architecture": "amd64", "os": "linux", "config": {"Labels": labels}}, + separators=(",", ":"), + ).encode() + config_digest = "sha256:" + hashlib.sha256(config).hexdigest() + manifest = json.dumps( + { + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": config_digest, + "size": len(config), + }, + "layers": [], + }, + separators=(",", ":"), + ).encode() + manifest_digest = "sha256:" + hashlib.sha256(manifest).hexdigest() + return manifest_digest, manifest, config_digest, config + + +class _RegistryOpener: + def __init__(self, repository, manifest_digest, manifest, config_digest, config): + self.repository = repository + self.manifest_digest = manifest_digest + self.manifest = manifest + self.config_digest = config_digest + self.config = config + self.requests = [] + + def open(self, request, timeout=None): + self.requests.append((request, timeout)) + path = urlparse(request.full_url).path + method = request.get_method() + root = f"/v2/{self.repository}" + if path == f"{root}/manifests/{self.manifest_digest}" and method == "GET": + return _Response( + self.manifest, + headers={"Docker-Content-Digest": self.manifest_digest}, + ) + if path == f"{root}/blobs/{self.config_digest}": + return _Response(self.config if method == "GET" else b"", status=200) + raise AssertionError(f"unexpected registry request {method} {path}") + + +def _challenge_error(repository, *, scope=None): + scope = scope or f"repository:{repository}:pull" + headers = Message() + headers["WWW-Authenticate"] = ( + 'Bearer realm="https://gitea.example.com/v2/token",' + f'service="container_registry",scope="{scope}"' + ) + return HTTPError("https://gitea.example.com/v2/", 401, "Unauthorized", headers, io.BytesIO()) + + +def test_registry_requires_anonymous_get_denial_with_exact_repository_scope( + status_module, monkeypatch +): + repository = "test-owner/test-image" + digest = "sha256:" + "a" * 64 + public_opener = _StaticOpener(_Response(b"public manifest", status=200)) + monkeypatch.setattr(status_module, "build_opener", lambda *_args: public_opener) + assert status_module._forge_registry_challenge(repository, f"manifests/{digest}") is None + assert public_opener.requests[0][0].get_method() == "GET" + + private_opener = _StaticOpener(_challenge_error(repository)) + monkeypatch.setattr(status_module, "build_opener", lambda *_args: private_opener) + token_url = status_module._forge_registry_challenge(repository, f"manifests/{digest}") + assert token_url is not None + assert urlparse(token_url).hostname == "gitea.example.com" + assert urlparse(token_url).path == "/v2/token" + assert "repository%3Atest-owner%2Ftest-image%3Apull" in token_url + + wrong_scope = _StaticOpener( + _challenge_error(repository, scope="repository:someone-else/image:pull") + ) + monkeypatch.setattr(status_module, "build_opener", lambda *_args: wrong_scope) + assert status_module._forge_registry_challenge(repository, f"blobs/{digest}") is None + + +def test_registry_token_exchange_uses_fixed_gitea_token_and_scoped_challenge( + status_module, monkeypatch +): + repository = f"{_FORGE_PRIVATE_OWNER}/test-image" + digest = "sha256:" + "a" * 64 + monkeypatch.setattr( + status_module, + "_forge_registry_challenge", + lambda _repository, _path: "https://gitea.example.com/v2/token?scope=repository%3Atest-owner%2Ftest-image%3Apull", + ) + opener = _StaticOpener(_Response(json.dumps({"token": "t" * 40}).encode())) + monkeypatch.setattr(status_module, "build_opener", lambda *_args: opener) + assert status_module._forge_registry_auth(repository, digest, b"fixed-gitea-token") == "t" * 40 + request = opener.requests[0][0] + assert request.get_method() == "GET" + assert request.get_header("Authorization") == "Basic " + base64.b64encode( + b"jens:fixed-gitea-token" + ).decode() + + +def test_registry_attestation_uses_protected_owner_and_binds_both_image_roles( + status_module, monkeypatch +): + config = _forge_config() + monkeypatch.setattr(status_module, "_load_forge_bootstrap_config", lambda _path: config) + monkeypatch.setattr( + status_module, + "_gitea_validation_settings", + lambda: ("https://gitea.example.com/api/v1", b"fixed-token"), + ) + owner_checks = [] + image_checks = [] + + def owner_is_private(owner, *_args): + owner_checks.append(owner) + return True + + def image_pullable(repository, digest, _token, **kwargs): + image_checks.append((repository, digest, kwargs)) + return True + + profile = { + "revision": "a" * 40, + "workspace_image_digest": "sha256:" + "a" * 64, + "frontdoor_image_digest": "sha256:" + "b" * 64, + } + result, proof = status_module._forge_image_registry_attestation( + profile, + owner_is_private=owner_is_private, + image_pullable=image_pullable, + ) + assert result == "passed" + assert owner_checks == [_FORGE_PRIVATE_OWNER] + assert [item[:2] for item in image_checks] == [ + (f"{_FORGE_PRIVATE_OWNER}/itworx-forge", profile["workspace_image_digest"]), + (f"{_FORGE_PRIVATE_OWNER}/itworx-forge-frontdoor", profile["frontdoor_image_digest"]), + ] + assert all( + item[2]["expected_revision"] == profile["revision"] + and item[2]["approved_owner"] == _FORGE_PRIVATE_OWNER + for item in image_checks + ) + assert [item[2]["expected_package"] for item in image_checks] == [ + "itworx-forge", + "itworx-forge-frontdoor", + ] + assert proof == { + "owner": _FORGE_PRIVATE_OWNER, + "ownerVisibility": "private", + "workspaceDigest": profile["workspace_image_digest"], + "frontdoorDigest": profile["frontdoor_image_digest"], + "workspacePullable": True, + "frontdoorPullable": True, + "workspaceSource": _FORGE_SOURCE_URL, + "workspaceRevision": profile["revision"], + "frontdoorSource": _FORGE_SOURCE_URL, + "frontdoorRevision": profile["revision"], + } + + +def test_registry_attestation_stays_blocked_without_protected_owner(status_module, monkeypatch): + monkeypatch.setattr(status_module, "_load_forge_bootstrap_config", lambda _path: None) + result, proof = status_module._forge_image_registry_attestation( + { + "revision": "a" * 40, + "workspace_image_digest": "sha256:" + "a" * 64, + "frontdoor_image_digest": "sha256:" + "b" * 64, + } + ) + assert result == "blocked" + assert proof is None + + +def test_registry_owner_limited_visibility_is_not_private(status_module, monkeypatch): + def owner_result(visibility): + return _StaticOpener( + _Response( + json.dumps({"login": "jens", "visibility": visibility}).encode() + ) + ) + + monkeypatch.setattr(status_module, "build_opener", lambda *_args: owner_result("limited")) + assert not status_module._forge_registry_owner_is_private( + "jens", "https://gitea.example.com/api/v1", b"gitea-token" + ) + monkeypatch.setattr(status_module, "build_opener", lambda *_args: owner_result("private")) + assert status_module._forge_registry_owner_is_private( + "jens", "https://gitea.example.com/api/v1", b"gitea-token" + ) + assert not status_module._forge_registry_owner_is_private( + "Owner.With.Dot", "https://gitea.example.com/api/v1", b"gitea-token" + ) + + +def test_registry_config_blob_must_match_digest_source_and_revision(status_module, monkeypatch): + repository = f"{_FORGE_PRIVATE_OWNER}/itworx-forge" + revision = "a" * 40 + matching_labels = { + "org.opencontainers.image.source": _FORGE_SOURCE_URL, + "org.opencontainers.image.revision": revision, + } + + def verify(labels, *, tampered_config=False): + manifest_digest, manifest, config_digest, config = _registry_artifact(labels) + opener = _RegistryOpener( + repository, + manifest_digest, + manifest, + config_digest, + (b" " + config[1:]) if tampered_config else config, + ) + monkeypatch.setattr(status_module, "build_opener", lambda *_args: opener) + monkeypatch.setattr( + status_module, + "_forge_registry_challenge", + lambda _repository, _path: "https://gitea.example.com/v2/token", + ) + monkeypatch.setattr( + status_module, "_forge_registry_auth", lambda *_args, **_kwargs: "b" * 40 + ) + result = status_module._forge_registry_image_pullable( + repository, + manifest_digest, + b"gitea-token", + approved_owner=_FORGE_PRIVATE_OWNER, + expected_package="itworx-forge", + expected_revision=revision, + ) + return result, opener + + accepted, opener = verify(matching_labels) + assert accepted + config_gets = [ + request for request, _timeout in opener.requests + if request.get_method() == "GET" and "/blobs/" in request.full_url + ] + assert len(config_gets) == 1 + assert config_gets[0].get_header("Authorization") == "Bearer " + "b" * 40 + + wrong_source = {**matching_labels, "org.opencontainers.image.source": "https://example.invalid/source"} + wrong_revision = {**matching_labels, "org.opencontainers.image.revision": "d" * 40} + assert not verify(wrong_source)[0] + assert not verify(wrong_revision)[0] + assert not verify(matching_labels, tampered_config=True)[0] + + +def test_registry_pullability_rejects_wrong_owner_and_package_role(status_module, monkeypatch): + owner = _FORGE_PRIVATE_OWNER + repository = f"{owner}/itworx-forge" + labels = { + "org.opencontainers.image.source": _FORGE_SOURCE_URL, + "org.opencontainers.image.revision": "a" * 40, + } + manifest_digest, manifest, config_digest, config = _registry_artifact(labels) + opener = _RegistryOpener(repository, manifest_digest, manifest, config_digest, config) + monkeypatch.setattr(status_module, "build_opener", lambda *_args: opener) + monkeypatch.setattr( + status_module, + "_forge_registry_challenge", + lambda *_args: "https://gitea.example.com/v2/token", + ) + monkeypatch.setattr(status_module, "_forge_registry_auth", lambda *_args, **_kwargs: "b" * 40) + + assert not status_module._forge_registry_image_pullable( + "other-owner/itworx-forge", + manifest_digest, + b"gitea-token", + approved_owner=owner, + expected_package="itworx-forge", + expected_revision="a" * 40, + ) + wrong_role_repository = f"{owner}/unexpected-image" + wrong_role_opener = _RegistryOpener( + wrong_role_repository, manifest_digest, manifest, config_digest, config + ) + monkeypatch.setattr(status_module, "build_opener", lambda *_args: wrong_role_opener) + assert not status_module._forge_registry_image_pullable( + wrong_role_repository, + manifest_digest, + b"gitea-token", + approved_owner=owner, + expected_package="itworx-forge", + expected_revision="a" * 40, + ) + + +def test_authentik_readiness_requires_exact_mapping_key_and_redirect_contract( + status_module, monkeypatch +): + auth_base = "https://auth.example-host.com" + issuer = status_module._FORGE_OIDC_ISSUER + mapping_ids = { + "openid": "01234567-89ab-4cde-8fab-0123456789ab", + "email": "21234567-89ab-4cde-8fab-0123456789ab", + "profile": "31234567-89ab-4cde-8fab-0123456789ab", + } + provider_id = "1234567890" + signing_key_id = "11234567-89ab-4cde-8fab-0123456789ab" + config = { + "issuer": issuer, + "audience": "itworx-forge", + "client_id": "itworx-forge", + "authentik_api_url": auth_base, + "launch_url": "https://forge.example.com", + "authorization_flow": "default-provider-authorization-implicit-consent", + "invalidation_flow": "default-provider-invalidation-flow", + "authentik_openid_mapping_id": mapping_ids["openid"], + "authentik_email_mapping_id": mapping_ids["email"], + "authentik_profile_mapping_id": mapping_ids["profile"], + "authentik_provider_id": provider_id, + "authentik_signing_key_id": signing_key_id, + } + certificate = _forge_oidc_test_certificate() + fingerprint = certificate["fingerprint"] + jwk = certificate["jwk"] + assert isinstance(jwk, dict) + kid = jwk["kid"] + payloads = { + issuer + ".well-known/openid-configuration": { + "issuer": issuer, + "jwks_uri": issuer + "jwks/", + "id_token_signing_alg_values_supported": ["RS256"], + "scopes_supported": ["openid", "email", "profile"], + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + }, + f"{auth_base}/api/v3/core/applications/itworx-forge/": { + "pk": "41234567-89ab-4cde-8fab-0123456789ab", + "slug": "itworx-forge", + "provider": int(provider_id), + "meta_launch_url": "https://forge.example.com", + "meta_description": "Managed by AppOps Forge OIDC v1.", + }, + f"{auth_base}/api/v3/providers/oauth2/{provider_id}/": { + "pk": int(provider_id), + "client_secret": "a" * 40, + "client_id": "itworx-forge", + "client_type": "confidential", + "redirect_uris": [{ + "matching_mode": "strict", + "url": "https://forge.example.com/auth/callback", + }], + "issuer_mode": "per_provider", + "include_claims_in_id_token": True, + "sub_mode": "hashed_user_id", + "authorization_flow": config["authorization_flow"], + "invalidation_flow": config["invalidation_flow"], + "property_mappings": [mapping_ids["openid"], mapping_ids["email"], mapping_ids["profile"]], + "signing_key": signing_key_id, + }, + f"{auth_base}/api/v3/propertymappings/provider/scope/{mapping_ids['openid']}/": { + "pk": mapping_ids["openid"], + "managed": status_module._FORGE_OPENID_MAPPING_MANAGED, + "scope_name": "openid", + "expression": ( + "# This scope is required by the OpenID-spec, and must as such exist in authentik.\n" + "# The scope by itself does not grant any information\nreturn {}" + ), + }, + f"{auth_base}/api/v3/propertymappings/provider/scope/{mapping_ids['email']}/": { + "pk": mapping_ids["email"], + "managed": status_module._FORGE_EMAIL_MAPPING_MANAGED, + "scope_name": "email", + "expression": 'return {\n "email": request.user.email,\n "email_verified": False\n}', + }, + f"{auth_base}/api/v3/propertymappings/provider/scope/{mapping_ids['profile']}/": { + "pk": mapping_ids["profile"], + "name": status_module._FORGE_PROFILE_MAPPING_NAME, + "scope_name": "profile", + "expression": status_module._FORGE_PROFILE_MAPPING_EXPRESSION, + }, + f"{auth_base}/api/v3/crypto/certificatekeypairs/{signing_key_id}/": { + "pk": signing_key_id, + "key_type": "RSA", + "private_key_available": True, + "fingerprint_sha256": fingerprint, + }, + f"{auth_base}/api/v3/crypto/certificatekeypairs/{signing_key_id}/view_certificate/": { + "data": certificate["data"], + }, + issuer + "jwks/": { + "keys": [jwk] + }, + } + group_ids = { + "forge-viewers": "51234567-89ab-4cde-8fab-0123456789ab", + "forge-operators": "52234567-89ab-4cde-8fab-0123456789ab", + "forge-owners": "53234567-89ab-4cde-8fab-0123456789ab", + } + group_rows = {} + for index, name in enumerate(status_module._FORGE_GROUP_NAMES, start=1): + group = { + "pk": group_ids[name], + "name": name, + "is_superuser": False, + "attributes": {}, + "roles": [], + "parents": [], + "children": [], + # Membership is preserved and is not a privilege field on this gate. + "users": [17000 + index, 18000 + index], + } + group_rows[name] = group + payloads[f"{auth_base}/api/v3/core/groups/?name={name}&page_size=2"] = { + "results": [group] + } + + class _MappedOpener: + def __init__(self): + self.requests = [] + self.responses = {} + + def open(self, request, timeout=None): + self.requests.append((request, timeout)) + value = payloads.get(request.full_url) + if value is None: + raise AssertionError(f"unexpected Authentik request {request.full_url}") + response = _Response(json.dumps(value).encode()) + self.responses[request.full_url] = response + return response + + opener = _MappedOpener() + monkeypatch.setattr(status_module, "_load_forge_bootstrap_config", lambda _path: config) + delivered_secret = {"value": b"a" * 40} + secret_reads = {"token": 0, "client": 0} + + def private_secret(path): + assert path.name == "authentik_read_token.secret" + secret_reads["token"] += 1 + return b"r" * 40 + + def forge_secret(_root, name): + assert name == "oidc_client_secret" + secret_reads["client"] += 1 + return delivered_secret["value"] + + monkeypatch.setattr(status_module, "_private_secret_file", private_secret) + monkeypatch.setattr(status_module, "_read_forge_printable_secret", forge_secret) + monkeypatch.setattr(status_module, "build_opener", lambda *_args: opener) + status, proof = status_module._forge_oidc_attestation() + assert status == "passed" + assert proof == { + "issuer": issuer, + "audience": "itworx-forge", + "clientId": "itworx-forge", + "clientType": "confidential", + "callback": "https://forge.example.com/auth/callback", + "applicationId": "41234567-89ab-4cde-8fab-0123456789ab", + "providerId": provider_id, + "clientSecretMatched": True, + "authorizationFlowId": config["authorization_flow"], + "invalidationFlowId": config["invalidation_flow"], + "roleGroups": ["forge-operators", "forge-owners", "forge-viewers"], + "scopeMappingIds": mapping_ids, + "scopeMappingExpressionSha256": status_module._FORGE_OIDC_SCOPE_MAPPING_EXPRESSION_SHA256, + "profileMappingName": status_module._FORGE_PROFILE_MAPPING_NAME, + "standardMappingsManaged": True, + "supportedScopes": ["email", "openid", "profile"], + "authorizationCodeFlowSupported": True, + "signingKeyId": signing_key_id, + "signingKeyType": "RSA", + "signingKeyPrivateAvailable": True, + "signingKeyFingerprintSha256": fingerprint, + "signingKeyKid": kid, + "tokenSigningAlgorithm": "RS256", + "jwksKid": kid, + } + assert group_rows["forge-operators"]["users"] == [17002, 18002] + assert "a" * 40 not in json.dumps(proof) + + disk_reads_before_held_check = dict(secret_reads) + held_candidate = { + "config": config, + "oidc_client_secret": b"a" * 40, + "authentik_read_token": b"r" * 40, + } + assert status_module._forge_oidc_attestation(held_candidate=held_candidate) == ( + "passed", + proof, + ) + assert secret_reads == disk_reads_before_held_check + + def assert_group_mutation_is_blocked( + field: str, value: object = None, *, remove: bool = False + ): + group = group_rows["forge-operators"] + original = group.get(field) + if remove: + group.pop(field, None) + else: + group[field] = value + assert status_module._forge_oidc_attestation() == ("blocked", None) + if original is not None or field in group: + group[field] = original + else: + group.pop(field, None) + + # Group identity must be a canonical, nonnil RFC UUID string. False and + # integer IDs must not impersonate one. + for invalid_pk in ( + 17, + False, + "00000000-0000-0000-0000-000000000000", + group_ids["forge-operators"].upper(), + ): + assert_group_mutation_is_blocked("pk", invalid_pk) + for missing_field in ("is_superuser", "attributes", "roles", "parents", "children"): + assert_group_mutation_is_blocked(missing_field, remove=True) + for invalid_superuser in (True, 0, "false"): + assert_group_mutation_is_blocked("is_superuser", invalid_superuser) + for invalid_attributes in ({"owner": "unmanaged"}, []): + assert_group_mutation_is_blocked("attributes", invalid_attributes) + for relation in ("roles", "parents", "children"): + assert_group_mutation_is_blocked(relation, [group_ids["forge-viewers"]]) + + import builtins + + original_import = builtins.__import__ + + def without_cryptography(name, *args, **kwargs): + if name == "cryptography" or name.startswith("cryptography."): + raise ImportError("test dependency is unavailable") + return original_import(name, *args, **kwargs) + + with monkeypatch.context() as crypto_patch: + crypto_patch.setattr(builtins, "__import__", without_cryptography) + assert status_module._forge_oidc_attestation() == ("unknown", None) + + public_urls = { + issuer + ".well-known/openid-configuration", + issuer + "jwks/", + } + assert all( + request.get_header("Authorization") is None + if request.full_url in public_urls + else request.get_header("Authorization") == "Bearer " + "r" * 40 + for request, _timeout in opener.requests + ) + requested_urls = [request.full_url for request, _timeout in opener.requests] + assert f"{auth_base}/api/v3/core/applications/itworx-forge/" in requested_urls + assert not any( + "/api/v3/core/applications/?" in url for url in requested_urls + ) + certificate_url = f"{auth_base}/api/v3/crypto/certificatekeypairs/{signing_key_id}/view_certificate/" + assert opener.responses[certificate_url].read_sizes == [65537] + assert kid == "8cf5f32a6c6d9a876fd0284980a25ce0" + + provider_url = f"{auth_base}/api/v3/providers/oauth2/{provider_id}/" + # Authentik exposes property_mappings as an unordered many-to-many list. + # Exact membership matters; response order does not. + payloads[provider_url]["property_mappings"] = [ + mapping_ids["profile"], mapping_ids["openid"], mapping_ids["email"] + ] + assert status_module._forge_oidc_attestation()[0] == "passed" + payloads[provider_url]["property_mappings"] = [ + mapping_ids["openid"], mapping_ids["email"], mapping_ids["profile"] + ] + profile_mapping_url = f"{auth_base}/api/v3/propertymappings/provider/scope/{mapping_ids['profile']}/" + payloads[profile_mapping_url]["expression"] = status_module._FORGE_PROFILE_MAPPING_EXPRESSION.replace("\n", "\r\n") + assert status_module._forge_oidc_attestation()[0] == "passed" + payloads[profile_mapping_url]["expression"] = status_module._FORGE_PROFILE_MAPPING_EXPRESSION + payloads[provider_url]["redirect_uris"][0]["redirect_uri_type"] = "authorization" + assert status_module._forge_oidc_attestation() == ("blocked", None) + del payloads[provider_url]["redirect_uris"][0]["redirect_uri_type"] + + # A UUID application primary key and its separately pinned numeric provider + # identity are different Authentik resource types. + application_url = f"{auth_base}/api/v3/core/applications/itworx-forge/" + payloads[application_url]["pk"] = 8 + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[application_url]["pk"] = "41234567-89ab-4cde-8fab-0123456789ab" + payloads[application_url]["provider"] = 8 + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[application_url]["provider"] = int(provider_id) + config["authentik_provider_id"] = "8" + assert status_module._forge_oidc_attestation() == ("blocked", None) + config["authentik_provider_id"] = provider_id + config["authentik_provider_id"] = "1" * 11 + assert status_module._forge_oidc_attestation() == ("blocked", None) + config["authentik_provider_id"] = provider_id + + # Reject legacy group-only, stock-profile/incorrect profile mapping, extra + # provider scopes, and any scope-to-ID swaps. + payloads[provider_url]["property_mappings"] = [mapping_ids["profile"]] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[provider_url]["property_mappings"] = [ + mapping_ids["openid"], mapping_ids["email"], mapping_ids["profile"], + "41234567-89ab-4cde-8fab-0123456789ab", + ] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[provider_url]["property_mappings"] = [ + mapping_ids["openid"], mapping_ids["email"], mapping_ids["profile"] + ] + payloads[profile_mapping_url]["name"] = "User profile" + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[profile_mapping_url]["name"] = status_module._FORGE_PROFILE_MAPPING_NAME + payloads[profile_mapping_url]["managed"] = "" + assert status_module._forge_oidc_attestation() == ("blocked", None) + del payloads[profile_mapping_url]["managed"] + original_openid_id = config["authentik_openid_mapping_id"] + config["authentik_openid_mapping_id"] = mapping_ids["email"] + assert status_module._forge_oidc_attestation() == ("blocked", None) + config["authentik_openid_mapping_id"] = original_openid_id + config["authentik_email_mapping_id"] = config["authentik_openid_mapping_id"] + assert status_module._forge_oidc_attestation() == ("blocked", None) + config["authentik_email_mapping_id"] = mapping_ids["email"] + + discovery_url = issuer + ".well-known/openid-configuration" + payloads[discovery_url]["scopes_supported"] = ["openid", "email"] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[discovery_url]["scopes_supported"] = ["openid", "email", "profile"] + payloads[discovery_url]["response_types_supported"] = [] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[discovery_url]["response_types_supported"] = ["code"] + payloads[discovery_url]["grant_types_supported"] = [] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[discovery_url]["grant_types_supported"] = ["authorization_code"] + payloads[discovery_url].pop("scopes_supported") + assert status_module._forge_oidc_attestation() == ("unknown", None) + payloads[discovery_url]["scopes_supported"] = ["openid", "email", "profile"] + + # Certificate, authenticated fingerprint and RSA JWKS public numbers form + # one binding; metadata booleans alone cannot open this check. + keypair_url = f"{auth_base}/api/v3/crypto/certificatekeypairs/{signing_key_id}/" + jwks_url = issuer + "jwks/" + modern_kid = base64.urlsafe_b64encode(bytes(range(64))).decode("ascii").rstrip("=") + payloads[jwks_url]["keys"][0]["kid"] = modern_kid + modern_status, modern_proof = status_module._forge_oidc_attestation() + assert modern_status == "passed" + assert modern_proof is not None + assert modern_proof["signingKeyKid"] == modern_kid + assert modern_proof["jwksKid"] == modern_kid + payloads[jwks_url]["keys"][0]["kid"] = "A" * 85 + "B" + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[jwks_url]["keys"][0]["kid"] = kid + + alternate = _forge_oidc_test_certificate() + payloads[certificate_url]["data"] = alternate["data"] + payloads[keypair_url]["fingerprint_sha256"] = alternate["fingerprint"] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[certificate_url]["data"] = certificate["data"] + payloads[keypair_url]["fingerprint_sha256"] = fingerprint + + payloads[keypair_url]["fingerprint_sha256"] = alternate["fingerprint"] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[keypair_url]["fingerprint_sha256"] = fingerprint + + payloads[jwks_url]["keys"][0]["n"] = alternate["jwk"]["n"] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[jwks_url]["keys"][0] = jwk + + payloads[jwks_url]["keys"][0]["e"] = "Aw" + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[jwks_url]["keys"][0] = jwk + + payloads[keypair_url]["key_type"] = "EC" + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[keypair_url]["key_type"] = "RSA" + + ec_certificate = _forge_oidc_test_certificate(elliptic_curve=True) + payloads[certificate_url]["data"] = ec_certificate["data"] + payloads[keypair_url]["fingerprint_sha256"] = ec_certificate["fingerprint"] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[certificate_url]["data"] = certificate["data"] + payloads[keypair_url]["fingerprint_sha256"] = fingerprint + + payloads[jwks_url]["keys"] = [jwk, {**jwk, "kid": "0" * 32}] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[jwks_url]["keys"] = [jwk] + + # Duplicate KIDs are ambiguous even when only one RSA key matches the + # certificate; relying party key selection is by KID, not modulus. + payloads[jwks_url]["keys"] = [jwk, {**alternate["jwk"], "kid": kid}] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[jwks_url]["keys"] = [jwk] + + weak_certificate = _forge_oidc_test_certificate(rsa_key_size=1024) + payloads[certificate_url]["data"] = weak_certificate["data"] + payloads[keypair_url]["fingerprint_sha256"] = weak_certificate["fingerprint"] + payloads[jwks_url]["keys"] = [weak_certificate["jwk"]] + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[certificate_url]["data"] = certificate["data"] + payloads[keypair_url]["fingerprint_sha256"] = fingerprint + payloads[jwks_url]["keys"] = [jwk] + + payloads[provider_url]["client_secret"] = "mismatched-client-secret" + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[provider_url]["client_secret"] = "a" * 40 + del payloads[provider_url]["client_secret"] + assert status_module._forge_oidc_attestation() == ("unknown", None) + payloads[provider_url]["client_secret"] = "a" * 40 + delivered_secret["value"] = None + assert status_module._forge_oidc_attestation() == ("unknown", None) + delivered_secret["value"] = b"a" * 40 + profile_mapping_url = f"{auth_base}/api/v3/propertymappings/provider/scope/{mapping_ids['profile']}/" + payloads[profile_mapping_url]["expression"] = "return {'groups': ['admin']}" + assert status_module._forge_oidc_attestation() == ("blocked", None) + payloads[profile_mapping_url]["expression"] = status_module._FORGE_PROFILE_MAPPING_EXPRESSION + payloads[certificate_url]["data"] = "x" * (64 * 1024) + assert status_module._forge_oidc_attestation() == ("unknown", None) + assert opener.responses[certificate_url].read_sizes[-1] == 65537 diff --git a/backend/tests/test_forge_oidc_api.py b/backend/tests/test_forge_oidc_api.py new file mode 100644 index 0000000..7374739 --- /dev/null +++ b/backend/tests/test_forge_oidc_api.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import os +from contextlib import contextmanager + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine, select + +from backend.app.services import user_access + +_REAL_CURRENT_USER = user_access.current_user +_PROVISION_PATH = "/api/apps/authentik/forge/oidc/provision" + + +@pytest.fixture +def forge_identity_client( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.core.config import settings + from backend.app.db import session as db_session + from backend.app.db.init_db import init_db + from backend.app.main import app + from backend.app.services import forge_oidc_provisioning + + if os.name == "nt": + # The shared Windows runner temp ACL is deliberately not the production + # data-directory ACL; isolate route tests from that host configuration. + monkeypatch.setattr( + forge_oidc_provisioning, + "_windows_private_path", + lambda *_args, **_kwargs: None, + ) + else: + monkeypatch.setattr( + forge_oidc_provisioning, + "_FORGE_OIDC_LOCK_ROOT", + tmp_path / "forge-oidc-runtime-lock", + ) + + engine = create_engine( + f"sqlite:///{tmp_path / 'forge-oidc-auth.db'}", + connect_args={"check_same_thread": False}, + ) + monkeypatch.setattr(db_session, "engine", engine) + monkeypatch.setenv("APPOPS_REQUIRE_OPERATOR_AUTH", "false") + monkeypatch.setattr(settings, "operator_proxy_auth_enabled", False) + monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:owner"], raising=False) + monkeypatch.setattr(settings, "auth_admin_emails", [], raising=False) + monkeypatch.setattr(settings, "data_dir", str(tmp_path)) + monkeypatch.setattr(user_access, "current_user", _REAL_CURRENT_USER) + SQLModel.metadata.create_all(engine) + init_db() + client = TestClient(app, client=("198.18.10.20", 42000)) + yield client + engine.dispose() + + +def _sign_in(client: TestClient, subject: str) -> None: + from backend.app.db import session as db_session + from backend.app.services.user_access import issue_session, provision_user + + with Session(db_session.engine) as session: + user = provision_user( + session, + "authentik", + "https://auth.example/application/o/appops/", + {"sub": subject, "email": f"{subject}@example.com"}, + ) + cookie = issue_session(session, user) + client.cookies.set("appops_user_session", cookie) + + +def test_forge_oidc_route_requires_authenticated_operator_and_csrf( + forge_identity_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.services import setup_service + + original_loader = setup_service.apply_stored_connector_settings + loader_calls = [] + + def tracked_loader(session, connector_ids=None): + loader_calls.append(connector_ids) + return original_loader(session, connector_ids) + + monkeypatch.setattr(setup_service, "apply_stored_connector_settings", tracked_loader) + unauthenticated = forge_identity_client.post(_PROVISION_PATH, json={}) + assert unauthenticated.status_code == 401 + + _sign_in(forge_identity_client, "owner") + missing_csrf = forge_identity_client.post(_PROVISION_PATH, json={}) + assert missing_csrf.status_code == 403 + assert loader_calls == [] + + +def test_forge_oidc_route_rejects_read_only_user_and_nonempty_body( + forge_identity_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.services import setup_service + + original_loader = setup_service.apply_stored_connector_settings + loader_calls = [] + + def tracked_loader(session, connector_ids=None): + loader_calls.append(connector_ids) + return original_loader(session, connector_ids) + + monkeypatch.setattr(setup_service, "apply_stored_connector_settings", tracked_loader) + _sign_in(forge_identity_client, "reader") + denied = forge_identity_client.post( + _PROVISION_PATH, + json={}, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + assert denied.status_code == 403 + + _sign_in(forge_identity_client, "owner") + invalid = forge_identity_client.post( + _PROVISION_PATH, + json={"client_id": "caller-selected"}, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + assert invalid.status_code == 422 + missing_body = forge_identity_client.post( + _PROVISION_PATH, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + assert missing_body.status_code == 422 + assert loader_calls == [] + + +def test_forge_oidc_route_loads_stored_connector_settings_and_passes_actor( + forge_identity_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.core.config import settings + from backend.app.db import session as db_session + from backend.app.db.models import AppSetting + from backend.app.services import forge_oidc_provisioning, setup_service + from backend.app.services.secret_store import set_secret + + _sign_in(forge_identity_client, "owner") + loader_calls = [] + constructed = [] + outcome_identities = [] + events = [] + protected_admin_token = "protected-authentik-admin-token-fixture" + monkeypatch.setattr(settings, "environment", "production") + monkeypatch.setattr(settings, "secret_key", "forge-oidc-api-secret-key-material-0123456789") + monkeypatch.setattr(settings, "secret_key_source", "env") + with Session(db_session.engine) as session: + session.add( + AppSetting( + key="connector:authentik:config", + value_json={"url": "https://auth.example", "verifyTls": True}, + is_secret=False, + ) + ) + set_secret(session, "AUTHENTIK_TOKEN", protected_admin_token) + session.commit() + + original_loader = setup_service.apply_stored_connector_settings + original_lock = forge_oidc_provisioning._forge_oidc_process_lock + original_store_status = forge_oidc_provisioning.secret_storage_status + + @contextmanager + def tracked_lock(): + with original_lock(): + events.append("lock-enter") + yield + events.append("lock-exit") + + def tracked_store_status(): + events.append("store-check") + return original_store_status() + + def tracked_loader(session, connector_ids=None): + events.append("connector-loader") + loader_calls.append(connector_ids) + assert connector_ids == {"authentik"} + return original_loader(session, connector_ids) + + class FakeAuthentikConnector: + def __init__(self): + constructed.append( + ( + settings.apis.get("AUTHENTIK_URL"), + settings.apis.get("AUTHENTIK_VERIFY_TLS"), + settings.apis.get("AUTHENTIK_TOKEN"), + ) + ) + + def ensure_forge_oidc_prerequisites(self, *, expected_client_secret): + assert expected_client_secret is None + return {"success": False, "status": "failed"} + + original_outcome = forge_oidc_provisioning._connector_outcome + + def tracked_outcome(session, *, identity, outcome, secret_created): + outcome_identities.append(identity) + return original_outcome( + session, + identity=identity, + outcome=outcome, + secret_created=secret_created, + ) + + monkeypatch.setattr(setup_service, "apply_stored_connector_settings", tracked_loader) + monkeypatch.setattr(forge_oidc_provisioning, "_forge_oidc_process_lock", tracked_lock) + monkeypatch.setattr(forge_oidc_provisioning, "secret_storage_status", tracked_store_status) + monkeypatch.setattr(forge_oidc_provisioning, "AuthentikConnector", FakeAuthentikConnector) + monkeypatch.setattr(forge_oidc_provisioning, "_connector_outcome", tracked_outcome) + response = forge_identity_client.post( + _PROVISION_PATH, + json={}, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "failed" + assert response.json()["message"] == "forge_oidc_provisioning_failed" + assert constructed == [("https://auth.example", True, protected_admin_token)] + assert loader_calls == [{"authentik"}] + assert events == ["lock-enter", "store-check", "connector-loader", "lock-exit"] + assert outcome_identities == ["authentik:owner"] + assert protected_admin_token not in response.text + assert "client_secret" not in response.json() + + +def test_forge_oidc_production_store_not_ready_does_not_load_connector( + forge_identity_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.core.config import settings + from backend.app.services import setup_service + + _sign_in(forge_identity_client, "owner") + monkeypatch.setattr(settings, "environment", "production") + monkeypatch.setattr(settings, "secret_key", "") + monkeypatch.setattr(settings, "secret_key_source", "missing") + loader_calls = [] + original_loader = setup_service.apply_stored_connector_settings + + def tracked_loader(session, connector_ids=None): + loader_calls.append(connector_ids) + return original_loader(session, connector_ids) + + monkeypatch.setattr(setup_service, "apply_stored_connector_settings", tracked_loader) + response = forge_identity_client.post( + _PROVISION_PATH, + json={}, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "blocked" + assert response.json()["message"] == "forge_oidc_secret_store_not_production_ready" + assert loader_calls == [] + + +def test_forge_oidc_route_returns_blocked_when_exact_policy_is_disabled( + forge_identity_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.db import session as db_session + from backend.app.db.models import Policy + from backend.app.services import setup_service + + loader_calls = [] + original_loader = setup_service.apply_stored_connector_settings + + def tracked_loader(session, connector_ids=None): + loader_calls.append(connector_ids) + return original_loader(session, connector_ids) + + monkeypatch.setattr(setup_service, "apply_stored_connector_settings", tracked_loader) + _sign_in(forge_identity_client, "owner") + with Session(db_session.engine) as session: + policy = session.exec( + select(Policy).where( + (Policy.action == "forge.oidc.provision") + & (Policy.target == "itworx-forge") + ) + ).one() + policy.manual = "blocked" + session.add(policy) + session.commit() + + response = forge_identity_client.post( + _PROVISION_PATH, + json={}, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + assert response.status_code == 200 + assert response.json()["status"] == "blocked" + assert response.json()["message"] == "forge_oidc_exact_target_policy_required" + assert loader_calls == [] diff --git a/backend/tests/test_forge_oidc_provisioning.py b/backend/tests/test_forge_oidc_provisioning.py new file mode 100644 index 0000000..e169d28 --- /dev/null +++ b/backend/tests/test_forge_oidc_provisioning.py @@ -0,0 +1,2137 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest +from pydantic import ValidationError +from sqlmodel import Session, SQLModel, create_engine, select + +FORGE_OPENID_MAPPING_ID = "75a34fd6-cc81-40b8-a8c3-2745af9567ab" +FORGE_EMAIL_MAPPING_ID = "312810bb-11da-47d8-97d4-f2155348eaa2" +FORGE_GROUP_MAPPING_ID = "7aa8c5f8-5e8e-4a1b-9c11-3a4f0a731cff" +FORGE_APPLICATION_ID = "f7212687-a3d0-4eaa-b209-427f4043f870" +FORGE_GROUP_IDS = { + "forge-viewers": "01e9b457-75be-4c43-a276-5ef49eb691f7", + "forge-operators": "6a238dab-1f85-4fce-b20d-c987a9d2924f", + "forge-owners": "a3430246-574b-4e40-9217-59c12b7babca", +} +_OPENID_MAPPING_EXPRESSION = ( + "# This scope is required by the OpenID-spec, and must as such exist in authentik.\n" + "# The scope by itself does not grant any information\nreturn {}" +) +_EMAIL_MAPPING_EXPRESSION = ( + 'return {\n "email": request.user.email,\n "email_verified": False\n}' +) + + +def _database(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + from backend.app.core.config import settings + from backend.app.db import session as db_session + from backend.app.db.init_db import init_db + from backend.app.services import forge_oidc_provisioning + + # Windows CI temp roots may inherit shared-runner ACLs. These tests exercise + # the provisioning contract; production's Windows ACL validator stays strict. + if os.name == "nt": + monkeypatch.setattr( + forge_oidc_provisioning, + "_windows_private_path", + lambda *_args, **_kwargs: None, + ) + else: + monkeypatch.setattr( + forge_oidc_provisioning, + "_FORGE_OIDC_LOCK_ROOT", + tmp_path / "forge-oidc-runtime-lock", + ) + + engine = create_engine( + f"sqlite:///{tmp_path / 'forge_oidc_test.db'}", + connect_args={"check_same_thread": False}, + ) + monkeypatch.setattr(db_session, "engine", engine) + monkeypatch.setattr(settings, "data_dir", str(tmp_path)) + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + return engine + + +def _configured_settings(monkeypatch: pytest.MonkeyPatch): + from backend.app.core.config import settings + + for key, value in { + "AUTHENTIK_URL": "https://auth.example-host.com", + "AUTHENTIK_TOKEN": "test-authentik-token-value", + "AUTHENTIK_VERIFY_TLS": True, + "AUTHENTIK_OIDC_AUTHORIZATION_FLOW": "authorization-flow-id", + "AUTHENTIK_OIDC_INVALIDATION_FLOW": "invalidation-flow-id", + "AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": "shared-generic-group-mapping-id", + "AUTHENTIK_OIDC_ROLE_CLAIM": "groups", + "AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID": "rsa-signing-key-id", + "AUTHENTIK_FORGE_OIDC_BINDING_VALID": True, + "AUTHENTIK_FORGE_OIDC_PROVIDER_ID": None, + "AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING": FORGE_OPENID_MAPPING_ID, + "AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING": FORGE_EMAIL_MAPPING_ID, + "AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING": FORGE_GROUP_MAPPING_ID, + }.items(): + monkeypatch.setitem(settings.apis, key, value) + monkeypatch.setattr(settings, "environment", "production") + monkeypatch.setattr(settings, "secret_key", "test-production-secret-key-material-0123456789") + monkeypatch.setattr(settings, "secret_key_source", "env") + + +def _forge_group_mapping(expression: str | None = None): + from backend.app.schemas.forge_oidc import ( + FORGE_OIDC_PROFILE_MAPPING_EXPRESSION, + FORGE_OIDC_PROFILE_MAPPING_NAME, + ) + + return { + "pk": FORGE_GROUP_MAPPING_ID, + "name": FORGE_OIDC_PROFILE_MAPPING_NAME, + "scope_name": "profile", + "expression": expression or FORGE_OIDC_PROFILE_MAPPING_EXPRESSION, + } + + +def _valid_forge_group(name: str, group_id: str | None = None, *, users=None): + return { + "pk": group_id or FORGE_GROUP_IDS[name], + "num_pk": {"forge-viewers": 14, "forge-operators": 15, "forge-owners": 16}[name], + "name": name, + "is_superuser": False, + "attributes": {}, + "parents": [], + "children": [], + "roles": [], + "users": [] if users is None else users, + } + + +def _valid_scope_mappings(): + from backend.app.schemas.forge_oidc import ( + FORGE_OIDC_EMAIL_MAPPING_MANAGED, + FORGE_OIDC_OPENID_MAPPING_MANAGED, + ) + + return [ + { + "pk": FORGE_OPENID_MAPPING_ID, + "managed": FORGE_OIDC_OPENID_MAPPING_MANAGED, + "scope_name": "openid", + "expression": _OPENID_MAPPING_EXPRESSION, + }, + { + "pk": FORGE_EMAIL_MAPPING_ID, + "managed": FORGE_OIDC_EMAIL_MAPPING_MANAGED, + "scope_name": "email", + "expression": _EMAIL_MAPPING_EXPRESSION, + }, + _forge_group_mapping(), + ] + + +def _valid_forge_groups(names=("forge-viewers", "forge-operators", "forge-owners")): + return [_valid_forge_group(name) for name in names] + + +def _mock_group_mapping_api(monkeypatch: pytest.MonkeyPatch, mapping=None): + from backend.app.connectors.authentik import AuthentikConnector + + groups_by_id = {group["pk"]: group for group in _valid_forge_groups()} + + def request(self, path, params=None): + if path.startswith("/core/groups/"): + assert params == { + "include_users": "true", + "include_children": "true", + "include_parents": "true", + "include_inherited_roles": "false", + } + group_id = path.rstrip("/").split("/")[-1] + return groups_by_id[group_id] + if params is not None: + raise AssertionError(f"Unexpected Authentik request: {path}") + if path == f"/propertymappings/provider/scope/{FORGE_OPENID_MAPPING_ID}/": + return { + "pk": FORGE_OPENID_MAPPING_ID, + "managed": "goauthentik.io/providers/oauth2/scope-openid", + "scope_name": "openid", + "expression": _OPENID_MAPPING_EXPRESSION, + } + if path == f"/propertymappings/provider/scope/{FORGE_EMAIL_MAPPING_ID}/": + return { + "pk": FORGE_EMAIL_MAPPING_ID, + "managed": "goauthentik.io/providers/oauth2/scope-email", + "scope_name": "email", + "expression": _EMAIL_MAPPING_EXPRESSION, + } + if path == f"/propertymappings/provider/scope/{FORGE_GROUP_MAPPING_ID}/": + return mapping if mapping is not None else _forge_group_mapping() + raise AssertionError(f"Unexpected Authentik request: {path}") + + monkeypatch.setattr(AuthentikConnector, "_request", request, raising=True) + + +def _install_forge_connector_remote( + monkeypatch: pytest.MonkeyPatch, + *, + application_list=None, + provider_list=None, + provider_response_id: str = "31", + provider_created_id: str = "31", + provider_timeout_after_commit: bool = False, + application_timeout_after_commit: bool = False, + application_fail_without_commit: bool = False, +): + from backend.app.connectors.authentik import AuthentikConnector + + _mock_group_mapping_api(monkeypatch) + remote = { + "applications": list(application_list or []), + "providers": list(provider_list or []), + } + writes = [] + behavior = { + "provider_response_id": provider_response_id, + "provider_created_id": provider_created_id, + "provider_timeout_after_commit": provider_timeout_after_commit, + "application_timeout_after_commit": application_timeout_after_commit, + "application_fail_without_commit": application_fail_without_commit, + } + + def paged(self, path, params=None): + if path == "/flows/instances/": + return [{"pk": "authorization-flow-id"}, {"pk": "invalidation-flow-id"}] + if path == "/crypto/certificatekeypairs/": + assert params == {"has_key": True} + return [{"pk": "rsa-signing-key-id", "key_type": "rsa", "private_key_available": True}] + if path == "/core/groups/": + return _valid_forge_groups() + if path == "/propertymappings/provider/scope/": + return _valid_scope_mappings() + if path == "/core/applications/": + return list(remote["applications"]) + if path == "/providers/oauth2/": + return list(remote["providers"]) + raise AssertionError(f"Unexpected Authentik read: {path}") + + def write(self, method, path, payload=None): + writes.append((method, path, payload)) + if path == "/providers/oauth2/": + provider = {"pk": behavior["provider_created_id"], **payload} + remote["providers"].append(provider) + if behavior["provider_timeout_after_commit"]: + raise RuntimeError("request timeout") + return {"pk": behavior["provider_response_id"]} + if path == "/core/applications/": + if behavior["application_fail_without_commit"]: + raise RuntimeError("request timeout") + remote["applications"].append({"pk": FORGE_APPLICATION_ID, **payload}) + if behavior["application_timeout_after_commit"]: + raise RuntimeError("request timeout") + return {"pk": FORGE_APPLICATION_ID} + raise AssertionError(f"Unexpected Authentik write: {method} {path}") + + monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True) + monkeypatch.setattr(AuthentikConnector, "_forge_oidc_write_request", write, raising=False) + monkeypatch.setattr(AuthentikConnector, "_forge_oidc_provider_secret_matches", lambda *_args: True) + monkeypatch.setattr(AuthentikConnector, "_forge_oidc_discovery_matches", lambda self: True) + return remote, writes, behavior + + +def _add_policy(session: Session, *, target: str = "itworx-forge", manual: str = "allowed"): + from backend.app.db.models import Policy + + existing = session.exec( + select(Policy).where( + (Policy.action == "forge.oidc.provision") & (Policy.target == target) + ) + ).first() + if existing is not None: + existing.manual = manual + session.add(existing) + session.commit() + session.refresh(existing) + return existing + policy = Policy( + id=f"forge-oidc-policy-{target}", + action="forge.oidc.provision", + target=target, + voice="blocked", + manual=manual, + risk="critical", + reason="Provision the fixed Forge OIDC client after explicit operator approval.", + ) + session.add(policy) + session.commit() + return policy + + +class RecordingAuthentik: + def __init__( + self, + result: dict[str, object] | None = None, + preflight: dict[str, object] | None = None, + remote_client_secret: str | None = None, + ): + self.calls: list[str] = [] + self.preflight_calls = 0 + self.verified_secrets: list[str] = [] + self.preflight = preflight or {"success": True, "status": "ready"} + self.remote_client_secret = remote_client_secret + self.result = result or { + "success": True, + "status": "created", + "message": "forge_oidc_created", + "applicationId": "f7212687-a3d0-4eaa-b209-427f4043f870", + "providerId": "31", + } + + def inspect_forge_oidc_application(self, *, expected_client_secret: str | None = None): + self.preflight_calls += 1 + if expected_client_secret is not None and self.preflight.get("status") in { + "already_configured", + "provider_orphan", + }: + self.verified_secrets.append(expected_client_secret) + if expected_client_secret != self.remote_client_secret: + return { + "success": False, + "status": "conflict", + "message": "forge_oidc_existing_client_secret_mismatch", + } + return self.preflight + + def ensure_forge_oidc_prerequisites(self, *, expected_client_secret: str | None = None): + return { + "success": True, + "status": "ready", + "scopeMappingIds": { + "openid": FORGE_OPENID_MAPPING_ID, + "email": FORGE_EMAIL_MAPPING_ID, + "profile": FORGE_GROUP_MAPPING_ID, + }, + } + + def ensure_forge_oidc_application(self, *, client_secret: str, persist_provider_id): + verified = self.inspect_forge_oidc_application(expected_client_secret=client_secret) + if verified.get("success") is not True: + return verified + if verified.get("status") != "already_configured": + self.calls.append(client_secret) + provider_id = str(verified.get("providerId") or self.result.get("providerId") or "") + if not persist_provider_id(provider_id): + return {"success": False, "status": "partial_failure", "message": "forge_oidc_binding_persist_failed"} + if self.preflight.get("status") == "already_configured": + return verified + return self.result + + +def test_forge_oidc_request_schema_rejects_caller_selected_targets(): + from backend.app.schemas.forge_oidc import ForgeOidcProvisionRequestV1 + + assert ForgeOidcProvisionRequestV1().model_dump() == {} + with pytest.raises(ValidationError): + ForgeOidcProvisionRequestV1.model_validate({"client_id": "other-client"}) + + +def test_forge_private_package_owner_has_no_default_and_reports_presence_only( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.core.config import Settings + + monkeypatch.delenv("FORGE_PRIVATE_PACKAGE_OWNER", raising=False) + unset = Settings() + assert unset.apis["FORGE_PRIVATE_PACKAGE_OWNER"] is None + assert unset.redact_env_preview()["FORGE_PRIVATE_PACKAGE_OWNER"] is False + + monkeypatch.setenv("FORGE_PRIVATE_PACKAGE_OWNER", "trusted-forge-owner") + configured = Settings() + assert configured.apis["FORGE_PRIVATE_PACKAGE_OWNER"] == "trusted-forge-owner" + assert configured.redact_env_preview()["FORGE_PRIVATE_PACKAGE_OWNER"] is True + + +def test_forge_oidc_profile_mapping_emits_only_allowlisted_groups_from_ak_groups(): + from types import SimpleNamespace + + from backend.app.schemas.forge_oidc import FORGE_OIDC_PROFILE_MAPPING_EXPRESSION + + source = "def evaluate(request, delete_none_values):\n" + "\n".join( + f" {line}" if line else "" for line in FORGE_OIDC_PROFILE_MAPPING_EXPRESSION.splitlines() + ) + namespace: dict[str, object] = {} + exec(compile(source, "", "exec"), namespace) + user = SimpleNamespace( + name="Forge User", + username="forge-user", + ak_groups=SimpleNamespace( + all=lambda: [ + SimpleNamespace(name="forge-owners"), + SimpleNamespace(name="unrelated-admin"), + SimpleNamespace(name="forge-viewers"), + ] + ), + ) + result = namespace["evaluate"]( # type: ignore[operator] + SimpleNamespace(user=user), lambda claims: {k: v for k, v in claims.items() if v is not None} + ) + assert result == { + "name": "Forge User", + "preferred_username": "forge-user", + "groups": ["forge-owners", "forge-viewers"], + } + assert not hasattr(user, "groups") + + +def test_forge_oidc_binding_schema_rejects_duplicate_and_caller_selected_ids(): + from backend.app.schemas.forge_oidc import ( + ForgeOidcBindingV1, + ForgeOidcScopeMappingIdsV1, + ) + + shared = "75a34fd6-cc81-40b8-a8c3-2745af9567ab" + with pytest.raises(ValidationError): + ForgeOidcBindingV1.model_validate( + { + "version": 1, + "providerId": None, + "scopeMappingIds": {"openid": shared, "email": shared, "profile": shared}, + } + ) + base = { + "providerId": None, + "scopeMappingIds": { + "openid": FORGE_OPENID_MAPPING_ID, + "email": FORGE_EMAIL_MAPPING_ID, + "profile": FORGE_GROUP_MAPPING_ID, + }, + } + for invalid_version in (True, 1.0, "1", 2): + with pytest.raises(ValidationError): + ForgeOidcBindingV1.model_validate({**base, "version": invalid_version}) + for invalid_provider_id in ("0", "01", "12345678901", "١٢٣", " 31"): + with pytest.raises(ValidationError): + ForgeOidcBindingV1.model_validate( + {**base, "version": 1, "providerId": invalid_provider_id} + ) + for invalid_uuid in ( + "00000000-0000-0000-0000-000000000000", + FORGE_OPENID_MAPPING_ID.upper(), + "not-a-uuid", + ): + with pytest.raises(ValidationError): + ForgeOidcScopeMappingIdsV1.model_validate( + { + "openid": invalid_uuid, + "email": FORGE_EMAIL_MAPPING_ID, + "profile": FORGE_GROUP_MAPPING_ID, + } + ) + with pytest.raises(ValidationError): + ForgeOidcBindingV1.model_validate( + { + "version": 1, + "providerId": None, + "scopeMappingIds": { + "openid": shared, + "email": "312810bb-11da-47d8-97d4-f2155348eaa2", + "profile": "de878051-a898-42ce-b46f-bd7d9b1dd171", + "groups": shared, + }, + } + ) + + +def test_forge_oidc_binding_loader_is_atomic_and_fails_closed_on_tampering( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import json + + from backend.app.core.config import settings + from backend.app.schemas.forge_oidc import FORGE_OIDC_BINDING_SECRET_REF + from backend.app.services.secret_store import set_secret + from backend.app.services.setup_service import _apply_forge_oidc_binding_to_runtime + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + valid = { + "version": 1, + "providerId": "31", + "scopeMappingIds": { + "openid": "75a34fd6-cc81-40b8-a8c3-2745af9567ab", + "email": "312810bb-11da-47d8-97d4-f2155348eaa2", + "profile": FORGE_GROUP_MAPPING_ID, + }, + } + with Session(engine) as session: + set_secret(session, FORGE_OIDC_BINDING_SECRET_REF, json.dumps(valid)) + assert _apply_forge_oidc_binding_to_runtime(session) is True + assert settings.apis["AUTHENTIK_FORGE_OIDC_BINDING_VALID"] is True + assert settings.apis["AUTHENTIK_FORGE_OIDC_PROVIDER_ID"] == "31" + assert settings.apis["AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING"] == valid["scopeMappingIds"]["openid"] + assert json.dumps(valid) not in str(settings.apis) + + tampered = dict(valid) + tampered["scopeMappingIds"] = { + "openid": valid["scopeMappingIds"]["openid"], + "email": valid["scopeMappingIds"]["openid"], + "profile": valid["scopeMappingIds"]["profile"], + } + set_secret(session, FORGE_OIDC_BINDING_SECRET_REF, json.dumps(tampered)) + assert _apply_forge_oidc_binding_to_runtime(session) is False + + duplicate_key = ( + '{"version":1,"version":1,"providerId":"31",' + '"scopeMappingIds":{"openid":"75a34fd6-cc81-40b8-a8c3-2745af9567ab",' + '"email":"312810bb-11da-47d8-97d4-f2155348eaa2",' + f'"profile":"{FORGE_GROUP_MAPPING_ID}"}}' + ) + set_secret(session, FORGE_OIDC_BINDING_SECRET_REF, duplicate_key) + assert _apply_forge_oidc_binding_to_runtime(session) is False + + assert settings.apis["AUTHENTIK_FORGE_OIDC_BINDING_VALID"] is False + assert settings.apis["AUTHENTIK_FORGE_OIDC_PROVIDER_ID"] is None + assert all(settings.apis[key] is None for key in ( + "AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING", + "AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING", + "AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING", + )) + + +def test_forge_oidc_provisioning_stays_blocked_without_registered_exact_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.db.models import AuditEvent + from backend.app.services.forge_oidc_provisioning import provision_forge_oidc + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + authentik = RecordingAuthentik() + with Session(engine) as session: + from backend.app.db.models import Policy + + exact = session.exec( + select(Policy).where( + (Policy.action == "forge.oidc.provision") + & (Policy.target == "itworx-forge") + ) + ).one() + session.delete(exact) + session.commit() + result = provision_forge_oidc( + session, + actor_identity="authentik:operator-1", + authentik=authentik, + ) + events = session.exec(select(AuditEvent)).all() + + assert result["status"] == "blocked" + assert result["message"] == "forge_oidc_exact_target_policy_required" + assert authentik.calls == [] + assert events[0].action_id == "forge.oidc.provision" + assert events[0].target == "itworx-forge" + assert events[0].policy_decision == "blocked" + + +def test_forge_oidc_registered_action_requires_exact_manual_policy( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.services.forge_oidc_provisioning import provision_forge_oidc + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + authentik = RecordingAuthentik() + with Session(engine) as session: + _add_policy(session) + result = provision_forge_oidc( + session, + actor_identity="authentik:operator-1", + authentik=authentik, + ) + + assert result["status"] == "created" + assert authentik.preflight_calls == 2 + assert len(authentik.calls) == 1 + + +def test_forge_oidc_wildcard_policy_cannot_authorize_mutation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.db.models import AuditEvent + from backend.app.services import forge_oidc_provisioning + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + authentik = RecordingAuthentik() + with Session(engine) as session: + _add_policy(session, target="*") + result = forge_oidc_provisioning.provision_forge_oidc( + session, + actor_identity="authentik:operator-1", + authentik=authentik, + ) + event = session.exec(select(AuditEvent)).one() + + assert result["status"] == "blocked" + assert result["message"] == "forge_oidc_exact_target_policy_required" + assert authentik.calls == [] + assert event.policy_decision == "blocked" + + +def test_forge_oidc_policy_is_one_exact_manual_critical_row_and_voice_blocked( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.db.models import Policy + from backend.app.services.action_registry import ( + ACTION_RISK, + ACTIONS, + action_default_target, + ) + from backend.app.services.policy_engine import evaluate_policy + + engine = _database(tmp_path, monkeypatch) + with Session(engine) as session: + policy = session.exec( + select(Policy).where(Policy.action == "forge.oidc.provision") + ).one() + assert "forge.oidc.provision" in ACTIONS + assert ACTION_RISK["forge.oidc.provision"] == "critical" + assert action_default_target("forge.oidc.provision") == "itworx-forge" + assert (policy.target, policy.manual, policy.voice, policy.risk) == ( + "itworx-forge", + "allowed", + "blocked", + "critical", + ) + assert evaluate_policy( + session, + action="forge.oidc.provision", + target="itworx-forge", + source="manual", + )["decision"] == "allowed" + for source, target in [("voice", "itworx-forge"), ("manual", "other-app")]: + assert evaluate_policy( + session, + action="forge.oidc.provision", + target=target, + source=source, + )["decision"] == "blocked" + assert evaluate_policy( + session, + action="authentik.create_provider", + target="authentik", + source="manual", + )["decision"] == "blocked" + + session.add( + Policy( + id="policy-forge-oidc-wildcard-test", + action="forge.oidc.provision", + target="*", + voice="blocked", + manual="allowed", + risk="critical", + reason="Must never authorize Forge provisioning.", + ) + ) + session.commit() + assert evaluate_policy( + session, + action="forge.oidc.provision", + target="itworx-forge", + source="manual", + )["decision"] == "blocked" + session.delete( + session.exec( + select(Policy).where(Policy.id == "policy-forge-oidc-wildcard-test") + ).one() + ) + session.add( + Policy( + id="policy-forge-oidc-duplicate-exact-test", + action="forge.oidc.provision", + target="itworx-forge", + voice="blocked", + manual="allowed", + risk="critical", + reason="A second exact row must fail closed.", + ) + ) + session.commit() + assert evaluate_policy( + session, + action="forge.oidc.provision", + target="itworx-forge", + source="manual", + )["decision"] == "blocked" + + +def test_forge_oidc_provisioning_uses_exact_policy_stores_secret_and_returns_no_secret( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.db.models import AuditEvent + from backend.app.services import forge_oidc_provisioning + from backend.app.services.secret_store import get_secret, has_secret + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + authentik = RecordingAuthentik() + generated = [] + monkeypatch.setattr( + forge_oidc_provisioning, + "_new_client_secret", + lambda: generated.append("one-time-client-secret-value-0123456789") + or "one-time-client-secret-value-0123456789", + ) + with Session(engine) as session: + policy = _add_policy(session) + monkeypatch.setattr( + forge_oidc_provisioning, + "evaluate_policy", + lambda *_args, **_kwargs: { + "decision": "allowed", + "scope_decision": "allowed", + "policy_id": policy.id, + "risk": "critical", + "reason": policy.reason, + }, + ) + result = forge_oidc_provisioning.provision_forge_oidc( + session, + actor_identity="authentik:operator-1", + authentik=authentik, + ) + stored_secret = get_secret(session, "ITWORX_FORGE_OIDC_CLIENT_SECRET", touch=False) + events = session.exec(select(AuditEvent)).all() + + assert result["status"] == "created" + assert result["clientId"] == "itworx-forge" + assert result["callbackUrl"] == "https://forge.example.com/auth/callback" + assert result["issuer"] == "https://auth.example-host.com/application/o/itworx-forge/" + assert result["groups"] == ["forge-viewers", "forge-operators", "forge-owners"] + assert result["secretRef"] == "ITWORX_FORGE_OIDC_CLIENT_SECRET" + assert "one-time-client-secret-value-0123456789" not in str(result) + assert authentik.calls == ["one-time-client-secret-value-0123456789"] + assert stored_secret == "one-time-client-secret-value-0123456789" + assert has_secret(session, "ITWORX_FORGE_OIDC_CLIENT_SECRET") + binding_json = get_secret(session, "AUTHENTIK_FORGE_OIDC_BINDING", touch=False) + assert binding_json is not None + assert json.loads(binding_json) == { + "version": 1, + "providerId": "31", + "scopeMappingIds": { + "openid": FORGE_OPENID_MAPPING_ID, + "email": FORGE_EMAIL_MAPPING_ID, + "profile": FORGE_GROUP_MAPPING_ID, + }, + } + assert "one-time-client-secret-value-0123456789" not in str(events) + assert generated == ["one-time-client-secret-value-0123456789"] + + +def test_forge_oidc_provisioning_reuses_existing_secret_on_retry( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.services import forge_oidc_provisioning + from backend.app.services.secret_store import get_secret, set_secret + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + authentik = RecordingAuthentik() + generated = [] + monkeypatch.setattr( + forge_oidc_provisioning, + "_new_client_secret", + lambda: generated.append("unexpected") or "unexpected", + ) + with Session(engine) as session: + policy = _add_policy(session) + set_secret( + session, + "ITWORX_FORGE_OIDC_CLIENT_SECRET", + "persisted-client-secret-value-0123456789", + used_by=["authentik:itworx-forge"], + ) + monkeypatch.setattr( + forge_oidc_provisioning, + "evaluate_policy", + lambda *_args, **_kwargs: { + "decision": "allowed", + "scope_decision": "allowed", + "policy_id": policy.id, + "risk": "critical", + "reason": policy.reason, + }, + ) + result = forge_oidc_provisioning.provision_forge_oidc( + session, + actor_identity="authentik:operator-1", + authentik=authentik, + ) + stored_secret = get_secret(session, "ITWORX_FORGE_OIDC_CLIENT_SECRET", touch=False) + + assert result["status"] == "created" + assert authentik.calls == ["persisted-client-secret-value-0123456789"] + assert stored_secret == "persisted-client-secret-value-0123456789" + assert generated == [] + + +def test_forge_oidc_existing_remote_client_requires_local_secret_reference( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.services import forge_oidc_provisioning + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + authentik = RecordingAuthentik( + preflight={ + "success": True, + "status": "already_configured", + "applicationId": "f7212687-a3d0-4eaa-b209-427f4043f870", + "providerId": "31", + } + ) + with Session(engine) as session: + policy = _add_policy(session) + monkeypatch.setattr( + forge_oidc_provisioning, + "evaluate_policy", + lambda *_args, **_kwargs: { + "decision": "allowed", + "scope_decision": "allowed", + "policy_id": policy.id, + "risk": "critical", + "reason": policy.reason, + }, + ) + result = forge_oidc_provisioning.provision_forge_oidc( + session, + actor_identity="authentik:operator-1", + authentik=authentik, + ) + + assert result["status"] == "conflict" + assert result["message"] == "forge_oidc_existing_client_secret_missing" + assert authentik.preflight_calls == 1 + assert authentik.calls == [] + + +def test_forge_oidc_existing_remote_client_with_local_secret_is_idempotent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.services import forge_oidc_provisioning + from backend.app.services.secret_store import set_secret + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + authentik = RecordingAuthentik( + preflight={ + "success": True, + "status": "already_configured", + "applicationId": "f7212687-a3d0-4eaa-b209-427f4043f870", + "providerId": "31", + }, + remote_client_secret="persisted-client-secret-value-0123456789", + ) + with Session(engine) as session: + policy = _add_policy(session) + set_secret( + session, + "ITWORX_FORGE_OIDC_CLIENT_SECRET", + "persisted-client-secret-value-0123456789", + used_by=["authentik:itworx-forge"], + ) + monkeypatch.setattr( + forge_oidc_provisioning, + "evaluate_policy", + lambda *_args, **_kwargs: { + "decision": "allowed", + "scope_decision": "allowed", + "policy_id": policy.id, + "risk": "critical", + "reason": policy.reason, + }, + ) + result = forge_oidc_provisioning.provision_forge_oidc( + session, + actor_identity="authentik:operator-1", + authentik=authentik, + ) + + assert result["status"] == "already_configured" + assert result["secretCreated"] is False + assert authentik.preflight_calls == 2 + assert authentik.verified_secrets == [ + "persisted-client-secret-value-0123456789", + "persisted-client-secret-value-0123456789", + ] + assert authentik.calls == [] + + +def test_forge_oidc_existing_remote_client_secret_mismatch_is_conflict_and_redacted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.services import forge_oidc_provisioning + from backend.app.services.secret_store import set_secret + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + local_secret = "persisted-local-client-secret-value" + remote_secret = "rotated-remote-client-secret-value" + authentik = RecordingAuthentik( + preflight={ + "success": True, + "status": "already_configured", + "applicationId": "f7212687-a3d0-4eaa-b209-427f4043f870", + "providerId": "31", + }, + remote_client_secret=remote_secret, + ) + with Session(engine) as session: + policy = _add_policy(session) + set_secret(session, "ITWORX_FORGE_OIDC_CLIENT_SECRET", local_secret) + monkeypatch.setattr( + forge_oidc_provisioning, + "evaluate_policy", + lambda *_args, **_kwargs: { + "decision": "allowed", + "scope_decision": "allowed", + "policy_id": policy.id, + "risk": "critical", + "reason": policy.reason, + }, + ) + result = forge_oidc_provisioning.provision_forge_oidc( + session, + actor_identity="authentik:operator-1", + authentik=authentik, + ) + + assert result["status"] == "conflict" + assert result["message"] == "forge_oidc_resource_conflict" + assert local_secret not in str(result) + assert remote_secret not in str(result) + assert authentik.verified_secrets == [local_secret] + assert authentik.calls == [] + + +def test_forge_oidc_concurrent_calls_create_one_pair_and_keep_one_secret( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + from concurrent.futures import ThreadPoolExecutor + from threading import RLock + + from backend.app.services import forge_oidc_provisioning + from backend.app.services.secret_store import get_secret + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + policy_lock = RLock() + + class ConcurrentAuthentik(RecordingAuthentik): + def __init__(self): + super().__init__() + self.remote_lock = RLock() + self.remote_created = False + self.remote_secret = None + self.create_count = 0 + + def inspect_forge_oidc_application(self, *, expected_client_secret=None): + with self.remote_lock: + if not self.remote_created: + return {"success": True, "status": "ready", "message": "forge_oidc_preflight_ready"} + if expected_client_secret is not None and expected_client_secret != self.remote_secret: + return {"success": False, "status": "conflict", "message": "forge_oidc_existing_client_secret_mismatch"} + return { + "success": True, + "status": "already_configured", + "message": "forge_oidc_already_configured", + "applicationId": FORGE_APPLICATION_ID, + "providerId": "31", + } + + def ensure_forge_oidc_application(self, *, client_secret: str, persist_provider_id): + with self.remote_lock: + observed = self.inspect_forge_oidc_application(expected_client_secret=client_secret) + if observed.get("success") is not True: + return observed + if observed.get("status") == "already_configured": + if not persist_provider_id("31"): + return {"success": False, "status": "partial_failure", "message": "forge_oidc_binding_persist_failed"} + return observed + if not persist_provider_id("31"): + return {"success": False, "status": "partial_failure", "message": "forge_oidc_binding_persist_failed"} + self.remote_created = True + self.remote_secret = client_secret + self.create_count += 1 + self.calls.append(client_secret) + return { + "success": True, + "status": "created", + "message": "forge_oidc_created", + "applicationId": FORGE_APPLICATION_ID, + "providerId": "31", + } + + authentik = ConcurrentAuthentik() + with Session(engine) as session: + policy = _add_policy(session) + + def allow_policy(*_args, **_kwargs): + with policy_lock: + return { + "decision": "allowed", + "scope_decision": "allowed", + "policy_id": policy.id, + "risk": "critical", + "reason": policy.reason, + } + + monkeypatch.setattr(forge_oidc_provisioning, "evaluate_policy", allow_policy) + + def provision_once(): + with Session(engine) as session: + return forge_oidc_provisioning.provision_forge_oidc( + session, + actor_identity="authentik:operator-1", + authentik=authentik, + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _index: provision_once(), range(2))) + + assert sorted(result["status"] for result in results) == ["already_configured", "created"] + assert authentik.create_count == 1 + assert len(authentik.calls) == 1 + with Session(engine) as session: + stored = get_secret(session, "ITWORX_FORGE_OIDC_CLIENT_SECRET", touch=False) + binding = json.loads(get_secret(session, "AUTHENTIK_FORGE_OIDC_BINDING", touch=False)) + assert stored == authentik.remote_secret + assert binding["providerId"] == "31" + + +def test_forge_oidc_provisioning_blocks_without_production_secret_storage( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.core.config import settings + from backend.app.services import forge_oidc_provisioning + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + authentik = RecordingAuthentik() + monkeypatch.setattr(settings, "secret_key_source", "missing") + monkeypatch.setattr(settings, "secret_key", None) + with Session(engine) as session: + policy = _add_policy(session) + monkeypatch.setattr( + forge_oidc_provisioning, + "evaluate_policy", + lambda *_args, **_kwargs: { + "decision": "allowed", + "scope_decision": "allowed", + "policy_id": policy.id, + "risk": "critical", + "reason": policy.reason, + }, + ) + result = forge_oidc_provisioning.provision_forge_oidc( + session, + actor_identity="authentik:operator-1", + authentik=authentik, + ) + + assert result["status"] == "blocked" + assert result["message"] == "forge_oidc_secret_store_not_production_ready" + assert authentik.calls == [] + + +def test_authentik_connector_creates_only_the_fixed_forge_oidc_contract( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + _mock_group_mapping_api(monkeypatch) + writes = [] + remote = {"applications": [], "providers": []} + + def paged(self, path, params=None): + if path == "/core/groups/": + return _valid_forge_groups() + if path == "/flows/instances/": + return [{"pk": "authorization-flow-id"}, {"pk": "invalidation-flow-id"}] + if path == "/crypto/certificatekeypairs/": + assert params == {"has_key": True} + return [ + { + "pk": "rsa-signing-key-id", + "key_type": "rsa", + "private_key_available": True, + } + ] + if path == "/propertymappings/provider/scope/": + return _valid_scope_mappings() + if path in {"/core/applications/", "/providers/oauth2/"}: + key = "applications" if path == "/core/applications/" else "providers" + return list(remote[key]) + raise AssertionError(f"Unexpected Authentik read: {path}") + + def write(self, method, path, payload=None): + writes.append((method, path, payload)) + if path == "/providers/oauth2/": + remote["providers"].append( + {"pk": "31", **payload, "property_mappings": list(reversed(payload["property_mappings"]))} + ) + return {"pk": "31"} + if path == "/core/applications/": + remote["applications"].append( + {"pk": FORGE_APPLICATION_ID, **payload} + ) + return {"pk": "f7212687-a3d0-4eaa-b209-427f4043f870"} + raise AssertionError(f"Unexpected Authentik write: {method} {path}") + + monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True) + monkeypatch.setattr(AuthentikConnector, "_forge_oidc_write_request", write, raising=False) + monkeypatch.setattr(AuthentikConnector, "_forge_oidc_provider_secret_matches", lambda *_args: True) + monkeypatch.setattr(AuthentikConnector, "_forge_oidc_discovery_matches", lambda self: True) + result = AuthentikConnector().ensure_forge_oidc_application( + client_secret="one-time-client-secret-value-0123456789", + persist_provider_id=lambda _provider_id: True, + ) + observed = AuthentikConnector().inspect_forge_oidc_application( + expected_client_secret="one-time-client-secret-value-0123456789" + ) + + assert observed["success"] is True, observed["message"] + assert result["success"] is True, result["message"] + assert result["status"] == "created" + assert result["applicationId"] == "f7212687-a3d0-4eaa-b209-427f4043f870" + assert result["providerId"] == "31" + assert writes[0][0:2] == ("POST", "/providers/oauth2/") + assert writes[0][2] == { + "name": "ITWorx Forge OIDC", + "authorization_flow": "authorization-flow-id", + "invalidation_flow": "invalidation-flow-id", + "client_type": "confidential", + "client_id": "itworx-forge", + "client_secret": "one-time-client-secret-value-0123456789", + "signing_key": "rsa-signing-key-id", + "redirect_uris": [{"matching_mode": "strict", "url": "https://forge.example.com/auth/callback"}], + "property_mappings": [FORGE_OPENID_MAPPING_ID, FORGE_EMAIL_MAPPING_ID, FORGE_GROUP_MAPPING_ID], + "include_claims_in_id_token": True, + "sub_mode": "hashed_user_id", + "issuer_mode": "per_provider", + } + assert writes[1] == ( + "POST", + "/core/applications/", + { + "name": "ITWorx Forge", + "slug": "itworx-forge", + "provider": "31", + "meta_launch_url": "https://forge.example.com", + "meta_description": "Managed by AppOps Forge OIDC v1.", + }, + ) + assert "one-time-client-secret-value-0123456789" not in str(result) + + +def test_authentik_connector_accepts_exact_existing_contract_without_writes( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + _mock_group_mapping_api(monkeypatch) + writes = [] + + def paged(self, path, params=None): + if path == "/core/groups/": + return _valid_forge_groups() + if path == "/flows/instances/": + return [{"pk": "authorization-flow-id"}, {"pk": "invalidation-flow-id"}] + if path == "/crypto/certificatekeypairs/": + return [ + { + "pk": "rsa-signing-key-id", + "key_type": "RSA", + "private_key_available": True, + } + ] + if path == "/propertymappings/provider/scope/": + return _valid_scope_mappings() + if path == "/core/applications/": + return [ + { + "pk": FORGE_APPLICATION_ID, + "name": "ITWorx Forge", + "slug": "itworx-forge", + "provider": "31", + "meta_launch_url": "https://forge.example.com", + "meta_description": "Managed by AppOps Forge OIDC v1.", + } + ] + if path == "/providers/oauth2/": + return [ + { + "pk": "31", + "name": "ITWorx Forge OIDC", + "client_id": "itworx-forge", + "client_type": "confidential", + "redirect_uris": [ + { + "matching_mode": "strict", + "url": "https://forge.example.com/auth/callback", + } + ], + "authorization_flow": "authorization-flow-id", + "invalidation_flow": "invalidation-flow-id", + "property_mappings": [FORGE_OPENID_MAPPING_ID, FORGE_EMAIL_MAPPING_ID, FORGE_GROUP_MAPPING_ID], + "include_claims_in_id_token": True, + "sub_mode": "hashed_user_id", + "issuer_mode": "per_provider", + "signing_key": "rsa-signing-key-id", + } + ] + raise AssertionError(f"Unexpected Authentik read: {path}") + + monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True) + monkeypatch.setattr(AuthentikConnector, "_forge_oidc_discovery_matches", lambda self: True) + monkeypatch.setattr( + AuthentikConnector, + "_forge_oidc_provider_secret_matches", + lambda self, provider_id, expected: provider_id == "31" and expected == "unused-client-secret-value-0123456789", + raising=True, + ) + monkeypatch.setattr( + AuthentikConnector, + "_forge_oidc_write_request", + lambda self, method, path, payload=None: writes.append((method, path, payload)), + raising=False, + ) + result = AuthentikConnector().ensure_forge_oidc_application( + client_secret="unused-client-secret-value-0123456789", + persist_provider_id=lambda _provider_id: True, + ) + + assert result == { + "success": True, + "status": "already_configured", + "message": "forge_oidc_already_configured", + "applicationId": "f7212687-a3d0-4eaa-b209-427f4043f870", + "providerId": "31", + } + assert writes == [] + + +def test_authentik_provider_contract_uses_mapping_identity_not_relation_order( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + provider = { + "pk": "31", + "name": "ITWorx Forge OIDC", + "client_id": "itworx-forge", + "client_type": "confidential", + "redirect_uris": [ + {"matching_mode": "strict", "url": "https://forge.example.com/auth/callback"} + ], + "authorization_flow": "authorization-flow-id", + "invalidation_flow": "invalidation-flow-id", + "property_mappings": [ + FORGE_GROUP_MAPPING_ID, + FORGE_EMAIL_MAPPING_ID, + FORGE_OPENID_MAPPING_ID, + ], + "include_claims_in_id_token": True, + "sub_mode": "hashed_user_id", + "issuer_mode": "per_provider", + "signing_key": "rsa-signing-key-id", + } + assert AuthentikConnector._forge_oidc_provider_matches( + provider, + signing_key_id="rsa-signing-key-id", + ) is True + for unsupported_field, value in (("grant_types", ["authorization_code"]), ("redirect_uri_type", "strict")): + assert AuthentikConnector._forge_oidc_provider_matches( + {**provider, unsupported_field: value}, + signing_key_id="rsa-signing-key-id", + ) is False + + +def test_authentik_group_numeric_alternate_id_is_never_used_as_group_pk( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + _mock_group_mapping_api(monkeypatch) + numeric_group = _valid_forge_group("forge-viewers", "42") + numeric_group["num_pk"] = 42 + + def paged(self, path, params=None): + if path == "/flows/instances/": + return [{"pk": "authorization-flow-id"}, {"pk": "invalidation-flow-id"}] + if path == "/crypto/certificatekeypairs/": + return [{"pk": "rsa-signing-key-id", "key_type": "rsa", "private_key_available": True}] + if path == "/core/groups/": + return [numeric_group] + if path == "/propertymappings/provider/scope/": + return _valid_scope_mappings() + return [] + + writes = [] + monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True) + monkeypatch.setattr( + AuthentikConnector, + "_forge_oidc_write_request", + lambda self, method, path, payload=None: writes.append((method, path, payload)), + raising=False, + ) + result = AuthentikConnector().ensure_forge_oidc_prerequisites() + + assert result["status"] == "conflict" + assert result["message"] == "forge_oidc_group_id_invalid" + assert writes == [] + + +def test_authentik_group_membership_conflict_blocks_prerequisite_mutations( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + _mock_group_mapping_api(monkeypatch) + + def paged(self, path, params=None): + if path == "/flows/instances/": + return [{"pk": "authorization-flow-id"}, {"pk": "invalidation-flow-id"}] + if path == "/crypto/certificatekeypairs/": + return [{"pk": "rsa-signing-key-id", "key_type": "rsa", "private_key_available": True}] + if path == "/core/groups/": + return _valid_forge_groups() + if path == "/propertymappings/provider/scope/": + return _valid_scope_mappings() + return [] + + def read_group(self, group_id): + group = next(item for item in _valid_forge_groups() if item["pk"] == group_id) + if group_id == FORGE_GROUP_IDS["forge-viewers"]: + return {**group, "users": ["unexpected-user"]} + return group + + writes = [] + monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True) + monkeypatch.setattr(AuthentikConnector, "_forge_oidc_read_group", read_group, raising=True) + monkeypatch.setattr( + AuthentikConnector, + "_forge_oidc_write_request", + lambda self, method, path, payload=None: writes.append((method, path, payload)), + raising=False, + ) + result = AuthentikConnector().ensure_forge_oidc_prerequisites() + + assert result["status"] == "conflict" + assert result["message"] == "forge_oidc_group_conflict" + assert writes == [] + + +def test_authentik_bound_exact_application_preserves_existing_group_memberships( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.core.config import settings + + _configured_settings(monkeypatch) + _mock_group_mapping_api(monkeypatch) + monkeypatch.setitem(settings.apis, "AUTHENTIK_FORGE_OIDC_PROVIDER_ID", "31") + secret = "stored-client-secret-value-0123456789" + provider = { + "pk": "31", + "name": "ITWorx Forge OIDC", + "client_id": "itworx-forge", + "client_type": "confidential", + "redirect_uris": [ + {"matching_mode": "strict", "url": "https://forge.example.com/auth/callback"} + ], + "authorization_flow": "authorization-flow-id", + "invalidation_flow": "invalidation-flow-id", + "property_mappings": [FORGE_OPENID_MAPPING_ID, FORGE_EMAIL_MAPPING_ID, FORGE_GROUP_MAPPING_ID], + "include_claims_in_id_token": True, + "sub_mode": "hashed_user_id", + "issuer_mode": "per_provider", + "signing_key": "rsa-signing-key-id", + } + application = { + "pk": FORGE_APPLICATION_ID, + "name": "ITWorx Forge", + "slug": "itworx-forge", + "provider": "31", + "meta_launch_url": "https://forge.example.com", + "meta_description": "Managed by AppOps Forge OIDC v1.", + } + remote, writes, _behavior = _install_forge_connector_remote( + monkeypatch, + application_list=[application], + provider_list=[provider], + ) + + def read_group(self, group_id): + name = next(name for name, value in FORGE_GROUP_IDS.items() if value == group_id) + users = ["existing-forge-user"] if name == "forge-viewers" else [] + return _valid_forge_group(name, users=users) + + monkeypatch.setattr(AuthentikConnector, "_forge_oidc_read_group", read_group, raising=True) + result = AuthentikConnector().ensure_forge_oidc_prerequisites(expected_client_secret=secret) + + assert result["success"] is True + assert result["status"] == "ready" + assert writes == [] + assert len(remote["applications"]) == len(remote["providers"]) == 1 + + +def test_authentik_prerequisites_create_only_absent_groups_and_reconcile_unknown_post( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.core.config import settings + + _configured_settings(monkeypatch) + monkeypatch.setitem(settings.apis, "AUTHENTIK_FORGE_OIDC_BINDING_VALID", False) + for key in ( + "AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING", + "AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING", + "AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING", + ): + monkeypatch.setitem(settings.apis, key, None) + + from backend.app.schemas.forge_oidc import ( + FORGE_OIDC_EMAIL_MAPPING_MANAGED, + FORGE_OIDC_OPENID_MAPPING_MANAGED, + FORGE_OIDC_PROFILE_MAPPING_EXPRESSION, + FORGE_OIDC_PROFILE_MAPPING_NAME, + ) + + state = { + "groups": [], + "mappings": [ + { + "pk": FORGE_OPENID_MAPPING_ID, + "managed": FORGE_OIDC_OPENID_MAPPING_MANAGED, + "scope_name": "openid", + "expression": _OPENID_MAPPING_EXPRESSION, + }, + { + "pk": FORGE_EMAIL_MAPPING_ID, + "managed": FORGE_OIDC_EMAIL_MAPPING_MANAGED, + "scope_name": "email", + "expression": _EMAIL_MAPPING_EXPRESSION, + }, + ], + "group_details": {}, + "mapping_details": {}, + "unknown_group_post": True, + } + writes = [] + + def paged(self, path, params=None): + if path == "/flows/instances/": + return [{"pk": "authorization-flow-id"}, {"pk": "invalidation-flow-id"}] + if path == "/crypto/certificatekeypairs/": + return [{"pk": "rsa-signing-key-id", "key_type": "rsa", "private_key_available": True}] + if path == "/core/groups/": + return list(state["groups"]) + if path == "/propertymappings/provider/scope/": + return list(state["mappings"]) + raise AssertionError(f"Unexpected Authentik read: {path}") + + def request(self, path, params=None): + if path.startswith("/core/groups/"): + assert params == { + "include_users": "true", + "include_children": "true", + "include_parents": "true", + "include_inherited_roles": "false", + } + return state["group_details"][path.rstrip("/").split("/")[-1]] + if path.startswith("/propertymappings/provider/scope/"): + return state["mapping_details"][path.rstrip("/").split("/")[-1]] + raise AssertionError(f"Unexpected Authentik request: {path}") + + def write(self, method, path, payload=None): + writes.append((method, path, payload)) + if path == "/core/groups/": + name = payload["name"] + group_id = FORGE_GROUP_IDS[name] + group = {"pk": group_id, **payload, "num_pk": 100 + len(state["groups"])} + state["groups"].append(group) + state["group_details"][group_id] = group + if state["unknown_group_post"]: + state["unknown_group_post"] = False + raise RuntimeError("timeout after commit") + return {"pk": group_id} + if path == "/propertymappings/provider/scope/": + mapping = {"pk": FORGE_GROUP_MAPPING_ID, **payload} + state["mappings"].append(mapping) + state["mapping_details"][FORGE_GROUP_MAPPING_ID] = mapping + return {"pk": FORGE_GROUP_MAPPING_ID} + raise AssertionError(f"Unexpected Authentik write: {method} {path}") + + monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True) + monkeypatch.setattr(AuthentikConnector, "_request", request, raising=True) + monkeypatch.setattr(AuthentikConnector, "_forge_oidc_write_request", write, raising=False) + result = AuthentikConnector().ensure_forge_oidc_prerequisites() + + assert result["success"] is True + assert result["scopeMappingIds"] == { + "openid": FORGE_OPENID_MAPPING_ID, + "email": FORGE_EMAIL_MAPPING_ID, + "profile": FORGE_GROUP_MAPPING_ID, + } + assert [path for _method, path, _payload in writes] == [ + "/core/groups/", + "/core/groups/", + "/core/groups/", + "/propertymappings/provider/scope/", + ] + assert all(group["is_superuser"] is False and group["users"] == [] for group in state["groups"]) + assert state["mapping_details"][FORGE_GROUP_MAPPING_ID]["name"] == FORGE_OIDC_PROFILE_MAPPING_NAME + assert state["mapping_details"][FORGE_GROUP_MAPPING_ID]["expression"] == FORGE_OIDC_PROFILE_MAPPING_EXPRESSION + + +def test_authentik_provider_creation_timeout_is_reconciled_before_application_write( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + _mock_group_mapping_api(monkeypatch) + remote, writes, _behavior = _install_forge_connector_remote( + monkeypatch, + provider_timeout_after_commit=True, + ) + persisted = [] + secret = "one-time-client-secret-value-0123456789" + + result = AuthentikConnector().ensure_forge_oidc_application( + client_secret=secret, + persist_provider_id=lambda provider_id: persisted.append(provider_id) or True, + ) + + assert result["success"] is True + assert result["status"] == "created" + assert result["providerId"] == "31" + assert [path for _method, path, _payload in writes].count("/providers/oauth2/") == 1 + assert [path for _method, path, _payload in writes].count("/core/applications/") == 1 + assert persisted == ["31"] + assert len(remote["providers"]) == len(remote["applications"]) == 1 + assert secret not in str(result) + + +def test_authentik_provider_post_response_must_match_exact_get_readback( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + _mock_group_mapping_api(monkeypatch) + _remote, writes, _behavior = _install_forge_connector_remote( + monkeypatch, + provider_response_id="31", + provider_created_id="32", + ) + persisted = [] + + result = AuthentikConnector().ensure_forge_oidc_application( + client_secret="one-time-client-secret-value-0123456789", + persist_provider_id=lambda provider_id: persisted.append(provider_id) or True, + ) + + assert result == { + "success": False, + "status": "partial_failure", + "message": "forge_oidc_provider_readback_mismatch", + } + assert [path for _method, path, _payload in writes] == ["/providers/oauth2/"] + assert persisted == [] + + +def test_authentik_application_timeout_retries_only_the_missing_exact_application( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + _mock_group_mapping_api(monkeypatch) + remote, writes, behavior = _install_forge_connector_remote( + monkeypatch, + application_fail_without_commit=True, + ) + def persist(_provider_id): + return True + + secret = "one-time-client-secret-value-0123456789" + + first = AuthentikConnector().ensure_forge_oidc_application( + client_secret=secret, + persist_provider_id=persist, + ) + assert first["status"] == "partial_failure" + assert first["message"] == "forge_oidc_application_creation_unknown" + assert len(remote["providers"]) == 1 + assert remote["applications"] == [] + + behavior["application_fail_without_commit"] = False + retry = AuthentikConnector().ensure_forge_oidc_application( + client_secret=secret, + persist_provider_id=persist, + ) + assert retry["success"] is True + assert retry["status"] == "created" + assert len(remote["providers"]) == 1 + assert len(remote["applications"]) == 1 + assert [path for _method, path, _payload in writes].count("/providers/oauth2/") == 1 + assert [path for _method, path, _payload in writes].count("/core/applications/") == 2 + + +def test_authentik_binding_persistence_failure_keeps_orphan_for_safe_retry( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + _mock_group_mapping_api(monkeypatch) + remote, writes, _behavior = _install_forge_connector_remote(monkeypatch) + + result = AuthentikConnector().ensure_forge_oidc_application( + client_secret="one-time-client-secret-value-0123456789", + persist_provider_id=lambda _provider_id: False, + ) + + assert result == { + "success": False, + "status": "partial_failure", + "message": "forge_oidc_binding_persist_failed", + } + assert len(remote["providers"]) == 1 + assert remote["applications"] == [] + assert [path for _method, path, _payload in writes] == ["/providers/oauth2/"] + + +@pytest.mark.parametrize("persistence_result", [1, "true", None]) +def test_authentik_provider_binding_persistence_requires_literal_true( + monkeypatch: pytest.MonkeyPatch, + persistence_result, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + remote, writes, _behavior = _install_forge_connector_remote(monkeypatch) + discovery_calls = [] + monkeypatch.setattr( + AuthentikConnector, + "_forge_oidc_discovery_matches", + lambda self: discovery_calls.append(True) or True, + ) + + result = AuthentikConnector().ensure_forge_oidc_application( + client_secret="one-time-client-secret-value-0123456789", + persist_provider_id=lambda _provider_id: persistence_result, + ) + + assert result == { + "success": False, + "status": "partial_failure", + "message": "forge_oidc_binding_persist_failed", + } + assert len(remote["providers"]) == 1 + assert remote["applications"] == [] + assert [path for _method, path, _payload in writes] == ["/providers/oauth2/"] + assert discovery_calls == [] + + +def test_authentik_get_observation_requires_literal_secret_verification(): + from backend.app.connectors.authentik import AuthentikConnector + + observation = { + "success": True, + "status": "provider_orphan", + "providerId": "31", + "secretVerified": True, + } + assert AuthentikConnector._forge_oidc_result_from_observation( + observation, + accepted_statuses={"provider_orphan"}, + ) is not None + for unverified in ( + {key: value for key, value in observation.items() if key != "secretVerified"}, + {**observation, "secretVerified": False}, + {**observation, "secretVerified": 1}, + {**observation, "secretVerified": "true"}, + ): + assert AuthentikConnector._forge_oidc_result_from_observation( + unverified, + accepted_statuses={"provider_orphan"}, + ) is None + + +def test_authentik_application_post_timeout_with_exact_get_pair_is_idempotent( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + _mock_group_mapping_api(monkeypatch) + remote, writes, _behavior = _install_forge_connector_remote( + monkeypatch, + application_timeout_after_commit=True, + ) + + result = AuthentikConnector().ensure_forge_oidc_application( + client_secret="one-time-client-secret-value-0123456789", + persist_provider_id=lambda _provider_id: True, + ) + + assert result["success"] is True + assert result["status"] == "already_configured" + assert len(remote["providers"]) == len(remote["applications"]) == 1 + assert [path for _method, path, _payload in writes].count("/providers/oauth2/") == 1 + assert [path for _method, path, _payload in writes].count("/core/applications/") == 1 + + +def test_authentik_provider_detail_secret_comparison_is_constant_time_and_not_retained( + monkeypatch: pytest.MonkeyPatch, +): + import backend.app.connectors.authentik as authentik_module + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + raw_secret = "remote-only-client-secret-value" + requested = [] + provider_detail = {"pk": "31", "client_secret": raw_secret} + + class Response: + status_code = 200 + + def json(self): + return provider_detail.copy() + + class Client: + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def get(self, url, *, headers): + requested.append((url, headers)) + return Response() + + monkeypatch.setattr(authentik_module.httpx, "Client", Client) + connector = AuthentikConnector() + assert connector._forge_oidc_provider_secret_matches("31", raw_secret) is True + assert connector._forge_oidc_provider_secret_matches("31", "different-local-secret") is False + provider_detail.clear() + provider_detail["pk"] = "31" + assert connector._forge_oidc_provider_secret_matches("31", raw_secret) is False + assert requested[0][0] == "https://auth.example-host.com/api/v3/providers/oauth2/31/" + assert raw_secret not in str(connector.last_error) + assert raw_secret not in str(connector._read_cache) + + +def test_authentik_connector_blocks_missing_groups_and_non_rsa_signing_key( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + writes = [] + + def paged(self, path, params=None): + if path == "/core/groups/": + return _valid_forge_groups(("forge-viewers", "forge-operators")) + if path == "/flows/instances/": + return [{"pk": "authorization-flow-id"}, {"pk": "invalidation-flow-id"}] + if path == "/crypto/certificatekeypairs/": + return [ + { + "pk": "rsa-signing-key-id", + "key_type": "rsa", + "private_key_available": True, + } + ] + return [] + + monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True) + monkeypatch.setattr( + AuthentikConnector, + "_forge_oidc_write_request", + lambda self, method, path, payload=None: writes.append((method, path, payload)), + raising=False, + ) + result = AuthentikConnector().ensure_forge_oidc_application( + client_secret="opaque-client-secret-value-0123456789", + persist_provider_id=lambda _provider_id: True, + ) + + assert result["success"] is False + assert result["status"] == "blocked" + assert result["message"] == "forge_oidc_required_groups_missing" + assert writes == [] + assert "opaque-client-secret-value-0123456789" not in str(result) + + +def test_authentik_connector_blocks_profile_mapping_that_does_not_emit_filtered_groups_claim( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + invalid_mapping = _forge_group_mapping('return {"roles": ["admin"]}') + _mock_group_mapping_api(monkeypatch, invalid_mapping) + writes = [] + + def paged(self, path, params=None): + if path == "/core/groups/": + return _valid_forge_groups() + if path == "/flows/instances/": + return [{"pk": "authorization-flow-id"}, {"pk": "invalidation-flow-id"}] + if path == "/crypto/certificatekeypairs/": + return [ + { + "pk": "rsa-signing-key-id", + "key_type": "rsa", + "private_key_available": True, + } + ] + if path == "/propertymappings/provider/scope/": + mappings = _valid_scope_mappings() + mappings[-1] = invalid_mapping + return mappings + return [] + + monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True) + monkeypatch.setattr( + AuthentikConnector, + "_forge_oidc_write_request", + lambda self, method, path, payload=None: writes.append((method, path, payload)), + raising=False, + ) + result = AuthentikConnector().ensure_forge_oidc_application( + client_secret="unused-client-secret-value-0123456789", + persist_provider_id=lambda _provider_id: True, + ) + + assert result["status"] == "conflict" + assert result["message"] == "forge_oidc_profile_mapping_mismatch" + assert writes == [] + + +def test_authentik_connector_refuses_mismatched_existing_resources_without_reconcile( + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.connectors.authentik import AuthentikConnector + + _configured_settings(monkeypatch) + _mock_group_mapping_api(monkeypatch) + writes = [] + + def paged(self, path, params=None): + if path == "/core/groups/": + return _valid_forge_groups() + if path == "/flows/instances/": + return [{"pk": "authorization-flow-id"}, {"pk": "invalidation-flow-id"}] + if path == "/crypto/certificatekeypairs/": + return [ + { + "pk": "rsa-signing-key-id", + "key_type": {"type": "RSA"}, + "private_key_available": True, + } + ] + if path == "/propertymappings/provider/scope/": + return _valid_scope_mappings() + if path == "/core/applications/": + return [ + { + "pk": "f7212687-a3d0-4eaa-b209-427f4043f870", + "slug": "itworx-forge", + "meta_launch_url": "https://forge.example.com", + "provider": "31", + "meta_description": "Managed by AppOps Forge OIDC v1.", + } + ] + if path == "/providers/oauth2/": + return [ + { + "pk": "31", + "client_id": "itworx-forge", + "client_type": "confidential", + "redirect_uris": [ + { + "matching_mode": "regex", + "url": "https://forge.example.com/.*", + } + ], + "authorization_flow": "authorization-flow-id", + "invalidation_flow": "invalidation-flow-id", + "property_mappings": [FORGE_OPENID_MAPPING_ID, FORGE_EMAIL_MAPPING_ID, FORGE_GROUP_MAPPING_ID], + "include_claims_in_id_token": True, + "sub_mode": "hashed_user_id", + "issuer_mode": "per_provider", + "signing_key": "rsa-signing-key-id", + } + ] + raise AssertionError(f"Unexpected Authentik read: {path}") + + monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True) + monkeypatch.setattr( + AuthentikConnector, + "_forge_oidc_write_request", + lambda self, method, path, payload=None: writes.append((method, path, payload)), + raising=False, + ) + result = AuthentikConnector().ensure_forge_oidc_application( + client_secret="opaque-client-secret-value-0123456789", + persist_provider_id=lambda _provider_id: True, + ) + + assert result["success"] is False + assert result["status"] == "conflict" + assert result["message"] == "forge_oidc_existing_contract_mismatch" + assert writes == [] + assert "opaque-client-secret-value-0123456789" not in str(result) + + +def test_forge_oidc_provision_lock_serializes_separate_worker_processes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.core.config import settings + from backend.app.services import forge_oidc_provisioning + from backend.app.services.forge_oidc_provisioning import _forge_oidc_process_lock + + if os.name == "nt": + monkeypatch.setattr( + forge_oidc_provisioning, + "_windows_private_path", + lambda *_args, **_kwargs: None, + ) + lock_root = tmp_path / "forge-oidc-runtime-lock" + if os.name != "nt": + monkeypatch.setattr(forge_oidc_provisioning, "_FORGE_OIDC_LOCK_ROOT", lock_root) + monkeypatch.setattr(settings, "data_dir", str(tmp_path)) + ready = tmp_path / "worker-ready" + acquired = tmp_path / "worker-acquired" + code = """\ +from pathlib import Path +import os +import sys +from backend.app.services.forge_oidc_provisioning import _forge_oidc_process_lock +from backend.app.services import forge_oidc_provisioning +if os.name == "nt": + forge_oidc_provisioning._windows_private_path = lambda *_args, **_kwargs: None +else: + forge_oidc_provisioning._FORGE_OIDC_LOCK_ROOT = Path(sys.argv[3]) +Path(sys.argv[1]).write_text('ready') +with _forge_oidc_process_lock(): + Path(sys.argv[2]).write_text('acquired') +""" + environment = os.environ.copy() + environment["APPOPS_DATA_DIR"] = str(tmp_path) + process = None + try: + with _forge_oidc_process_lock(): + process = subprocess.Popen( + [sys.executable, "-c", code, str(ready), str(acquired), str(lock_root)], + cwd=Path(__file__).resolve().parents[2], + env=environment, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + deadline = time.monotonic() + 10 + while not ready.exists() and process.poll() is None and time.monotonic() < deadline: + time.sleep(0.02) + assert ready.exists(), "worker did not start the lock attempt" + time.sleep(0.2) + assert process.poll() is None + assert not acquired.exists(), "worker entered the critical section before the lock was released" + + _stdout, stderr = process.communicate(timeout=10) + assert process.returncode == 0, stderr + assert acquired.read_text() == "acquired" + finally: + if process is not None and process.poll() is None: + process.terminate() + process.wait(timeout=5) + +def test_forge_oidc_provision_fails_closed_when_process_lock_is_unavailable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from backend.app.services import forge_oidc_provisioning + + engine = _database(tmp_path, monkeypatch) + _configured_settings(monkeypatch) + authentik = RecordingAuthentik() + + class UnavailableLock: + def __enter__(self): + raise forge_oidc_provisioning._ProvisionLockUnavailable + + def __exit__(self, *_args): + return False + + monkeypatch.setattr( + forge_oidc_provisioning, + "_forge_oidc_process_lock", + UnavailableLock, + ) + with Session(engine) as session: + result = forge_oidc_provisioning.provision_forge_oidc( + session, + actor_identity="authentik:operator-1", + authentik=authentik, + ) + + assert result["status"] == "failed" + assert result["message"] == "forge_oidc_provision_lock_unavailable" + assert authentik.preflight_calls == 0 + assert authentik.calls == [] + + +def test_forge_oidc_thread_lock_wait_is_bounded(monkeypatch: pytest.MonkeyPatch): + from backend.app.services import forge_oidc_provisioning + + monkeypatch.setattr(forge_oidc_provisioning, "_PROVISION_LOCK_TIMEOUT_SECONDS", 0.01) + assert forge_oidc_provisioning._SECRET_LOCK.acquire(blocking=False) + try: + with ( + pytest.raises(forge_oidc_provisioning._ProvisionLockUnavailable), + forge_oidc_provisioning._forge_oidc_process_lock(), + ): + pytest.fail("thread lock should not have been acquired") + finally: + forge_oidc_provisioning._SECRET_LOCK.release() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX flock regression") +def test_forge_oidc_process_lock_wait_is_bounded( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import errno + import fcntl + + from backend.app.services import forge_oidc_provisioning + + monkeypatch.setattr(forge_oidc_provisioning, "_FORGE_OIDC_LOCK_ROOT", tmp_path / "forge-oidc-runtime-lock") + monkeypatch.setattr(forge_oidc_provisioning, "_PROVISION_LOCK_TIMEOUT_SECONDS", 0.01) + attempts = 0 + + def always_busy(_fd: int, operation: int) -> None: + nonlocal attempts + attempts += 1 + if operation & fcntl.LOCK_UN: + return + raise BlockingIOError(errno.EAGAIN, "lock is busy") + + monkeypatch.setattr(fcntl, "flock", always_busy) + with ( + pytest.raises(forge_oidc_provisioning._ProvisionLockUnavailable), + forge_oidc_provisioning._forge_oidc_process_lock(), + ): + pytest.fail("process lock should have timed out") + assert attempts >= 1 + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX directory permission regression") +@pytest.mark.parametrize("unsafe_location", ["target", "parent"]) +def test_forge_oidc_lock_rejects_writable_data_directory_without_creating_lock( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + unsafe_location: str, +): + from backend.app.services import forge_oidc_provisioning + + unsafe_parent = tmp_path / "shared-writable" + unsafe_parent.mkdir(mode=0o700) + unsafe_parent.chmod(0o777) + if unsafe_location == "target": + lock_root = unsafe_parent + else: + lock_root = unsafe_parent / "private-child" + lock_root.mkdir(mode=0o700) + monkeypatch.setattr(forge_oidc_provisioning, "_FORGE_OIDC_LOCK_ROOT", lock_root) + + with ( + pytest.raises(forge_oidc_provisioning._ProvisionLockUnavailable), + forge_oidc_provisioning._forge_oidc_process_lock(), + ): + pytest.fail("writable data directory must not be trusted") + assert not (lock_root / forge_oidc_provisioning._PROVISION_LOCK_FILENAME).exists() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX lock-file metadata regression") +@pytest.mark.parametrize("unsafe_kind", ["hardlink", "permissions"]) +def test_forge_oidc_lock_rejects_unsafe_existing_lock_file_without_chmod( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + unsafe_kind: str, +): + from backend.app.services import forge_oidc_provisioning + + lock_root = tmp_path / "forge-oidc-runtime-lock" + lock_root.mkdir(mode=0o700) + lock_path = lock_root / forge_oidc_provisioning._PROVISION_LOCK_FILENAME + lock_path.write_bytes(b"") + lock_path.chmod(0o600) + if unsafe_kind == "hardlink": + os.link(lock_path, tmp_path / "outside-link") + else: + lock_path.chmod(0o666) + original_mode = lock_path.stat().st_mode + monkeypatch.setattr(forge_oidc_provisioning, "_FORGE_OIDC_LOCK_ROOT", lock_root) + + with ( + pytest.raises(forge_oidc_provisioning._ProvisionLockUnavailable), + forge_oidc_provisioning._forge_oidc_process_lock(), + ): + pytest.fail("unsafe lock file must not be trusted") + assert lock_path.stat().st_mode == original_mode + + +@pytest.mark.skipif(os.name != "nt", reason="Windows ACL regression") +def test_forge_oidc_windows_lock_rejects_untrusted_writer_acl(tmp_path: Path): + from backend.app.services.forge_oidc_provisioning import _windows_private_path + + unsafe_dir = tmp_path / "forge-oidc-untrusted-acl" + unsafe_dir.mkdir() + result = subprocess.run( + [ + "icacls", + str(unsafe_dir), + "/inheritance:r", + "/grant:r", + "*S-1-1-0:(OI)(CI)M", + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pytest.skip("Windows ACL tool is unavailable in this runner") + + with pytest.raises(OSError): + _windows_private_path(unsafe_dir, require_current_owner=False) diff --git a/backend/tests/test_forge_reader_api.py b/backend/tests/test_forge_reader_api.py new file mode 100644 index 0000000..16b1afa --- /dev/null +++ b/backend/tests/test_forge_reader_api.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import os + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine + +from backend.app.services import user_access + +_REAL_CURRENT_USER = user_access.current_user +_PROVISION_PATH = "/api/apps/authentik/forge/controller-reader/provision" + + +@pytest.fixture +def forge_reader_operator_client(tmp_path, monkeypatch: pytest.MonkeyPatch): + from backend.app.core.config import settings + from backend.app.db import session as db_session + from backend.app.db.init_db import init_db + from backend.app.main import app + + if os.name == "nt": + from backend.app.services import forge_oidc_provisioning + + monkeypatch.setattr( + forge_oidc_provisioning, + "_windows_private_path", + lambda *_args, **_kwargs: None, + ) + else: + from backend.app.services import forge_oidc_provisioning + + monkeypatch.setattr( + forge_oidc_provisioning, + "_FORGE_OIDC_LOCK_ROOT", + tmp_path / "forge-reader-api-lock", + ) + + engine = create_engine( + f"sqlite:///{tmp_path / 'forge-reader-operator.db'}", + connect_args={"check_same_thread": False}, + ) + monkeypatch.setattr(db_session, "engine", engine) + monkeypatch.setenv("APPOPS_REQUIRE_OPERATOR_AUTH", "false") + monkeypatch.setattr(settings, "operator_proxy_auth_enabled", False) + monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:owner"], raising=False) + monkeypatch.setattr(settings, "auth_admin_emails", [], raising=False) + monkeypatch.setattr(settings, "data_dir", str(tmp_path)) + monkeypatch.setattr(user_access, "current_user", _REAL_CURRENT_USER) + SQLModel.metadata.create_all(engine) + init_db() + client = TestClient(app, client=("198.18.10.20", 42000)) + yield client + engine.dispose() + + +def _spy_actual_connector_loader(monkeypatch: pytest.MonkeyPatch): + from backend.app.services import setup_service + + original = setup_service.apply_stored_connector_settings + calls = [] + + def tracked(session, connector_ids=None): + calls.append(connector_ids) + return original(session, connector_ids) + + monkeypatch.setattr(setup_service, "apply_stored_connector_settings", tracked) + return calls + + +def _sign_in(client: TestClient, subject: str) -> None: + from backend.app.db import session as db_session + from backend.app.services.user_access import issue_session, provision_user + + with Session(db_session.engine) as session: + user = provision_user( + session, + "authentik", + "https://auth.example/application/o/appops/", + {"sub": subject, "email": f"{subject}@example.com"}, + ) + cookie = issue_session(session, user) + client.cookies.set("appops_user_session", cookie) + + +def test_forge_reader_route_requires_authenticated_operator_and_csrf( + forge_reader_operator_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + loader_calls = _spy_actual_connector_loader(monkeypatch) + unauthenticated = forge_reader_operator_client.post(_PROVISION_PATH, json={}) + assert unauthenticated.status_code == 401 + + _sign_in(forge_reader_operator_client, "owner") + missing_csrf = forge_reader_operator_client.post(_PROVISION_PATH, json={}) + assert missing_csrf.status_code == 403 + assert loader_calls == [] + + +def test_forge_reader_route_rejects_read_only_user_and_caller_selectors( + forge_reader_operator_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + loader_calls = _spy_actual_connector_loader(monkeypatch) + _sign_in(forge_reader_operator_client, "reader") + denied = forge_reader_operator_client.post( + _PROVISION_PATH, + json={}, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + assert denied.status_code == 403 + + _sign_in(forge_reader_operator_client, "owner") + invalid = forge_reader_operator_client.post( + _PROVISION_PATH, + json={"token": "caller-selected"}, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + assert invalid.status_code == 422 + assert loader_calls == [] + + +def test_forge_reader_default_denied_policy_does_not_load_connector_or_mutate( + forge_reader_operator_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.db import session as db_session + from backend.app.db.models import Policy + + loader_calls = _spy_actual_connector_loader(monkeypatch) + _sign_in(forge_reader_operator_client, "owner") + with Session(db_session.engine) as session: + policy = session.get(Policy, "policy-forge-oidc-provision") + policy.manual = "blocked" + session.add(policy) + session.commit() + + response = forge_reader_operator_client.post( + _PROVISION_PATH, + json={}, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "blocked" + assert response.json()["message"] == "forge_reader_exact_target_policy_required" + assert loader_calls == [] + + +def test_forge_reader_route_passes_operator_and_projects_only_safe_result( + forge_reader_operator_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.api import routes_authentik + + _sign_in(forge_reader_operator_client, "owner") + loader_calls = _spy_actual_connector_loader(monkeypatch) + calls = [] + held_token = "reader-token-must-never-be-returned" + + def provision(session, *, actor_identity): + calls.append((session, actor_identity)) + return { + "success": True, + "status": "ready", + "message": "forge_reader_ready", + "ready": True, + "secretRef": "ITWORX_FORGE_AUTHENTIK_READ_TOKEN", + } + + monkeypatch.setattr(routes_authentik, "provision_forge_controller_reader", provision) + response = forge_reader_operator_client.post( + _PROVISION_PATH, + json={}, + headers={"X-AppOps-Session-CSRF": "1"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "success": True, + "status": "ready", + "message": "forge_reader_ready", + "ready": True, + "secretRef": "ITWORX_FORGE_AUTHENTIK_READ_TOKEN", + } + assert calls and calls[0][1] == "authentik:owner" + assert loader_calls == [] + assert held_token not in response.text diff --git a/backend/tests/test_forge_secret_bundle.py b/backend/tests/test_forge_secret_bundle.py new file mode 100644 index 0000000..bcbf483 --- /dev/null +++ b/backend/tests/test_forge_secret_bundle.py @@ -0,0 +1,747 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import stat +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BROKER = ROOT / "ops" / "gitea-deploy-broker" +if str(BROKER) not in sys.path: + sys.path.insert(0, str(BROKER)) + + +@pytest.fixture +def bundle_module(): + spec = importlib.util.spec_from_file_location( + "forge_secret_bundle_test", + BROKER / "forge_secret_bundle.py", + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _config(**overrides: str) -> bytes: + value = { + "schema": "itworx.forge-first-install-config/v4", + "issuer": "https://auth.example.test/application/o/forge/", + } + value.update(overrides) + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _initialize_test_roots(roots) -> None: + for path in dict.fromkeys( + (roots.config_root, roots.state_parent, roots.backup_root) + ): + path.mkdir(mode=0o700) + path.chmod(0o700) + + +def test_validator_must_return_literal_true_before_any_filesystem_effect( + bundle_module, tmp_path +): + oidc = b"oidc-secret-sentinel-123456789" + authentik = b"authentik-token-sentinel-123456789" + config = _config() + roots = bundle_module._Roots.for_test(tmp_path) + + for result in (False, None, 1): + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + oidc, + authentik, + config, + validate_config=lambda _raw, result=result: result, + roots=roots, + ) + assert list(tmp_path.iterdir()) == [] + + def raises_with_secret(_raw: bytes) -> bool: + raise RuntimeError("validation failed: " + oidc.decode("ascii")) + + with pytest.raises(bundle_module.ForgeSecretBundleError) as raised: + bundle_module._prepare_with_roots( + oidc, + authentik, + config, + validate_config=raises_with_secret, + roots=roots, + ) + assert oidc.decode("ascii") not in str(raised.value) + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize( + "config", + [ + b'{"schema":"v4","schema":"v4"}', + b'{ "schema":"v4"}', + b'{"schema":NaN}', + b"[]", + b"\xff", + ], +) +def test_rejects_duplicate_invalid_noncanonical_or_nonobject_config_without_effects( + bundle_module, tmp_path, config +): + roots = bundle_module._Roots.for_test(tmp_path) + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + b"oidc-secret-sentinel-123456789", + b"authentik-token-sentinel-123456789", + config, + validate_config=lambda _raw: True, + roots=roots, + ) + assert list(tmp_path.iterdir()) == [] + + +def test_rejects_supplied_secret_embedded_in_config_before_validator_or_writes( + bundle_module, tmp_path +): + oidc = b"oidc-secret-sentinel-123456789" + roots = bundle_module._Roots.for_test(tmp_path) + called = False + + def validate(_raw: bytes) -> bool: + nonlocal called + called = True + return True + + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + oidc, + b"authentik-token-sentinel-123456789", + _config(note=oidc.decode("ascii")), + validate_config=validate, + roots=roots, + ) + assert called is False + assert list(tmp_path.iterdir()) == [] + + +def test_rejects_escaped_secret_value_without_calling_validator( + bundle_module, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + secret = b'oidc-"quoted"-secret-123456789' + encoded = _config(note=secret.decode("ascii")) + called = False + + def validate(_raw: bytes) -> bool: + nonlocal called + called = True + return True + + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + secret, + b"authentik-token-sentinel-123456789", + encoded, + validate_config=validate, + roots=roots, + ) + assert called is False + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_creates_fixed_files_and_backup_and_retry_only_verifies( + bundle_module, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + oidc = b"oidc-secret-sentinel-123456789" + authentik = b"authentik-token-sentinel-123456789" + config = _config() + def validate(raw): + return raw == config + + + result = bundle_module._prepare_with_roots( + oidc, authentik, config, validate_config=validate, roots=roots + ) + + assert result == { + "status": "bundle_ready", + "secretFilesReady": 7, + "masterKeyBackupVerified": True, + "applyPreflight": "not_evaluated", + } + assert oidc.decode("ascii") not in repr(result) + assert authentik.decode("ascii") not in repr(result) + assert sorted(path.name for path in roots.secrets_root.iterdir()) == sorted( + f"{name}.secret" + for name in ( + "postgres_bootstrap_password", + "runtime_database_url", + "migrator_database_url", + "oidc_client_secret", + "session_secret", + "master_key", + "authentik_read_token", + ) + ) + assert (roots.secrets_root / "oidc_client_secret.secret").read_bytes() == oidc + assert ( + roots.secrets_root / "authentik_read_token.secret" + ).read_bytes() == authentik + master_key = (roots.secrets_root / "master_key.secret").read_bytes() + assert len(master_key) == 32 + assert (roots.backup_root / "master_key.backup").read_bytes() == master_key + + import urllib.parse + + generated = { + (roots.secrets_root / "postgres_bootstrap_password.secret").read_bytes(), + urllib.parse.urlsplit( + (roots.secrets_root / "runtime_database_url.secret").read_text( + encoding="ascii" + ) + ).password.encode("ascii"), + urllib.parse.urlsplit( + (roots.secrets_root / "migrator_database_url.secret").read_text( + encoding="ascii" + ) + ).password.encode("ascii"), + (roots.secrets_root / "session_secret.secret").read_bytes(), + } + assert len(generated) == 4 + assert all(len(value) >= 32 for value in generated) + + from forge_first_install import FORGE_SECRETS + + for name, (_logical, uid, gid, minimum, maximum) in FORGE_SECRETS.items(): + path = roots.secrets_root / f"{name}.secret" + info = path.stat() + assert (info.st_uid, info.st_gid) == (uid, gid) + assert info.st_mode & 0o777 == 0o400 + assert minimum <= info.st_size <= maximum + assert (roots.secrets_root.stat().st_mode & 0o777) == 0o700 + assert (roots.config_path.stat().st_mode & 0o777) == 0o600 + assert (roots.backup_root / "master_key.backup").stat().st_mode & 0o777 == 0o400 + + journal = roots.journal_path.read_text(encoding="utf-8") + assert "oidc-secret-sentinel-123456789" not in journal + assert "authentik-token-sentinel-123456789" not in journal + assert "sha256" not in journal.lower() + + before = { + path.name: (path.stat().st_ino, path.read_bytes()) + for path in roots.secrets_root.iterdir() + } + backup_identity = (roots.backup_root / "master_key.backup").stat().st_ino + config_identity = roots.config_path.stat().st_ino + original_write = bundle_module._write_all + + def reject_write(_fd: int, _payload: bytes) -> None: + raise AssertionError("a completed retry must not write") + + bundle_module._write_all = reject_write + retry = bundle_module._prepare_with_roots( + oidc, authentik, config, validate_config=validate, roots=roots + ) + bundle_module._write_all = original_write + assert retry == result + assert before == { + path.name: (path.stat().st_ino, path.read_bytes()) + for path in roots.secrets_root.iterdir() + } + assert (roots.backup_root / "master_key.backup").stat().st_ino == backup_identity + assert roots.config_path.stat().st_ino == config_identity + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_partial_retry_reuses_already_committed_password_and_recovers_backup( + bundle_module, monkeypatch, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + oidc = b"oidc-secret-sentinel-123456789" + authentik = b"authentik-token-sentinel-123456789" + config = _config() + original_write = bundle_module._write_all + failed = False + + def fail_runtime_write(fd: int, payload: bytes) -> None: + nonlocal failed + if payload.startswith(b"postgresql://") and not failed: + failed = True + raise OSError("injected write failure") + original_write(fd, payload) + + monkeypatch.setattr(bundle_module, "_write_all", fail_runtime_write) + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + oidc, + authentik, + config, + validate_config=lambda raw: raw == config, + roots=roots, + ) + bootstrap = roots.secrets_root / "postgres_bootstrap_password.secret" + original_bootstrap = bootstrap.read_bytes() + original_inode = bootstrap.stat().st_ino + + monkeypatch.setattr(bundle_module, "_write_all", original_write) + result = bundle_module._prepare_with_roots( + oidc, + authentik, + config, + validate_config=lambda raw: raw == config, + roots=roots, + ) + assert result["status"] == "bundle_ready" + assert bootstrap.read_bytes() == original_bootstrap + assert bootstrap.stat().st_ino == original_inode + master = (roots.secrets_root / "master_key.secret").read_bytes() + assert (roots.backup_root / "master_key.backup").read_bytes() == master + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_link_failure_after_durable_stage_keeps_and_reuses_original_secret( + bundle_module, monkeypatch, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + oidc = b"oidc-secret-sentinel-123456789" + authentik = b"authentik-token-sentinel-123456789" + config = _config() + original_link = os.link + failed = False + + def fail_runtime_link(source, destination, **kwargs): + nonlocal failed + if destination == "runtime_database_url.secret" and not failed: + failed = True + raise OSError("injected link failure") + return original_link(source, destination, **kwargs) + + monkeypatch.setattr(os, "link", fail_runtime_link) + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + oidc, + authentik, + config, + validate_config=lambda raw: raw == config, + roots=roots, + ) + + journal = json.loads(roots.journal_path.read_bytes()) + stage = journal["staging"] + assert stage["kind"] == "secret" + assert stage["name"] == "runtime_database_url" + staged_file = roots.secrets_root / stage["temporary"] + assert staged_file.is_file() + original_payload = staged_file.read_bytes() + assert original_payload.startswith(b"postgresql://forge_runtime:") + + monkeypatch.setattr(os, "link", original_link) + result = bundle_module._prepare_with_roots( + oidc, + authentik, + config, + validate_config=lambda raw: raw == config, + roots=roots, + ) + + assert result["status"] == "bundle_ready" + runtime_password = (roots.secrets_root / "runtime_database_url.secret").read_bytes() + assert runtime_password == original_payload + assert not staged_file.exists() + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_journal_directory_fsync_uncertainty_preserves_referenced_stage( + bundle_module, monkeypatch, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + oidc = b"oidc-secret-sentinel-123456789" + authentik = b"authentik-token-sentinel-123456789" + config = _config() + original_fsync = os.fsync + failed = False + + def fail_stage_directory_fsync(fd: int) -> None: + nonlocal failed + if not failed and stat.S_ISDIR(os.fstat(fd).st_mode): + try: + journal = json.loads(roots.journal_path.read_bytes()) + except (OSError, ValueError): + journal = {} + stage = journal.get("staging") + if stage is not None and stage.get("name") == "runtime_database_url": + failed = True + raise OSError("injected directory fsync uncertainty") + original_fsync(fd) + + monkeypatch.setattr(os, "fsync", fail_stage_directory_fsync) + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + oidc, + authentik, + config, + validate_config=lambda raw: raw == config, + roots=roots, + ) + + journal = json.loads(roots.journal_path.read_bytes()) + stage = journal["staging"] + assert stage["name"] == "runtime_database_url" + staged_file = roots.secrets_root / stage["temporary"] + assert staged_file.is_file() + original_payload = staged_file.read_bytes() + + monkeypatch.setattr(os, "fsync", original_fsync) + result = bundle_module._prepare_with_roots( + oidc, + authentik, + config, + validate_config=lambda raw: raw == config, + roots=roots, + ) + assert result["status"] == "bundle_ready" + assert ( + roots.secrets_root / "runtime_database_url.secret" + ).read_bytes() == original_payload + assert not staged_file.exists() + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_backup_link_failure_reuses_same_master_key_and_completes_backup( + bundle_module, monkeypatch, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + oidc = b"oidc-secret-sentinel-123456789" + authentik = b"authentik-token-sentinel-123456789" + config = _config() + original_link = os.link + failed = False + + def fail_backup_link(source, destination, **kwargs): + nonlocal failed + if destination == "master_key.backup" and not failed: + failed = True + raise OSError("injected backup link failure") + return original_link(source, destination, **kwargs) + + monkeypatch.setattr(os, "link", fail_backup_link) + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + oidc, + authentik, + config, + validate_config=lambda raw: raw == config, + roots=roots, + ) + + journal = json.loads(roots.journal_path.read_bytes()) + stage = journal["staging"] + assert stage["kind"] == "backup" + assert stage["name"] == "master_key.backup" + staged_file = roots.backup_root / stage["temporary"] + original_payload = staged_file.read_bytes() + original_master = (roots.secrets_root / "master_key.secret").read_bytes() + original_master_inode = (roots.secrets_root / "master_key.secret").stat().st_ino + assert original_payload == original_master + assert len(original_payload) == 32 + + monkeypatch.setattr(os, "link", original_link) + result = bundle_module._prepare_with_roots( + oidc, + authentik, + config, + validate_config=lambda raw: raw == config, + roots=roots, + ) + + assert result["status"] == "bundle_ready" + assert (roots.secrets_root / "master_key.secret").read_bytes() == original_master + assert ( + roots.secrets_root / "master_key.secret" + ).stat().st_ino == original_master_inode + assert (roots.backup_root / "master_key.backup").read_bytes() == original_payload + assert not staged_file.exists() + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_existing_secret_residue_is_never_overwritten_or_adopted( + bundle_module, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + roots.secrets_root.mkdir(mode=0o700) + residue = roots.secrets_root / "session_secret.secret" + residue.write_bytes(b"foreign-residue-must-survive") + residue.chmod(0o400) + before = residue.read_bytes() + + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + b"oidc-secret-sentinel-123456789", + b"authentik-token-sentinel-123456789", + _config(), + validate_config=lambda _raw: True, + roots=roots, + ) + assert residue.read_bytes() == before + assert not roots.journal_path.exists() + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_unsafe_ancestor_and_symlinked_secret_root_fail_without_chmod( + bundle_module, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + roots.config_root.chmod(0o777) + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + b"oidc-secret-sentinel-123456789", + b"authentik-token-sentinel-123456789", + _config(), + validate_config=lambda _raw: True, + roots=roots, + ) + assert roots.config_root.stat().st_mode & 0o777 == 0o777 + + roots.config_root.chmod(0o700) + target = tmp_path / "foreign-secret-dir" + target.mkdir(mode=0o700) + roots.secrets_root.symlink_to(target, target_is_directory=True) + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + b"oidc-secret-sentinel-123456789", + b"authentik-token-sentinel-123456789", + _config(), + validate_config=lambda _raw: True, + roots=roots, + ) + assert list(target.iterdir()) == [] + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_stale_journal_generation_is_rejected_by_compare_and_swap( + bundle_module, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + roots.state_root.mkdir(mode=0o700) + descriptor = os.open(roots.state_root, os.O_RDONLY | os.O_DIRECTORY) + try: + record = bundle_module._empty_journal() + bundle_module._write_journal(descriptor, record, expected_generation=None) + stale = dict(record) + record["status"] = "progress" + bundle_module._persist(descriptor, record) + before = roots.journal_path.read_bytes() + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._write_journal(descriptor, stale, expected_generation=0) + assert roots.journal_path.read_bytes() == before + finally: + os.close(descriptor) + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_backup_root_rejects_untracked_files_before_creating_journal( + bundle_module, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + foreign = roots.backup_root / "unrelated-backup-residue" + foreign.write_bytes(b"leave this untouched") + + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + b"oidc-secret-sentinel-123456789", + b"authentik-token-sentinel-123456789", + _config(), + validate_config=lambda _raw: True, + roots=roots, + ) + assert foreign.read_bytes() == b"leave this untouched" + assert not roots.state_root.exists() + assert not roots.secrets_root.exists() + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +@pytest.mark.parametrize("target", ["config", "backup"]) +def test_existing_config_or_backup_files_are_never_overwritten( + bundle_module, tmp_path, target +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + path = ( + roots.config_path + if target == "config" + else roots.backup_root / "master_key.backup" + ) + path.write_bytes(b"foreign-config-or-backup") + path.chmod(0o600) + original = path.read_bytes() + + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + b"oidc-secret-sentinel-123456789", + b"authentik-token-sentinel-123456789", + _config(), + validate_config=lambda _raw: True, + roots=roots, + ) + assert path.read_bytes() == original + assert not roots.state_root.exists() + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_hardlinked_existing_secret_fails_closed_without_rekeying( + bundle_module, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + args = ( + b"oidc-secret-sentinel-123456789", + b"authentik-token-sentinel-123456789", + _config(), + ) + bundle_module._prepare_with_roots( + *args, + validate_config=lambda _raw: True, + roots=roots, + ) + master = roots.secrets_root / "master_key.secret" + original = master.read_bytes() + foreign_link = tmp_path / "foreign-master-link" + os.link(master, foreign_link) + journal_before = roots.journal_path.read_bytes() + + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + *args, + validate_config=lambda _raw: True, + roots=roots, + ) + assert master.read_bytes() == original + assert roots.journal_path.read_bytes() == journal_before + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_pinned_file_reader_rejects_symlink_swap_between_stat_and_open( + bundle_module, monkeypatch, tmp_path +): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + roots.secrets_root.mkdir(mode=0o700) + target = roots.secrets_root / "trusted-content" + target.write_bytes(b"trusted") + target.chmod(0o400) + race = roots.secrets_root / "race-target" + race.write_bytes(b"before-race") + race.chmod(0o400) + directory_fd = os.open(roots.secrets_root, os.O_RDONLY | os.O_DIRECTORY) + original_stat_at = bundle_module._stat_at + swapped = False + + def swap_after_stat(fd: int, name: str): + nonlocal swapped + info = original_stat_at(fd, name) + if name == "race-target" and info is not None and not swapped: + swapped = True + os.unlink(name, dir_fd=fd) + os.symlink("trusted-content", name, dir_fd=fd) + return info + + monkeypatch.setattr(bundle_module, "_stat_at", swap_after_stat) + try: + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._read_file_at( + directory_fd, + "race-target", + uid=0, + gid=0, + min_size=1, + max_size=32, + allowed_modes=frozenset({0o400}), + ) + finally: + os.close(directory_fd) + assert swapped is True + assert race.is_symlink() + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the production helper requires POSIX root ownership", +) +def test_changed_master_key_backup_is_not_silently_rewritten(bundle_module, tmp_path): + roots = bundle_module._Roots.for_test(tmp_path) + _initialize_test_roots(roots) + args = ( + b"oidc-secret-sentinel-123456789", + b"authentik-token-sentinel-123456789", + _config(), + ) + bundle_module._prepare_with_roots( + *args, + validate_config=lambda _raw: True, + roots=roots, + ) + backup = roots.backup_root / "master_key.backup" + original = backup.read_bytes() + backup.write_bytes(bytes(byte ^ 0xFF for byte in original)) + journal_before = roots.journal_path.read_bytes() + + with pytest.raises(bundle_module.ForgeSecretBundleError): + bundle_module._prepare_with_roots( + *args, + validate_config=lambda _raw: True, + roots=roots, + ) + assert backup.read_bytes() == bytes(byte ^ 0xFF for byte in original) + assert (roots.secrets_root / "master_key.secret").read_bytes() == original + assert roots.journal_path.read_bytes() == journal_before diff --git a/backend/tests/test_forge_secret_delivery.py b/backend/tests/test_forge_secret_delivery.py new file mode 100644 index 0000000..3e37f03 --- /dev/null +++ b/backend/tests/test_forge_secret_delivery.py @@ -0,0 +1,1014 @@ +from __future__ import annotations + +import base64 +import json +import os +import re + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine, select + +from backend.app.services import user_access + +_REAL_CURRENT_USER = user_access.current_user +_DELIVERY_PATH = "/api/deployments/forge-bootstrap/secrets" + + +def test_forge_secret_preparation_has_a_default_denied_exact_operator_policy( + tmp_path, monkeypatch +): + from backend.app.db import session as db_session + from backend.app.db.init_db import init_db + from backend.app.db.models import Policy + from backend.app.services.action_registry import ( + ACTION_RISK, + ACTIONS, + action_default_target, + ) + from backend.app.services.policy_engine import evaluate_policy + + engine = create_engine(f"sqlite:///{tmp_path / 'forge-secret-policy.db'}") + monkeypatch.setattr(db_session, "engine", engine) + try: + init_db() + with Session(engine) as session: + rows = session.exec( + select(Policy).where(Policy.action == "forge.secrets.prepare") + ).all() + assert "forge.secrets.prepare" in ACTIONS + assert ACTION_RISK["forge.secrets.prepare"] == "critical" + assert action_default_target("forge.secrets.prepare") == "itworx-forge" + assert len(rows) == 1 + policy = rows[0] + assert (policy.id, policy.target, policy.manual, policy.voice, policy.risk) == ( + "policy-forge-secret-prepare", + "itworx-forge", + "blocked", + "blocked", + "critical", + ) + denied = evaluate_policy( + session, + action="forge.secrets.prepare", + target="itworx-forge", + source="manual", + ) + assert denied["decision"] == "blocked" + assert denied["scope_decision"] == "blocked" + + policy.manual = "allowed" + policy.voice = "blocked" + session.add(policy) + session.commit() + init_db() + with Session(engine) as session: + policy = session.exec( + select(Policy).where(Policy.action == "forge.secrets.prepare") + ).one() + assert policy.manual == "allowed" + allowed = evaluate_policy( + session, + action="forge.secrets.prepare", + target="itworx-forge", + source="manual", + ) + assert allowed["decision"] == "allowed" + assert allowed["scope_decision"] == "allowed" + assert allowed["policy_id"] == "policy-forge-secret-prepare" + finally: + engine.dispose() + + +def _profile(): + from backend.app.schemas.forge_bootstrap import ForgeBootstrapPreflightV1 + + return ForgeBootstrapPreflightV1( + api_version="itworx.forge-bootstrap-preflight/v1", + revision="a" * 40, + workspace_image_digest="sha256:" + "b" * 64, + frontdoor_image_digest="sha256:" + "c" * 64, + ) + + +@pytest.fixture +def forge_secret_operator_client(tmp_path, monkeypatch: pytest.MonkeyPatch): + from backend.app.core.config import settings + from backend.app.db import session as db_session + from backend.app.db.init_db import init_db + from backend.app.main import app + + if os.name == "nt": + from backend.app.services import forge_oidc_provisioning + + monkeypatch.setattr( + forge_oidc_provisioning, + "_windows_private_path", + lambda *_args, **_kwargs: None, + ) + else: + from backend.app.services import forge_oidc_provisioning + + monkeypatch.setattr( + forge_oidc_provisioning, + "_FORGE_OIDC_LOCK_ROOT", + tmp_path / "forge-secret-api-lock", + ) + + engine = create_engine( + f"sqlite:///{tmp_path / 'forge-secret-operator.db'}", + connect_args={"check_same_thread": False}, + ) + monkeypatch.setattr(db_session, "engine", engine) + monkeypatch.setenv("APPOPS_REQUIRE_OPERATOR_AUTH", "false") + monkeypatch.setattr(settings, "operator_proxy_auth_enabled", False) + monkeypatch.setattr(settings, "auth_admin_subjects", ["authentik:owner"], raising=False) + monkeypatch.setattr(settings, "auth_admin_emails", [], raising=False) + monkeypatch.setattr(settings, "data_dir", str(tmp_path)) + monkeypatch.setattr(user_access, "current_user", _REAL_CURRENT_USER) + SQLModel.metadata.create_all(engine) + init_db() + client = TestClient(app, client=("198.18.10.20", 42000)) + yield client + engine.dispose() + + +def _sign_in_operator(client: TestClient, subject: str) -> None: + from backend.app.db import session as db_session + from backend.app.services.user_access import issue_session, provision_user + + with Session(db_session.engine) as session: + user = provision_user( + session, + "authentik", + "https://auth.example/application/o/appops/", + {"sub": subject, "email": f"{subject}@example.com"}, + ) + cookie = issue_session(session, user) + client.cookies.set("appops_user_session", cookie) + + +def _spy_actual_setup_loader(monkeypatch: pytest.MonkeyPatch): + from backend.app.services import setup_service + + original = setup_service.apply_stored_connector_settings + calls = [] + + def tracked(session, connector_ids=None): + calls.append(connector_ids) + return original(session, connector_ids) + + monkeypatch.setattr(setup_service, "apply_stored_connector_settings", tracked) + return calls + + +def test_secret_delivery_broker_sends_one_bounded_hmac_signed_fixed_request( + monkeypatch, +): + from backend.app.services import deployment_broker + from backend.app.services.deployment_broker import DeploymentBrokerClient + + config_bytes = b'{"schema":"itworx.forge-first-install-config/v4"}' + oidc_secret = b"o" * 40 + reader_token = b"r" * 48 + broker_secret = "b" * 48 + response_body = b'{"status":"bundle_ready"}' + observed = {} + + class Response: + status_code = 200 + headers = {"content-length": str(len(response_body))} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def iter_bytes(self, *, chunk_size): + assert chunk_size == 16 * 1024 + yield response_body + + class Client: + def __init__(self, *, timeout, verify, follow_redirects): + observed["client"] = (timeout, verify, follow_redirects) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def stream(self, method, url, *, content, headers): + observed.update(method=method, url=url, content=content, headers=headers) + return Response() + + monkeypatch.setattr(deployment_broker.httpx, "Client", Client) + client = DeploymentBrokerClient( + base_url="https://controller.example", + verify_tls=True, + ) + result = client.forge_secret_delivery( + _profile(), + config_bytes=config_bytes, + oidc_client_secret=oidc_secret, + authentik_read_token=reader_token, + secret=broker_secret, + ) + + path = "/v1/appops/forge/bootstrap/secrets" + expected_payload = { + "apiVersion": "itworx.forge-secret-delivery/v1", + "target": "itworx-forge", + "profile": _profile().model_dump(mode="json"), + "configBase64": base64.b64encode(config_bytes).decode("ascii"), + "oidcClientSecretBase64": base64.b64encode(oidc_secret).decode("ascii"), + "authentikReadTokenBase64": base64.b64encode(reader_token).decode("ascii"), + } + expected_body = json.dumps(expected_payload, sort_keys=True, separators=(",", ":")).encode() + headers = observed["headers"] + assert observed["client"] == (client.timeout_seconds, True, False) + assert observed["method"] == "POST" + assert observed["url"] == f"https://controller.example{path}" + assert observed["content"] == expected_body + assert len(expected_body) <= 16_384 + assert headers["Content-Type"] == "application/json" + assert headers["Accept-Encoding"] == "identity" + assert re.fullmatch(r"[0-9]{10}", headers["X-ITWorx-Deployment-Timestamp"]) + assert re.fullmatch(r"[0-9a-f]{32}", headers["X-ITWorx-Deployment-Nonce"]) + assert headers["X-ITWorx-Deployment-Signature"] == DeploymentBrokerClient._signature( + broker_secret, + "POST", + path, + headers["X-ITWorx-Deployment-Timestamp"], + headers["X-ITWorx-Deployment-Nonce"], + expected_body, + ) + assert result == {"status": "response", "response": {"status": "bundle_ready"}} + for secret in (broker_secret.encode(), oidc_secret, reader_token): + assert secret not in json.dumps(result).encode() + + +@pytest.mark.parametrize( + "failure", + ["http_error_with_secret", "duplicate_json", "oversized", "pathological_content_length"], +) +def test_secret_delivery_broker_never_echoes_untrusted_controller_responses( + monkeypatch, failure +): + from backend.app.services import deployment_broker + from backend.app.services.deployment_broker import DeploymentBrokerClient + + secret = b"response-secret-must-never-escape" + if failure == "http_error_with_secret": + body = b'{"message":"response-secret-must-never-escape"}' + status = 502 + elif failure == "duplicate_json": + body = b'{"status":"bundle_ready","status":"bad"}' + status = 200 + elif failure == "pathological_content_length": + body = b'{"status":"bundle_ready"}' + status = 200 + else: + body = b"x" * (64 * 1024 + 1) + status = 200 + + class Response: + status_code = status + headers = ( + {"content-length": "9" * 5000} + if failure == "pathological_content_length" + else {} + ) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def iter_bytes(self, *, chunk_size): + for offset in range(0, len(body), chunk_size): + yield body[offset : offset + chunk_size] + + class Client: + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def stream(self, *_args, **_kwargs): + return Response() + + monkeypatch.setattr(deployment_broker.httpx, "Client", Client) + result = DeploymentBrokerClient( + base_url="https://controller.example", verify_tls=True + ).forge_secret_delivery( + _profile(), + config_bytes=b"{}", + oidc_client_secret=b"o" * 40, + authentik_read_token=b"r" * 48, + secret="b" * 48, + ) + + assert result["status"] == "outcome_unknown" + assert secret.decode() not in json.dumps(result) + + +def _delivery_setup(tmp_path, monkeypatch, *, manual="allowed"): + + from sqlmodel import SQLModel + + from backend.app.core.config import settings + from backend.app.db.models import AppSetting, Policy + from backend.app.schemas.enrollment import ( + MANAGED_DOCKER_APPDATA_ROOT, + EnrollmentTrustConfigV1, + ) + from backend.app.services.enrollment_plane import TRUST_SETTING_KEY + from backend.app.services.secret_store import set_secret + + engine = create_engine(f"sqlite:///{tmp_path / 'forge-secret-delivery.db'}") + SQLModel.metadata.create_all(engine) + session = Session(engine) + monkeypatch.setattr(settings, "environment", "production") + monkeypatch.setattr(settings, "secret_key", "delivery-test-secret-key-material-0123456789") + monkeypatch.setattr(settings, "secret_key_source", "env") + for key, value in { + "AUTHENTIK_OIDC_AUTHORIZATION_FLOW": "forge-authz-flow", + "AUTHENTIK_OIDC_INVALIDATION_FLOW": "forge-logout-flow", + "AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID": "11234567-89ab-4cde-8fab-0123456789ab", + "FORGE_PRIVATE_PACKAGE_OWNER": "itworx-private", + "AUTHENTIK_URL": "https://auth.example-host.com", + "AUTHENTIK_TOKEN": "a" * 48, + "AUTHENTIK_VERIFY_TLS": True, + "AUTHENTIK_TIMEOUT_SECONDS": "10", + }.items(): + monkeypatch.setitem(settings.apis, key, value) + trust = EnrollmentTrustConfigV1( + controller_identity="unraid-controller", + bootstrap_secret_ref="ENROLLMENT_BOOTSTRAP", + trusted_appdata_root=MANAGED_DOCKER_APPDATA_ROOT, + managed_appdata_backup_acknowledged=True, + approved_networks=("itworx-mcp-hub-npm",), + ) + session.add( + AppSetting( + key=TRUST_SETTING_KEY, + is_secret=False, + value_json=trust.model_dump(mode="json"), + ) + ) + session.add( + Policy( + id="policy-forge-secret-prepare", + action="forge.secrets.prepare", + target="itworx-forge", + manual=manual, + voice="blocked", + risk="critical", + reason="unit test policy", + ) + ) + session.commit() + for name, value in { + "ENROLLMENT_BOOTSTRAP": "e" * 48, + "FORGE_DEPLOYMENT_BROKER": "b" * 48, + "AUTHENTIK_TOKEN": "a" * 48, + "ITWORX_FORGE_OIDC_CLIENT_SECRET": "o" * 40, + "AUTHENTIK_FORGE_OIDC_BINDING": '{"version":1}', + "AUTHENTIK_FORGE_READER_BINDING": '{"version":1}', + "ITWORX_FORGE_AUTHENTIK_READ_TOKEN": "r" * 48, + }.items(): + set_secret(session, name, value) + return engine, session + + +def _successful_controller_response(profile): + return { + "apiVersion": "itworx.forge-secret-delivery/v1", + "target": "itworx-forge", + "status": "bundle_ready", + "profile": profile.model_dump(mode="json"), + "controllerArtifact": { + "controllerVersion": "2.4.1", + "sourceRepository": "jens/unraid-appops-gateway", + "sourceRevision": "d" * 40, + "sourceTree": "e" * 40, + "artifactSha256": "f" * 64, + }, + "secretFilesReady": 7, + "masterKeyBackupVerified": True, + "applyPreflight": "not_evaluated", + } + + +def _wire_delivery_for_unit_test(monkeypatch, *, broker_response=None): + from contextlib import contextmanager + from types import SimpleNamespace + + from backend.app.services import deployment_broker, forge_bootstrap_apply + from backend.app.services import forge_controller_reader as reader + from backend.app.services import forge_secret_delivery as delivery + + @contextmanager + def unlocked(): + yield + + verified = reader._VerifiedForgeDeliveryInputs( + context={ + "applicationId": "01234567-89ab-4cde-8fab-0123456789ab", + "providerId": "7", + "scopeMappingIds": { + "openid": "01234567-89ab-4cde-8fab-0123456789ab", + "email": "21234567-89ab-4cde-8fab-0123456789ab", + "profile": "31234567-89ab-4cde-8fab-0123456789ab", + }, + "groupIds": { + "forge-viewers": "41234567-89ab-4cde-8fab-0123456789ab", + "forge-operators": "51234567-89ab-4cde-8fab-0123456789ab", + "forge-owners": "61234567-89ab-4cde-8fab-0123456789ab", + }, + "signingKeyId": "11234567-89ab-4cde-8fab-0123456789ab", + }, + oidc_client_secret=b"o" * 40, + authentik_read_token=b"r" * 48, + ) + monkeypatch.setattr(delivery, "_forge_oidc_process_lock", unlocked) + monkeypatch.setattr(delivery, "secret_storage_status", lambda: {"productionReady": True}) + monkeypatch.setattr(forge_bootstrap_apply, "_controller_secret", lambda _session: "b" * 48) + monkeypatch.setattr(delivery, "_fixed_authentik_connector", lambda _session: SimpleNamespace()) + monkeypatch.setattr(reader, "_verify_forge_delivery_inputs", lambda *_args: verified) + monkeypatch.setattr(delivery, "AuthentikConnector", lambda: SimpleNamespace()) + calls = [] + + def deliver(_self, profile, **kwargs): + calls.append((profile, kwargs)) + return {"status": "response", "response": broker_response or _successful_controller_response(profile)} + + monkeypatch.setattr(deployment_broker.DeploymentBrokerClient, "forge_secret_delivery", deliver) + return delivery, verified, calls + + +def test_delivery_policy_must_be_one_exact_matching_manual_policy(tmp_path, monkeypatch): + from backend.app.db.models import Policy + from backend.app.services import forge_secret_delivery as delivery + + engine, session = _delivery_setup(tmp_path, monkeypatch) + try: + assert delivery._exact_secret_prepare_policy_allowed(session) == "policy-forge-secret-prepare" + session.add( + Policy( + id="policy-forge-secret-wildcard", + action="forge.secrets.prepare", + target="*", + manual="allowed", + voice="blocked", + risk="critical", + ) + ) + session.commit() + assert delivery._exact_secret_prepare_policy_allowed(session) is None + finally: + session.close() + engine.dispose() + + +def test_delivery_policy_requires_allowed_scope_and_same_exact_policy_id( + tmp_path, monkeypatch +): + from backend.app.services import forge_secret_delivery as delivery + + engine, session = _delivery_setup(tmp_path, monkeypatch) + try: + monkeypatch.setattr( + delivery, + "evaluate_policy", + lambda *_args, **_kwargs: { + "decision": "allowed", + "scope_decision": None, + "policy_id": "policy-forge-secret-prepare", + }, + ) + assert delivery._exact_secret_prepare_policy_allowed(session) is None + monkeypatch.setattr( + delivery, + "evaluate_policy", + lambda *_args, **_kwargs: { + "decision": "allowed", + "scope_decision": "allowed", + "policy_id": "some-other-policy", + }, + ) + assert delivery._exact_secret_prepare_policy_allowed(session) is None + finally: + session.close() + engine.dispose() + + +def test_delivery_policy_duplicate_exact_row_fails_closed(tmp_path, monkeypatch): + from backend.app.db.models import Policy + from backend.app.services import forge_secret_delivery as delivery + + engine, session = _delivery_setup(tmp_path, monkeypatch) + try: + session.add( + Policy( + id="policy-forge-secret-prepare-duplicate", + action="forge.secrets.prepare", + target="itworx-forge", + manual="allowed", + voice="blocked", + risk="critical", + ) + ) + session.commit() + assert delivery._exact_secret_prepare_policy_allowed(session) is None + finally: + session.close() + engine.dispose() + + +def test_delivery_policy_rejection_precedes_secret_and_connector_access(tmp_path, monkeypatch): + from backend.app.services import forge_secret_delivery as delivery + + engine, session = _delivery_setup(tmp_path, monkeypatch, manual="blocked") + secret_reads = [] + connector_calls = [] + monkeypatch.setattr(delivery, "has_secret", lambda *_args: secret_reads.append("has") or True) + monkeypatch.setattr(delivery, "AuthentikConnector", lambda: connector_calls.append("connector")) + try: + result = delivery.prepare_forge_secret_delivery( + session, + profile=_profile(), + idempotency_key="prepare-0001", + actor_identity="operator@example.test", + ) + assert result["status"] == "blocked" + assert result["message"] == "forge_secret_exact_target_policy_required" + assert secret_reads == [] + assert connector_calls == [] + finally: + session.close() + engine.dispose() + + +def test_delivery_uses_only_protected_config_and_returns_safe_verified_projection( + tmp_path, monkeypatch +): + from backend.app.db.models import AuditEvent + from backend.app.services.secret_store import get_secret + + engine, session = _delivery_setup(tmp_path, monkeypatch) + delivery, verified, calls = _wire_delivery_for_unit_test(monkeypatch) + try: + result = delivery.prepare_forge_secret_delivery( + session, + profile=_profile(), + idempotency_key="prepare-0001", + actor_identity="operator@example.test", + ) + assert result["success"] is True + assert result["status"] == "bundle_ready" + assert result["applyPreflight"] == "not_evaluated" + assert result["secretFilesReady"] == 7 + assert result["masterKeyBackupVerified"] is True + assert len(calls) == 1 + profile, delivered = calls[0] + assert profile.model_dump(mode="json") == _profile().model_dump(mode="json") + assert delivered["oidc_client_secret"] == b"o" * 40 + assert delivered["authentik_read_token"] == b"r" * 48 + assert delivered["secret"] == "b" * 48 + assert delivered["secret"] != "e" * 48 + config_bytes = delivered["config_bytes"] + config = json.loads(config_bytes) + assert list(config) == sorted( + [ + "schema", "issuer", "audience", "client_id", "authentik_api_url", + "launch_url", "authorization_flow", "invalidation_flow", + "authentik_openid_mapping_id", "authentik_email_mapping_id", + "authentik_profile_mapping_id", "authentik_provider_id", + "authentik_signing_key_id", "private_package_owner", + ] + ) + assert config == { + "schema": "itworx.forge-first-install-config/v4", + "issuer": "https://auth.example-host.com/application/o/itworx-forge/", + "audience": "itworx-forge", + "client_id": "itworx-forge", + "authentik_api_url": "https://auth.example-host.com", + "launch_url": "https://forge.example.com", + "authorization_flow": "forge-authz-flow", + "invalidation_flow": "forge-logout-flow", + "authentik_openid_mapping_id": verified.context["scopeMappingIds"]["openid"], + "authentik_email_mapping_id": verified.context["scopeMappingIds"]["email"], + "authentik_profile_mapping_id": verified.context["scopeMappingIds"]["profile"], + "authentik_provider_id": "7", + "authentik_signing_key_id": verified.context["signingKeyId"], + "private_package_owner": "itworx-private", + } + assert config_bytes == json.dumps(config, sort_keys=True, separators=(",", ":")).encode() + assert b"o" * 40 not in config_bytes + assert b"r" * 48 not in config_bytes + response_text = json.dumps(result) + for held in ("b" * 48, "o" * 40, "r" * 48): + assert held not in response_text + audit_rows = session.exec(select(AuditEvent)).all() + assert len(audit_rows) == 1 + assert audit_rows[0].action_id == "forge.secrets.prepare" + audit_text = json.dumps(audit_rows[0].redacted_metadata or {}) + for held in ("b" * 48, "o" * 40, "r" * 48): + assert held not in audit_text + assert get_secret(session, "ITWORX_FORGE_AUTHENTIK_READ_TOKEN", touch=False) == "r" * 48 + finally: + session.close() + engine.dispose() + + +def test_invalid_controller_response_and_post_delivery_audit_failure_are_unknown( + tmp_path, monkeypatch +): + from backend.app.services import deployment_broker + + engine, session = _delivery_setup(tmp_path, monkeypatch) + raw_secret = "r" * 48 + malformed = _successful_controller_response(_profile()) + malformed["profile"]["revision"] = "c" * 40 + delivery, _verified, calls = _wire_delivery_for_unit_test( + monkeypatch, broker_response=malformed + ) + try: + result = delivery.prepare_forge_secret_delivery( + session, + profile=_profile(), + idempotency_key="prepare-0001", + actor_identity="operator@example.test", + ) + assert result["status"] == "outcome_unknown" + assert raw_secret not in json.dumps(result) + assert len(calls) == 1 + + monkeypatch.setattr( + delivery, + "log_event", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError(raw_secret)), + ) + monkeypatch.setattr( + deployment_broker.DeploymentBrokerClient, + "forge_secret_delivery", + lambda *_args, **_kwargs: {"status": "response", "response": _successful_controller_response(_profile())}, + ) + audit_failure = delivery.prepare_forge_secret_delivery( + session, + profile=_profile(), + idempotency_key="prepare-0002", + actor_identity="operator@example.test", + ) + assert audit_failure["status"] == "outcome_unknown" + assert raw_secret not in json.dumps(audit_failure) + finally: + session.close() + engine.dispose() + + +def test_missing_dedicated_broker_secret_blocks_before_connector_and_delivery( + tmp_path, monkeypatch +): + from backend.app.services import forge_bootstrap_apply + + engine, session = _delivery_setup(tmp_path, monkeypatch) + delivery, _verified, calls = _wire_delivery_for_unit_test(monkeypatch) + monkeypatch.setattr(forge_bootstrap_apply, "_controller_secret", lambda _session: None) + monkeypatch.setattr(delivery, "AuthentikConnector", lambda: pytest.fail("connector must not load")) + try: + result = delivery.prepare_forge_secret_delivery( + session, + profile=_profile(), + idempotency_key="prepare-0001", + actor_identity="operator@example.test", + ) + assert result["status"] == "blocked" + assert result["message"] == "forge_secret_controller_trust_required" + assert calls == [] + finally: + session.close() + engine.dispose() + + +@pytest.mark.parametrize( + "failure", + ["production_flag_integer", "managed_root_drift", "backup_ack_integer", "missing_reader_token"], +) +def test_delivery_prerequisite_drift_blocks_before_verification_or_broker( + tmp_path, monkeypatch, failure +): + from sqlalchemy import update + + from backend.app.db.models import AppSetting + from backend.app.services.enrollment_plane import TRUST_SETTING_KEY + from backend.app.services.secret_store import delete_secret + + engine, session = _delivery_setup(tmp_path, monkeypatch) + delivery, _verified, calls = _wire_delivery_for_unit_test(monkeypatch) + if failure == "production_flag_integer": + monkeypatch.setattr(delivery, "secret_storage_status", lambda: {"productionReady": 1}) + elif failure in {"managed_root_drift", "backup_ack_integer"}: + trust = session.get(AppSetting, TRUST_SETTING_KEY) + if failure == "managed_root_drift": + trust.value_json = { + **trust.value_json, + "trusted_appdata_root": "/mnt/cache/appdata", + } + session.add(trust) + session.commit() + else: + # AppSetting's typed assignment coerces nested integer 1 to bool. + # Write malformed persisted JSON directly to exercise strict decode. + session.execute( + update(AppSetting) + .where(AppSetting.key == TRUST_SETTING_KEY) + .values( + value_json={ + **trust.value_json, + "managed_appdata_backup_acknowledged": 1, + } + ) + ) + session.commit() + session.expire_all() + else: + delete_secret(session, "ITWORX_FORGE_AUTHENTIK_READ_TOKEN") + try: + result = delivery.prepare_forge_secret_delivery( + session, + profile=_profile(), + idempotency_key="prepare-prereq", + actor_identity="operator@example.test", + ) + assert result["status"] == "blocked" + assert calls == [] + assert result["message"] in { + "forge_secret_store_not_production_ready", + "forge_secret_controller_trust_required", + "forge_secret_verified_reader_required", + } + finally: + session.close() + engine.dispose() + + +def test_fixed_authentik_runtime_requires_matching_encrypted_admin_secret( + tmp_path, monkeypatch +): + from backend.app.core.config import settings + from backend.app.services import forge_secret_delivery as delivery + from backend.app.services.secret_store import delete_secret, get_secret + + engine, session = _delivery_setup(tmp_path, monkeypatch) + try: + assert delivery._fixed_authentik_connector(session) is not None + assert get_secret(session, "AUTHENTIK_TOKEN", touch=False) == settings.apis["AUTHENTIK_TOKEN"] + + delete_secret(session, "AUTHENTIK_TOKEN") + assert settings.apis["AUTHENTIK_TOKEN"] == "a" * 48 + assert delivery._fixed_authentik_connector(session) is None + finally: + session.close() + engine.dispose() + + +def test_delivery_refuses_legacy_plaintext_authentik_secret_without_migrating( + tmp_path, monkeypatch +): + from backend.app.db.models import AppSetting + from backend.app.services import forge_secret_delivery as delivery + from backend.app.services.secret_store import get_secret + + engine, session = _delivery_setup(tmp_path, monkeypatch) + real_connector_factory = delivery._fixed_authentik_connector + delivery, _verified, calls = _wire_delivery_for_unit_test(monkeypatch) + monkeypatch.setattr(delivery, "_fixed_authentik_connector", real_connector_factory) + legacy = AppSetting( + key="connector:authentik:secret", + is_secret=True, + value_json={"value": "legacy-admin-token-must-not-be-migrated"}, + ) + session.add(legacy) + session.commit() + original = dict(legacy.value_json) + try: + result = delivery.prepare_forge_secret_delivery( + session, + profile=_profile(), + idempotency_key="prepare-legacy", + actor_identity="operator@example.test", + ) + assert result["status"] == "blocked" + assert result["message"] == "forge_secret_authentik_configuration_required" + assert not calls + assert session.get(AppSetting, "connector:authentik:secret").value_json == original + assert get_secret(session, "AUTHENTIK_TOKEN", touch=False) == "a" * 48 + assert "legacy-admin-token-must-not-be-migrated" not in json.dumps(result) + finally: + session.close() + engine.dispose() + + +def test_lock_exit_failure_after_controller_delivery_is_outcome_unknown(tmp_path, monkeypatch): + from contextlib import contextmanager + + engine, session = _delivery_setup(tmp_path, monkeypatch) + delivery, _verified, calls = _wire_delivery_for_unit_test(monkeypatch) + + @contextmanager + def lock_fails_on_exit(): + yield + raise RuntimeError("post-delivery cleanup failure") + + monkeypatch.setattr(delivery, "_forge_oidc_process_lock", lock_fails_on_exit) + try: + result = delivery.prepare_forge_secret_delivery( + session, + profile=_profile(), + idempotency_key="prepare-0001", + actor_identity="operator@example.test", + ) + assert len(calls) == 1 + assert result["status"] == "outcome_unknown" + assert result["message"] == "forge_secret_controller_outcome_unknown" + assert "post-delivery cleanup failure" not in json.dumps(result) + finally: + session.close() + engine.dispose() + + +def test_unrecognized_reader_failure_code_never_reaches_result_or_audit(tmp_path, monkeypatch): + from backend.app.services import forge_controller_reader as reader + + engine, session = _delivery_setup(tmp_path, monkeypatch) + delivery, _verified, calls = _wire_delivery_for_unit_test(monkeypatch) + marker = "reader-error-contains-held-secret" * 2 + + def invalid_verifier(*_args): + raise reader._ReaderFailure("conflict", marker) + + monkeypatch.setattr(reader, "_verify_forge_delivery_inputs", invalid_verifier) + try: + result = delivery.prepare_forge_secret_delivery( + session, + profile=_profile(), + idempotency_key="prepare-0001", + actor_identity="operator@example.test", + ) + assert result["status"] == "conflict" + assert result["message"] == "forge_secret_reader_proof_invalid" + assert marker not in json.dumps(result) + assert calls == [] + finally: + session.close() + engine.dispose() + + +def test_forge_secret_delivery_api_requires_authenticated_operator_and_csrf( + forge_secret_operator_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + loader_calls = _spy_actual_setup_loader(monkeypatch) + unauthenticated = forge_secret_operator_client.post( + _DELIVERY_PATH, + json={"profile": _profile().model_dump(mode="json")}, + ) + assert unauthenticated.status_code == 401 + + _sign_in_operator(forge_secret_operator_client, "owner") + missing_csrf = forge_secret_operator_client.post( + _DELIVERY_PATH, + json={"profile": _profile().model_dump(mode="json")}, + headers={"Idempotency-Key": "prepare-api-01"}, + ) + assert missing_csrf.status_code == 403 + assert loader_calls == [] + + +def test_forge_secret_delivery_api_rejects_read_only_and_extra_profile_fields( + forge_secret_operator_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + loader_calls = _spy_actual_setup_loader(monkeypatch) + profile = _profile().model_dump(mode="json") + _sign_in_operator(forge_secret_operator_client, "reader") + denied = forge_secret_operator_client.post( + _DELIVERY_PATH, + json={"profile": profile}, + headers={ + "X-AppOps-Session-CSRF": "1", + "Idempotency-Key": "prepare-api-02", + }, + ) + assert denied.status_code == 403 + + _sign_in_operator(forge_secret_operator_client, "owner") + profile["caller_config"] = "must-not-be-forwarded" + invalid = forge_secret_operator_client.post( + _DELIVERY_PATH, + json={"profile": profile}, + headers={ + "X-AppOps-Session-CSRF": "1", + "Idempotency-Key": "prepare-api-03", + }, + ) + assert invalid.status_code == 422 + assert loader_calls == [] + + +def test_forge_secret_delivery_api_delegates_only_profile_key_and_actor( + forge_secret_operator_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.services import forge_secret_delivery + + _sign_in_operator(forge_secret_operator_client, "owner") + calls = [] + safe_result = { + "success": True, + "status": "bundle_ready", + "message": "forge_secret_bundle_ready", + "target": "itworx-forge", + "revision": "a" * 40, + "controllerArtifact": { + "controllerVersion": "2.4.1", + "sourceRepository": "jens/unraid-appops-gateway", + "sourceRevision": "d" * 40, + "sourceTree": "e" * 40, + "artifactSha256": "f" * 64, + }, + "secretFilesReady": 7, + "masterKeyBackupVerified": True, + "applyPreflight": "not_evaluated", + } + + def prepare(session, *, profile, idempotency_key, actor_identity): + calls.append((session, profile, idempotency_key, actor_identity)) + return safe_result + + monkeypatch.setattr(forge_secret_delivery, "prepare_forge_secret_delivery", prepare) + response = forge_secret_operator_client.post( + _DELIVERY_PATH, + json={"profile": _profile().model_dump(mode="json")}, + headers={ + "X-AppOps-Session-CSRF": "1", + "Idempotency-Key": "prepare-api-04", + }, + ) + + assert response.status_code == 200 + assert response.json() == safe_result + assert len(calls) == 1 + assert calls[0][1].model_dump(mode="json") == _profile().model_dump(mode="json") + assert calls[0][2:] == ("prepare-api-04", "authentik:owner") + + +def test_forge_secret_delivery_api_default_deny_precedes_loader_secret_and_broker( + forge_secret_operator_client: TestClient, + monkeypatch: pytest.MonkeyPatch, +): + from backend.app.services import forge_secret_delivery as delivery + from backend.app.services import setup_service + + _sign_in_operator(forge_secret_operator_client, "owner") + original_loader = setup_service.apply_stored_connector_settings + loader_calls = [] + + def tracked_loader(session, connector_ids=None): + loader_calls.append(connector_ids) + return original_loader(session, connector_ids) + + monkeypatch.setattr(setup_service, "apply_stored_connector_settings", tracked_loader) + monkeypatch.setattr( + delivery, + "_fixed_authentik_connector", + lambda *_args: pytest.fail("default-deny must precede connector loading"), + ) + monkeypatch.setattr( + delivery, + "has_secret", + lambda *_args: pytest.fail("default-deny must precede protected secret lookups"), + ) + + response = forge_secret_operator_client.post( + _DELIVERY_PATH, + json={"profile": _profile().model_dump(mode="json")}, + headers={ + "X-AppOps-Session-CSRF": "1", + "Idempotency-Key": "prepare-default-deny", + }, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "blocked" + assert response.json()["message"] == "forge_secret_exact_target_policy_required" + assert loader_calls == [] diff --git a/backend/tests/test_forge_secret_delivery_controller.py b/backend/tests/test_forge_secret_delivery_controller.py new file mode 100644 index 0000000..0990297 --- /dev/null +++ b/backend/tests/test_forge_secret_delivery_controller.py @@ -0,0 +1,863 @@ +from __future__ import annotations + +import base64 +import hashlib +import http.client +import importlib.util +import json +import os +import subprocess +import sys +import threading +import time +import types +import uuid +from http.server import ThreadingHTTPServer +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BROKER = ROOT / "ops" / "gitea-deploy-broker" +KEY = b"forge-delivery-test-hmac-key-32bytes" +OIDC_SECRET = b"oidc-client-secret-sentinel-1234567890" +READ_TOKEN = b"authentik-read-token-sentinel-1234567890" +ARTIFACT = { + "controllerVersion": "1.2.3", + "sourceRepository": "jens/unraid-appops-gateway", + "sourceRevision": "1" * 40, + "sourceTree": "2" * 40, + "artifactSha256": "3" * 64, + "builtAt": "2026-10-03T10:00:00Z", +} +PROFILE = { + "api_version": "itworx.forge-bootstrap-preflight/v1", + "revision": "a" * 40, + "workspace_image_digest": "sha256:" + "b" * 64, + "frontdoor_image_digest": "sha256:" + "c" * 64, +} + + +def _load_status_module(): + try: + import fcntl # noqa: F401 - use the real Linux flock implementation in tests. + except ImportError: # pragma: no cover - Windows-only import shim. + fcntl = types.ModuleType("fcntl") + fcntl.LOCK_EX = 2 + fcntl.LOCK_UN = 8 + fcntl.flock = lambda *_args: None + sys.modules["fcntl"] = fcntl + spec = importlib.util.spec_from_file_location( + "forge_secret_delivery_status_test", BROKER / "status_server.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def status_module(): + return _load_status_module() + + +@pytest.fixture +def endpoint(status_module): + server = ThreadingHTTPServer(("127.0.0.1", 0), status_module.Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_address + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def _config(**overrides: str) -> bytes: + value = { + "schema": "itworx.forge-first-install-config/v4", + "issuer": "https://auth.example-host.com/application/o/itworx-forge/", + "audience": "itworx-forge", + "client_id": "itworx-forge", + "authentik_api_url": "https://auth.example-host.com", + "launch_url": "https://forge.example.com", + "authorization_flow": "default-provider-authorization-implicit-consent", + "invalidation_flow": "default-provider-invalidation-flow", + "authentik_openid_mapping_id": "01234567-89ab-4cde-8fab-0123456789ab", + "authentik_email_mapping_id": "21234567-89ab-4cde-8fab-0123456789ab", + "authentik_profile_mapping_id": "31234567-89ab-4cde-8fab-0123456789ab", + "authentik_provider_id": "1234567890", + "authentik_signing_key_id": "11234567-89ab-4cde-8fab-0123456789ab", + "private_package_owner": "itworx-forge-private", + } + value.update(overrides) + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def _payload(status_module, *, config_bytes: bytes | None = None, **overrides): + config_bytes = config_bytes if config_bytes is not None else _config() + value = { + "apiVersion": status_module.FORGE_SECRET_DELIVERY_API_VERSION, + "target": status_module.FORGE_SECRET_DELIVERY_TARGET, + "profile": dict(PROFILE), + "configBase64": base64.b64encode(config_bytes).decode("ascii"), + "oidcClientSecretBase64": base64.b64encode(OIDC_SECRET).decode("ascii"), + "authentikReadTokenBase64": base64.b64encode(READ_TOKEN).decode("ascii"), + } + value.update(overrides) + return value + + +def _signed_headers(status_module, body: bytes, *, nonce: str | None = None): + timestamp = str(int(time.time())) + nonce = nonce or uuid.uuid4().hex + signature = status_module._request_signature( + KEY, + method="POST", + path=status_module.FORGE_SECRET_DELIVERY_PATH, + timestamp=timestamp, + nonce=nonce, + raw_body=body, + ) + return { + "Content-Type": "application/json", + "X-ITWorx-Deployment-Timestamp": timestamp, + "X-ITWorx-Deployment-Nonce": nonce, + "X-ITWorx-Deployment-Signature": signature, + } + + +def _post(address, path: str, body: bytes, headers: dict[str, str]): + connection = http.client.HTTPConnection(*address, timeout=3) + try: + connection.request("POST", path, body=body, headers=headers) + response = connection.getresponse() + return response.status, response.read() + finally: + connection.close() + + +def _install_hmac_test(monkeypatch, status_module): + consumed: set[str] = set() + monkeypatch.setattr(status_module, "_forge_first_install_secret", lambda: KEY) + + def consume(_secret, nonce): + if nonce in consumed: + return False + consumed.add(nonce) + return True + + monkeypatch.setattr(status_module, "_consume_appops_nonce", consume) + return consumed + + +def _install_successful_prerequisites(monkeypatch, status_module, helper=None): + counters = {"artifact": 0, "store": 0, "backup": 0, "runtime": 0} + + def artifact(): + counters["artifact"] += 1 + return dict(ARTIFACT) + + def store(): + counters["store"] += 1 + return ("/var/lib/docker", "itworx-controller-hub-releases", (1, 2), (1, 2)) + + def backup(): + counters["backup"] += 1 + return True + + def runtime(profile, *, _held_candidate): + counters["runtime"] += 1 + assert profile == PROFILE + assert isinstance(_held_candidate, dict) + return {"status": "bundle_prerequisites_passed"} + + monkeypatch.setattr(status_module, "_forge_first_install_requests_are_absent", lambda: True) + monkeypatch.setattr(status_module, "_forge_controller_artifact_identity", artifact) + monkeypatch.setattr(status_module, "_forge_release_store_identity", store) + monkeypatch.setattr(status_module, "_forge_fixed_backup_volume_is_ready", backup) + monkeypatch.setattr(status_module, "_forge_bootstrap_runtime_checks", runtime) + if helper is not None: + monkeypatch.setattr(status_module, "_prepare_forge_secret_bundle", helper) + return counters + + +def test_candidate_parser_requires_canonical_config_and_secret_free_strings(status_module): + parsed = status_module._forge_secret_delivery_candidate(_payload(status_module)) + assert parsed is not None + profile, candidate = parsed + assert profile == PROFILE + assert candidate["config_bytes"] == _config() + assert candidate["oidc_client_secret"] == OIDC_SECRET + assert candidate["authentik_read_token"] == READ_TOKEN + + pretty = json.dumps(json.loads(_config()), indent=2).encode() + assert status_module._forge_secret_delivery_candidate( + _payload(status_module, config_bytes=pretty) + ) is None + secret_bearing_config = _config(authorization_flow=OIDC_SECRET.decode()) + assert status_module._forge_secret_delivery_candidate( + _payload(status_module, config_bytes=secret_bearing_config) + ) is None + noncanonical = _payload(status_module) + noncanonical["oidcClientSecretBase64"] += "\n" + assert status_module._forge_secret_delivery_candidate(noncanonical) is None + + +def test_invalid_authentication_has_no_remote_or_bundle_side_effects( + status_module, endpoint, monkeypatch +): + counters = {"runtime": 0, "bundle": 0} + consumed = _install_hmac_test(monkeypatch, status_module) + monkeypatch.setattr( + status_module, + "_forge_controller_artifact_identity", + lambda: pytest.fail("unauthenticated input reached local gate"), + ) + monkeypatch.setattr( + status_module, + "_forge_release_store_identity", + lambda: pytest.fail("unauthenticated input reached volume probe"), + ) + monkeypatch.setattr( + status_module, + "_forge_fixed_backup_volume_is_ready", + lambda: pytest.fail("unauthenticated input reached backup probe"), + ) + monkeypatch.setattr( + status_module, + "_forge_bootstrap_runtime_checks", + lambda *_args, **_kwargs: counters.__setitem__("runtime", counters["runtime"] + 1), + ) + monkeypatch.setattr( + status_module, + "_prepare_forge_secret_bundle", + lambda *_args, **_kwargs: counters.__setitem__("bundle", counters["bundle"] + 1), + ) + body = json.dumps(_payload(status_module), separators=(",", ":")).encode() + headers = _signed_headers(status_module, body) + headers["X-ITWorx-Deployment-Signature"] = "0" * 64 + + status, response = _post( + endpoint, + status_module.FORGE_SECRET_DELIVERY_PATH, + body, + headers, + ) + assert status == 403 + assert response == b'{"status":"authentication_rejected"}\n' + assert counters == {"runtime": 0, "bundle": 0} + assert consumed == set() + + +def test_duplicate_json_key_is_rejected_before_auth_or_remote_checks( + status_module, endpoint, monkeypatch +): + consumed = _install_hmac_test(monkeypatch, status_module) + called = [] + monkeypatch.setattr( + status_module, + "_forge_prepare_secret_delivery_bundle", + lambda *_args, **_kwargs: called.append(True), + ) + body = b'{"apiVersion":"x","apiVersion":"y"}' + status, response = _post( + endpoint, + status_module.FORGE_SECRET_DELIVERY_PATH, + body, + { + "Content-Type": "application/json", + "X-ITWorx-Deployment-Timestamp": str(int(time.time())), + "X-ITWorx-Deployment-Nonce": uuid.uuid4().hex, + "X-ITWorx-Deployment-Signature": "0" * 64, + }, + ) + assert status == 400 + assert response == b'{"status":"invalid_json"}\n' + assert called == [] + assert consumed == set() + + +@pytest.mark.parametrize( + ("body", "content_type", "expected_status"), + [ + (b"{}", "application/json; charset=utf-8", 400), + (b"x" * 16_385, "application/json", 413), + ], +) +def test_body_limits_and_content_type_fail_before_auth( + status_module, endpoint, monkeypatch, body, content_type, expected_status +): + consumed = _install_hmac_test(monkeypatch, status_module) + monkeypatch.setattr( + status_module, + "_forge_controller_artifact_identity", + lambda: pytest.fail("invalid HTTP framing reached local gate"), + ) + headers = { + "Content-Type": content_type, + "X-ITWorx-Deployment-Timestamp": str(int(time.time())), + "X-ITWorx-Deployment-Nonce": uuid.uuid4().hex, + "X-ITWorx-Deployment-Signature": "0" * 64, + } + status, _response = _post( + endpoint, status_module.FORGE_SECRET_DELIVERY_PATH, body, headers + ) + assert status == expected_status + assert consumed == set() + + +@pytest.mark.parametrize("failed_gate", ["artifact", "store", "backup", "runtime"]) +def test_every_fixed_prerequisite_gate_precedes_bundle_writer( + status_module, endpoint, monkeypatch, failed_gate +): + _install_hmac_test(monkeypatch, status_module) + calls = {name: 0 for name in ("artifact", "store", "backup", "runtime", "helper")} + + def artifact(): + calls["artifact"] += 1 + return None if failed_gate == "artifact" else dict(ARTIFACT) + + def store(): + calls["store"] += 1 + return None if failed_gate == "store" else ("fixed", "volume", (1, 2), (1, 2)) + + def backup(): + calls["backup"] += 1 + return failed_gate != "backup" + + def runtime(profile, *, _held_candidate): + calls["runtime"] += 1 + return { + "status": "bundle_prerequisites_blocked" + if failed_gate == "runtime" + else "bundle_prerequisites_passed" + } + + def helper(*_args, **_kwargs): + calls["helper"] += 1 + return { + "status": "bundle_ready", + "secretFilesReady": 7, + "masterKeyBackupVerified": True, + "applyPreflight": "not_evaluated", + } + + monkeypatch.setattr(status_module, "_forge_first_install_requests_are_absent", lambda: True) + monkeypatch.setattr(status_module, "_forge_controller_artifact_identity", artifact) + monkeypatch.setattr(status_module, "_forge_release_store_identity", store) + monkeypatch.setattr(status_module, "_forge_fixed_backup_volume_is_ready", backup) + monkeypatch.setattr(status_module, "_forge_bootstrap_runtime_checks", runtime) + monkeypatch.setattr(status_module, "_prepare_forge_secret_bundle", helper) + body = json.dumps(_payload(status_module), separators=(",", ":")).encode() + + status, _response = _post( + endpoint, + status_module.FORGE_SECRET_DELIVERY_PATH, + body, + _signed_headers(status_module, body), + ) + assert status == 409 + assert calls["helper"] == 0 + if failed_gate in {"artifact", "store", "backup"}: + assert calls["runtime"] == 0 + else: + assert calls["runtime"] == 1 + + +def test_signed_route_returns_only_sanitized_bundle_readiness_and_replay_is_rejected( + status_module, endpoint, monkeypatch +): + consumed = _install_hmac_test(monkeypatch, status_module) + helper_calls = [] + + def helper(oidc_secret, read_token, config_bytes, *, validate_config): + helper_calls.append((oidc_secret, read_token, config_bytes)) + assert oidc_secret == OIDC_SECRET + assert read_token == READ_TOKEN + assert validate_config(config_bytes) is True + return { + "status": "bundle_ready", + "secretFilesReady": 7, + "masterKeyBackupVerified": True, + "applyPreflight": "not_evaluated", + } + + counters = _install_successful_prerequisites(monkeypatch, status_module, helper) + monkeypatch.setattr( + status_module._forge_first_install_controller, + "remember_preflight", + lambda *_args: pytest.fail("bundle readiness must not remember a preflight"), + ) + monkeypatch.setattr( + status_module._forge_first_install_controller, + "apply", + lambda *_args: pytest.fail("bundle readiness must not start first install"), + ) + body = json.dumps(_payload(status_module), separators=(",", ":")).encode() + headers = _signed_headers(status_module, body) + nonce = headers["X-ITWorx-Deployment-Nonce"] + status, response = _post( + endpoint, status_module.FORGE_SECRET_DELIVERY_PATH, body, headers + ) + assert status == 200 + decoded = json.loads(response) + assert decoded == { + "apiVersion": status_module.FORGE_SECRET_DELIVERY_API_VERSION, + "target": "itworx-forge", + "status": "bundle_ready", + "profile": PROFILE, + "controllerArtifact": { + key: ARTIFACT[key] + for key in ( + "controllerVersion", + "sourceRepository", + "sourceRevision", + "sourceTree", + "artifactSha256", + ) + }, + "secretFilesReady": 7, + "masterKeyBackupVerified": True, + "applyPreflight": "not_evaluated", + } + assert "secretFilesReady" in decoded + assert OIDC_SECRET.decode() not in response.decode() + assert READ_TOKEN.decode() not in response.decode() + assert base64.b64encode(OIDC_SECRET).decode() not in response.decode() + assert base64.b64encode(READ_TOKEN).decode() not in response.decode() + assert len(consumed) == 1 and nonce in consumed + assert helper_calls == [(OIDC_SECRET, READ_TOKEN, _config())] + assert counters == {"artifact": 2, "store": 2, "backup": 2, "runtime": 1} + + status, replay_response = _post( + endpoint, status_module.FORGE_SECRET_DELIVERY_PATH, body, headers + ) + assert status == 403 + assert replay_response == b'{"status":"authentication_rejected"}\n' + assert len(helper_calls) == 1 + assert counters == {"artifact": 2, "store": 2, "backup": 2, "runtime": 1} + + +def test_new_nonce_retries_same_bundle_and_changed_input_is_rejected( + status_module, endpoint, monkeypatch +): + _install_hmac_test(monkeypatch, status_module) + from forge_secret_bundle import ForgeSecretBundleError + + accepted_inputs = None + helper_calls = [] + + def helper(oidc_secret, read_token, config_bytes, *, validate_config): + nonlocal accepted_inputs + inputs = (oidc_secret, read_token, config_bytes) + helper_calls.append(inputs) + assert validate_config(config_bytes) is True + if accepted_inputs is None: + accepted_inputs = inputs + elif inputs != accepted_inputs: + raise ForgeSecretBundleError("input_changed") + return { + "status": "bundle_ready", + "secretFilesReady": 7, + "masterKeyBackupVerified": True, + "applyPreflight": "not_evaluated", + } + + _install_successful_prerequisites(monkeypatch, status_module, helper) + + def send(payload): + body = json.dumps(payload, separators=(",", ":")).encode() + return _post( + endpoint, + status_module.FORGE_SECRET_DELIVERY_PATH, + body, + _signed_headers(status_module, body), + ) + + assert send(_payload(status_module))[0] == 200 + assert send(_payload(status_module))[0] == 200 + changed = _payload(status_module) + changed["oidcClientSecretBase64"] = base64.b64encode(b"x" * 40).decode("ascii") + status, response = send(changed) + assert status == 409 + assert json.loads(response) == { + "status": "secret_bundle_failed", + "code": "input_changed", + } + assert accepted_inputs == (OIDC_SECRET, READ_TOKEN, _config()) + assert helper_calls == [ + accepted_inputs, + accepted_inputs, + (b"x" * 40, READ_TOKEN, _config()), + ] + assert OIDC_SECRET.decode() not in response.decode() + assert READ_TOKEN.decode() not in response.decode() + + +@pytest.mark.skipif( + os.name != "posix" or getattr(os, "geteuid", lambda: 1)() != 0, + reason="the real bundle helper requires POSIX root ownership", +) +def test_signed_http_delivery_uses_real_bundle_helper_and_never_rotates( + status_module, endpoint, monkeypatch, tmp_path +): + _install_hmac_test(monkeypatch, status_module) + import importlib + + bundle_module = importlib.import_module("forge_secret_bundle") + assert Path(bundle_module.__file__).resolve() == ( + BROKER / "forge_secret_bundle.py" + ).resolve() + root = tmp_path / "real-bundle-root" + root.mkdir(mode=0o700) + root.chmod(0o700) + roots = bundle_module._Roots.for_test(root) + for path in (roots.config_root, roots.state_parent, roots.backup_root): + path.mkdir(mode=0o700, exist_ok=True) + path.chmod(0o700) + monkeypatch.setattr(bundle_module, "_PRODUCTION_ROOTS", roots) + + counters = _install_successful_prerequisites( + monkeypatch, status_module + ) + monkeypatch.setattr( + status_module._forge_first_install_controller, + "remember_preflight", + lambda *_args: pytest.fail("bundle readiness must not persist a preflight"), + ) + monkeypatch.setattr( + status_module._forge_first_install_controller, + "apply", + lambda *_args: pytest.fail("bundle readiness must not start first install"), + ) + + def send(payload): + body = json.dumps(payload, separators=(",", ":")).encode() + return _post( + endpoint, + status_module.FORGE_SECRET_DELIVERY_PATH, + body, + _signed_headers(status_module, body), + ) + + status, response = send(_payload(status_module)) + assert status == 200 + decoded = json.loads(response) + assert decoded["status"] == "bundle_ready" + assert decoded["secretFilesReady"] == 7 + assert decoded["masterKeyBackupVerified"] is True + assert decoded["applyPreflight"] == "not_evaluated" + assert OIDC_SECRET.decode() not in response.decode() + assert READ_TOKEN.decode() not in response.decode() + + secret_paths = sorted(roots.secrets_root.iterdir()) + assert {path.name for path in secret_paths} == { + "postgres_bootstrap_password.secret", + "runtime_database_url.secret", + "migrator_database_url.secret", + "oidc_client_secret.secret", + "session_secret.secret", + "master_key.secret", + "authentik_read_token.secret", + } + assert (roots.secrets_root / "oidc_client_secret.secret").read_bytes() == OIDC_SECRET + assert (roots.secrets_root / "authentik_read_token.secret").read_bytes() == READ_TOKEN + assert roots.config_path.read_bytes() == _config() + master_key = (roots.secrets_root / "master_key.secret").read_bytes() + assert len(master_key) == 32 + backup_path = roots.backup_root / "master_key.backup" + assert backup_path.read_bytes() == master_key + + protected_paths = [*secret_paths, roots.config_path, backup_path] + + def identities(): + return { + path: (path.stat().st_ino, path.read_bytes()) + for path in protected_paths + } + + original = identities() + status, retry_response = send(_payload(status_module)) + assert status == 200 + assert json.loads(retry_response)["status"] == "bundle_ready" + assert identities() == original + + changed = _payload(status_module) + changed["authentikReadTokenBase64"] = base64.b64encode( + b"different-authentik-read-token-value-12345" + ).decode("ascii") + status, changed_response = send(changed) + assert status == 409 + assert json.loads(changed_response) == { + "status": "secret_bundle_failed", + "code": "input_changed", + } + assert identities() == original + assert OIDC_SECRET.decode() not in changed_response.decode() + assert READ_TOKEN.decode() not in changed_response.decode() + assert counters == {"artifact": 6, "store": 6, "backup": 6, "runtime": 3} + + +@pytest.mark.parametrize( + "bad_result", + [ + { + "status": "bundle_ready", + "secretFilesReady": True, + "masterKeyBackupVerified": True, + "applyPreflight": "not_evaluated", + }, + { + "status": "bundle_ready", + "secretFilesReady": 7, + "masterKeyBackupVerified": 1, + "applyPreflight": "not_evaluated", + }, + { + "status": "bundle_ready", + "secretFilesReady": 7, + "masterKeyBackupVerified": True, + "applyPreflight": "not_evaluated", + "unexpected": "must be rejected", + }, + ], +) +def test_malformed_core_helper_results_are_not_accepted( + status_module, endpoint, monkeypatch, bad_result +): + _install_hmac_test(monkeypatch, status_module) + _install_successful_prerequisites( + monkeypatch, status_module, lambda *_args, **_kwargs: bad_result + ) + body = json.dumps(_payload(status_module), separators=(",", ":")).encode() + status, response = _post( + endpoint, + status_module.FORGE_SECRET_DELIVERY_PATH, + body, + _signed_headers(status_module, body), + ) + assert status == 409 + assert json.loads(response) == { + "status": "secret_bundle_failed", + "code": "bundle_result_unverified", + } + + +def test_unrecognized_core_error_code_is_replaced_with_fixed_code( + status_module, endpoint, monkeypatch +): + _install_hmac_test(monkeypatch, status_module) + from forge_secret_bundle import ForgeSecretBundleError + + private_error = "unapproved-" + OIDC_SECRET.decode() + + def helper(*_args, **_kwargs): + raise ForgeSecretBundleError(private_error) + + _install_successful_prerequisites(monkeypatch, status_module, helper) + body = json.dumps(_payload(status_module), separators=(",", ":")).encode() + status, response = _post( + endpoint, + status_module.FORGE_SECRET_DELIVERY_PATH, + body, + _signed_headers(status_module, body), + ) + assert status == 409 + assert json.loads(response) == { + "status": "secret_bundle_failed", + "code": "bundle_preparation_failed", + } + assert private_error.encode() not in response + assert OIDC_SECRET.decode().encode() not in response + + +def test_helper_error_text_is_never_returned(status_module, endpoint, monkeypatch): + _install_hmac_test(monkeypatch, status_module) + + def helper(*_args, **_kwargs): + raise RuntimeError("disk failed: " + OIDC_SECRET.decode()) + + _install_successful_prerequisites(monkeypatch, status_module, helper) + body = json.dumps(_payload(status_module), separators=(",", ":")).encode() + status, response = _post( + endpoint, + status_module.FORGE_SECRET_DELIVERY_PATH, + body, + _signed_headers(status_module, body), + ) + assert status == 409 + assert OIDC_SECRET.decode() not in response.decode() + assert READ_TOKEN.decode() not in response.decode() + assert b"disk failed" not in response + + +def test_held_candidate_runtime_skips_destination_secrets_and_preflight_receipt( + status_module, monkeypatch, tmp_path +): + parsed = status_module._forge_secret_delivery_candidate(_payload(status_module)) + assert parsed is not None + profile, candidate = parsed + app_root = tmp_path / "managed-appdata" + postgres = app_root / "itworx-forge" / "postgres" + postgres.mkdir(parents=True) + postgres.chmod(0o700) + os.chown(postgres, 70, 70) + app_root.chmod(0o700) + monkeypatch.setattr(status_module, "MANAGED_APPDATA_ROOT", app_root) + monkeypatch.setattr(status_module, "_refresh_appdata_root_selection", lambda: app_root) + monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda _root: True) + monkeypatch.setattr(status_module, "_forge_app_dir_metadata_is_safe", lambda _info: True) + monkeypatch.setattr(status_module, "_forge_gitea_main_revision", lambda: profile["revision"]) + manifest = b"fixed compose bytes used by this unit test" + monkeypatch.setattr( + status_module, + "FORGE_COMPOSE_SHA256", + hashlib.sha256(manifest).hexdigest(), + ) + calls = {"registry": 0, "oidc": 0, "secret_metadata": 0} + + def registry(_profile, *, candidate_config): + calls["registry"] += 1 + assert candidate_config is candidate["config"] + return "passed", {"ownerVisibility": "private"} + + def oidc(*, held_candidate): + calls["oidc"] += 1 + assert held_candidate is candidate + return "passed", {"clientSecretMatched": True} + + def secret_metadata(*_args, **_kwargs): + calls["secret_metadata"] += 1 + pytest.fail("held candidate must not read destination secret files") + + monkeypatch.setattr(status_module, "_forge_image_registry_attestation", registry) + monkeypatch.setattr(status_module, "_forge_oidc_attestation", oidc) + monkeypatch.setattr(status_module, "_forge_secret_metadata", secret_metadata) + monkeypatch.setattr(status_module, "_load_forge_bootstrap_config", lambda *_args: pytest.fail("held config fell back to disk")) + monkeypatch.setattr(status_module, "_forge_push_validation_attestation", lambda _revision: ("passed", {"context": "fixed"})) + monkeypatch.setattr(status_module, "_forge_local_image_present", lambda _image: None) + monkeypatch.setattr( + status_module._forge_first_install_controller, + "remember_preflight", + lambda *_args: pytest.fail("secret bundle readiness must not persist a preflight receipt"), + ) + + def fake_run(arguments, **_kwargs): + if arguments[:2] == ["git", "--git-dir"]: + return subprocess.CompletedProcess(arguments, 0, stdout=manifest, stderr=b"") + if arguments == ["docker", "network", "ls", "--format", "{{.Name}}"]: + return subprocess.CompletedProcess(arguments, 0, stdout="itworx-mcp-hub-npm\n", stderr="") + if arguments[:3] == ["docker", "network", "inspect"]: + return subprocess.CompletedProcess( + arguments, + 0, + stdout=json.dumps({"Name": "itworx-mcp-hub-npm", "Driver": "bridge", "Scope": "local"}), + stderr="", + ) + if arguments == ["docker", "ps", "-a", "--format", "{{.Names}}"]: + return subprocess.CompletedProcess(arguments, 0, stdout="", stderr="") + pytest.fail(f"unexpected runtime command: {arguments}") + + monkeypatch.setattr(status_module.subprocess, "run", fake_run) + result = status_module._forge_bootstrap_runtime_checks( + profile, _held_candidate=candidate + ) + assert result["status"] == "bundle_prerequisites_passed" + assert "secret_delivery" not in result["checks"] + assert calls == {"registry": 1, "oidc": 1, "secret_metadata": 0} + + +def test_release_store_proof_checks_fixed_volume_alias_and_pinned_identity( + status_module, monkeypatch +): + release_store = status_module._mcp_hub_release_store + document = { + "Name": release_store.VOLUME_NAME, + "Driver": "local", + "Scope": "local", + "Mountpoint": release_store.VOLUME_HOST_ROOT.as_posix(), + "Options": None, + "Labels": dict(release_store.EXPECTED_LABELS), + } + commands = [] + + def run_fixed(arguments, *, maximum_stdout, **_kwargs): + commands.append((arguments, maximum_stdout)) + if arguments[:2] == ["docker", "info"]: + return b"/var/lib/docker\n", b"" + if arguments[:3] == ["docker", "volume", "inspect"]: + return json.dumps(document).encode(), b"" + pytest.fail(f"unexpected command {arguments}") + + class _PinnedDirectory: + def __init__(self, identity): + self.value = identity + + def identity(self): + return self.value + + class _Store: + host = _PinnedDirectory((4, 5)) + logical = _PinnedDirectory((4, 5)) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def assert_current(self): + return None + + opened = [] + + def open_fixed(metadata, docker_root_dir): + opened.append((metadata, docker_root_dir)) + return _Store() + + monkeypatch.setattr(status_module, "_forge_bounded_readonly_command", run_fixed) + monkeypatch.setattr(status_module, "_repopulse_adoption_alias_mounts_ready", lambda: True) + monkeypatch.setattr( + status_module._repopulse_adoption_store, + "capability_report", + lambda: {"controllerAliasesReady": True}, + ) + monkeypatch.setattr(release_store, "open_fixed_release_store", open_fixed) + identity = status_module._forge_release_store_identity() + assert identity == ( + "/var/lib/docker", + release_store.VOLUME_NAME, + (4, 5), + (4, 5), + ) + assert opened == [(document, "/var/lib/docker")] + assert [item[0] for item in commands] == [ + ["docker", "info", "--format", "{{.DockerRootDir}}"], + [ + "docker", + "volume", + "inspect", + "--format", + "{{json .}}", + release_store.VOLUME_NAME, + ], + ] + + opened.clear() + document["Labels"] = {"itworx.purpose": "different-volume"} + assert status_module._forge_release_store_identity() is None + assert opened == [] + + +def test_backup_volume_verifier_requires_its_read_only_none_contract( + status_module, monkeypatch +): + backup = types.ModuleType("forge_backup_paths") + backup.validate_forge_backup_volume = lambda: None + monkeypatch.setitem(sys.modules, "forge_backup_paths", backup) + assert status_module._forge_fixed_backup_volume_is_ready() is True + backup.validate_forge_backup_volume = lambda: True + assert status_module._forge_fixed_backup_volume_is_ready() is False diff --git a/backend/tests/test_mcp_rag_n8n_integrations.py b/backend/tests/test_mcp_rag_n8n_integrations.py index e8b7e28..a278c4f 100644 --- a/backend/tests/test_mcp_rag_n8n_integrations.py +++ b/backend/tests/test_mcp_rag_n8n_integrations.py @@ -267,6 +267,36 @@ def test_forge_bootstrap_preflight_is_read_only_and_rejects_generic_apply(integr json={"operation": "apply", "plan": profile}, ) assert generic_apply.status_code == 422 + generic_rollback = client.post( + "/api/deployments", + headers={**human_headers(client), "Idempotency-Key": "forge-generic-rollback-0001"}, + json={"operation": "rollback", "plan": profile}, + ) + assert generic_rollback.status_code == 422 + + apply_profile = { + "api_version": "itworx.forge-first-install/v1", + "revision": profile["revision"], + "workspace_image_digest": profile["workspace_image_digest"], + "frontdoor_image_digest": profile["frontdoor_image_digest"], + "preflight_job_id": result["id"], + "preflight_sha256": result["preflightSha256"], + } + dedicated_apply = client.post( + "/api/deployments/forge-bootstrap/first-install", + headers={**human_headers(client), "Idempotency-Key": "forge-api-install-0001"}, + json=apply_profile, + ) + assert dedicated_apply.status_code == 409 + assert "passed Forge preflight" in dedicated_apply.json()["detail"] + assert client.get( + "/api/deployments/forge-bootstrap/first-install/forge-install-" + "0" * 64, + headers=human_headers(client), + ).status_code == 404 + assert client.get( + "/api/deployments/forge-install-" + "0" * 64, + headers=human_headers(client), + ).status_code == 404 async def exercise_mcp(): async with Client(mcp_server.appops_mcp) as mcp_client: @@ -280,6 +310,23 @@ def test_forge_bootstrap_preflight_is_read_only_and_rejects_generic_apply(integr {"plan": profile, "idempotencyKey": "forge-mcp-apply-0001"}, ) assert apply.structured_content["status"] == "invalid" + forge_apply = await mcp_client.call_tool( + "forge.bootstrap.apply", + {"profile": apply_profile, "idempotencyKey": "forge-mcp-install-0001"}, + ) + assert forge_apply.structured_content["status"] == "invalid" + forge_status = await mcp_client.call_tool( + "forge.bootstrap.status", {"jobId": "forge-install-" + "0" * 64} + ) + assert forge_status.structured_content["status"] == "not_found" + generic_forge_status = await mcp_client.call_tool( + "deployment.status", {"jobId": "forge-install-" + "0" * 64} + ) + assert generic_forge_status.structured_content["status"] == "invalid" + forge_resume = await mcp_client.call_tool( + "forge.bootstrap.resume", {"jobId": "forge-install-" + "0" * 64} + ) + assert forge_resume.structured_content["status"] == "not_found" asyncio.run(exercise_mcp()) diff --git a/backend/tests/test_runner_capacity_controller.py b/backend/tests/test_runner_capacity_controller.py index fbc412b..dd2bc9c 100644 --- a/backend/tests/test_runner_capacity_controller.py +++ b/backend/tests/test_runner_capacity_controller.py @@ -2,13 +2,13 @@ from __future__ import annotations import importlib.util import io +import json import sys import types from pathlib import Path import pytest - ROOT = Path(__file__).resolve().parents[2] CONTROLLER_ROOT = ROOT / "ops" / "gitea-deploy-broker" CONTROLLER_PATH = CONTROLLER_ROOT / "runner_capacity_controller.py" @@ -123,8 +123,18 @@ def test_host_snapshot_handles_absent_load_tuple(capacity_module, monkeypatch): assert result["host"] is None -@pytest.mark.parametrize("raw", ["", "pswpin 1\n", "pswpin -1\npswpout 2\n", - "pswpin 1\npswpin 2\npswpout 3\n", "pswpin invalid\npswpout 2\n", "x" * 65537]) +@pytest.mark.parametrize( + "raw", + [ + "", + "pswpin 1\n", + "pswpin -1\npswpout 2\n", + "pswpin 1\npswpin 2\npswpout 3\n", + "pswpin invalid\npswpout 2\n", + "x" * 65537, + ], + ids=["empty", "missing-counter", "negative", "duplicate", "invalid", "oversized"], +) def test_strict_swap_reader_rejects_missing_or_invalid_counters(capacity_module, monkeypatch, raw): monkeypatch.setattr(capacity_module, "Path", lambda _path: types.SimpleNamespace( open=lambda **_kwargs: io.StringIO(raw) @@ -161,6 +171,96 @@ def test_host_snapshot_rejects_invalid_global_metrics(capacity_module, monkeypat assert result["host"] is None +def test_snapshot_and_cycle_treat_empty_optional_burst_inspect_as_not_configured( + capacity_module, monkeypatch +): + service = capacity_module.RunnerCapacityController() + commands = [] + state_reads = [] + + def inspect_only(arguments, **kwargs): + commands.append((arguments, kwargs)) + assert arguments == ["docker", "inspect", capacity_module.BURST_RUNNER_NAME] + assert kwargs == {"check": False} + return "[]" + + def unexpected(*_args, **_kwargs): + pytest.fail("an absent optional burst runner must not reach registration, state, or mutation") + + monkeypatch.setattr(service, "_run", inspect_only) + monkeypatch.setattr(service, "_read_state", lambda: state_reads.append("read") or {}) + for name in ("_runner_registration", "_gitea_request", "_write_state", "_record", "_set_disabled"): + monkeypatch.setattr(service, name, unexpected) + + snapshot = service.snapshot() + result = service.cycle() + + assert snapshot["status"] == "not_configured" + assert snapshot["runner"] == capacity_module.BURST_RUNNER_NAME + assert result["status"] == "not_configured" + assert result["runner"] == capacity_module.BURST_RUNNER_NAME + assert state_reads == ["read"] + assert commands == [ + (["docker", "inspect", capacity_module.BURST_RUNNER_NAME], {"check": False}), + (["docker", "inspect", capacity_module.BURST_RUNNER_NAME], {"check": False}), + ] + + +@pytest.mark.parametrize( + ("raw", "reason"), + [ + ("not-json", "runner_capacity_container_invalid"), + ("{}", "runner_capacity_container_invalid"), + ( + json.dumps([ + {"Config": {"Labels": { + "com.docker.compose.project": "gitea-deploy-runners", + "tech.itworx.role": "gitea-actions-burst-runner", + }}, "State": {}}, + {"Config": {"Labels": { + "com.docker.compose.project": "gitea-deploy-runners", + "tech.itworx.role": "gitea-actions-burst-runner", + }}, "State": {}}, + ]), + "runner_capacity_container_invalid", + ), + ( + json.dumps([{ + "Config": {"Labels": { + "com.docker.compose.project": "foreign-project", + "tech.itworx.role": "gitea-actions-burst-runner", + }}, + "State": {}, + }]), + "runner_capacity_container_identity_invalid", + ), + ], +) +def test_cycle_still_refuses_malformed_foreign_or_multiple_burst_inspect_results( + capacity_module, monkeypatch, raw, reason +): + service = capacity_module.RunnerCapacityController() + commands = [] + + def inspect_only(arguments, **kwargs): + commands.append((arguments, kwargs)) + return raw + + def unexpected(*_args, **_kwargs): + pytest.fail("invalid burst inventory must stop before registration or mutation") + + monkeypatch.setattr(service, "_run", inspect_only) + for name in ("_runner_registration", "_gitea_request", "_write_state", "_record", "_set_disabled"): + monkeypatch.setattr(service, name, unexpected) + + with pytest.raises(capacity_module.RunnerCapacityError, match=reason): + service.cycle() + + assert commands == [ + (["docker", "inspect", capacity_module.BURST_RUNNER_NAME], {"check": False}) + ] + + def _wire_common(service, monkeypatch, *, running=False, busy=False, disabled=True): monkeypatch.setattr( service, @@ -183,7 +283,7 @@ def _wire_common(service, monkeypatch, *, running=False, busy=False, disabled=Tr "labels": ["linux-validation", "ubuntu-latest"], }, ) - monkeypatch.setattr(service, "_read_state", lambda: {}) + monkeypatch.setattr(service, "_read_state", dict) monkeypatch.setattr(service, "_write_state", lambda _state: None) monkeypatch.setattr(service, "_record", lambda _event: None) monkeypatch.setattr(service, "_host_guard_state", lambda: ("clear", "")) diff --git a/backend/tests/test_runner_fleet_specialize.py b/backend/tests/test_runner_fleet_specialize.py index c703c8d..3c01781 100644 --- a/backend/tests/test_runner_fleet_specialize.py +++ b/backend/tests/test_runner_fleet_specialize.py @@ -421,6 +421,119 @@ def test_successful_main_run_accepts_actual_gitea_run_without_workflow_id( assert calls[1].endswith("/actions/runs?branch=main&limit=100&page=1") +def test_source_image_fetch_accepts_actual_gitea_commit_tree_shape( + specialize, monkeypatch, tmp_path +): + revision = "a" * 40 + tree = "b" * 40 + calls = [] + workflow_calls = [] + + def api(path): + calls.append(path) + if path.endswith("/branches/main"): + return {"commit": {"id": revision}} + if path.endswith(f"/git/commits/{revision}"): + return {"sha": revision, "commit": {"tree": {"sha": tree}}} + if "/actions/runs/" in path and "/artifacts?" in path: + return { + "artifacts": [ + { + "name": specialize.IMAGE_ARTIFACT_PREFIX + revision, + "expired": False, + "id": 44, + "size_in_bytes": 1, + } + ] + } + raise AssertionError(f"unexpected fixed Gitea API path: {path}") + + def successful_run(workflow_path, requested_revision): + assert requested_revision == revision + workflow_calls.append(workflow_path) + return {"id": 41 if workflow_path == specialize.IMAGE_WORKFLOW else 42} + + class ReachedArtifactDownload(Exception): + pass + + def download(_artifact_id, _destination): + raise ReachedArtifactDownload + + monkeypatch.setattr(specialize, "_api_json", api) + monkeypatch.setattr(specialize, "_successful_main_run", successful_run) + monkeypatch.setattr(specialize, "_download_artifact_zip", download) + + with pytest.raises(ReachedArtifactDownload): + specialize._fetch_source_image_artifact(revision, tree, tmp_path) + + assert workflow_calls == [specialize.IMAGE_WORKFLOW, specialize.FULL_WORKFLOW] + assert calls == [ + f"/repos/{specialize.SOURCE_REPOSITORY_API}/branches/main", + f"/repos/{specialize.SOURCE_REPOSITORY_API}/git/commits/{revision}", + f"/repos/{specialize.SOURCE_REPOSITORY_API}/actions/runs/41/artifacts?name={specialize.IMAGE_ARTIFACT_PREFIX}{revision}", + ] + + +@pytest.mark.parametrize( + ("commit_value", "error"), + [ + ({"sha": "a" * 40}, "source_tree_mismatch"), + ({"sha": "a" * 40, "commit": {}}, "source_tree_mismatch"), + ( + {"sha": "a" * 40, "commit": {"tree": {"sha": "c" * 40}}}, + "source_tree_mismatch", + ), + ( + {"sha": "a" * 40, "tree": {"sha": "b" * 40}}, + "source_tree_mismatch", + ), + ( + {"sha": "c" * 40, "commit": {"tree": {"sha": "b" * 40}}}, + "source_revision_mismatch", + ), + ( + {"commit": {"tree": {"sha": "b" * 40}}}, + "source_revision_mismatch", + ), + ], +) +def test_source_image_fetch_rejects_noncanonical_gitea_commit_payload_before_artifact_lookup( + specialize, monkeypatch, tmp_path, commit_value, error +): + revision = "a" * 40 + calls = [] + workflow_calls = [] + + def api(path): + calls.append(path) + if path.endswith("/branches/main"): + return {"commit": {"id": revision}} + if path.endswith(f"/git/commits/{revision}"): + return commit_value + raise AssertionError(f"unexpected fixed Gitea API path: {path}") + + def unexpected_workflow(*_args): + workflow_calls.append(True) + raise AssertionError("workflow lookup must not follow an untrusted commit response") + + monkeypatch.setattr(specialize, "_api_json", api) + monkeypatch.setattr(specialize, "_successful_main_run", unexpected_workflow) + monkeypatch.setattr( + specialize, + "_download_artifact_zip", + lambda *_args: pytest.fail("artifact download must not follow an untrusted commit response"), + ) + + with pytest.raises(specialize.RunnerFleetSpecializeError, match=error): + specialize._fetch_source_image_artifact(revision, "b" * 40, tmp_path) + + assert calls == [ + f"/repos/{specialize.SOURCE_REPOSITORY_API}/branches/main", + f"/repos/{specialize.SOURCE_REPOSITORY_API}/git/commits/{revision}", + ] + assert workflow_calls == [] + + @pytest.mark.parametrize( "changes", [ diff --git a/backend/tests/test_setup_onboarding.py b/backend/tests/test_setup_onboarding.py index 8945060..d84ee01 100644 --- a/backend/tests/test_setup_onboarding.py +++ b/backend/tests/test_setup_onboarding.py @@ -93,12 +93,59 @@ def test_portainer_config_save_never_returns_secret(api_client): "verifyTls": False, } assert secret not in str(payload) - all_settings = api_client.get("/api/settings/connectors").json() assert secret not in str(all_settings) assert next(item for item in all_settings if item["connectorId"] == "portainer")["hasSecret"] is True +def test_generic_authentik_setup_cannot_set_or_overwrite_forge_oidc_binding(api_client): + import json + + from sqlmodel import Session + + from backend.app.db import session as db_session + from backend.app.schemas.forge_oidc import FORGE_OIDC_BINDING_SECRET_REF + from backend.app.services.secret_store import get_secret, set_secret + from backend.app.services.setup_service import _apply_forge_oidc_binding_to_runtime + from backend.app.core.config import settings + + binding = json.dumps( + { + "version": 1, + "providerId": "31", + "scopeMappingIds": { + "openid": "75a34fd6-cc81-40b8-a8c3-2745af9567ab", + "email": "312810bb-11da-47d8-97d4-f2155348eaa2", + "profile": "7aa8c5f8-5e8e-4a1b-9c11-3a4f0a731cff", + }, + } + ) + with Session(db_session.engine) as session: + set_secret(session, FORGE_OIDC_BINDING_SECRET_REF, binding) + + rejected = api_client.patch( + "/api/settings/connectors/authentik", + json={"forgeOidcBinding": binding, "providerId": "999"}, + ) + assert rejected.status_code == 422 + + saved = api_client.patch( + "/api/settings/connectors/authentik", + json={"url": "https://auth.example-host.com", "secret": "GENERIC_AUTHENTIK_TOKEN"}, + ) + assert saved.status_code == 200 + assert binding not in saved.text + assert "GENERIC_AUTHENTIK_TOKEN" not in saved.text + with Session(db_session.engine) as session: + assert get_secret(session, FORGE_OIDC_BINDING_SECRET_REF, touch=False) == binding + assert _apply_forge_oidc_binding_to_runtime(session) is True + assert settings.apis["AUTHENTIK_FORGE_OIDC_PROVIDER_ID"] == "31" + + all_settings = api_client.get("/api/settings/connectors").json() + assert binding not in str(all_settings) + assert "GENERIC_AUTHENTIK_TOKEN" not in str(all_settings) + + def test_portainer_config_save_trims_safe_fields(api_client): payload = api_client.patch( "/api/settings/connectors/portainer",