Publish curated Unraid-AppOps-Gateway source from 451646fee806a47e3256731766980a05a262dcdd
This commit is contained in:
1 parent
532f3d9f26
commit
0a82153d95
43 files changed
+17722
-93
No files matched your search
@@ -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"]
|
||||
@@ -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.
|
||||
+137
-47
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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"]),
|
||||
|
||||
@@ -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."),
|
||||
]
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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':
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 == []
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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", ""))
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user