Publish curated Unraid-AppOps-Gateway source from ad0a390e431166c3198a2ac3bec010c2b44f2dea

This commit is contained in:
Public Source Publisher committed 2026-10-02 09:56:13 +00:00
1 parent 29c1e3d5a1
commit 98d31477f9
35 files changed
+7264 -185

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
# Public source export
This source snapshot was generated from the private canonical repository at revision `fe49858778940715742d53eea89241f01878f7ee`. 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 `ad0a390e431166c3198a2ac3bec010c2b44f2dea`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
+94 -49
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"sourceRevision": "fe49858778940715742d53eea89241f01878f7ee",
"sourceRevision": "ad0a390e431166c3198a2ac3bec010c2b44f2dea",
"files": [
{
"path": ".dockerignore",
@@ -64,8 +64,8 @@
},
{
"path": "backend/app/api/routes_actions.py",
"sha256": "ff5a5fc977e2cd35617b3fec42bc225c40fde896725a1f906e971382f2bbce60",
"bytes": 19745
"sha256": "f555523d9d285cf1bbb97f59f8ff0cf4c824583fb4c152c86a52fe1c1d47147b",
"bytes": 19807
},
{
"path": "backend/app/api/routes_ai.py",
@@ -129,8 +129,8 @@
},
{
"path": "backend/app/api/routes_enrollments.py",
"sha256": "8efcba1e85c57a439f30a7f5eb663b0140fb425ac32134909a942e02b2a562b8",
"bytes": 3237
"sha256": "5da9f18add09638f8a2d8dddc7c92c4790a94f76e9ead4639dfb35a9b8f247cb",
"bytes": 9878
},
{
"path": "backend/app/api/routes_exposure_settings.py",
@@ -419,13 +419,13 @@
},
{
"path": "backend/app/schemas/deployment.py",
"sha256": "e6adc82b323591a7f7fa29099dcddb1c0d4efb829a0fadac6d10d4736b4c2ce8",
"bytes": 15857
"sha256": "39fd2f54073498020871d16397eedc7471942c82ce307e3c137d49fab662e2b4",
"bytes": 16174
},
{
"path": "backend/app/schemas/enrollment.py",
"sha256": "e8c360c5b6ed499488854763a60ab9ed820d5d44ebd254de70e44089b10e1082",
"bytes": 16000
"sha256": "552e6acbf6ec5ebce6683d0b6df39ecbfe5a14bd6c3c528480d42d499f7218cc",
"bytes": 17667
},
{
"path": "backend/app/schemas/exposure_preferences.py",
@@ -439,13 +439,13 @@
},
{
"path": "backend/app/services/action_execution.py",
"sha256": "2095356d5594ac90a5c519da5047df5a6989ef5f406879c66c3fb04420bb77d9",
"bytes": 77357
"sha256": "fb32be9b89ac8235f826aabd8cb6ca0068fc9f5f2e5fc95a9963dac8c1df2cb3",
"bytes": 83876
},
{
"path": "backend/app/services/action_registry.py",
"sha256": "0a574da6365d6e81535ca58b977b85d50eae794c2dff62fb59648542161fdf25",
"bytes": 20298
"sha256": "b34a8217b0bbdaf1a9cda8d7bc310f5577a3dea0abf4bcc95d106a8227dd8ea9",
"bytes": 20499
},
{
"path": "backend/app/services/ai_remediation_planner.py",
@@ -489,8 +489,8 @@
},
{
"path": "backend/app/services/controller_artifact_promoter.py",
"sha256": "8b6dc4f0773f225b45740bde205876cfe5e961a3dec102b0b3d0c7600dea236a",
"bytes": 16121
"sha256": "557cc8dfc7fd5f8625dc36eab9bdef730b7a4cc8fa9bf273954b4aabae190ab0",
"bytes": 16381
},
{
"path": "backend/app/services/controller_bootstrap_import.py",
@@ -499,8 +499,8 @@
},
{
"path": "backend/app/services/deployment_broker.py",
"sha256": "cb95116841e3598afc63cd0a104d5359335fb08fa8124cbc8c0ce303b730f6d1",
"bytes": 22320
"sha256": "c8e58f76b1f245553c1a419e33ad919e3366cc54b7f9bdcc8698d46abbff0c6b",
"bytes": 22786
},
{
"path": "backend/app/services/deployment_control_plane.py",
@@ -519,8 +519,8 @@
},
{
"path": "backend/app/services/enrollment_plane.py",
"sha256": "a60926f639fcce51c60f0aa86772a1b2994014d2567ef94c2361eba6e0aad843",
"bytes": 51512
"sha256": "079163e8634996f572e573d657eae1ec1a72fa7ac739e86e47510a60f248b3f4",
"bytes": 57487
},
{
"path": "backend/app/services/enrollment_profiles.py",
@@ -579,8 +579,8 @@
},
{
"path": "backend/app/services/mcp_hub_deployment_reconciliation.py",
"sha256": "1833b95ad7663b91f0f54ce6e92950a059efb8cb121d4e2c8af7f821a5525a49",
"bytes": 29680
"sha256": "d45921d486b7debba7dc7d68a274beb2103c20fc5b2968f9380b5c22dfea6d95",
"bytes": 31417
},
{
"path": "backend/app/services/mcp_operations.py",
@@ -629,8 +629,8 @@
},
{
"path": "backend/app/services/policy_engine.py",
"sha256": "148706d64e81a51cf448729f2a4ef387b282849a0f1c4c3273e9a71cee14fd19",
"bytes": 10026
"sha256": "8213e8d213498b58c41bf7a9ae2a905e814b6ad9b6cfad2d7752df69d444634b",
"bytes": 10925
},
{
"path": "backend/app/services/portfolio_deployment_enrollment.py",
@@ -754,8 +754,8 @@
},
{
"path": "backend/app/services/user_access.py",
"sha256": "bc952cb4bedddef1b769e2c77848b89b64f182b288273c1a79b3a09e349e633b",
"bytes": 9840
"sha256": "61c994dc09c413f04838015f8ca0ea6f75d45aec1b63fa21716e3be69adc7c3f",
"bytes": 9918
},
{
"path": "backend/app/services/voice_catalog.py",
@@ -814,18 +814,28 @@
},
{
"path": "backend/tests/test_controller_artifact_promoter.py",
"sha256": "afb12b325d23db5a7d52cdadb68f0b0294da136c678f387b03c4f82d064be110",
"bytes": 3179
"sha256": "590a7155052851e767a048d15f7f6624070a68c020e90b32ed8801ceab11e972",
"bytes": 3672
},
{
"path": "backend/tests/test_controller_bootstrap_import_host.py",
"sha256": "541f786746b42447fb738f14343fb6d1cb0097d10a5e4949f0e940e111a6aec3",
"bytes": 1577
},
{
"path": "backend/tests/test_controller_config_paths.py",
"sha256": "9cbe851651abec8c5a792d9843ceca308993e71f67bdcbc081ae7eb9a96a0888",
"bytes": 5207
},
{
"path": "backend/tests/test_controller_hub_release_volume.py",
"sha256": "2bc2345aa0ea65aa78471aac85909c671040041c44cdb67d01c127c1be17eb70",
"bytes": 18590
},
{
"path": "backend/tests/test_controller_state_reconciliation.py",
"sha256": "4c6a73fb5afa0b08b32ef7e7d3c344ce9f28108065d693fb05a1b38afee23e88",
"bytes": 10743
"sha256": "d00b8bcf388e17f0c1b8999d4b511fede7212b8ef83282905328d1b0b4b70f76",
"bytes": 10523
},
{
"path": "backend/tests/test_deluge_connector.py",
@@ -834,8 +844,8 @@
},
{
"path": "backend/tests/test_deploy_broker.py",
"sha256": "4ccb7c184becccf9bbf64220861555b0b237504f51f094bab1ed0ac372c19d53",
"bytes": 287177
"sha256": "7a59aa36564fb056def3453324f376bb0580fdeee99a32d4a3180a22fd16bebc",
"bytes": 305380
},
{
"path": "backend/tests/test_deployment_auto_rollback.py",
@@ -869,8 +879,8 @@
},
{
"path": "backend/tests/test_enrollment_plane.py",
"sha256": "b6ddf4e4407910a2bb9728993cd53c01624d0c709b5f295b764ed87eeed921d7",
"bytes": 19682
"sha256": "5979d8922fa03c0954b0c928b263278c1123c48a2f5dda701d543ff4eb1ccffa",
"bytes": 26847
},
{
"path": "backend/tests/test_exposure_preferences.py",
@@ -917,6 +927,16 @@
"sha256": "52533816bc0147fc9ae51710a37357fd50f05434bb6ecb7df959ecdafb2f2202",
"bytes": 12873
},
{
"path": "backend/tests/test_managed_appdata_root.py",
"sha256": "87c364e61acc980101948be82d0c9ef81cddff6472f40d15b1cae9b7d489794f",
"bytes": 13552
},
{
"path": "backend/tests/test_managed_storage_action.py",
"sha256": "5a6638724dbd06384040971beb1513ec186c042c617f5292467e9415bea84c50",
"bytes": 11900
},
{
"path": "backend/tests/test_managed_validation_checkout.py",
"sha256": "92239d5c8760034c145a917534dd4622d507af334eaa0c10db3a310b53bae482",
@@ -929,14 +949,24 @@
},
{
"path": "backend/tests/test_mcp_hub_deployment_reconciliation.py",
"sha256": "54b3859f77cec92ecbe60d455c50524640878161570a648febf9c9037bddccf1",
"bytes": 13332
"sha256": "0400d0b0ca31a62d869166b9368fa0c018c0e44edba98387d12633976988da29",
"bytes": 14176
},
{
"path": "backend/tests/test_mcp_hub_governed_topology.py",
"sha256": "f521c7d843f7d5fe9a5ac3d68bde1f072f38991d5e30e8f9bea72b77061c79f8",
"bytes": 106551
},
{
"path": "backend/tests/test_mcp_hub_policy_reconciliation.py",
"sha256": "50cf920443570240331d8851195bdd3140eeb93397b938c7f78b35749f6b7399",
"bytes": 5041
},
{
"path": "backend/tests/test_mcp_hub_release_store.py",
"sha256": "91dbd0da6a131f76a04c51f93e31a77f4acd057f788f4b1a6fce75d37c23525e",
"bytes": 18199
},
{
"path": "backend/tests/test_mcp_rag_n8n_integrations.py",
"sha256": "0b2891cd4b545ced2e737e4caa1506ceedecb4600247223b3490749213348fba",
@@ -1099,8 +1129,8 @@
},
{
"path": "backend/tests/test_repopulse_controller_adoption_store.py",
"sha256": "bca0ffdb89b153f667dd6fb055509a2c8503920f22012912e389658bb3cbdacc",
"bytes": 41832
"sha256": "e63025aea8d587d072026e7e5643c5c5d764b875275a84e23004f4450b24d34b",
"bytes": 41807
},
{
"path": "backend/tests/test_repopulse_legacy_adoption_protocol.py",
@@ -1229,8 +1259,8 @@
},
{
"path": "backend/tests/test_user_auth.py",
"sha256": "e338549947f7f278d379756d78f22e82f554362c30a3bb964d41caddbf6b506b",
"bytes": 10618
"sha256": "80e96c6544afcc0569245862c877553907c79bbf80cf5cab320e0b15fb3c20d6",
"bytes": 35017
},
{
"path": "backend/tests/test_widefrog_connector.py",
@@ -1274,13 +1304,13 @@
},
{
"path": "pnpm-lock.yaml",
"sha256": "281ba56e326813ea51eb0cc1c6aeee0d0ec221a1f999893ab9785a3660746d15",
"bytes": 215211
"sha256": "f699c9ed2c5649b0d41f0e01420ef377161b08ea163c58fd00c5d09cf696fe0d",
"bytes": 215991
},
{
"path": "pnpm-workspace.yaml",
"sha256": "b059f783c0ec992afc463b9d327611321a44ce054100ea36eac8b7980d2ac45c",
"bytes": 8906
"sha256": "bd8c09cbbfdf628ec0c5cce6ceee452d6c8b75373f61da28f347b157fb96a7d4",
"bytes": 9120
},
{
"path": "postcss.config.js",
@@ -1354,8 +1384,8 @@
},
{
"path": "src/api/liveClient.ts",
"sha256": "78d28c6778caace577fe465903d9dc7b8f328d261d6f4399aea97a845880f4c6",
"bytes": 61657
"sha256": "12536177fde01cc870bc6aa24d31d922db9341ef2842c4ef244fe0cee4de98c3",
"bytes": 62080
},
{
"path": "src/api/userSession.test.ts",
@@ -1417,6 +1447,11 @@
"sha256": "fd4f8deab1c1397e087400cb57d9f11ae0e8bc366f9f2219f6aa2e513584e11b",
"bytes": 6514
},
{
"path": "src/components/ManagedStorageSettings.tsx",
"sha256": "660f3445d3307e08eb54913c0e1665e59bad8a7a1cb42548961cbef372d79bf9",
"bytes": 22209
},
{
"path": "src/components/MediaPipelineFlowDiagram.tsx",
"sha256": "0fc9113adc47648cbcf9236ce9ed6cd54a36fa7f078d6010d4d034aa442e9ea6",
@@ -2042,6 +2077,16 @@
"sha256": "c303e620f58ec8714e8bed2c77d7cb3cf02c6957bda0d7f3790a0351b71ab59d",
"bytes": 845
},
{
"path": "src/lib/enrollmentStorageView.test.ts",
"sha256": "879253b9abf373052f4b9abea95d0403f83299b8bd0a9408d38f78104d030e97",
"bytes": 8947
},
{
"path": "src/lib/enrollmentStorageView.ts",
"sha256": "549163b4ec2c51a62515ccfef293b51876bea7cbabbe8b1ed3ff74dedc956d68",
"bytes": 11255
},
{
"path": "src/lib/exposureView.test.ts",
"sha256": "a2b91f8d2f0a3536e1e930b2be8dcc73c0e424d2e43ff0ca8b34c56ee3a9a005",
@@ -2654,8 +2699,8 @@
},
{
"path": "src/pages/Settings.tsx",
"sha256": "3f1f336a191975547085c24a264023b1263d152bc53308b63eca5ddc848dfb3c",
"bytes": 28127
"sha256": "14dd631e2cdbeb39e34d3a587106a434a281f71b9d5c86b5aefcac12d8674ad0",
"bytes": 28242
},
{
"path": "src/pages/Setup.tsx",
@@ -2729,8 +2774,8 @@
},
{
"path": "src/types/appops.ts",
"sha256": "4b57009e8fe4bf8e83395da9d02eb88c2b5d841e057d975cbe12938307ccf055",
"bytes": 72561
"sha256": "5ca792943c3d25d8c6180911c46f1ed8e3ea5e3c1993d7542d01690e81030036",
"bytes": 73737
},
{
"path": "src/utils/toast.ts",
+10 -6
View File
@@ -7,14 +7,15 @@ from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlmodel import Session, select
from ..db.session import get_session
from ..db.models import App, AppSetting, utc_now
from ..services.audit_logger import log_event
from ..services.container_restart_service import execute_container_restart
from ..services.policy_engine import evaluate_policy
from ..db.session import get_session
from ..services.action_execution import (
action_execution_profile,
execute_governed_action,
)
from ..services.action_registry import (
ACTIONS,
ACTION_RISK,
ACTIONS,
action_blast_radius,
action_capability,
action_category,
@@ -24,7 +25,9 @@ from ..services.action_registry import (
action_safeguards,
)
from ..services.approval_service import create_approval
from ..services.action_execution import action_execution_profile, execute_governed_action
from ..services.audit_logger import log_event
from ..services.container_restart_service import execute_container_restart
from ..services.policy_engine import evaluate_policy
from ..services.preflight_service import build_preflight
router = APIRouter(prefix='/api/actions')
@@ -446,6 +449,7 @@ def execute(payload: ActionRequest, session: Session = Depends(get_session), ide
})
if action in {
'controller.managed-storage.initialize',
'deployment.targets.sync',
'deployment.target.deploy_workmesh',
'deployment.target.register_portfolio',
+166 -3
View File
@@ -2,20 +2,37 @@
from __future__ import annotations
from copy import deepcopy
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict
from sqlalchemy import insert as sql_insert
from sqlalchemy import update as sql_update
from sqlalchemy.exc import SQLAlchemyError
from sqlmodel import Session
from ..core.security import require_operator_request
from ..core.time import utc_now
from ..db.models import AppSetting
from ..db.session import get_session
from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1
from ..services.enrollment_plane import EnrollmentError, EnrollmentPlane
from ..schemas.enrollment import (
LEGACY_APPDATA_ROOT,
EnrollmentPlanV1,
EnrollmentStorageRootSelectionV1,
EnrollmentTrustConfigV1,
)
from ..services.audit_logger import log_event
from ..services.enrollment_plane import (
TRUST_SETTING_KEY,
EnrollmentError,
EnrollmentPlane,
)
from ..services.repopulse_adoption_journal import (
RepoPulseAdoptionJournalError,
get_repopulse_adoption_status,
)
from ..services.user_access import current_user
def _require_operator(request: Request) -> str:
@@ -58,10 +75,156 @@ def enrollment_trust_status(session: EnrollmentSession):
@router.put("/trust")
def update_enrollment_trust(payload: EnrollmentTrustConfigV1, session: EnrollmentSession):
def update_enrollment_trust(
payload: EnrollmentTrustConfigV1,
session: EnrollmentSession,
request: Request,
):
actor = current_user(request)
if actor.role != "admin":
row = session.get(AppSetting, TRUST_SETTING_KEY)
original_value = deepcopy(row.value_json) if row is not None else None
original_updated_at = row.updated_at if row is not None else None
if row is None or row.is_secret:
existing = None
else:
try:
existing = EnrollmentTrustConfigV1.model_validate(original_value)
except ValueError:
existing = None
if row is None:
# Preserve initial legacy setup for settings.manage users, but the
# fixed managed-root opt-in must be made by an administrator.
selector_changed = (
payload.trusted_appdata_root != LEGACY_APPDATA_ROOT
or payload.managed_appdata_backup_acknowledged is not False
)
elif existing is None:
# A corrupt stored trust document has no safe selector baseline.
selector_changed = True
else:
selector_changed = (
payload.trusted_appdata_root != existing.trusted_appdata_root
or payload.managed_appdata_backup_acknowledged
!= existing.managed_appdata_backup_acknowledged
)
if selector_changed:
raise HTTPException(
status_code=403,
detail="Administrator role is required to change the appdata root.",
)
try:
if row is None:
# A concurrent administrator may create the trust row after the
# absence check. The unique key makes this insert a safe CAS:
# losing the race is a conflict, never an overwrite.
session.execute(
sql_insert(AppSetting).values(
key=TRUST_SETTING_KEY,
value_json=payload.model_dump(mode="json"),
is_secret=False,
updated_at=utc_now(),
)
)
else:
result = session.execute(
sql_update(AppSetting)
.where(AppSetting.key == TRUST_SETTING_KEY)
.where(AppSetting.updated_at == original_updated_at)
.where(AppSetting.value_json == original_value)
.values(
value_json=payload.model_dump(mode="json"),
is_secret=False,
updated_at=utc_now(),
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
session.rollback()
raise HTTPException(status_code=409, detail="enrollment_trust_changed")
session.commit()
except HTTPException:
raise
except SQLAlchemyError:
session.rollback()
raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None
session.expire_all()
return EnrollmentPlane.trust_status(session)
return EnrollmentPlane.set_trust_config(session, payload)
@router.patch("/trust/storage-root")
def update_enrollment_storage_root(
payload: EnrollmentStorageRootSelectionV1,
session: EnrollmentSession,
request: Request,
):
"""Change only the fixed storage selector and persist its backup acknowledgement."""
if current_user(request).role != "admin":
raise HTTPException(status_code=403, detail="Administrator role is required.")
trust = EnrollmentPlane.get_trust_config(session)
row = session.get(AppSetting, TRUST_SETTING_KEY)
if trust is None or row is None:
raise HTTPException(status_code=409, detail="enrollment_trust_not_configured")
original_value = deepcopy(row.value_json)
original_updated_at = row.updated_at
try:
stored_trust = EnrollmentTrustConfigV1.model_validate(original_value)
except ValueError:
raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None
if stored_trust.model_dump(mode="json") != trust.model_dump(mode="json"):
raise HTTPException(status_code=409, detail="enrollment_trust_changed")
updated = EnrollmentTrustConfigV1.model_validate({
**original_value,
**payload.model_dump(mode="json"),
})
before = {
"trustedAppdataRoot": trust.trusted_appdata_root,
"managedAppdataBackupAcknowledged": trust.managed_appdata_backup_acknowledged,
}
after = {
"trustedAppdataRoot": updated.trusted_appdata_root,
"managedAppdataBackupAcknowledged": updated.managed_appdata_backup_acknowledged,
}
try:
result = session.execute(
sql_update(AppSetting)
.where(AppSetting.key == TRUST_SETTING_KEY)
.where(AppSetting.updated_at == original_updated_at)
.where(AppSetting.value_json == original_value)
.values(
value_json=updated.model_dump(mode="json"),
is_secret=False,
updated_at=utc_now(),
)
.execution_options(synchronize_session=False)
)
except SQLAlchemyError:
session.rollback()
raise HTTPException(status_code=409, detail="enrollment_trust_changed") from None
if result.rowcount != 1:
session.rollback()
raise HTTPException(status_code=409, detail="enrollment_trust_changed")
session.expire(row)
log_event(
session,
source="settings",
identity="manual",
action_id="enrollment.trust.storage_root.update",
target="tower/deployment-controller",
risk="high",
policy_decision="allowed",
reason="Authenticated settings.manage operator selected a fixed appdata root and recorded its backup acknowledgement.",
result="updated",
target_type="controller",
before_state=before,
after_state=after,
redacted_metadata={"backupLocation": "dockerRootDir" if updated.managed_appdata_backup_acknowledged else None},
)
return EnrollmentPlane.trust_status(session)
@router.post("")
def request_enrollment(
payload: EnrollmentRequestV1,
+8 -2
View File
@@ -34,7 +34,9 @@ _SAFE_PATH = re.compile(
r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$))(?:[A-Za-z0-9]|\.[A-Za-z0-9])[A-Za-z0-9_./-]{0,239}$"
)
TRUSTED_APPCONFIG_ROOT = "/srv/example/appdata"
LEGACY_APPCONFIG_ROOT = "/srv/example/appdata"
MANAGED_APPCONFIG_ROOT = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
TRUSTED_APPCONFIG_ROOTS = (LEGACY_APPCONFIG_ROOT, MANAGED_APPCONFIG_ROOT)
class _StrictModel(BaseModel):
@@ -177,6 +179,10 @@ class DeploymentPlanV1(_StrictModel):
image_repository: str
image_tag: str
topology: DeploymentTopologyV1
trusted_appdata_root: Literal[
"/srv/example/appdata",
"/var/lib/docker/volumes/itworx-managed-appdata/_data",
] = LEGACY_APPCONFIG_ROOT
proxy: ProxyHostV1 | None = None
oidc: OidcProvisioningV1 | None = None
health_path: str = "/healthz"
@@ -257,7 +263,7 @@ class DeploymentPlanV1(_StrictModel):
@property
def appdata_path(self) -> str:
return f"{TRUSTED_APPCONFIG_ROOT}/{self.application_id}"
return f"{self.trusted_appdata_root}/{self.application_id}"
@property
def public_url(self) -> str | None:
+37 -3
View File
@@ -29,6 +29,9 @@ DeploymentProfileName = Literal[
"repopulse-authenticated",
]
LEGACY_APPDATA_ROOT = "/srv/example/appdata"
MANAGED_DOCKER_APPDATA_ROOT = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
class _StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
@@ -218,6 +221,10 @@ class EnrollmentPlanV1(_StrictModel):
storage: EnrollmentStorageV1
network: EnrollmentNetworkV1
artifact: EnrollmentArtifactV1
trusted_appdata_root: Literal[
"/srv/example/appdata",
"/var/lib/docker/volumes/itworx-managed-appdata/_data",
] = "/srv/example/appdata"
exposure: EnrollmentExposureV1 | None = None
authentication: EnrollmentAuthenticationV1 = EnrollmentAuthenticationV1()
secrets: EnrollmentSecretsV1 = EnrollmentSecretsV1()
@@ -271,7 +278,8 @@ class EnrollmentPlanV1(_StrictModel):
if expects_oidc != self.secrets.oidc_client_secret_required:
raise ValueError("OIDC secret requirement must match the selected profile")
if self.profile == "repopulse-authenticated" and (
self.application_id != "repopulse"
self.trusted_appdata_root != "/srv/example/appdata"
or self.application_id != "repopulse"
or self.repository != "jens/repopulse"
or self.environment != "production"
or self.target.container_name != "repopulse"
@@ -295,7 +303,7 @@ class EnrollmentPlanV1(_StrictModel):
@property
def appdata_path(self) -> str:
return f"/srv/example/appdata/{self.application_id}"
return f"{self.trusted_appdata_root}/{self.application_id}"
@property
def data_path(self) -> str:
@@ -311,7 +319,11 @@ class EnrollmentTrustConfigV1(_StrictModel):
controller_identity: str
bootstrap_secret_ref: str
trusted_appdata_root: Literal["/srv/example/appdata"] = "/srv/example/appdata"
trusted_appdata_root: Literal[
LEGACY_APPDATA_ROOT,
MANAGED_DOCKER_APPDATA_ROOT,
] = LEGACY_APPDATA_ROOT
managed_appdata_backup_acknowledged: bool = Field(default=False, strict=True)
approved_networks: tuple[str, ...] = ()
allowed_domain_suffixes: tuple[str, ...] = ()
autonomous_profiles: tuple[DeploymentProfileName, ...] = ()
@@ -372,4 +384,26 @@ class EnrollmentTrustConfigV1(_StrictModel):
for group in groups:
if group is not None and not re.fullmatch(r"[A-Za-z0-9 _.-]{2,100}", group):
raise ValueError("OIDC role group name is invalid")
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
if self.trusted_appdata_root == managed_root and not self.managed_appdata_backup_acknowledged:
raise ValueError("managed Docker-root storage requires explicit backup acknowledgement")
if self.trusted_appdata_root != managed_root and self.managed_appdata_backup_acknowledged:
raise ValueError("Docker-root backup acknowledgement is valid only for the managed root")
return self
class EnrollmentStorageRootSelectionV1(_StrictModel):
"""Narrow operator selection for the fixed managed storage root."""
trusted_appdata_root: Literal[
LEGACY_APPDATA_ROOT,
MANAGED_DOCKER_APPDATA_ROOT,
]
managed_appdata_backup_acknowledged: bool = Field(strict=True)
@model_validator(mode="after")
def require_managed_backup_acknowledgement(self) -> EnrollmentStorageRootSelectionV1:
managed = self.trusted_appdata_root == MANAGED_DOCKER_APPDATA_ROOT
if managed != self.managed_appdata_backup_acknowledged:
raise ValueError("managed Docker-root storage requires explicit backup acknowledgement")
return self
+180 -2
View File
@@ -21,6 +21,7 @@ from .setup_service import apply_stored_connector_settings
GENERIC_EXECUTOR_ACTIONS = {
"container.restart",
"controller.artifact.promote",
"controller.managed-storage.initialize",
"deployment.targets.sync",
"deployment.runnerless.provision",
"deployment.target.deploy_workmesh",
@@ -69,6 +70,10 @@ APPROVAL_EXECUTOR_ACTIONS = {
"unraid.vm.pause",
}
MCP_TYPED_EXECUTOR_ACTIONS = APPROVAL_EXECUTOR_ACTIONS | {
"controller.managed-storage.initialize",
}
HUMAN_APPROVAL_ONLY_ACTIONS = {"deployment.target.promote_workmesh", "privatemuse.comfyui.install_artifact"}
_APPROVAL_STEPS = {
@@ -148,7 +153,7 @@ def approval_execution_supported(action: str) -> bool:
def mcp_execution_supported(action: str) -> bool:
"""MCP is a no-human-approval surface; never admit human-only executors."""
return approval_execution_supported(action) and action not in HUMAN_APPROVAL_ONLY_ACTIONS
return action in MCP_TYPED_EXECUTOR_ACTIONS and action not in HUMAN_APPROVAL_ONLY_ACTIONS
def _execute_deployment_target_sync(
@@ -1758,7 +1763,7 @@ def execute_governed_action(
small allowlist of typed rollback/post-check capable executors below is reachable.
"""
if not approval_execution_supported(action):
if not mcp_execution_supported(action):
return {
"success": False,
"status": "failed",
@@ -1786,6 +1791,24 @@ def execute_governed_action(
original_policy_decision=original_policy_decision,
)
if action == "controller.managed-storage.initialize":
if target != "tower/deployment-controller":
return {
"success": False,
"status": "failed",
"result": "blocked",
"message": "Managed storage initialization accepts only tower/deployment-controller.",
"policyDecision": "blocked",
"originalPolicyDecision": original_policy_decision,
}
return _execute_managed_appdata_initialize(
session,
source=source,
risk=risk,
reason=reason,
original_policy_decision=original_policy_decision,
)
if action == "runner.fleet.upgrade_v2":
if target.strip().lower() != "tower/runner-fleet-v2":
return {
@@ -2058,6 +2081,161 @@ def execute_governed_action(
}
def _execute_managed_appdata_initialize(
session: Session,
*,
source: str,
risk: str,
reason: str,
original_policy_decision: str,
) -> dict[str, Any]:
"""Initialize only the fixed storage marker after trust and policy opt-in."""
from .deployment_broker import DeploymentBrokerClient
from .enrollment_plane import EnrollmentPlane
from .secret_store import get_secret
action = "controller.managed-storage.initialize"
target = "tower/deployment-controller"
trust = EnrollmentPlane.get_trust_config(session)
if (
trust is None
or trust.trusted_appdata_root != "/var/lib/docker/volumes/itworx-managed-appdata/_data"
or trust.managed_appdata_backup_acknowledged is not True
):
audit = log_event(
session,
source="action",
identity=source,
action_id=action,
target=target,
target_type="controller",
risk=risk,
policy_decision="allowed",
reason="Managed Docker-root storage requires an explicit trusted selector and backup acknowledgement.",
result="blocked",
redacted_metadata={"executor": action, "trustOptIn": False},
)
return {
"success": False,
"status": "failed",
"result": "blocked",
"message": "Managed storage is not explicitly selected and acknowledged in enrollment trust.",
"auditId": audit.id,
"policyDecision": "allowed",
"originalPolicyDecision": original_policy_decision,
}
bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False)
if not bootstrap_secret:
audit = log_event(
session,
source="action",
identity=source,
action_id=action,
target=target,
target_type="controller",
risk=risk,
policy_decision="allowed",
reason="Controller bootstrap capability is unavailable by its configured reference.",
result="failed",
redacted_metadata={"executor": action, "secretMaterialReturned": False},
)
return {
"success": False,
"status": "failed",
"result": "failed",
"message": "Controller bootstrap capability is unavailable.",
"auditId": audit.id,
"policyDecision": "allowed",
"originalPolicyDecision": original_policy_decision,
}
steps = [
"Verify the exact target, trust selector and backup acknowledgement",
"Authenticate the fixed controller request with bootstrap HMAC and replay protection",
"Validate the protected controller, target registry, repository root and fixed Docker volume",
"Initialize the selector only if the managed Docker volume is empty and unchanged",
"Re-read the fixed selector and record sanitized controller readiness",
]
job = create_job(
session,
action_id=action,
target=target,
source=source,
risk=risk,
policy_decision="allowed",
steps=steps,
)
start_job(session, job)
set_job_progress(session, job, 20, "Validating fixed managed-storage initialization prerequisites.")
try:
result = DeploymentBrokerClient().initialize_managed_appdata(secret=bootstrap_secret)
reported_status = result.get("status")
status = (
reported_status
if isinstance(reported_status, str)
and reported_status in {"initialized", "already_initialized"}
else "refused"
)
success = (
status in {"initialized", "already_initialized"}
and result.get("trustedAppdataRoot") == trust.trusted_appdata_root
and result.get("backupLocation") == "dockerRootDir"
)
message = (
"Managed storage selector initialized and verified."
if success
else "Managed storage initialization was refused or unavailable."
)
except Exception:
result = {"status": "error"}
status = "error"
success = False
message = "Managed storage initialization was refused or unavailable."
completed = complete_job(
session,
job,
status="succeeded" if success else "failed",
result_summary=message,
)
audit = log_event(
session,
source="action",
identity=source,
action_id=action,
target=target,
target_type="controller",
risk=risk,
policy_decision="allowed",
reason=reason,
result="success" if success else "failed",
execution_plan={"steps": steps, "jobId": completed.id},
after_state={
"status": status,
"trustedAppdataRoot": trust.trusted_appdata_root if success else None,
"backupLocation": "dockerRootDir" if success else None,
},
redacted_metadata={
"executor": action,
"target": target,
"integration": "mcp" if source == "chatgpt-mcp" else "operator",
"originalPolicyDecision": original_policy_decision,
"secretMaterialReturned": False,
},
)
return {
"success": success,
"status": "success" if success else "failed",
"result": "success" if success else "failed",
"message": message,
"jobId": completed.id,
"auditId": audit.id,
"policyDecision": "allowed",
"originalPolicyDecision": original_policy_decision,
}
def execute_approved_action(session: Session, approval: Approval) -> dict[str, Any]:
if not approval_execution_supported(approval.action):
return {
+4
View File
@@ -8,6 +8,7 @@ ACTIONS = [
'container.stop',
'runner.fleet.upgrade_v2',
'controller.artifact.promote',
'controller.managed-storage.initialize',
'gateway.clear_cache',
'stack.status',
'stack.redeploy',
@@ -214,6 +215,7 @@ ACTION_RISK = {
'container.stop': 'high',
'runner.fleet.upgrade_v2': 'high',
'controller.artifact.promote': 'high',
'controller.managed-storage.initialize': 'high',
'gateway.clear_cache': 'high',
'stack.status': 'low',
'stack.redeploy': 'high',
@@ -525,6 +527,8 @@ def action_category(action: str) -> str:
def action_default_target(action: str) -> str:
if action == 'controller.artifact.promote':
return 'tower/deployment-controller'
if action == 'controller.managed-storage.initialize':
return 'tower/deployment-controller'
if action == 'runner.fleet.upgrade_v2':
return 'tower/runner-fleet-v2'
if action == 'deployment.targets.sync':
@@ -39,12 +39,12 @@ def helper_container_spec(*, revision: str, image_id: str) -> dict[str, Any]:
raise ValueError("controller_promoter_image_invalid")
command = f"""set -Eeuo pipefail
umask 077
revision='{revision}'
version='{CONTROLLER_VERSION}'
repo='/gitea-repositories/jens/unraid-appops-gateway.git'
work='/mnt/cache/appdata/gitea-deploy-runners/.controller-promoter-{revision[:12]}'
rm -rf -- "$work"
install -d -m 0700 "$work"
work="$(mktemp -d -p /tmp 'itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX')"
[[ -d "$work" && ! -L "$work" && "$(stat -c '%u:%a' "$work")" == '0:700' ]]
cleanup() {{ rm -rf -- "$work"; }}
trap cleanup EXIT
command -v git >/dev/null
@@ -59,7 +59,10 @@ git --git-dir="$repo" archive --format=tar "$revision" \
ops/gitea-deploy-broker \
scripts/prepare_controller_artifact.py \
scripts/reconcile_controller_state.py \
scripts/run_repopulse_adoption.py > "$work/controller.tar"
scripts/run_repopulse_adoption.py \
scripts/prepare_controller_hub_release_volume.py \
scripts/upgrade_deployment_controller.sh > "$work/controller.tar"
chmod 0600 "$work/controller.tar"
archive_sha="$(sha256sum "$work/controller.tar" | cut -d ' ' -f 1)"
git --git-dir="$repo" show "$revision:scripts/upgrade_deployment_controller.sh" > "$work/upgrade.sh"
chmod 0700 "$work/upgrade.sh"
@@ -70,6 +73,7 @@ bash "$work/upgrade.sh" \
--source-revision "$revision" \
--source-tree "$tree" \
--controller-version "$version" \
--image-id '{image_id}' \
--confirm UPGRADE_DEPLOYMENT_CONTROLLER_V2
"""
@@ -88,6 +92,7 @@ bash "$work/upgrade.sh" \
"/mnt/cache:/mnt/cache",
"/mnt/user:/mnt/user",
"/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro",
"/var/lib/docker:/var/lib/docker:ro",
"/var/run/docker.sock:/var/run/docker.sock",
],
"NetworkMode": "host",
+12
View File
@@ -311,6 +311,18 @@ class DeploymentBrokerClient:
payload={},
)
def initialize_managed_appdata(self, *, secret: str) -> dict[str, Any]:
"""Initialize only the fixed managed storage root through bootstrap HMAC."""
return self._request(
"POST",
"/v1/appops/managed-storage/initialize",
secret=secret,
payload={
"apiVersion": "itworx.managed-storage-initialize/v1",
"target": "tower/deployment-controller",
},
)
def repopulse_adoption_capabilities(self, *, secret: str) -> dict[str, Any]:
"""Read fixed RepoPulse adoption readiness through bootstrap HMAC v2."""
return self._request(
+123 -4
View File
@@ -22,7 +22,12 @@ from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
from ..connectors.portainer import PortainerConnector
from ..core.time import utc_now
from ..db.models import AppSetting, Enrollment, EnrollmentJob, Policy
from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1
from ..schemas.enrollment import (
LEGACY_APPDATA_ROOT,
MANAGED_DOCKER_APPDATA_ROOT,
EnrollmentPlanV1,
EnrollmentTrustConfigV1,
)
from .audit_logger import log_event
from .deployment_broker import DeploymentBrokerClient
from .enrollment_profiles import PROFILE_VERSION, profile_contract, profile_summary
@@ -40,6 +45,47 @@ _TERMINAL = {"ready", "failed", "compensation_required", "preflight_failed"}
_CONTROLLER_EVIDENCE = {"status", "targetId", "repository", "applicationId", "appdataPath"}
_PROXY_EVIDENCE = {"success", "status", "hostId", "hostname"}
_OIDC_EVIDENCE = {"success", "status", "applicationId", "providerId", "clientSecretCreated"}
_APPDATA_ROOT_STATUSES = {"ready", "needs_initialization", "unavailable"}
def _trusted_appdata_roots_readiness(
controller_capability: dict[str, Any],
) -> list[dict[str, str]]:
roots = (LEGACY_APPDATA_ROOT, MANAGED_DOCKER_APPDATA_ROOT)
unavailable = {root: "unavailable" for root in roots}
reported = controller_capability.get("availableTrustedAppdataRoots")
if isinstance(reported, list):
if len(reported) != len(roots):
return [{"root": root, "status": unavailable[root]} for root in roots]
statuses: dict[str, str] = {}
for item in reported:
if not isinstance(item, dict):
return [{"root": root, "status": unavailable[root]} for root in roots]
root = item.get("root")
status = item.get("status")
if (
not isinstance(root, str)
or root not in unavailable
or root in statuses
or not isinstance(status, str)
or status not in _APPDATA_ROOT_STATUSES
):
return [{"root": expected, "status": unavailable[expected]} for expected in roots]
statuses[root] = status
if set(statuses) != set(roots):
return [{"root": root, "status": unavailable[root]} for root in roots]
return [{"root": root, "status": statuses[root]} for root in roots]
# A legacy V1 controller reports only its configured root. It cannot attest
# independent managed-volume readiness, so only its exact legacy root can
# be marked ready through this compatibility path.
if "availableTrustedAppdataRoots" not in controller_capability:
if (
controller_capability.get("status") == "ready"
and controller_capability.get("trustedAppdataRoot") == LEGACY_APPDATA_ROOT
):
unavailable[LEGACY_APPDATA_ROOT] = "ready"
return [{"root": root, "status": unavailable[root]} for root in roots]
class EnrollmentError(ValueError):
@@ -135,6 +181,8 @@ class EnrollmentPlane:
"status": "needs_setup",
"blockers": ["enrollment_trust_not_configured"],
"configured": False,
"trustedAppdataRoot": LEGACY_APPDATA_ROOT,
"managedAppdataBackupAcknowledged": False,
"secretStore": storage,
}
return {
@@ -143,6 +191,7 @@ class EnrollmentPlane:
"configured": True,
"controllerIdentity": trust.controller_identity,
"trustedAppdataRoot": trust.trusted_appdata_root,
"managedAppdataBackupAcknowledged": trust.managed_appdata_backup_acknowledged,
"approvedNetworks": list(trust.approved_networks),
"allowedDomainSuffixes": list(trust.allowed_domain_suffixes),
"autonomousProfiles": list(trust.autonomous_profiles),
@@ -162,11 +211,43 @@ class EnrollmentPlane:
bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False)
if bootstrap_secret:
controller_capability = self.broker.enrollment_capabilities(secret=bootstrap_secret)
available_roots = _trusted_appdata_roots_readiness(controller_capability)
if trust is not None:
selected_root = next(
(
item
for item in available_roots
if item["root"] == trust.trusted_appdata_root
),
None,
)
controller_root_matches = bool(selected_root and selected_root.get("status") == "ready")
else:
controller_root_matches = False
broker_ready = bool(
bootstrap
and self.broker.is_configured()
and controller_capability.get("status") == "ready"
and controller_root_matches
)
controller_managed_storage = controller_capability.get("managedStorage")
if not isinstance(controller_managed_storage, dict):
controller_managed_storage = {}
if trust is None or trust.trusted_appdata_root != MANAGED_DOCKER_APPDATA_ROOT:
managed_storage_status = "not_selected"
managed_storage_blocker = ""
elif not trust.managed_appdata_backup_acknowledged:
managed_storage_status = "blocked"
managed_storage_blocker = "managed_appdata_backup_acknowledgement_required"
elif controller_root_matches and controller_managed_storage.get("status") == "ready":
managed_storage_status = "ready"
managed_storage_blocker = ""
elif controller_managed_storage.get("initializationAvailable") is True:
managed_storage_status = "initialization_required"
managed_storage_blocker = "managed_appdata_initialization_required"
else:
managed_storage_status = "unavailable"
managed_storage_blocker = "managed_appdata_volume_unavailable"
npm_ready = self.npm.is_configured()
oidc_configured = bool(
trust
@@ -193,9 +274,27 @@ class EnrollmentPlane:
"policyProvisioning": item(configured, "policy_template_unavailable"),
"rollback": item(broker_ready, "deployment_executor_unavailable"),
"verification": item(broker_ready, "deployment_executor_unavailable"),
"availableTrustedAppdataRoots": available_roots,
"managedStorage": {
"status": managed_storage_status,
"blockerCode": managed_storage_blocker,
"root": MANAGED_DOCKER_APPDATA_ROOT,
"backupScope": "dockerRootDir",
"backupAcknowledged": bool(
trust and trust.managed_appdata_backup_acknowledged
),
},
"controller": {
"status": str(controller_capability.get("status") or "unavailable"),
"reason": str(controller_capability.get("reason") or "deployment_executor_unavailable"),
"status": "ready" if broker_ready else "unavailable",
"reason": (
""
if broker_ready
else str(controller_capability.get("reason") or (
"trusted_appdata_root_mismatch"
if controller_capability.get("status") == "ready" and not controller_root_matches
else "deployment_executor_unavailable"
))
),
},
"profiles": [profile_summary(name) for name in ("internal-webapp", "internal-webapp-oidc", "internal-service", "repopulse-authenticated")],
}
@@ -233,6 +332,7 @@ class EnrollmentPlane:
"container_name": plan.target.container_name,
"compose_file": plan.target.compose_file,
"appdata_path": plan.appdata_path,
"trusted_appdata_root": plan.trusted_appdata_root,
"data_path": plan.data_path,
"runtime_uid": plan.storage.runtime_uid,
"runtime_gid": plan.storage.runtime_gid,
@@ -264,6 +364,17 @@ class EnrollmentPlane:
checks.append(_check("network", "passed", "Requested Docker network is within the trusted profile boundary."))
else:
checks.append(_check("network_unavailable", "blocked", "Requested Docker network is not approved for enrollment."))
# RepoPulse is an established, fixed legacy-root exception; the
# operator's selected root applies to other new targets only.
expected_appdata_root = (
LEGACY_APPDATA_ROOT
if plan.profile == "repopulse-authenticated"
else trust.trusted_appdata_root
)
if plan.trusted_appdata_root == expected_appdata_root:
checks.append(_check("trusted_appdata_root", "passed", "Requested appdata root matches its fixed profile trust boundary."))
else:
checks.append(_check("trusted_appdata_root_mismatch", "blocked", "Requested appdata root does not match the explicit operator trust boundary."))
if plan.profile in trust.autonomous_profiles:
checks.append(_check("autonomous_profile", "passed", "Deployment profile is approved for autonomous enrollment."))
else:
@@ -397,7 +508,15 @@ class EnrollmentPlane:
checks.append(_check("secret_store", "passed" if storage_ready else "blocked", "Production-grade write-only secret storage is ready." if storage_ready else "Enrollment requires APPOPS_SECRET_KEY from a protected environment variable or file; the development fallback is not permitted."))
if trust:
checks.append(_check("controller_bootstrap_secret", "passed" if has_secret(session, trust.bootstrap_secret_ref) else "blocked", "Controller bootstrap secret is available by reference." if has_secret(session, trust.bootstrap_secret_ref) else "Controller bootstrap secret reference is not configured."))
if has_secret(session, trust.bootstrap_secret_ref):
expected_appdata_root = (
LEGACY_APPDATA_ROOT
if plan.profile == "repopulse-authenticated"
else trust.trusted_appdata_root
)
root_matches_trust = plan.trusted_appdata_root == expected_appdata_root
if not root_matches_trust:
checks.append(_check("controller_target", "blocked", "Controller preflight was skipped because the requested appdata root is outside the profile trust boundary."))
elif has_secret(session, trust.bootstrap_secret_ref):
controller = self.broker.enrollment_preflight(
self._controller_payload(plan, enrollment_id="enr-00000000000000000000"),
secret=get_secret(session, trust.bootstrap_secret_ref, touch=False) or "",
@@ -10,6 +10,7 @@ from ..core.redaction import redact_message
_CONTROLLER = "gitea-deploy-control"
_REPOSITORY = "jens/itworx_mcp_hub"
_COMPOSE_PROJECT = "itworx-mcp-hub"
_TOPOLOGY_CONTRACT = "itworx.mcp-hub-topology/17-v1"
# One-time fail-closed re-attestation for the existing MCP Hub runtime. The
# script never builds, restarts, recreates, stops or deploys a container. It
@@ -28,11 +29,12 @@ import sys
import time
repository, expected_compose_project = sys.argv[1:3]
registry_path = Path("/opt/gitea-deploy/projects.json")
host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")
registry_path = Path("/controller-config/projects.json")
host_registry_path = Path("/controller-hub-releases/controller-config/projects.json")
state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state")
global_lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock")
owned_image_source = "https://gitea.example.com/jens/itworx-mcp-hub"
topology_contract = "itworx.mcp-hub-topology/17-v1"
if repository != "jens/itworx_mcp_hub" or expected_compose_project != "itworx-mcp-hub":
raise SystemExit(40)
@@ -69,6 +71,10 @@ expected_fixed = {
"itworx-mcp-hub-web-1",
"itworx-mcp-hub-worker-1",
}
expected_runtime = expected_fixed | {
"itworx-mcp-hub-connector-ingestion-1",
"itworx-mcp-hub-ingestion-clamd-1",
}
if (
project.get("branch") != "main"
or project.get("slug") != expected_compose_project
@@ -77,6 +83,7 @@ if (
or set(expected_containers) != expected_fixed
):
raise SystemExit(23)
expected_containers = sorted(expected_runtime)
state_path = state_root / f"{expected_compose_project}.json"
project_lock_path = state_root / "locks" / f"{expected_compose_project}.lock"
@@ -310,12 +317,33 @@ with project_lock:
receipt = state.get("live_receipt")
recorded_artifacts = state.get("live_artifacts")
registered_live_containers = (
recorded_artifacts.get("containers")
if isinstance(recorded_artifacts, dict)
else None
)
if isinstance(registered_live_containers, dict) and set(registered_live_containers) == expected_fixed:
print("mcp-hub-topology-migration-required", flush=True)
raise SystemExit(56)
already_reconciled = (
state.get("live_legacy_provenance") is True
and recorded_status == "success"
recorded_status == "success"
and recorded_live_status == "healthy"
and re.fullmatch(r"[0-9a-f]{40}", recorded_live) is not None
and isinstance(recorded_artifacts, dict)
and state.get("compose_project") == expected_compose_project
and isinstance(registered_live_containers, dict)
and set(registered_live_containers) == expected_runtime
and recorded_artifacts.get("topology_contract") == topology_contract
and (
state.get("live_legacy_provenance") is True
or (
isinstance(receipt, dict)
and receipt.get("schema") == "gitea-deploy-receipt.v1"
and receipt.get("repository") == repository
and receipt.get("revision") == recorded_live
)
)
)
if not already_reconciled and (
recorded_status != "failed"
@@ -513,7 +541,11 @@ with project_lock:
if not drift_found:
raise SystemExit(52)
artifacts = {"containers": container_artifacts, "compose_sha256": ""}
artifacts = {
"containers": container_artifacts,
"compose_sha256": "",
"topology_contract": topology_contract,
}
now = int(time.time())
payload = {
"schema_version": 3,
@@ -678,8 +710,16 @@ class McpHubDeploymentReconciliation:
53: "reconciled MCP Hub deployment state could not be written durably",
54: "reconciled MCP Hub deployment state did not verify after write",
55: "unknown MCP Hub live status was not produced by a proven recoverable snapshot-stage failure",
56: "MCP Hub 15-to-17 topology migration requires its guarded protected-main deployment",
}
if exit_code != 0:
if exit_code == 56:
return {
"status": "topology_migration_required",
"repository": _REPOSITORY,
"composeProject": _COMPOSE_PROJECT,
"reason": reasons[exit_code],
}
return {
"status": "validation_failed",
"reason": reasons.get(exit_code, "MCP Hub deployment reconciliation failed closed"),
+22
View File
@@ -142,6 +142,28 @@ def evaluate_policy(
'scope_decision': None,
}
if action == "controller.managed-storage.initialize":
exact_policy = session.exec(
select(Policy).where(
(Policy.action == action) & (Policy.target == "tower/deployment-controller")
)
).first()
allowed = bool(
target == "tower/deployment-controller"
and source != "voice"
and exact_policy is not None
and exact_policy.manual == "allowed"
)
return {
"decision": "allowed" if allowed else "blocked",
"reason": (
"Managed storage initialization requires an exact-target explicit operator policy."
),
"risk": ACTION_RISK.get(action, "high"),
"policy_id": exact_policy.id if allowed and exact_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 {
+6 -1
View File
@@ -170,7 +170,12 @@ def required_permission(path: str, method: str) -> str:
return "settings.manage"
if path.startswith(("/api/settings/connectors", "/api/connectors", "/api/setup/")):
return "connectors.manage"
if path.startswith(("/api/settings/", "/api/system/")) or path in {"/api/ai/rag/reindex", "/api/knowledge/reindex", "/api/enrollments/trust"}:
if path.startswith(("/api/settings/", "/api/system/")) or path in {
"/api/ai/rag/reindex",
"/api/knowledge/reindex",
"/api/enrollments/trust",
"/api/enrollments/trust/storage-root",
}:
return "settings.manage"
if path.startswith("/api/apps/") and path.endswith(("/metadata", "/container-mapping")):
return "inventory.manage"
@@ -38,6 +38,7 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded():
"/mnt/cache:/mnt/cache",
"/mnt/user:/mnt/user",
"/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro",
"/var/lib/docker:/var/lib/docker:ro",
"/var/run/docker.sock:/var/run/docker.sock",
}
assert spec["Cmd"][:2] == ["/bin/bash", "-lc"]
@@ -47,6 +48,13 @@ def test_controller_promoter_helper_spec_is_fixed_and_bounded():
assert revision in command
assert CONTROLLER_VERSION in command
assert "/gitea-repositories/jens/unraid-appops-gateway.git" in command
assert f"itworx-controller-artifact-promoter-{revision[:12]}.XXXXXX" in command
assert "work='/mnt/cache" not in command
assert "rm -rf -- \"$work\"" in command
assert "chmod 0600 \"$work/controller.tar\"" in command
assert "scripts/prepare_controller_hub_release_volume.py" in command
assert "scripts/upgrade_deployment_controller.sh > \"$work/controller.tar\"" in command
assert f"--image-id '{image_id}'" in command
@pytest.mark.parametrize(
@@ -0,0 +1,116 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def load_paths():
spec = importlib.util.spec_from_file_location(
"controller_config_paths_test", ROOT / "ops/gitea-deploy-broker/controller_paths.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_production_code_uses_fixed_protected_mutable_config():
paths = load_paths()
code = Path("/opt/gitea-deploy")
assert paths.controller_config_root(code) == Path("/controller-hub-releases/controller-config")
assert paths.controller_readonly_config_root(code) == Path("/controller-config")
assert paths.controller_config_root(code) != code
def test_private_install_stage_keeps_its_captured_config(tmp_path):
paths = load_paths()
stage = tmp_path / "controller"
assert paths.controller_config_root(stage) == stage
assert paths.controller_readonly_config_root(stage) == stage
def test_config_location_has_no_environment_override(monkeypatch):
paths = load_paths()
monkeypatch.setenv("ITWORX_CONTROLLER_CONFIG_ROOT", "/untrusted")
monkeypatch.setenv("CONTROLLER_CONFIG_ROOT", "/untrusted")
assert paths.controller_config_root(Path("/opt/gitea-deploy")) == Path(
"/controller-hub-releases/controller-config"
)
def test_repopulse_store_keeps_readonly_and_mutable_config_aliases():
paths = load_paths()
monkeypatch_path = ROOT / "ops/gitea-deploy-broker"
import sys
if str(monkeypatch_path) not in sys.path:
sys.path.insert(0, str(monkeypatch_path))
spec = importlib.util.spec_from_file_location(
"repopulse_config_alias_test", monkeypatch_path / "repopulse_adoption_store.py"
)
store = importlib.util.module_from_spec(spec)
spec.loader.exec_module(store)
code = Path("/opt/gitea-deploy")
assert store.CONTROLLER_READONLY_ROOT == paths.controller_readonly_config_root(code)
assert store.CONTROLLER_RW_ROOT == paths.controller_config_root(code)
assert store.CONTROLLER_READONLY_ROOT != store.CONTROLLER_RW_ROOT
def test_registry_updates_use_mutable_config_and_preserve_code(monkeypatch, tmp_path):
monkeypatch.syspath_prepend(str(ROOT / "ops/gitea-deploy-broker"))
import controller_paths
config = tmp_path / "config"
config.mkdir(mode=0o755)
registry = config / "projects.json"
registry.write_text("{}\n", encoding="utf-8")
registry.chmod(0o600)
code = ROOT / "ops/gitea-deploy-broker"
original_code = (code / "status_server.py").read_bytes()
monkeypatch.setattr(controller_paths, "controller_config_root", lambda _root: config)
spec = importlib.util.spec_from_file_location("mutable_config_controller_test", code / "status_server.py")
controller = importlib.util.module_from_spec(spec)
spec.loader.exec_module(controller)
assert controller.PROJECTS_PATH == registry
assert controller.WEBHOOK_SECRET_ROOT == config / "webhook-secrets"
assert controller.APPOPS_DEPLOYMENT_SECRET_ROOT == config / "appops-deployment-secrets"
assert controller.GITEA_VALIDATION_TOKEN_FILE == config / "gitea-validation-token"
assert controller.PRIVATE_TLS_CONFIG_PATH == config / "private-tls.json"
assert controller.CONTROLLER_RELEASE_PATH == code / "controller-release.json"
controller._write_projects({"jens/example": {"slug": "example"}})
assert controller._load_projects() == {"jens/example": {"slug": "example"}}
assert registry.stat().st_mode & 0o777 == 0o600
assert (code / "status_server.py").read_bytes() == original_code
def test_status_reads_hub_state_through_the_fixed_pinned_adapter(monkeypatch, tmp_path):
monkeypatch.syspath_prepend(str(ROOT / "ops/gitea-deploy-broker"))
import mcp_hub_release_store
code = ROOT / "ops/gitea-deploy-broker"
spec = importlib.util.spec_from_file_location("hub_state_controller_test", code / "status_server.py")
controller = importlib.util.module_from_spec(spec)
spec.loader.exec_module(controller)
state = tmp_path / "state"
state.mkdir()
(state / "itworx-mcp-hub.json").write_text('{"live_revision":"untrusted-path"}')
(state / "ordinary-project.json").write_text('{"live_revision":"ordinary"}')
monkeypatch.setattr(controller, "STATE_ROOT", state)
monkeypatch.setattr(
mcp_hub_release_store, "read_hub_state_bytes", lambda: b'{"live_revision":"pinned"}'
)
assert controller._deployment_state("itworx-mcp-hub") == {"live_revision": "pinned"}
assert controller._deployment_state("ordinary-project") == {"live_revision": "ordinary"}
def unsafe():
raise mcp_hub_release_store.ReleaseStoreError("state path changed")
monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", unsafe)
assert controller._deployment_state("itworx-mcp-hub") == {}
monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", lambda: b'[]')
assert controller._deployment_state("itworx-mcp-hub") == {}
monkeypatch.setattr(mcp_hub_release_store, "read_hub_state_bytes", lambda: b'\xff')
assert controller._deployment_state("itworx-mcp-hub") == {}
@@ -0,0 +1,461 @@
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location(
"controller_hub_volume", ROOT / "scripts/prepare_controller_hub_release_volume.py"
)
module = importlib.util.module_from_spec(spec)
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"
def volume():
return {
"Name": module.VOLUME,
"Driver": "local",
"Scope": "local",
"Options": None,
"Labels": dict(module.LABELS),
"Mountpoint": module.MOUNTPOINT.as_posix(),
}
@pytest.mark.parametrize(
("key", "value"),
[
("Name", "operator-volume"),
("Driver", "nfs"),
("Scope", "global"),
("Options", {"device": "/mnt/cache/appdata"}),
("Labels", {}),
("Mountpoint", "/mnt/cache/unsafe"),
],
)
def test_unknown_or_redirected_volume_is_never_adopted(key, value):
item = volume()
item[key] = value
with pytest.raises(module.VolumeTransitionError, match="identity_invalid"):
module.validate_volume(item)
def test_only_the_exact_local_volume_is_accepted():
module.validate_volume(volume())
def model():
return {
"name": "gitea-deploy-runners",
"services": {
"deploy-control": {
"container_name": module.CONTAINER,
"image": "immutable-controller",
"volumes": [
{
"type": "bind",
"source": module.DOCKER_ROOT.as_posix(),
"target": module.DOCKER_ROOT.as_posix(),
"read_only": True,
},
{
"type": "bind",
"source": module.CONTROLLER_DIR.as_posix(),
"target": module.EXECUTABLE_TARGET,
"read_only": True,
},
],
"environment": {"PRIVATE_SETTING": "unchanged"},
},
"runner": {
"image": "pinned-runner",
"volumes": [
{"type": "bind", "source": "/fixed/data", "target": "/data"}
],
},
},
}
@pytest.fixture
def compose(monkeypatch):
baseline = model()
calls = []
def run(arguments, *, data=None, **_kwargs):
calls.append((arguments, data))
if arguments[:3] == ["docker", "image", "inspect"]:
return IMAGE_ID.encode("ascii")
if arguments[-2:] == ["--format", "json"]:
return data if data and data.startswith(b"{") else json.dumps(baseline).encode()
return b""
monkeypatch.setattr(module, "_run", run)
return baseline, calls
def test_compose_uses_protected_code_and_two_config_views(compose):
baseline, calls = compose
rendered = json.loads(module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID))
service = rendered["services"]["deploy-control"]
assert service["image"] == IMAGE_ID
assert service["pull_policy"] == "never"
volumes = service["volumes"]
assert {
"type": "bind",
"source": module._expected_revision_path(REVISION).as_posix(),
"target": module.EXECUTABLE_TARGET,
"read_only": True,
} in volumes
assert {
"type": "volume",
"source": module.VOLUME,
"target": module.TARGET,
"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
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"] == module.CONFIG_TARGET))
assert rendered["volumes"] == {
module.VOLUME: {"name": module.VOLUME, "external": True}
}
assert rendered["services"]["runner"] == baseline["services"]["runner"]
assert service["environment"] == baseline["services"]["deploy-control"]["environment"]
compose_calls = [
arguments for arguments, _ in calls if arguments[:2] == ["docker", "compose"]
]
assert compose_calls
assert all("--project-directory" in arguments for arguments in compose_calls)
assert all(module.MOUNTPOINT.as_posix() in arguments for arguments in compose_calls)
assert all("--env-file" in arguments and "/dev/null" in arguments for arguments in compose_calls)
assert all("-f" in arguments and "-" in arguments for arguments in compose_calls)
assert calls[0][1] == ORIGINAL_COMPOSE
@pytest.mark.parametrize("damage", ["target_collision", "foreign_volume", "graph_rw", "relative_bind", "env_file"])
def test_compose_rejects_unsafe_or_unpinned_activation_inputs(compose, damage):
baseline, _ = compose
if damage == "target_collision":
baseline["services"]["deploy-control"]["volumes"].append(
{"type": "bind", "source": "/unsafe", "target": module.TARGET}
)
elif damage == "foreign_volume":
baseline["services"]["runner"]["volumes"].append(
{"type": "volume", "source": module.VOLUME, "target": "/foreign"}
)
elif damage == "graph_rw":
baseline["services"]["deploy-control"]["volumes"][0]["read_only"] = False
elif damage == "relative_bind":
baseline["services"]["runner"]["volumes"][0]["source"] = "./unsafe"
else:
baseline["services"]["deploy-control"]["env_file"] = [".env"]
with pytest.raises(module.VolumeTransitionError):
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
def test_compose_tag_replacement_cannot_change_controller_startup_image(compose, monkeypatch):
_baseline, calls = compose
original_run = module._run
def replaced_tag(arguments, **kwargs):
if arguments[:3] == ["docker", "image", "inspect"]:
return ("sha256:" + "c" * 64).encode("ascii")
return original_run(arguments, **kwargs)
monkeypatch.setattr(module, "_run", replaced_tag)
with pytest.raises(module.VolumeTransitionError, match="image_reference_changed"):
module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
assert not any(
arguments[-1:] == ["deploy-control"] and "up" in arguments
for arguments, _ in calls
)
def test_config_snapshot_has_exact_allowlist_and_broker_token():
entries = {
"projects.json": (b"{}", 0o600),
"broker-token": (b"secret", 0o600),
"gitea-validation.json": (b"{}", 0o600),
"gitea-validation-token": (b"secret", 0o600),
"private-tls.json": (b"{}", 0o600),
"appops-enrollment-bootstrap": (b"secret", 0o600),
"webhook-secrets/forge": (b"secret", 0o600),
"appops-deployment-secrets/repopulse": (b"secret", 0o600),
"status_server.py": (b"code", 0o644),
}
directories = {
"": 0o755,
"webhook-secrets": 0o700,
"appops-deployment-secrets": 0o700,
}
config, config_dirs = module._config_tree(entries, directories)
assert set(config) == set(entries) - {"status_server.py"}
assert "broker-token" in config
assert config_dirs == directories
def test_unknown_config_file_is_rejected():
entries = {
"projects.json": (b"{}", 0o600),
"broker-token": (b"secret", 0o600),
"gitea-validation.json": (b"{}", 0o600),
"gitea-validation-token": (b"secret", 0o600),
"shell.py": (b"unexpected", 0o600),
}
with pytest.raises(module.VolumeTransitionError, match="config_snapshot_unknown"):
module._config_tree(entries, {"": 0o755})
def test_partial_config_tree_is_accepted_only_for_crash_resume():
partial = {"projects.json": (b"{}", 0o600), "broker-token": (b"secret", 0o600)}
assert module._config_tree(partial, {"": 0o755}, allow_partial=True) == (
partial, {"": 0o755}
)
with pytest.raises(module.VolumeTransitionError, match="snapshot_incomplete"):
module._config_tree(partial, {"": 0o755})
def test_config_snapshot_requires_private_mode_for_every_file():
entries = {
"projects.json": (b"{}", 0o644),
"broker-token": (b"secret", 0o600),
"gitea-validation.json": (b"{}", 0o600),
"gitea-validation-token": (b"secret", 0o600),
}
with pytest.raises(module.VolumeTransitionError, match="config_file_unsafe"):
module._config_tree(entries, {"": 0o755})
def test_previous_private_tls_is_validated_from_the_attested_config_snapshot(
tmp_path, monkeypatch
):
previous = tmp_path / "previous-controller"
previous.mkdir(mode=0o700)
(previous / "status_server.py").write_text("# captured validator\n", encoding="utf-8")
(previous / "private-tls.json").write_bytes(b"captured private config")
calls = []
monkeypatch.setattr(module, "_run", lambda arguments, **kwargs: calls.append((arguments, kwargs)) or b"")
module._validate_previous_private_tls(tmp_path)
assert calls == [(
["python3", str(previous / "status_server.py"), "--check-private-tls-config"],
{"max_output": 4096},
)]
(previous / "private-tls.json").unlink()
calls.clear()
module._validate_previous_private_tls(tmp_path)
assert calls == []
@pytest.mark.skipif(os.name == "nt", reason="private TLS snapshot guard requires POSIX symlink semantics")
def test_previous_private_tls_rejects_symlinked_snapshot(tmp_path):
previous = tmp_path / "previous-controller"
previous.mkdir(mode=0o700)
(previous / "status_server.py").write_text("# captured validator\n", encoding="utf-8")
target = tmp_path / "tls.json"
target.write_bytes(b"unsafe")
(previous / "private-tls.json").symlink_to(target)
with pytest.raises(module.VolumeTransitionError, match="private_tls_unsafe"):
module._validate_previous_private_tls(tmp_path)
@pytest.mark.parametrize("kind", ["artifacts", "config"])
def test_writer_is_exact_image_only_bounded_and_has_no_host_paths(kind):
command = module._writer_spec(IMAGE_ID, REVISION, kind=kind)
assert command[0:3] == ["docker", "create", "--name"]
assert module.WRITER_NAME in command
assert IMAGE_ID in command
assert "--network" in command and command[command.index("--network") + 1] == "none"
assert "--read-only" in command and "--cap-drop" in command
assert "--cpu-quota" in command and "--memory" in command and "--pids-limit" in command
assert "--mount" in command
mount = command[command.index("--mount") + 1]
assert mount == f"type=volume,source={module.VOLUME},target={module.TARGET},volume-nocopy"
assert not any(item.startswith(("/mnt/", "/var/lib/")) for item in command)
def test_promoted_compose_actions_use_only_stdin_and_fixed_service(compose):
baseline, calls = compose
rendered = module.rendered_model(ORIGINAL_COMPOSE, REVISION, IMAGE_ID)
module.compose_action(rendered, "stop")
module.compose_action(rendered, "up")
assert calls[-2][0][-2:] == ["stop", module.SERVICE]
assert calls[-1][0][-5:] == ["up", "-d", "--force-recreate", "--pull", "never", module.SERVICE][-5:]
assert calls[-2][1] == rendered
assert calls[-1][1] == rendered
assert baseline["name"] == "gitea-deploy-runners"
def test_installer_has_no_shared_executable_stage_or_compose_path():
source = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8")
assert "mktemp -d -p /tmp \"itworx-controller-upgrade-" in source
assert 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE"' not in source
assert 'python3 "${work}/${PREPARER_NAME}"' in source
assert 'mv -- "$CONTROLLER_DIR"' not in source
assert 'cp -a -- "$CONTROLLER_DIR"' not in source
assert "ops/gitea-deploy-broker/mcp_hub_release_store.py" in source
def test_installer_release_order_matches_renderer_contract():
renderer_spec = importlib.util.spec_from_file_location(
"controller_renderer", ROOT / "scripts/prepare_controller_artifact.py"
)
renderer = importlib.util.module_from_spec(renderer_spec)
renderer_spec.loader.exec_module(renderer)
assert tuple(module.RELEASE_FILES) == tuple(renderer._ARTIFACT_FILES)
assert module.RELEASE_FILES[:3] == (
"controller_paths.py", "mcp_hub_release_store.py", "deploy.py"
)
@pytest.mark.skipif(os.name == "nt", reason="dirfd swap test requires POSIX openat")
def test_mirror_updates_stay_on_the_open_directory_after_path_swap(tmp_path, monkeypatch):
original = tmp_path / "controller"
moved = tmp_path / "controller-pinned"
original.mkdir()
before_entries = {
"controller_paths.py": (b"prior paths", 0o644),
"mcp_hub_release_store.py": (b"prior Hub helper", 0o644),
}
for name, (value, _mode) in before_entries.items():
(original / name).write_bytes(value)
prior = {
name: (f"prior:{name}".encode(), 0o644)
for name in module.LEGACY_RELEASE_FILES_2_1_13
}
prior["controller-release.json"] = (b"prior manifest", 0o644)
updated = {
name: (f"updated:{name}".encode(), 0o644)
for name in module.RELEASE_FILES
}
updated["controller-release.json"] = (b"updated manifest", 0o644)
prior_tar = module._release_tar_bytes("b" * 40, (prior, {"": 0o755}))
updated_tar = module._release_tar_bytes("c" * 40, (updated, {"": 0o755}))
mirror_tar = module._release_tar_bytes("a" * 40, (before_entries, {"": 0o755}))
current_path = original
monkeypatch.setattr(
module, "_directory",
lambda _path, _anchor: os.open(current_path, os.O_RDONLY | os.O_DIRECTORY),
)
read_item = module._file_info_at_optional
swapped = False
def swap_after_first_read(descriptor, name):
nonlocal swapped
value = read_item(descriptor, name)
if not swapped:
original.rename(moved)
original.mkdir()
swapped = True
return value
monkeypatch.setattr(module, "_file_info_at_optional", swap_after_first_read)
module._sync_controller_mirror(
prior_tar, updated_tar, mirror_tar, direction="forward"
)
assert swapped and list(original.iterdir()) == []
assert (moved / "controller_paths.py").read_bytes() == b"updated:controller_paths.py"
assert (moved / "mcp_hub_release_store.py").read_bytes() == b"updated:mcp_hub_release_store.py"
current_path = moved
monkeypatch.setattr(module, "_file_info_at_optional", read_item)
module._sync_controller_mirror(
updated_tar, prior_tar, mirror_tar, direction="rollback"
)
assert {path.name: path.read_bytes() for path in moved.iterdir()} == {
name: value for name, (value, _mode) in before_entries.items()
}
@pytest.mark.skipif(os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
reason="private /tmp ownership validation requires root on POSIX")
def test_private_stage_rejects_symlinked_source_directory():
import shutil
import uuid
work = Path("/tmp") / f"itworx-controller-upgrade-{uuid.uuid4().hex[:12]}.Ab3xY7"
source = work / "source"
source.mkdir(parents=True, mode=0o700)
source.chmod(0o700)
work.chmod(0o700)
try:
module._validate_private_stage(source)
source.rename(work / "source-real")
source.symlink_to(work / "source-real", target_is_directory=True)
with pytest.raises(module.VolumeTransitionError, match="private_stage_invalid"):
module._validate_private_stage(source)
finally:
shutil.rmtree(work)
def test_source_compose_preflight_rejects_host_file_reads_before_compose(compose):
_baseline, calls = compose
unsafe = (
b"name: gitea-deploy-runners\nservices:\n deploy-control:\n"
b" image: x\n env_file: /tmp/secret\n"
)
with pytest.raises(module.VolumeTransitionError, match="external_source_invalid"):
module.rendered_model(unsafe, REVISION, IMAGE_ID)
assert calls == []
def test_capture_program_is_fixed_descriptor_based_and_compiles():
legacy = module._controller_capture_source(config_root_in_code=True)
protected = module._controller_capture_source(config_root_in_code=False)
compile(legacy, "legacy-controller-capture", "exec")
compile(protected, "protected-controller-capture", "exec")
assert "config_root='/opt/gitea-deploy'" in legacy
assert "config_root='/controller-config'" in protected
assert "os.O_NOFOLLOW" in legacy and "os.fstat(fd)" in legacy
assert "open(path,'rb')" not in legacy
def test_prior_release_capture_must_match_protected_git_tree(monkeypatch):
files = module.LEGACY_RELEASE_FILES_2_1_5
entries = {name: (f"source:{name}".encode(), 0o644) for name in files}
source_revision = "32a6eece0e3e1276946abdc04196c819daf02e2a"
source_tree = "e0d7e80b79504b964d968e51e0d21c288cac8f0d"
manifest = {
"apiVersion": "itworx.controller-artifact/v1",
"controllerVersion": "2.1.5",
"sourceRepository": "jens/unraid-appops-gateway",
"sourceRevision": source_revision,
"sourceTree": source_tree,
"artifactSha256": module._artifact_digest(entries, files),
"builtAt": "2026-10-01T00:00:00.000Z",
}
entries["controller-release.json"] = (
json.dumps(manifest, separators=(",", ":")).encode(), 0o644
)
identity = module._release_identity(entries)
outputs = {"rev-parse": (source_tree + "\n").encode()}
outputs.update({
f"ops/gitea-deploy-broker/{name}": entries[name][0]
for name in files if name != "run_repopulse_adoption.py"
})
outputs["scripts/run_repopulse_adoption.py"] = entries["run_repopulse_adoption.py"][0]
def run(arguments, **_kwargs):
return outputs[arguments[-1].split(":", 1)[-1]] if arguments[-2] == "show" else outputs["rev-parse"]
monkeypatch.setattr(module, "_run", run)
module._verify_git_source_identity(entries, identity)
outputs["ops/gitea-deploy-broker/deploy.py"] = b"replaced source"
with pytest.raises(module.VolumeTransitionError, match="source_mismatch"):
module._verify_git_source_identity(entries, identity)
@@ -181,60 +181,55 @@ def test_successful_reconciliation_can_be_rolled_back_before_controller_swap(tmp
assert not journal.exists()
def test_upgrade_stops_old_writer_before_locking_cache_and_reconciles_while_stopped():
def test_upgrade_prepares_before_stop_and_reconciles_between_stop_and_activation():
script = UPGRADE_PATH.read_text(encoding="utf-8")
cache_lock = 'flock -x "$cache_lock_fd"'
stop = 'writers_stopped=1\ndocker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"'
reconcile = 'python3 "${stage}/reconcile_controller_state.py" \\\n --legacy-root'
backup = 'cp -a -- "$CONTROLLER_DIR" "$backup/controller"'
swap = 'mv -- "$CONTROLLER_DIR" "$previous"'
prepare = 'backup="$(python3 "${work}/${PREPARER_NAME}" prepare \\'
stop = 'python3 "${work}/${PREPARER_NAME}" stop --backup "$backup"'
reconcile = 'python3 "${work}/reconcile_controller_state.py" \\\n --legacy-root'
activate = 'python3 "${work}/${PREPARER_NAME}" activate --backup "$backup"'
assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in script
assert script.index(stop) < script.index(cache_lock)
assert script.index(stop) < script.index(backup)
assert script.index(cache_lock) < script.index(backup)
assert script.index(backup) < script.index(reconcile)
assert script.index(reconcile) < script.index(swap)
assert 'flock -u "$cache_lock_fd"' not in script
assert script.index(prepare) < script.index(stop)
assert script.index(stop) < script.index(reconcile)
assert script.index(reconcile) < script.index(activate)
assert 'flock -x "$promotion_lock_fd"' in script
assert 'mv -- "$CONTROLLER_DIR"' not in script
assert 'cp -a -- "$CONTROLLER_DIR"' not in script
def test_upgrade_retains_and_validates_optional_private_tls_before_stopping_controller():
def test_upgrade_prepares_and_validates_captured_optional_private_tls_before_stopping_controller():
script = UPGRADE_PATH.read_text(encoding="utf-8")
stage_copy = 'cp -a -- "$CONTROLLER_DIR" "${stage}/controller"'
tls_gate = (
'if [[ -e "${stage}/controller/private-tls.json" '
'|| -L "${stage}/controller/private-tls.json" ]]; then\n'
' python3 "${stage}/controller/status_server.py" --check-private-tls-config\n'
'fi'
)
stop = (
'writers_stopped=1\ndocker compose --project-directory "$ROOT" '
'-f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"'
)
prepare = 'backup="$(python3 "${work}/${PREPARER_NAME}" prepare \\'
stop = 'python3 "${work}/${PREPARER_NAME}" stop --backup "$backup"'
preparer = (ROOT / "scripts/prepare_controller_hub_release_volume.py").read_text(encoding="utf-8")
assert stage_copy in script
assert script.index(stage_copy) < script.index(tls_gate)
assert script.index(tls_gate) < script.index(stop)
assert 'cp -a -- "$CONTROLLER_DIR" "$backup/controller"' in script
assert "_validate_previous_private_tls(stage_root)" in preparer
assert script.index(prepare) < script.index(stop)
def test_upgrade_rolls_back_state_before_restoring_or_starting_the_old_writer():
script = UPGRADE_PATH.read_text(encoding="utf-8")
restore = script[script.index("restore_previous() {") : script.index("cleanup() {")]
stop = 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"'
stop = 'if ! python3 "${work}/${PREPARER_NAME}" rollback --backup "$backup" --stop-only; then'
rollback = '--rollback'
restore_controller = 'mv -- "$previous" "$CONTROLLER_DIR"'
start = 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" up -d --force-recreate "$COMPOSE_SERVICE"'
restore_controller = 'if ! python3 "${work}/${PREPARER_NAME}" rollback --backup "$backup"; then'
assert restore.index(stop) < restore.index(rollback)
assert restore.index(rollback) < restore.index(restore_controller)
assert restore.index(restore_controller) < restore.index(start)
assert "the old writer will remain stopped" in script
reconcile = 'python3 "${stage}/reconcile_controller_state.py" \\\n --legacy-root'
assert script.index("reconciliation_active=1", script.index("writers_stopped=1")) < script.index(reconcile)
assert "controller remains stopped" in script
assert 'mv -- "$previous"' not in restore
assert 'docker compose --project-directory "$ROOT"' not in restore
reconcile = 'python3 "${work}/reconcile_controller_state.py"'
stopped = script.index("writers_stopped=1", script.index(stop))
reconcile_index = script.index(reconcile, stopped)
activate_index = script.index(
'python3 "${work}/${PREPARER_NAME}" activate --backup "$backup"',
reconcile_index,
)
assert stopped < reconcile_index < activate_index
assert '[[ -f "$migration_journal" && ! -L "$migration_journal" ]]' in restore
assert '[[ -e "$migration_journal" || -L "$migration_journal" ]]' in restore
+432
View File
@@ -2226,6 +2226,96 @@ def test_managed_repopulse_render_accepts_only_its_read_only_source_mount(
deploy_module.validate_managed_v1_runtime(project, "a" * 40, "jens/repopulse")
def test_managed_appdata_roots_are_fixed_and_bound_to_each_registration(
deploy_module, monkeypatch, tmp_path
):
legacy_root = tmp_path / "legacy-appdata"
managed_root = tmp_path / "managed-volume" / "_data"
monkeypatch.setattr(deploy_module, "LEGACY_MANAGED_APPCONFIG_ROOT", legacy_root)
monkeypatch.setattr(deploy_module, "MANAGED_APPCONFIG_ROOT", legacy_root)
monkeypatch.setattr(deploy_module, "MANAGED_APPCONFIG_VOLUME_ROOT", managed_root)
monkeypatch.setattr(deploy_module, "managed_appdata_volume_initialized", lambda: False)
assert deploy_module._registered_managed_appdata_root({}) == legacy_root
assert deploy_module._registered_managed_appdata_root(
{"managed_appdata_root": legacy_root.as_posix()}
) == legacy_root
with pytest.raises(deploy_module.DeployError, match="not initialized and available"):
deploy_module._registered_managed_appdata_root(
{"managed_appdata_root": managed_root.as_posix()}
)
with pytest.raises(deploy_module.DeployError, match="fixed registered roots"):
deploy_module._registered_managed_appdata_root(
{"managed_appdata_root": (tmp_path / "arbitrary").as_posix()}
)
monkeypatch.setattr(deploy_module, "managed_appdata_volume_initialized", lambda: True)
assert deploy_module._registered_managed_appdata_root(
{"managed_appdata_root": managed_root.as_posix()}
) == managed_root
for root, host in ((legacy_root, "legacy.example"), (managed_root, "managed.example")):
env_path = root / "demo" / "deploy.env"
env_path.parent.mkdir(parents=True)
env_path.write_text(f"APPOPS_PUBLIC_URL=https://{host}\n", encoding="utf-8")
if os.name != "nt":
env_path.chmod(0o600)
project = {
"appops_managed_target": True,
"managed_plan_version": "itworx.deployment/v1",
"managed_profile": "internal-webapp",
"managed_application_id": "demo",
"managed_runtime_environment_keys": ["APPOPS_PUBLIC_URL"],
"env_source": (legacy_root / "demo" / "deploy.env").as_posix(),
}
assert deploy_module.managed_runtime_environment(project) == {
"APPOPS_PUBLIC_URL": "https://legacy.example"
}
project["managed_appdata_root"] = managed_root.as_posix()
project["env_source"] = (managed_root / "demo" / "deploy.env").as_posix()
assert deploy_module.managed_runtime_environment(project) == {
"APPOPS_PUBLIC_URL": "https://managed.example"
}
def test_managed_project_path_is_derived_from_its_stored_appdata_root(
deploy_module, monkeypatch
):
monkeypatch.setattr(deploy_module, "managed_appdata_volume_initialized", lambda: True)
monkeypatch.setattr(deploy_module, "git_path_exists", lambda *_args: True)
project = {
"containers": ["demo"],
"source_dir": (deploy_module.EXACT_SOURCE_ROOT / "demo").as_posix(),
"exact_source": True,
"compose_dir": ".",
"compose_files": ["compose.yml"],
"compose_project": "demo",
"managed_plan_version": "itworx.deployment/v1",
"managed_application_id": "demo",
"managed_appdata_path": "/srv/example/appdata/demo",
"managed_image_repository": "demo",
"managed_image_tag": "production",
"managed_proxy_network": "itworx-proxy",
"managed_internal_port": 8000,
"appops_deployment_secret_file": "jens__demo",
}
deploy_module.validate_project_config(Path("repo.git"), project, "a" * 40)
managed = {
**project,
"managed_appdata_root": deploy_module.MANAGED_APPCONFIG_VOLUME_ROOT.as_posix(),
"managed_appdata_path": (
deploy_module.MANAGED_APPCONFIG_VOLUME_ROOT / "demo"
).as_posix(),
}
deploy_module.validate_project_config(Path("repo.git"), managed, "a" * 40)
remapped = {**managed, "managed_appdata_path": "/srv/example/appdata/demo"}
with pytest.raises(deploy_module.DeployError, match="not derived"):
deploy_module.validate_project_config(Path("repo.git"), remapped, "a" * 40)
def test_repopulse_project_registry_requires_its_fixed_build_identity(
deploy_module, monkeypatch
):
@@ -4582,6 +4672,25 @@ def _appops_plan(revision: str) -> dict[str, object]:
}
def test_appops_plan_root_must_match_the_registered_target_root(status_module):
project = _appops_project()
plan = _appops_plan("a" * 40)
assert status_module._appops_plan({"jens/repo-a": project}, plan) == (
"jens/repo-a", "a" * 40, project,
)
project["managed_appdata_root"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
project["managed_appdata_path"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data/repo-a"
alternate_plan = {**plan, "trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data"}
assert status_module._appops_plan({"jens/repo-a": project}, alternate_plan) == (
"jens/repo-a", "a" * 40, project,
)
assert status_module._appops_plan({"jens/repo-a": project}, plan) is None
drifted = {**project, "managed_appdata_path": "/srv/example/appdata/repo-a"}
assert status_module._appops_plan({"jens/repo-a": drifted}, alternate_plan) is None
def _write_appops_secret(root: Path, name: str, value: bytes) -> None:
root.mkdir(mode=0o700)
root.chmod(0o700)
@@ -5838,6 +5947,7 @@ def _controller_enrollment_payload(*, credential: bool = False) -> dict[str, obj
"profile": "internal-webapp-oidc",
"container_name": "wsbc-portal",
"compose_file": ".itworx/deployment.yml",
"trusted_appdata_root": "/srv/example/appdata",
"appdata_path": "/srv/example/appdata/wsbc-portal",
"data_path": "/srv/example/appdata/wsbc-portal/data",
"runtime_uid": 1654,
@@ -5871,6 +5981,7 @@ def _repopulse_controller_enrollment_payload(*, credential: bool = False) -> dic
"profile": "repopulse-authenticated",
"container_name": "repopulse",
"compose_file": ".itworx/deployment.yml",
"trusted_appdata_root": "/srv/example/appdata",
"appdata_path": "/srv/example/appdata/repopulse",
"data_path": "/srv/example/appdata/repopulse/data",
"runtime_uid": 10001,
@@ -5929,8 +6040,10 @@ def test_controller_repopulse_runtime_seal_fixes_gitea_url_and_hides_values(stat
env_path = app_root / "deploy.managed.env"
env_path.write_text("", encoding="utf-8")
monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", appdata_root)
monkeypatch.setattr(status_module, "LEGACY_APPDATA_ROOT", appdata_root)
monkeypatch.setattr(status_module, "EXACT_SOURCE_ROOT", tmp_path / "sources")
payload = _repopulse_controller_enrollment_payload(credential=True)
payload["trusted_appdata_root"] = appdata_root.as_posix()
payload["appdata_path"] = app_root.as_posix()
payload["data_path"] = (app_root / "data").as_posix()
enrollment = status_module._controller_enrollment(payload, require_credential=True)
@@ -6106,6 +6219,76 @@ def test_controller_enrollment_contract_rejects_arbitrary_compose_and_paths(stat
assert status_module._controller_enrollment(unsafe, require_credential=True) is None
def test_controller_enrollment_derives_paths_from_the_selected_fixed_root(status_module, monkeypatch):
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", Path(managed_root))
monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda root: root.as_posix() == managed_root)
alternate = _controller_enrollment_payload()
alternate["trusted_appdata_root"] = managed_root
alternate["appdata_path"] = managed_root + "/wsbc-portal"
alternate["data_path"] = managed_root + "/wsbc-portal/data"
enrollment = status_module._controller_enrollment(alternate, require_credential=False)
assert enrollment is not None
project = status_module._enrollment_project(enrollment)
assert project["managed_appdata_root"] == managed_root
assert project["managed_appdata_path"] == managed_root + "/wsbc-portal"
unsafe_path = {**alternate, "data_path": "/srv/example/appdata/wsbc-portal/data"}
assert status_module._controller_enrollment(unsafe_path, require_credential=False) is None
unsupported_root = {**alternate, "trusted_appdata_root": "/srv/example/other"}
assert status_module._controller_enrollment(unsupported_root, require_credential=False) is None
def test_controller_accepts_legacy_requests_after_managed_marker_is_initialized(status_module, monkeypatch):
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
monkeypatch.setattr(status_module, "_refresh_appdata_root_selection", lambda: Path(managed_root))
monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda root: root.as_posix() == managed_root)
legacy = _controller_enrollment_payload()
assert legacy["trusted_appdata_root"] == "/srv/example/appdata"
enrollment = status_module._controller_enrollment(legacy, require_credential=False)
assert enrollment is not None
assert enrollment["trusted_appdata_root"] == "/srv/example/appdata"
assert "managed_appdata_root" not in status_module._enrollment_project(enrollment)
def test_controller_accepts_legacy_v1_payload_without_root_field(status_module):
legacy = _controller_enrollment_payload()
legacy.pop("trusted_appdata_root")
enrollment = status_module._controller_enrollment(legacy, require_credential=False)
assert enrollment is not None
assert enrollment["trusted_appdata_root"] == "/srv/example/appdata"
assert "managed_appdata_root" not in status_module._enrollment_project(enrollment)
def test_repopulse_enrollment_remains_bound_to_legacy_appdata_root(status_module, monkeypatch):
monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", Path("/var/lib/docker/volumes/itworx-managed-appdata/_data"))
payload = _repopulse_controller_enrollment_payload()
payload["trusted_appdata_root"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
payload["appdata_path"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data/repopulse"
payload["data_path"] = "/var/lib/docker/volumes/itworx-managed-appdata/_data/repopulse/data"
assert status_module._controller_enrollment(payload, require_credential=False) is None
def test_fixed_repopulse_profile_stays_legacy_with_managed_marker(status_module, monkeypatch):
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
monkeypatch.setattr(status_module, "_refresh_appdata_root_selection", lambda: Path(managed_root))
monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda root: root.as_posix() == managed_root)
enrollment = status_module._controller_enrollment(
_repopulse_controller_enrollment_payload(),
require_credential=False,
)
assert enrollment is not None
assert enrollment["trusted_appdata_root"] == "/srv/example/appdata"
def test_controller_runtime_seal_accepts_only_profile_owned_keys(status_module, tmp_path, monkeypatch):
appdata_root = tmp_path / "appdata"
app_root = appdata_root / "wsbc-portal"
@@ -6113,8 +6296,10 @@ def test_controller_runtime_seal_accepts_only_profile_owned_keys(status_module,
env_path = app_root / "deploy.env"
env_path.write_text("", encoding="utf-8")
monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", appdata_root)
monkeypatch.setattr(status_module, "LEGACY_APPDATA_ROOT", appdata_root)
monkeypatch.setattr(status_module, "EXACT_SOURCE_ROOT", tmp_path / "sources")
payload = _controller_enrollment_payload(credential=True)
payload["trusted_appdata_root"] = appdata_root.as_posix()
payload["appdata_path"] = app_root.as_posix()
payload["data_path"] = (app_root / "data").as_posix()
enrollment = status_module._controller_enrollment(payload, require_credential=True)
@@ -6154,6 +6339,7 @@ def test_controller_enrollment_replay_requires_the_existing_target_credential(st
projects_path.write_text(json.dumps({"jens/windows-support-bundle-collector": project}), encoding="utf-8")
monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path)
monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_SECRET_ROOT", secrets_root)
monkeypatch.setattr(status_module, "appdata_root_is_safe", lambda _root: True)
enrollment["target_credential"] = "y" * 64
status, _evidence = status_module._enroll_controller_target(enrollment)
@@ -7316,3 +7502,249 @@ def test_workmesh_backup_receipt_requires_bound_manifest_and_verified_restore_by
assert status_module._verified_workmesh_backup_receipt(
request_id, candidate, preflight, inputs
) is None
def _managed_storage_http_request(
status_module,
server,
*,
body: bytes,
nonce: str,
secret: bytes | None,
signature: str | None = None,
signed_path: str | None = None,
):
path = status_module.MANAGED_APPDATA_INITIALIZE_PATH
timestamp = str(int(status_module.time.time()))
headers = {"Content-Type": "application/json", "Content-Length": str(len(body))}
if nonce:
headers["X-ITWorx-Deployment-Nonce"] = nonce
if timestamp:
headers["X-ITWorx-Deployment-Timestamp"] = timestamp
if signature is None and secret is not None:
signature = status_module._request_signature(
secret,
method="POST",
path=signed_path or path,
timestamp=timestamp,
nonce=nonce,
raw_body=body,
)
if signature is not None:
headers["X-ITWorx-Deployment-Signature"] = signature
connection = http.client.HTTPConnection(*server.server_address, timeout=2)
connection.request("POST", path, body=body, headers=headers)
response = connection.getresponse()
status = response.status
payload = json.loads(response.read())
connection.close()
return status, payload, headers
def test_managed_appdata_initializer_http_requires_exact_signed_one_time_request(
status_module, tmp_path, monkeypatch
):
secret = b"managed-storage-bootstrap-" + b"x" * 48
secret_path = tmp_path / "bootstrap"
secret_path.write_bytes(secret)
secret_path.chmod(0o600)
nonce_root = tmp_path / "nonces"
monkeypatch.setattr(status_module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", secret_path)
monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", nonce_root)
monkeypatch.setattr(
status_module.Handler,
"_managed_storage_initialization_prerequisites",
lambda _handler: True,
)
initialized = []
monkeypatch.setattr(
status_module,
"ensure_managed_appdata_root",
lambda: initialized.append(True) or "initialized",
)
monkeypatch.setattr(
status_module,
"_refresh_appdata_root_selection",
lambda: status_module.MANAGED_APPDATA_ROOT,
)
server = status_module.BoundedThreadingHTTPServer(
("127.0.0.1", 0), status_module.Handler
)
serving = threading.Thread(target=server.serve_forever, daemon=True)
serving.start()
body = json.dumps(
{
"apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION,
"target": status_module.MANAGED_APPDATA_INITIALIZE_TARGET,
},
separators=(",", ":"),
).encode("utf-8")
try:
success_status, success_payload, success_headers = _managed_storage_http_request(
status_module,
server,
body=body,
nonce="a" * 32,
secret=secret,
)
assert success_status == 200
assert success_payload == {
"status": "initialized",
"trustedAppdataRoot": status_module.MANAGED_APPDATA_ROOT.as_posix(),
"backupLocation": "dockerRootDir",
}
assert initialized == [True]
assert secret.decode("ascii") not in json.dumps(success_payload)
replay = http.client.HTTPConnection(*server.server_address, timeout=2)
replay.request(
"POST",
status_module.MANAGED_APPDATA_INITIALIZE_PATH,
body=body,
headers=success_headers,
)
replay_response = replay.getresponse()
replay_payload = json.loads(replay_response.read())
replay.close()
assert replay_response.status == 403
assert replay_payload == {"status": "authentication_rejected"}
assert initialized == [True]
invalid_requests = [
_managed_storage_http_request(
status_module,
server,
body=body,
nonce="b" * 32,
secret=secret,
signature="0" * 64,
),
_managed_storage_http_request(
status_module,
server,
body=body,
nonce="c" * 32,
secret=secret,
signed_path="/v1/appops/enrollments/capabilities",
),
_managed_storage_http_request(
status_module,
server,
body=body,
nonce="d" * 32,
secret=None,
),
]
assert [status for status, _payload, _headers in invalid_requests] == [403, 403, 403]
assert initialized == [True]
for index, payload in enumerate(
[
{
"apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION,
"target": "tower/other-controller",
},
{
"apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION,
"target": status_module.MANAGED_APPDATA_INITIALIZE_TARGET,
"path": "C:/private/sentinel",
},
],
start=1,
):
raw_body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
rejected_status, rejected_payload, _headers = _managed_storage_http_request(
status_module,
server,
body=raw_body,
nonce=f"{index:032x}",
secret=secret,
)
assert rejected_status == 400
assert rejected_payload == {"status": "invalid_managed_storage_request"}
assert "C:/private/sentinel" not in json.dumps(rejected_payload)
oversized_status, oversized_payload, _headers = _managed_storage_http_request(
status_module,
server,
body=b"x" * 1025,
nonce="e" * 32,
secret=None,
)
assert oversized_status == 413
assert oversized_payload == {"status": "invalid_body_size"}
assert initialized == [True]
finally:
server.shutdown()
server.server_close()
serving.join(timeout=2)
def test_managed_appdata_initializer_http_prerequisite_and_refusal_are_sanitized(
status_module, tmp_path, monkeypatch
):
secret = b"managed-storage-bootstrap-" + b"y" * 48
secret_path = tmp_path / "bootstrap"
secret_path.write_bytes(secret)
secret_path.chmod(0o600)
monkeypatch.setattr(status_module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", secret_path)
monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", tmp_path / "nonces")
prerequisites = {"ready": False}
monkeypatch.setattr(
status_module.Handler,
"_managed_storage_initialization_prerequisites",
lambda _handler: prerequisites["ready"],
)
calls = []
monkeypatch.setattr(
status_module,
"ensure_managed_appdata_root",
lambda: calls.append(True) or "initialized",
)
monkeypatch.setattr(
status_module,
"_refresh_appdata_root_selection",
lambda: status_module.MANAGED_APPDATA_ROOT,
)
server = status_module.BoundedThreadingHTTPServer(
("127.0.0.1", 0), status_module.Handler
)
serving = threading.Thread(target=server.serve_forever, daemon=True)
serving.start()
body = json.dumps(
{
"apiVersion": status_module.MANAGED_APPDATA_INITIALIZE_API_VERSION,
"target": status_module.MANAGED_APPDATA_INITIALIZE_TARGET,
},
separators=(",", ":"),
).encode("utf-8")
try:
status, payload, _headers = _managed_storage_http_request(
status_module, server, body=body, nonce="f" * 32, secret=secret
)
assert status == 503
assert payload == {"status": "managed_storage_prerequisite_unavailable"}
assert calls == []
prerequisites["ready"] = True
def refuse_with_sensitive_diagnostic():
raise status_module.UnsafeManagedAppdataRoot(
"secret/path sentinel C:/private/appdata"
)
monkeypatch.setattr(
status_module, "ensure_managed_appdata_root", refuse_with_sensitive_diagnostic
)
status, payload, _headers = _managed_storage_http_request(
status_module, server, body=body, nonce="1" * 32, secret=secret
)
assert status == 409
assert payload == {"status": "managed_storage_initialization_refused"}
assert "secret/path sentinel" not in json.dumps(payload)
assert "C:/private/appdata" not in json.dumps(payload)
assert secret.decode("ascii") not in json.dumps(payload)
finally:
server.shutdown()
server.server_close()
serving.join(timeout=2)
+170 -1
View File
@@ -74,12 +74,14 @@ def _trust():
class _Broker:
appdata_root = "/srv/example/appdata"
def is_configured(self):
return True
def enrollment_capabilities(self, *, secret):
assert secret == "b" * 64
return {"status": "ready"}
return {"status": "ready", "trustedAppdataRoot": self.appdata_root}
def enrollment_preflight(self, enrollment, *, secret):
assert secret == "b" * 64
@@ -251,6 +253,25 @@ def test_enrollment_schema_rejects_plaintext_secrets_and_untrusted_topology():
raise AssertionError("wildcard hostname must be rejected")
def test_appdata_root_defaults_to_legacy_and_allows_only_the_fixed_managed_root():
plan = _plan()
assert plan.trusted_appdata_root == "/srv/example/appdata"
assert plan.appdata_path == "/srv/example/appdata/wsbc-portal"
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
managed = _plan(trusted_appdata_root=managed_root)
assert managed.appdata_path == managed_root + "/wsbc-portal"
plan_payload = _plan().model_dump()
plan_payload["trusted_appdata_root"] = "/srv/example/other"
trust_payload = _trust().model_dump()
trust_payload["trusted_appdata_root"] = "/srv/example/other"
with pytest.raises(ValueError):
EnrollmentPlanV1.model_validate(plan_payload)
with pytest.raises(ValueError):
EnrollmentTrustConfigV1.model_validate(trust_payload)
def test_repopulse_profile_accepts_only_its_fixed_authenticated_target():
plan = _repopulse_plan()
assert plan.application_id == "repopulse"
@@ -291,6 +312,34 @@ def test_repopulse_preflight_requires_existing_secret_and_trusted_npm_access_lis
assert session.exec(select(EnrollmentJob)).all() == []
def test_repopulse_legacy_profile_remains_usable_when_new_target_root_is_managed(tmp_path):
class RecordingBroker(_RepoPulseBroker):
def __init__(self):
super().__init__()
self.preflight_payloads = []
def enrollment_preflight(self, enrollment, *, secret):
self.preflight_payloads.append(dict(enrollment))
return super().enrollment_preflight(enrollment, secret=secret)
broker = RecordingBroker()
with _session(tmp_path) as session:
_configure(session)
set_secret(session, "REPOPULSE_GITEA_TOKEN", "opaque-gitea-token")
EnrollmentPlane.set_trust_config(
session,
_trust().model_copy(update={
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
"managed_appdata_backup_acknowledged": True,
}),
)
result = _plane(broker=broker, npm=_RepoPulseNpm()).preflight(session, _repopulse_plan())
assert result["status"] == "ready"
assert "trusted_appdata_root_mismatch" not in result["blockers"]
assert broker.preflight_payloads[0]["trusted_appdata_root"] == "/srv/example/appdata"
def test_repopulse_enrollment_seals_exact_runtime_values_without_exposing_secrets(tmp_path):
with _session(tmp_path) as session:
_configure(session)
@@ -347,6 +396,126 @@ def test_capabilities_require_a_live_bootstrap_controller_contract(tmp_path):
assert result["controller"]["status"] == "ready"
def test_capabilities_block_when_controller_root_differs_from_operator_trust(tmp_path):
class AlternateRootBroker(_Broker):
appdata_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
with _session(tmp_path) as session:
_configure(session)
result = _plane(broker=AlternateRootBroker()).capabilities(session)
assert result["targetEnrollment"]["status"] == "unavailable"
assert result["controller"]["status"] == "unavailable"
assert result["controller"]["reason"] == "trusted_appdata_root_mismatch"
def test_capabilities_treat_roots_as_independent_availability_not_global_selection(tmp_path):
legacy_root = "/srv/example/appdata"
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
class MultiRootBroker(_Broker):
def enrollment_capabilities(self, *, secret):
assert secret == "b" * 64
return {
"status": "ready",
"trustedAppdataRoot": managed_root,
"availableTrustedAppdataRoots": [
{"root": legacy_root, "status": "ready", "reason": ""},
{"root": managed_root, "status": "ready", "reason": ""},
],
"managedStorage": {"status": "ready", "initializationAvailable": False},
}
with _session(tmp_path) as session:
_configure(session)
plane = _plane(broker=MultiRootBroker())
legacy = plane.capabilities(session)
assert legacy["targetEnrollment"]["status"] == "ready"
assert legacy["managedStorage"]["status"] == "not_selected"
assert legacy["availableTrustedAppdataRoots"] == [
{"root": legacy_root, "status": "ready"},
{"root": managed_root, "status": "ready"},
]
EnrollmentPlane.set_trust_config(
session,
_trust().model_copy(update={
"trusted_appdata_root": managed_root,
"managed_appdata_backup_acknowledged": True,
}),
)
managed = plane.capabilities(session)
assert managed["targetEnrollment"]["status"] == "ready"
assert managed["managedStorage"]["status"] == "ready"
assert managed["availableTrustedAppdataRoots"] == [
{"root": legacy_root, "status": "ready"},
{"root": managed_root, "status": "ready"},
]
def test_capabilities_fail_closed_and_hide_unrecognized_controller_roots(tmp_path):
class MalformedRootsBroker(_Broker):
def enrollment_capabilities(self, *, secret):
return {
"status": "ready",
"trustedAppdataRoot": "/srv/example/appdata",
"availableTrustedAppdataRoots": [
{"root": "/srv/example/appdata", "status": "ready"},
{"root": "/etc/private", "status": "ready", "reason": "sensitive controller path"},
],
}
with _session(tmp_path) as session:
_configure(session)
result = _plane(broker=MalformedRootsBroker()).capabilities(session)
assert result["targetEnrollment"]["status"] == "unavailable"
assert result["availableTrustedAppdataRoots"] == [
{"root": "/srv/example/appdata", "status": "unavailable"},
{"root": "/var/lib/docker/volumes/itworx-managed-appdata/_data", "status": "unavailable"},
]
assert "/etc/private" not in repr(result)
assert "sensitive controller path" not in repr(result)
def test_enrollment_preflight_binds_root_to_trust_and_controller_payload(tmp_path):
class RecordingBroker(_Broker):
def __init__(self):
self.preflight_payloads = []
def enrollment_preflight(self, enrollment, *, secret):
self.preflight_payloads.append(dict(enrollment))
return super().enrollment_preflight(enrollment, secret=secret)
broker = RecordingBroker()
with _session(tmp_path) as session:
_configure(session)
plane = _plane(broker=broker)
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
alternate = _plan(trusted_appdata_root=managed_root)
blocked = plane.preflight(session, alternate)
assert blocked["status"] == "blocked"
assert "trusted_appdata_root_mismatch" in blocked["blockers"]
assert broker.preflight_payloads == []
EnrollmentPlane.set_trust_config(
session,
_trust().model_copy(update={
"trusted_appdata_root": managed_root,
"managed_appdata_backup_acknowledged": True,
}),
)
payload = plane._controller_payload(
alternate,
enrollment_id="enr-00000000000000000000",
)
assert payload["trusted_appdata_root"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data"
assert payload["appdata_path"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data/wsbc-portal"
assert payload["data_path"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data/wsbc-portal/data"
def test_enrollment_creates_write_only_credentials_exact_policies_and_is_idempotent(tmp_path):
with _session(tmp_path) as session:
_configure(session)
+330
View File
@@ -0,0 +1,330 @@
from __future__ import annotations
import importlib.util
import os
import shutil
import stat
import uuid
from pathlib import Path
from types import ModuleType
import pytest
ROOT = Path(__file__).resolve().parents[2]
BROKER_ROOT = ROOT / "ops" / "gitea-deploy-broker"
INSTALLER_PATH = BROKER_ROOT / "prepare_managed_appdata_root.py"
def _load_module(name: str, path: Path) -> ModuleType | None:
helper_path = BROKER_ROOT / "managed_appdata_paths.py"
if not path.is_file() or not helper_path.is_file():
return None
if str(BROKER_ROOT) not in os.sys.path:
os.sys.path.insert(0, str(BROKER_ROOT))
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
return None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def root_owned_test_tree():
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
pytest.skip("requires a POSIX root-owned test tree")
parent = Path("/root")
if parent.is_symlink() or not parent.is_dir():
pytest.skip("requires the fixed root-owned /root test parent")
base = parent / f"appops-managed-root-test-{uuid.uuid4().hex}"
base.mkdir(mode=0o700)
base.chmod(0o700)
try:
yield base
finally:
if base.parent == parent and base.name.startswith("appops-managed-root-test-"):
shutil.rmtree(base)
def _root_safe_path(base: Path, *components: str) -> Path:
current = base
for component in components:
current = current / component
current.mkdir(mode=0o700)
current.chmod(0o700)
return current
def test_open_fixed_directory_walks_real_owned_components_without_symlinks(
root_owned_test_tree, monkeypatch
):
paths = _load_module("managed_appdata_open_real_chain_test", BROKER_ROOT / "managed_appdata_paths.py")
assert paths is not None
target = _root_safe_path(root_owned_test_tree, "docker", "volumes", "itworx-managed-appdata", "_data")
monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target)
descriptor = paths._open_fixed_directory(target)
try:
info = os.fstat(descriptor)
assert (info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) == (0, 0, 0o700)
finally:
os.close(descriptor)
def test_open_fixed_directory_rejects_symlink_component(root_owned_test_tree, monkeypatch):
paths = _load_module("managed_appdata_open_symlink_test", BROKER_ROOT / "managed_appdata_paths.py")
assert paths is not None
real_target = _root_safe_path(root_owned_test_tree, "real", "managed")
alias = root_owned_test_tree / "alias"
alias.symlink_to(real_target.parent, target_is_directory=True)
target = alias / real_target.name
monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target)
with pytest.raises(OSError):
paths._open_fixed_directory(target)
def test_open_fixed_directory_rejects_world_writable_ancestor(root_owned_test_tree, monkeypatch):
paths = _load_module("managed_appdata_open_world_writable_test", BROKER_ROOT / "managed_appdata_paths.py")
assert paths is not None
unsafe = root_owned_test_tree / "unsafe"
unsafe.mkdir(mode=0o777)
unsafe.chmod(0o777)
target = _root_safe_path(unsafe, "managed")
monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target)
with pytest.raises(paths.UnsafeManagedAppdataRoot):
paths._open_fixed_directory(target)
@pytest.mark.parametrize("owner_field", ["uid", "gid"])
def test_open_fixed_directory_rejects_wrong_owner_or_group(
root_owned_test_tree, monkeypatch, owner_field
):
paths = _load_module(f"managed_appdata_open_wrong_{owner_field}_test", BROKER_ROOT / "managed_appdata_paths.py")
assert paths is not None
owner = root_owned_test_tree / "wrong-owner"
target = _root_safe_path(root_owned_test_tree, "wrong-owner", "managed")
if not hasattr(os, "chown"):
pytest.skip("requires POSIX chown for the owned test directory")
if owner_field == "uid":
os.chown(owner, 65534, 0)
else:
os.chown(owner, 0, 65534)
monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target)
with pytest.raises(paths.UnsafeManagedAppdataRoot):
paths._open_fixed_directory(target)
@pytest.mark.parametrize("acl_name", ["system.posix_acl_access", "system.posix_acl_default"])
def test_open_fixed_directory_rejects_reported_acl(root_owned_test_tree, monkeypatch, acl_name):
paths = _load_module("managed_appdata_open_acl_test", BROKER_ROOT / "managed_appdata_paths.py")
assert paths is not None
acl_component = root_owned_test_tree / "acl"
target = _root_safe_path(root_owned_test_tree, "acl", "managed")
acl_stat = acl_component.stat()
original_listxattr = os.listxattr
def listxattr(path, *args, **kwargs):
if isinstance(path, int):
info = os.fstat(path)
if (info.st_dev, info.st_ino) == (acl_stat.st_dev, acl_stat.st_ino):
return [acl_name]
return original_listxattr(path, *args, **kwargs)
monkeypatch.setattr(paths, "MANAGED_APPDATA_ROOT", target)
monkeypatch.setattr(paths.os, "listxattr", listxattr)
with pytest.raises(paths.UnsafeManagedAppdataRoot, match="unreviewed ACL"):
paths._open_fixed_directory(target)
def test_fixed_controller_installer_creates_private_root_and_refuses_drift(tmp_path, monkeypatch):
installer = _load_module("managed_appdata_installer_test", INSTALLER_PATH)
assert installer is not None, "the fixed managed-appdata installer must ship with the controller artifact"
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
pytest.skip("requires POSIX root ownership semantics")
target = tmp_path / "volume-root"
target.mkdir(mode=0o755)
target.chmod(0o755)
monkeypatch.setattr(installer, "MANAGED_APPDATA_ROOT", target)
monkeypatch.setattr(installer, "validate_initialization_prerequisites", lambda: True)
monkeypatch.setattr(installer, "configured_appdata_root", lambda: target)
monkeypatch.setattr(installer, "_open_fixed_directory", lambda _path: os.open(target, os.O_RDONLY | os.O_DIRECTORY))
assert installer.ensure_managed_appdata_root() == "initialized"
info = target.lstat()
assert stat.S_ISDIR(info.st_mode)
assert (info.st_uid, info.st_gid, stat.S_IMODE(info.st_mode)) == (0, 0, 0o700)
marker = target / installer.MANAGED_ROOT_MARKER
assert marker.read_bytes() == installer.MANAGED_ROOT_MARKER_BYTES
assert stat.S_IMODE(marker.lstat().st_mode) == 0o400
assert installer.ensure_managed_appdata_root() == "already_initialized"
target.chmod(0o777)
with pytest.raises(installer.UnsafeManagedAppdataRoot):
installer.ensure_managed_appdata_root()
assert stat.S_IMODE(target.lstat().st_mode) == 0o777
def test_fixed_controller_installer_refuses_existing_data_without_deleting_it(tmp_path, monkeypatch):
installer = _load_module("managed_appdata_installer_reject_test", INSTALLER_PATH)
assert installer is not None
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
pytest.skip("requires POSIX root ownership semantics")
target = tmp_path / "volume-root"
target.mkdir(mode=0o755)
target.chmod(0o755)
monkeypatch.setattr(installer, "MANAGED_APPDATA_ROOT", target)
monkeypatch.setattr(installer, "validate_initialization_prerequisites", lambda: True)
monkeypatch.setattr(installer, "configured_appdata_root", lambda: target)
monkeypatch.setattr(installer, "_open_fixed_directory", lambda _path: os.open(target, os.O_RDONLY | os.O_DIRECTORY))
(target / "existing-data").write_text("keep", encoding="utf-8")
with pytest.raises(installer.UnsafeManagedAppdataRoot):
installer.ensure_managed_appdata_root()
assert (target / "existing-data").read_text(encoding="utf-8") == "keep"
assert not (target / installer.MANAGED_ROOT_MARKER).exists()
def test_initialized_volume_is_idempotent_without_enumerating_application_data(tmp_path, monkeypatch):
installer = _load_module("managed_appdata_installer_idempotent_test", INSTALLER_PATH)
assert installer is not None
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
pytest.skip("requires POSIX root ownership semantics")
target = tmp_path / "volume-root"
target.mkdir(mode=0o700)
(target / installer.MANAGED_ROOT_MARKER).write_bytes(installer.MANAGED_ROOT_MARKER_BYTES)
(target / installer.MANAGED_ROOT_MARKER).chmod(0o400)
(target / "app-data").mkdir()
monkeypatch.setattr(installer, "MANAGED_APPDATA_ROOT", target)
monkeypatch.setattr(installer, "validate_initialization_prerequisites", lambda: True)
monkeypatch.setattr(installer, "configured_appdata_root", lambda: target)
monkeypatch.setattr(installer, "_open_fixed_directory", lambda _path: os.open(target, os.O_RDONLY | os.O_DIRECTORY))
monkeypatch.setattr(installer.os, "listdir", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("data must not be enumerated")))
assert installer.ensure_managed_appdata_root() == "already_initialized"
def _valid_mounts(paths):
return [
{
"Type": "volume",
"Name": paths.VOLUME_NAME,
"Source": paths.VOLUME_HOST_ROOT.as_posix(),
"Destination": paths.MANAGED_APPDATA_ROOT.as_posix(),
"Driver": "local",
"RW": True,
},
{
"Type": "bind",
"Name": "",
"Source": paths.DOCKER_ROOT.as_posix(),
"Destination": paths.DOCKER_ROOT.as_posix(),
"Driver": "",
"RW": False,
},
]
def test_controller_mount_inventory_accepts_only_exact_volume_and_readonly_docker_root():
paths = _load_module("managed_appdata_mounts_test", BROKER_ROOT / "managed_appdata_paths.py")
assert paths is not None
mounts = _valid_mounts(paths)
paths.validate_controller_mounts(
mounts,
controller_id_before="a" * 64,
controller_id_after="a" * 64,
)
@pytest.mark.parametrize(
"mutate",
[
lambda m: m[0].update(Name="other-volume"),
lambda m: m[0].update(Type="bind"),
lambda m: m[0].update(Driver="nfs"),
lambda m: m[0].update(Source="/var/lib/docker/volumes/other/_data"),
lambda m: m[0].update(Destination="/managed-appdata"),
lambda m: m[0].update(RW=False),
lambda m: m[1].update(RW=True),
lambda m: m.append(dict(m[0])),
lambda m: m.append({"Type": "bind", "Source": "/var/lib/docker/volumes", "Destination": "/var/lib/docker/volumes", "RW": True}),
lambda m: m.append({"Type": "bind", "Source": "/var/lib/docker/volumes/itworx-managed-appdata/_data/app", "Destination": "/var/lib/docker/volumes/itworx-managed-appdata/_data/app", "RW": True}),
lambda m: m.append({"Type": "bind", "Source": "/var/lib/docker", "Destination": "/var/lib/docker", "RW": False}),
],
)
def test_controller_mount_inventory_fails_closed_on_mount_drift(mutate):
paths = _load_module("managed_appdata_mounts_reject_test", BROKER_ROOT / "managed_appdata_paths.py")
assert paths is not None
mounts = _valid_mounts(paths)
mutate(mounts)
with pytest.raises(paths.UnsafeManagedAppdataRoot):
paths.validate_controller_mounts(
mounts,
controller_id_before="b" * 64,
controller_id_after="b" * 64,
)
def test_controller_mount_inventory_rejects_container_replacement_during_inspection():
paths = _load_module("managed_appdata_mounts_race_test", BROKER_ROOT / "managed_appdata_paths.py")
assert paths is not None
with pytest.raises(paths.UnsafeManagedAppdataRoot):
paths.validate_controller_mounts(
_valid_mounts(paths),
controller_id_before="c" * 64,
controller_id_after="d" * 64,
)
@pytest.mark.parametrize(
"override",
[
{"Name": "other"},
{"Driver": "nfs"},
{"Scope": "global"},
{"Mountpoint": "/var/lib/docker/volumes/other/_data"},
{"Options": {"type": "nfs"}},
{"Labels": {"itworx.managed-by": "other"}},
],
)
def test_volume_metadata_rejects_drift(override):
paths = _load_module("managed_appdata_volume_reject_test", BROKER_ROOT / "managed_appdata_paths.py")
assert paths is not None
document = {
"Name": paths.VOLUME_NAME,
"Driver": "local",
"Scope": "local",
"Mountpoint": paths.VOLUME_HOST_ROOT.as_posix(),
"Options": None,
"Labels": dict(paths.VOLUME_LABELS),
}
document.update(override)
with pytest.raises(paths.UnsafeManagedAppdataRoot):
paths.validate_volume_metadata(document, paths.DOCKER_ROOT.as_posix())
def test_controller_rejects_world_writable_legacy_root_without_mutating_it(tmp_path):
paths = _load_module("managed_appdata_paths_test", BROKER_ROOT / "managed_appdata_paths.py")
assert paths is not None, "the controller must centralize fixed managed-appdata path checks"
if os.name != "posix":
pytest.skip("requires POSIX permission bits")
root = tmp_path / "legacy"
root.mkdir()
paths.LEGACY_APPDATA_ROOT = root
root.chmod(0o777)
assert paths.appdata_root_is_safe(root) is False
assert stat.S_IMODE(root.lstat().st_mode) == 0o777
root.chmod(0o750)
assert paths.appdata_root_is_safe(root) is True
@@ -0,0 +1,273 @@
from __future__ import annotations
import pytest
from sqlmodel import Session, SQLModel, create_engine, select
from backend.app.db.models import AppSetting, AuditEvent, JobRun, Policy
from backend.app.services.action_execution import (
approval_execution_supported,
execute_governed_action,
mcp_execution_supported,
)
from backend.app.services.action_registry import (
ACTION_RISK,
ACTIONS,
action_default_target,
)
from backend.app.services.policy_engine import evaluate_policy
ACTION = "controller.managed-storage.initialize"
TARGET = "tower/deployment-controller"
def test_managed_storage_initialization_is_typed_high_risk_and_fixed_target():
assert ACTION in ACTIONS
assert ACTION_RISK[ACTION] == "high"
assert action_default_target(ACTION) == TARGET
assert not approval_execution_supported(ACTION)
assert mcp_execution_supported(ACTION)
def test_managed_storage_policy_defaults_to_blocked_and_requires_exact_manual_allow(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'managed-storage-policy.db'}")
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
assert evaluate_policy(session, ACTION, TARGET, "manual")["decision"] == "blocked"
session.add(Policy(
id="managed-storage-wildcard",
action=ACTION,
target="*",
voice="blocked",
manual="allowed",
risk="high",
))
session.commit()
assert evaluate_policy(session, ACTION, TARGET, "manual")["decision"] == "blocked"
session.add(Policy(
id="managed-storage-exact",
action=ACTION,
target=TARGET,
voice="allowed",
manual="allowed",
risk="high",
))
session.commit()
assert evaluate_policy(session, ACTION, TARGET, "manual")["decision"] == "allowed"
assert evaluate_policy(session, ACTION, "tower/other-controller", "manual")["decision"] == "blocked"
assert evaluate_policy(session, ACTION, TARGET, "voice")["decision"] == "blocked"
engine.dispose()
def _executor_engine(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'managed-storage-executor.db'}")
SQLModel.metadata.create_all(engine)
return engine
def _managed_trust():
from backend.app.schemas.enrollment import EnrollmentTrustConfigV1
return EnrollmentTrustConfigV1.model_validate({
"controller_identity": "unraid-controller",
"bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP",
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
"managed_appdata_backup_acknowledged": True,
"approved_networks": ["itworx-mcp-hub-npm"],
"allowed_domain_suffixes": ["example.com"],
"autonomous_profiles": ["repopulse-authenticated"],
"repopulse_proxy_access_list_id": 42,
"oidc_issuer": None,
"oidc_role_claim": "roles",
"administrator_group": None,
"support_agent_group": None,
"read_only_group": None,
})
def _execute_initializer(session):
return execute_governed_action(
session,
action=ACTION,
target=TARGET,
source="chatgpt-mcp",
risk="high",
reason="Operator explicitly selected managed storage and acknowledged its backup scope.",
original_policy_decision="allowed",
)
@pytest.mark.parametrize("trust_state", ["missing", "legacy"])
def test_managed_storage_executor_refuses_missing_selection_or_ack_without_broker_call(
tmp_path, monkeypatch, trust_state
):
from backend.app.services.deployment_broker import DeploymentBrokerClient
engine = _executor_engine(tmp_path)
if trust_state == "legacy":
legacy = _managed_trust().model_copy(update={
"trusted_appdata_root": "/srv/example/appdata",
"managed_appdata_backup_acknowledged": False,
})
with Session(engine) as setup:
setup.add(AppSetting(
key="deployment_enrollment:trust",
value_json=legacy.model_dump(mode="json"),
is_secret=False,
))
setup.commit()
monkeypatch.setattr(
DeploymentBrokerClient,
"initialize_managed_appdata",
lambda *_args, **_kwargs: pytest.fail("broker must not be called without managed trust opt-in"),
)
with Session(engine) as session:
result = _execute_initializer(session)
assert result["success"] is False
assert result["result"] == "blocked"
assert result["auditId"]
assert session.exec(select(JobRun)).all() == []
event = session.exec(select(AuditEvent)).one()
assert event.result == "blocked"
assert event.redacted_metadata == {"executor": ACTION, "trustOptIn": False}
engine.dispose()
def test_managed_storage_executor_requires_bootstrap_secret_without_broker_call(tmp_path, monkeypatch):
from backend.app.services.deployment_broker import DeploymentBrokerClient
from backend.app.services.enrollment_plane import EnrollmentPlane
engine = _executor_engine(tmp_path)
monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust()))
monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
DeploymentBrokerClient,
"initialize_managed_appdata",
lambda *_args, **_kwargs: pytest.fail("broker must not be called without bootstrap secret"),
)
with Session(engine) as session:
result = _execute_initializer(session)
assert result["success"] is False
assert result["result"] == "failed"
assert result["auditId"]
assert session.exec(select(JobRun)).all() == []
event = session.exec(select(AuditEvent)).one()
assert event.result == "failed"
assert event.redacted_metadata == {"executor": ACTION, "secretMaterialReturned": "[REDACTED]"}
engine.dispose()
@pytest.mark.parametrize("status", ["initialized", "already_initialized"])
def test_managed_storage_executor_records_durable_success_without_secret_material(
tmp_path, monkeypatch, status
):
from backend.app.services.deployment_broker import DeploymentBrokerClient
from backend.app.services.enrollment_plane import EnrollmentPlane
engine = _executor_engine(tmp_path)
monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust()))
monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: "temporary-bootstrap-secret")
broker_calls = []
def initialize(_client, *, secret):
broker_calls.append(secret)
return {
"status": status,
"trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
"backupLocation": "dockerRootDir",
}
monkeypatch.setattr(DeploymentBrokerClient, "initialize_managed_appdata", initialize)
with Session(engine) as session:
result = _execute_initializer(session)
assert result["success"] is True
assert result["status"] == "success"
assert result["jobId"] and result["auditId"]
assert broker_calls == ["temporary-bootstrap-secret"]
with Session(engine) as session:
job = session.get(JobRun, result["jobId"])
event = session.get(AuditEvent, result["auditId"])
assert job is not None and job.status == "succeeded"
assert event is not None and event.result == "success"
assert event.after_state == {
"status": status,
"trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
"backupLocation": "dockerRootDir",
}
assert event.redacted_metadata["secretMaterialReturned"] == "[REDACTED]"
assert "temporary-bootstrap-secret" not in repr(event.model_dump())
assert "temporary-bootstrap-secret" not in repr(result)
engine.dispose()
@pytest.mark.parametrize(
"broker_result",
[
{"status": "initialized", "trustedAppdataRoot": "/tmp/wrong-root", "backupLocation": "dockerRootDir"},
{"status": "initialized", "trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data", "backupLocation": "external-share"},
{
"status": "secret-sentinel-/var/lib/private",
"trustedAppdataRoot": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
"backupLocation": "dockerRootDir",
},
],
ids=["wrong-root", "wrong-backup-location", "untrusted-status"],
)
def test_managed_storage_executor_fails_closed_on_mismatched_broker_attestation(
tmp_path, monkeypatch, broker_result
):
from backend.app.services.deployment_broker import DeploymentBrokerClient
from backend.app.services.enrollment_plane import EnrollmentPlane
engine = _executor_engine(tmp_path)
monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust()))
monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: "temporary-bootstrap-secret")
monkeypatch.setattr(DeploymentBrokerClient, "initialize_managed_appdata", lambda *_args, **_kwargs: broker_result)
with Session(engine) as session:
result = _execute_initializer(session)
assert result["success"] is False
assert result["result"] == "failed"
assert "temporary-bootstrap-secret" not in repr(result)
assert "/tmp/wrong-root" not in repr(result)
assert "secret-sentinel-/var/lib/private" not in repr(result)
event = session.get(AuditEvent, result["auditId"])
assert event is not None and event.result == "failed"
assert event.after_state["trustedAppdataRoot"] is None
assert event.after_state["backupLocation"] is None
if broker_result["status"].startswith("secret-sentinel"):
assert event.after_state["status"] == "refused"
assert "/tmp/wrong-root" not in repr(event.model_dump())
assert "temporary-bootstrap-secret" not in repr(event.model_dump())
assert "secret-sentinel-/var/lib/private" not in repr(event.model_dump())
job = session.get(JobRun, result["jobId"])
assert job is not None and job.status == "failed"
assert "secret-sentinel-/var/lib/private" not in repr(job.model_dump())
engine.dispose()
def test_managed_storage_executor_sanitizes_broker_exception(tmp_path, monkeypatch):
from backend.app.services.deployment_broker import DeploymentBrokerClient
from backend.app.services.enrollment_plane import EnrollmentPlane
engine = _executor_engine(tmp_path)
monkeypatch.setattr(EnrollmentPlane, "get_trust_config", staticmethod(lambda _session: _managed_trust()))
monkeypatch.setattr("backend.app.services.secret_store.get_secret", lambda *_args, **_kwargs: "temporary-bootstrap-secret")
def raise_broker_error(*_args, **_kwargs):
raise RuntimeError("temporary-bootstrap-secret failed under /var/lib/private")
monkeypatch.setattr(DeploymentBrokerClient, "initialize_managed_appdata", raise_broker_error)
with Session(engine) as session:
result = _execute_initializer(session)
assert result["success"] is False
assert result["message"] == "Managed storage initialization was refused or unavailable."
assert "temporary-bootstrap-secret" not in repr(result)
assert "/var/lib/private" not in repr(result)
event = session.get(AuditEvent, result["auditId"])
assert event is not None and event.result == "failed"
assert "temporary-bootstrap-secret" not in repr(event.model_dump())
assert "/var/lib/private" not in repr(event.model_dump())
engine.dispose()
@@ -61,8 +61,8 @@ def test_reconciliation_is_fixed_to_mcp_hub_and_reconciles_only_verified_failed_
assert create[2]["json"]["AttachStderr"] is False
script = command[2]
assert "/opt/gitea-deploy/projects.json" in script
assert "/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json" in script
assert "/controller-config/projects.json" in script
assert "/controller-hub-releases/controller-config/projects.json" in script
assert "os.path.samefile(registry_path, host_registry_path)" in script
assert "/mnt/cache/appdata/gitea-deploy-runners/state" in script
assert "/srv/example/appdata/gitea-deploy-runners/deploy.lock" not in script
@@ -102,7 +102,9 @@ def test_reconciliation_proves_complete_live_compose_topology_and_preserves_actu
assert 'not runtime_state.get("Running")' in script
assert 'health in {"starting", "unhealthy"}' in script
assert 'image_id = str(container.get("Image") or "")' in script
assert 'artifacts = {"containers": container_artifacts, "compose_sha256": ""}' in script
assert '"containers": container_artifacts' in script
assert '"compose_sha256": ""' in script
assert '"topology_contract": topology_contract' in script
assert '"live_legacy_provenance": True' in script
assert '"live_artifacts": artifacts' in script
assert '"live_revision": recorded_live' in script
@@ -123,6 +125,23 @@ def test_reconciliation_recognizes_only_the_fixed_mcp_hub_oci_source_as_owned():
assert '"live_revision": attempt_revision' not in script
def test_reconciliation_requires_the_controller_17_service_topology_migration():
result = McpHubDeploymentReconciliation(FakePortainer(exit_code=56)).run()
assert result == {
"status": "topology_migration_required",
"repository": "jens/itworx_mcp_hub",
"composeProject": "itworx-mcp-hub",
"reason": "MCP Hub 15-to-17 topology migration requires its guarded protected-main deployment",
}
fake = FakePortainer()
McpHubDeploymentReconciliation(fake).run()
script = fake.calls[0][2]["json"]["Cmd"][2]
assert 'topology_contract = "itworx.mcp-hub-topology/17-v1"' in script
assert 'recorded_artifacts.get("topology_contract") == topology_contract' in script
assert '"topology_contract": topology_contract' in script
def test_owned_image_ancestry_can_fail_closed_to_the_canonical_gitea_store():
fake = FakePortainer()
McpHubDeploymentReconciliation(fake).run()
File diff suppressed because it is too large. Load diff
+443
View File
@@ -0,0 +1,443 @@
from __future__ import annotations
import copy
import importlib.util
import json
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
STORE_PATH = ROOT / "ops" / "gitea-deploy-broker" / "mcp_hub_release_store.py"
def _load_store():
spec = importlib.util.spec_from_file_location("mcp_hub_release_store_test", STORE_PATH)
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 store():
return _load_store()
def _volume(store, **overrides):
value = {
"Name": store.VOLUME_NAME,
"Driver": "local",
"Scope": "local",
"Mountpoint": store.VOLUME_HOST_ROOT.as_posix(),
"Options": None,
"Labels": dict(store.EXPECTED_LABELS),
}
return {**value, **overrides}
def test_fixed_volume_contract_rejects_unexpected_options_labels_and_root(store):
assert store.validate_volume_metadata(_volume(store), "/var/lib/docker") is None
bad_documents = [
_volume(store, Name="other-volume"),
_volume(store, Driver="local-persist"),
_volume(store, Scope="global"),
_volume(store, Mountpoint="/srv/example/appdata/releases"),
_volume(store, Options={"o": "bind", "device": "/tmp"}),
_volume(store, Labels={**store.EXPECTED_LABELS, "attacker": "extra"}),
_volume(store, Labels={"itworx.managed-by": "deployment-controller"}),
]
for document in bad_documents:
with pytest.raises(store.ReleaseStoreError):
store.validate_volume_metadata(document, "/var/lib/docker")
with pytest.raises(store.ReleaseStoreError):
store.validate_volume_metadata(_volume(store), "/var/lib/docker-alt")
def test_release_store_fails_closed_when_acl_metadata_cannot_be_inspected(store, monkeypatch):
def unavailable(_fd):
raise OSError("filesystem ACL metadata is unavailable")
monkeypatch.setattr(store.os, "listxattr", unavailable, raising=False)
with pytest.raises(store.ReleaseStoreError, match="cannot inspect filesystem ACLs"):
store._require_no_acl(1)
@pytest.mark.skipif(
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
reason="Hub state reader verifies root-owned fixed storage",
)
@pytest.mark.parametrize("state_mode", [0o700, 0o755])
def test_fixed_hub_state_reader_is_bounded_and_rejects_symlink_or_missing_state(
store, tmp_path, monkeypatch, state_mode
):
controller_root = tmp_path / "controller"
state_root = controller_root / "state"
controller_root.mkdir(mode=0o700)
controller_root.chmod(0o700)
monkeypatch.setattr(store, "CONTROLLER_STATE_PARENT", controller_root)
monkeypatch.setattr(store, "CONTROLLER_STATE_DIRECTORY", state_root)
assert store.read_hub_state_bytes() is None
with pytest.raises(store.ReleaseStoreError):
store.read_hub_state_bytes(missing_ok=False)
state_root.mkdir(mode=state_mode)
state_root.chmod(state_mode)
state_file = state_root / store.HUB_STATE_FILENAME
state_file.write_bytes(b'{"status":"success"}')
state_file.chmod(0o600)
assert store.read_hub_state_bytes(missing_ok=False) == b'{"status":"success"}'
attacker_root = tmp_path / "attacker"
attacker_root.mkdir(mode=0o700)
attacker_state = attacker_root / store.HUB_STATE_FILENAME
attacker_state.write_bytes(b'{"status":"attacker"}')
attacker_state.chmod(0o600)
state_file.unlink()
state_file.symlink_to(attacker_state)
with pytest.raises(store.ReleaseStoreError, match="unavailable or untrusted"):
store.read_hub_state_bytes()
assert attacker_state.read_bytes() == b'{"status":"attacker"}'
@pytest.mark.skipif(
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
reason="Hub state reader verifies root-owned fixed storage",
)
@pytest.mark.parametrize("state_mode", [0o775, 0o777, 0o750])
def test_fixed_hub_state_reader_rejects_unapproved_directory_modes(
store, tmp_path, monkeypatch, state_mode
):
controller = tmp_path / "controller"
state = controller / "state"
state.mkdir(parents=True)
controller.chmod(0o755)
state.chmod(state_mode)
state_file = state / store.HUB_STATE_FILENAME
state_file.write_bytes(b'{"status":"success"}')
state_file.chmod(0o600)
monkeypatch.setattr(store, "CONTROLLER_STATE_PARENT", controller)
monkeypatch.setattr(store, "CONTROLLER_STATE_DIRECTORY", state)
with pytest.raises(store.ReleaseStoreError, match="unavailable or untrusted"):
store.read_hub_state_bytes()
@pytest.mark.skipif(
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
reason="Hub state reader path-swap test requires root-owned POSIX fixtures",
)
def test_fixed_hub_state_reader_rejects_parent_swap_during_read(store, tmp_path, monkeypatch):
controller_root = tmp_path / "controller"
state_root = controller_root / "state"
state_root.mkdir(parents=True, mode=0o700)
controller_root.chmod(0o700)
state_file = state_root / store.HUB_STATE_FILENAME
state_file.write_bytes(b"trusted")
state_file.chmod(0o600)
attacker_root = tmp_path / "attacker"
attacker_state_root = attacker_root / "state"
attacker_state_root.mkdir(parents=True, mode=0o700)
attacker_file = attacker_state_root / store.HUB_STATE_FILENAME
attacker_file.write_bytes(b"attacker")
attacker_file.chmod(0o600)
monkeypatch.setattr(store, "CONTROLLER_STATE_PARENT", controller_root)
monkeypatch.setattr(store, "CONTROLLER_STATE_DIRECTORY", state_root)
original_read_at = store.read_at
def read_then_swap(*args, **kwargs):
result = original_read_at(*args, **kwargs)
controller_root.rename(tmp_path / "controller.saved")
controller_root.symlink_to(attacker_root, target_is_directory=True)
return result
monkeypatch.setattr(store, "read_at", read_then_swap)
with pytest.raises(store.ReleaseStoreError, match="unavailable or untrusted"):
store.read_hub_state_bytes()
assert attacker_file.read_bytes() == b"attacker"
_RENDERED_2A948_SERVICES = (
"control-api",
"connector-appops",
"connector-gitea",
"connector-ingestion",
"connector-knowledge",
"connector-mobilityops",
"connector-projectbrain",
"connector-stitch",
"connector-unraid",
"contextforge",
"dataplane-router",
"docker-read-broker",
"ingestion-clamd",
"postgres",
"redis",
"web",
"worker",
)
_RENDERED_2A948_RELEASE_BINDS = (
("connector-gitea", "services/runtime/connector_gitea.py", "/opt/hub/services/runtime/connector_gitea.py", "file"),
("contextforge", "infrastructure/images/contextforge/entrypoint.sh", "/opt/itworx/contextforge-entrypoint.sh", "file"),
("contextforge", "infrastructure/images/contextforge/runtime-contract.json", "/opt/itworx/contextforge-runtime-contract.json", "file"),
("contextforge", "scripts/contextforge_runtime_contract.py", "/opt/itworx/contextforge-runtime-contract.py", "file"),
("control-api", "catalog", "/opt/hub/catalog", "directory"),
("dataplane-router", "catalog", "/opt/hub/catalog", "directory"),
("dataplane-router", "services/runtime/dataplane.py", "/opt/hub/services/runtime/dataplane.py", "file"),
("postgres", "infrastructure/postgres/init", "/docker-entrypoint-initdb.d", "directory"),
("postgres", "infrastructure/images/postgres/entrypoint.sh", "/opt/itworx/postgres-entrypoint.sh", "file"),
("redis", "infrastructure/images/redis/entrypoint.sh", "/opt/itworx/redis-entrypoint.sh", "file"),
("redis", "infrastructure/images/redis/healthcheck.sh", "/opt/itworx/redis-healthcheck.sh", "file"),
("web", "services/runtime/web.py", "/opt/hub/services/runtime/web.py", "file"),
("worker", "catalog", "/opt/hub/catalog", "directory"),
("worker", "services/runtime/state_sync.py", "/opt/hub/services/runtime/state_sync.py", "file"),
)
def _rendered_2a948_model(store, revision):
"""Mount projection captured from protected Hub main commit 2a948b17."""
services = {name: {"volumes": []} for name in _RENDERED_2A948_SERVICES}
for service, suffix, target, _kind in _RENDERED_2A948_RELEASE_BINDS:
services[service]["volumes"].append(
{
"type": "bind",
"source": store.logical_release_path(revision) + "/" + suffix,
"target": target,
"read_only": True,
"mode": "ro",
}
)
# These are rendered non-release binds and must remain unchanged.
services["docker-read-broker"]["volumes"].append(
{
"type": "bind",
"source": "/var/run/docker.sock",
"target": "/var/run/docker.sock",
"read_only": True,
}
)
services["postgres"]["volumes"].append(
{"type": "volume", "source": "itworx-mcp-hub-postgres", "target": "/var/lib/postgresql/data"}
)
return {"services": services}
def test_exact_rendered_2a948_four_file_model_translates_all_release_binds_only(store):
revision = "a" * 40
model = _rendered_2a948_model(store, revision)
store.validate_release_mount_inventory(model, revision, source_kind="logical")
translated = store.translate_release_mounts(model, revision)
store.validate_release_mount_inventory(translated, revision, source_kind="host")
assert store.RELEASE_BIND_CONTRACT == _RENDERED_2A948_RELEASE_BINDS
assert len(_RENDERED_2A948_RELEASE_BINDS) == 14
for service, suffix, target, _kind in _RENDERED_2A948_RELEASE_BINDS:
original = next(
mount for mount in model["services"][service]["volumes"]
if mount.get("target") == target
)
observed = next(
mount for mount in translated["services"][service]["volumes"]
if mount.get("target") == target
)
assert original["source"] == f"{store.logical_release_path(revision)}/{suffix}"
assert observed["source"] == f"{store.host_release_path(revision)}/{suffix}"
assert observed["read_only"] is True
assert translated["services"]["docker-read-broker"]["volumes"] == model["services"]["docker-read-broker"]["volumes"]
assert translated["services"]["postgres"]["volumes"][-1] == model["services"]["postgres"]["volumes"][-1]
assert model["services"]["control-api"]["volumes"][0]["source"].startswith(
store.logical_release_path(revision)
)
mutations = []
missing = copy.deepcopy(model)
missing["services"]["redis"]["volumes"].pop(0)
mutations.append(missing)
writable = copy.deepcopy(model)
writable["services"]["worker"]["volumes"][0]["read_only"] = False
mutations.append(writable)
wrong_service = copy.deepcopy(model)
wrong_service["services"]["contextforge"]["volumes"][0]["target"] = "/tmp/entrypoint.sh"
mutations.append(wrong_service)
undeclared_leaf = copy.deepcopy(model)
undeclared_leaf["services"]["control-api"]["volumes"].append(
{
"type": "bind",
"source": f"{store.logical_release_path(revision)}/scripts",
"target": "/opt/hub/scripts",
"read_only": True,
}
)
mutations.append(undeclared_leaf)
release_root = copy.deepcopy(model)
release_root["services"]["control-api"]["volumes"][0]["source"] = store.logical_release_path(revision)
mutations.append(release_root)
volume_ancestor = copy.deepcopy(model)
volume_ancestor["services"]["control-api"]["volumes"][0]["source"] = store.DOCKER_ROOT.as_posix()
mutations.append(volume_ancestor)
for unsafe in mutations:
with pytest.raises(store.ReleaseStoreError):
store.translate_release_mounts(unsafe, revision)
def test_private_overlay_contains_only_fixed_volume_leaf_sources(store):
revision = "b" * 40
content = json.loads(store.compose_override_bytes(revision))
text = json.dumps(content)
assert "${" not in text
assert "secret" not in text.lower()
mounts = [
(service, mount)
for service, config in content["services"].items()
for mount in config["volumes"]
]
assert len(mounts) == len(_RENDERED_2A948_RELEASE_BINDS)
for service, suffix, target, _kind in _RENDERED_2A948_RELEASE_BINDS:
mount = next(item for name, item in mounts if name == service and item["target"] == target)
assert mount["source"] == f"{store.host_release_path(revision)}/{suffix}"
assert mount["type"] == "bind"
assert mount["read_only"] is True
assert mount["bind"] == {"create_host_path": False}
assert "privileged" not in text and "/controller-hub-releases:" not in text
@pytest.mark.skipif(
os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0,
reason="release-leaf identity checks require root-owned POSIX fixtures",
)
def test_release_leaf_verifier_allows_only_regular_files_and_declared_directories(store, tmp_path, monkeypatch):
revision = "c" * 40
volume_root = tmp_path / "volume"
releases = volume_root / store.RELEASES_DIRECTORY
release = releases / revision
release.mkdir(parents=True, mode=0o700)
releases.chmod(0o700)
unique: dict[str, str] = {}
for _service, suffix, _target, kind in store.RELEASE_BIND_CONTRACT:
unique[suffix] = kind
for suffix, kind in unique.items():
target = release.joinpath(*suffix.split("/"))
if kind == "directory":
target.mkdir(parents=True, mode=0o700, exist_ok=True)
else:
target.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
target.write_bytes(b"protected")
target.chmod(0o444)
for path in sorted(release.rglob("*"), reverse=True):
if path.is_dir():
path.chmod(0o555)
release.chmod(0o555)
pinned = store.PinnedDirectory.open(volume_root, check_ancestors=False)
fixed = store.FixedReleaseStore(host=pinned, logical=pinned)
monkeypatch.setattr(fixed, "assert_current", lambda: None)
try:
store.verify_release_bind_leaves(fixed, revision)
bad_file = release / "infrastructure" / "images" / "redis" / "entrypoint.sh"
bad_file.parent.chmod(0o755)
bad_file.unlink()
outside = tmp_path / "outside.sh"
outside.write_text("attacker", encoding="utf-8")
bad_file.symlink_to(outside)
bad_file.parent.chmod(0o555)
with pytest.raises(store.ReleaseStoreError, match="symlink"):
store.verify_release_bind_leaves(fixed, revision)
finally:
pinned.close()
@pytest.mark.skipif(os.name == "nt", reason="descriptor identity checks require POSIX openat")
def test_pinned_store_refuses_ancestor_symlink_swap_before_path_readback(store, tmp_path):
parent = tmp_path / "shared"
parent.mkdir(mode=0o700)
original = parent / "controller"
original.mkdir(mode=0o700)
marker = original / "state.json"
marker.write_bytes(b"old-state")
marker.chmod(0o600)
pinned = store.PinnedDirectory.open(original, check_ancestors=False)
attacker = tmp_path / "attacker"
attacker.mkdir(mode=0o700)
(attacker / "state.json").write_bytes(b"attacker-state")
moved = parent / "controller.saved"
try:
original.rename(moved)
original.symlink_to(attacker, target_is_directory=True)
with pytest.raises(store.ReleaseStoreError, match="path identity changed"):
pinned.assert_current(original)
assert store.read_at(pinned.fd, "state.json", max_bytes=64) == b"old-state"
assert (attacker / "state.json").read_bytes() == b"attacker-state"
finally:
pinned.close()
@pytest.mark.skipif(os.name == "nt", reason="atomic dirfd replacement requires POSIX openat")
def test_pinned_atomic_replace_uses_original_inode_and_detects_swapped_path(store, tmp_path):
parent = tmp_path / "shared"
parent.mkdir(mode=0o700)
original = parent / "controller"
original.mkdir(mode=0o700)
pinned = store.PinnedDirectory.open(original, check_ancestors=False)
attacker = tmp_path / "attacker"
attacker.mkdir(mode=0o700)
def swap_path():
original.rename(parent / "controller.saved")
original.symlink_to(attacker, target_is_directory=True)
try:
with pytest.raises(store.ReleaseStoreError, match="path identity changed"):
store.atomic_replace_at(
pinned.fd,
"state.json",
b"new-state",
expected=None,
after_replace=swap_path,
path_for_identity_check=original,
)
with pytest.raises(FileNotFoundError):
store.read_at(pinned.fd, "state.json", max_bytes=64)
assert not (attacker / "state.json").exists()
finally:
pinned.close()
@pytest.mark.skipif(os.name == "nt", reason="atomic dirfd replacement requires POSIX openat")
def test_pinned_atomic_replace_restores_previous_bytes_after_ancestor_swap(store, tmp_path):
parent = tmp_path / "shared"
parent.mkdir(mode=0o700)
original = parent / "controller"
original.mkdir(mode=0o700)
state = original / "state.json"
state.write_bytes(b"old-state")
state.chmod(0o600)
pinned = store.PinnedDirectory.open(original, check_ancestors=False)
attacker = tmp_path / "attacker"
attacker.mkdir(mode=0o700)
def swap_path():
original.rename(parent / "controller.saved")
original.symlink_to(attacker, target_is_directory=True)
try:
with pytest.raises(store.ReleaseStoreError, match="path identity changed"):
store.atomic_replace_at(
pinned.fd,
"state.json",
b"candidate-state",
expected=b"old-state",
path_for_identity_check=original,
after_replace=swap_path,
)
assert store.read_at(pinned.fd, "state.json", max_bytes=64) == b"old-state"
assert not (attacker / "state.json").exists()
finally:
pinned.close()
@@ -42,6 +42,16 @@ def _sha(value: str) -> str:
return hashlib.sha256(value.encode("ascii")).hexdigest()
def _second_process_lock(path_text, started, locked):
import fcntl
started.set()
with open(path_text, "a+") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
locked.set()
fcntl.flock(handle, fcntl.LOCK_UN)
def test_intent_binding_matches_appops_canonical_digest() -> None:
module = _load_store()
idempotency = _sha("repopulse-adoption-key")
@@ -799,19 +809,10 @@ def test_project_flock_blocks_a_second_process(store_env):
ready = multiprocessing.Event()
acquired = multiprocessing.Event()
def second_process_lock(path_text, started, locked):
import fcntl
started.set()
with open(path_text, "a+") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
locked.set()
fcntl.flock(handle, fcntl.LOCK_UN)
with store.transaction():
assert store.load_intent() is None
child = multiprocessing.Process(
target=second_process_lock,
target=_second_process_lock,
args=(str(module.PROJECT_LOCK), ready, acquired),
)
child.start()
+600
View File
@@ -202,3 +202,603 @@ def test_operational_users_cannot_change_enrollment_trust(identity_client):
sign_in(identity_client, "owner")
# Administrator reaches schema validation; an operator is rejected first.
assert identity_client.put("/api/enrollments/trust", json={}, headers=headers).status_code == 422
def _enrollment_trust_payload(*, appdata_root: str, backup_acknowledged: bool) -> dict:
return {
"controller_identity": "unraid-controller",
"bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP",
"trusted_appdata_root": appdata_root,
"managed_appdata_backup_acknowledged": backup_acknowledged,
"approved_networks": ["itworx-mcp-hub-npm"],
"allowed_domain_suffixes": ["example.com"],
"autonomous_profiles": ["repopulse-authenticated"],
"repopulse_proxy_access_list_id": 42,
"oidc_issuer": None,
"oidc_role_claim": "roles",
"administrator_group": None,
"support_agent_group": None,
"read_only_group": None,
}
@pytest.mark.parametrize(
("initial_root", "initial_ack", "requested_root", "requested_ack"),
[
(
"/srv/example/appdata",
False,
"/var/lib/docker/volumes/itworx-managed-appdata/_data",
True,
),
(
"/var/lib/docker/volumes/itworx-managed-appdata/_data",
True,
"/srv/example/appdata",
False,
),
],
ids=["initial-managed-selection", "managed-root-reset"],
)
def test_settings_manage_put_cannot_change_storage_root(
identity_client, initial_root, initial_ack, requested_root, requested_ack
):
from sqlmodel import Session, select
from backend.app.db import session as database
from backend.app.db.models import AppSetting, AuditEvent
reader_id = sign_in(identity_client, "reader")
sign_in(identity_client, "owner")
headers = {"X-AppOps-Session-CSRF": "1"}
assert identity_client.patch(
f"/api/auth/users/{reader_id}",
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
headers=headers,
).status_code == 200
before = _enrollment_trust_payload(
appdata_root=initial_root, backup_acknowledged=initial_ack
)
with Session(database.engine) as db:
db.add(AppSetting(key="deployment_enrollment:trust", value_json=before, is_secret=False))
db.commit()
audit_ids_before = set(db.exec(select(AuditEvent.id)).all())
sign_in(identity_client, "reader")
response = identity_client.put(
"/api/enrollments/trust",
json=_enrollment_trust_payload(
appdata_root=requested_root, backup_acknowledged=requested_ack
),
headers=headers,
)
assert response.status_code == 403
with Session(database.engine) as db:
saved = db.get(AppSetting, "deployment_enrollment:trust")
assert saved is not None and saved.value_json == before
assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before
def test_settings_manage_put_cannot_create_initial_managed_trust(identity_client):
from sqlmodel import Session, select
from backend.app.db import session as database
from backend.app.db.models import AppSetting, AuditEvent
reader_id = sign_in(identity_client, "reader")
sign_in(identity_client, "owner")
headers = {"X-AppOps-Session-CSRF": "1"}
assert identity_client.patch(
f"/api/auth/users/{reader_id}",
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
headers=headers,
).status_code == 200
with Session(database.engine) as db:
audit_ids_before = set(db.exec(select(AuditEvent.id)).all())
sign_in(identity_client, "reader")
response = identity_client.put(
"/api/enrollments/trust",
json=_enrollment_trust_payload(
appdata_root="/var/lib/docker/volumes/itworx-managed-appdata/_data",
backup_acknowledged=True,
),
headers=headers,
)
assert response.status_code == 403
with Session(database.engine) as db:
assert db.get(AppSetting, "deployment_enrollment:trust") is None
assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before
def test_settings_manage_put_can_create_initial_legacy_trust(identity_client):
from sqlmodel import Session
from backend.app.db import session as database
from backend.app.db.models import AppSetting
reader_id = sign_in(identity_client, "reader")
sign_in(identity_client, "owner")
headers = {"X-AppOps-Session-CSRF": "1"}
assert identity_client.patch(
f"/api/auth/users/{reader_id}",
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
headers=headers,
).status_code == 200
sign_in(identity_client, "reader")
legacy = _enrollment_trust_payload(
appdata_root="/srv/example/appdata", backup_acknowledged=False
)
response = identity_client.put("/api/enrollments/trust", json=legacy, headers=headers)
assert response.status_code == 200
with Session(database.engine) as db:
saved = db.get(AppSetting, "deployment_enrollment:trust")
assert saved is not None
assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata"
assert saved.value_json["managed_appdata_backup_acknowledged"] is False
def test_settings_manage_put_keeps_other_trust_edits_and_admin_can_select_managed_root(identity_client):
from sqlmodel import Session
from backend.app.db import session as database
from backend.app.db.models import AppSetting
reader_id = sign_in(identity_client, "reader")
sign_in(identity_client, "owner")
headers = {"X-AppOps-Session-CSRF": "1"}
assert identity_client.patch(
f"/api/auth/users/{reader_id}",
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
headers=headers,
).status_code == 200
legacy = _enrollment_trust_payload(
appdata_root="/srv/example/appdata", backup_acknowledged=False
)
with Session(database.engine) as db:
db.add(AppSetting(key="deployment_enrollment:trust", value_json=legacy, is_secret=False))
db.commit()
sign_in(identity_client, "reader")
unrelated_update = {
**legacy,
"approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"],
}
response = identity_client.put(
"/api/enrollments/trust", json=unrelated_update, headers=headers
)
assert response.status_code == 200
with Session(database.engine) as db:
saved = db.get(AppSetting, "deployment_enrollment:trust")
assert saved is not None
assert saved.value_json["approved_networks"] == ["itworx-mcp-hub-npm", "itworx-npm"]
assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata"
assert saved.value_json["managed_appdata_backup_acknowledged"] is False
sign_in(identity_client, "owner")
managed = _enrollment_trust_payload(
appdata_root="/var/lib/docker/volumes/itworx-managed-appdata/_data",
backup_acknowledged=True,
)
response = identity_client.put("/api/enrollments/trust", json=managed, headers=headers)
assert response.status_code == 200
with Session(database.engine) as db:
saved = db.get(AppSetting, "deployment_enrollment:trust")
assert saved is not None
assert saved.value_json["trusted_appdata_root"] == managed["trusted_appdata_root"]
assert saved.value_json["managed_appdata_backup_acknowledged"] is True
def test_settings_manage_put_cas_preserves_concurrent_admin_storage_selection(identity_client):
from sqlalchemy import event
from sqlmodel import Session, select
from backend.app.core.time import utc_now
from backend.app.db import session as database
from backend.app.db.models import AppSetting, AuditEvent
from backend.app.services.enrollment_plane import TRUST_SETTING_KEY
reader_id = sign_in(identity_client, "reader")
sign_in(identity_client, "owner")
headers = {"X-AppOps-Session-CSRF": "1"}
assert identity_client.patch(
f"/api/auth/users/{reader_id}",
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
headers=headers,
).status_code == 200
original = _enrollment_trust_payload(
appdata_root="/srv/example/appdata", backup_acknowledged=False
)
with database.engine.connect() as connection:
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
with Session(database.engine) as db:
db.add(AppSetting(key=TRUST_SETTING_KEY, value_json=original, is_secret=False))
db.commit()
audit_ids_before = set(db.exec(select(AuditEvent.id)).all())
sign_in(identity_client, "reader")
injected = False
def select_managed_root_before_put_write(
_connection, _cursor, statement, parameters, _context, _executemany
):
nonlocal injected
normalized = statement.lstrip().upper()
if (
not injected
and normalized.startswith("UPDATE APP_SETTINGS")
and TRUST_SETTING_KEY in str(parameters)
):
injected = True
with Session(database.engine) as concurrent_session:
row = concurrent_session.get(AppSetting, TRUST_SETTING_KEY)
assert row is not None
row.value_json = {
**row.value_json,
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
"managed_appdata_backup_acknowledged": True,
}
row.updated_at = utc_now()
concurrent_session.add(row)
concurrent_session.commit()
event.listen(database.engine, "before_cursor_execute", select_managed_root_before_put_write)
try:
response = identity_client.put(
"/api/enrollments/trust",
json={
**original,
"approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"],
},
headers=headers,
)
finally:
event.remove(database.engine, "before_cursor_execute", select_managed_root_before_put_write)
assert injected is True
assert response.status_code == 409
with Session(database.engine) as db:
saved = db.get(AppSetting, TRUST_SETTING_KEY)
assert saved is not None
assert saved.value_json["trusted_appdata_root"] == "/var/lib/docker/volumes/itworx-managed-appdata/_data"
assert saved.value_json["managed_appdata_backup_acknowledged"] is True
assert saved.value_json["approved_networks"] == original["approved_networks"]
assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before
def test_settings_manage_initial_legacy_put_loses_if_admin_creates_trust_concurrently(identity_client):
from sqlalchemy import event
from sqlmodel import Session, select
from backend.app.db import session as database
from backend.app.db.models import AppSetting, AuditEvent
from backend.app.services.enrollment_plane import TRUST_SETTING_KEY
reader_id = sign_in(identity_client, "reader")
sign_in(identity_client, "owner")
headers = {"X-AppOps-Session-CSRF": "1"}
assert identity_client.patch(
f"/api/auth/users/{reader_id}",
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
headers=headers,
).status_code == 200
with database.engine.connect() as connection:
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
with Session(database.engine) as db:
audit_ids_before = set(db.exec(select(AuditEvent.id)).all())
sign_in(identity_client, "reader")
admin_value = _enrollment_trust_payload(
appdata_root="/var/lib/docker/volumes/itworx-managed-appdata/_data",
backup_acknowledged=True,
)
injected = False
def create_admin_trust_before_put_insert(
_connection, _cursor, statement, parameters, _context, _executemany
):
nonlocal injected
normalized = statement.lstrip().upper()
if (
not injected
and normalized.startswith("INSERT INTO APP_SETTINGS")
and TRUST_SETTING_KEY in str(parameters)
):
injected = True
with Session(database.engine) as concurrent_session:
concurrent_session.add(
AppSetting(key=TRUST_SETTING_KEY, value_json=admin_value, is_secret=False)
)
concurrent_session.commit()
event.listen(database.engine, "before_cursor_execute", create_admin_trust_before_put_insert)
try:
response = identity_client.put(
"/api/enrollments/trust",
json=_enrollment_trust_payload(
appdata_root="/srv/example/appdata", backup_acknowledged=False
),
headers=headers,
)
finally:
event.remove(database.engine, "before_cursor_execute", create_admin_trust_before_put_insert)
assert injected is True
assert response.status_code == 409
with Session(database.engine) as db:
saved = db.get(AppSetting, TRUST_SETTING_KEY)
assert saved is not None and saved.value_json == admin_value
assert set(db.exec(select(AuditEvent.id)).all()) == audit_ids_before
def test_storage_root_patch_is_admin_only_and_preserves_other_trust_fields(identity_client):
from sqlmodel import Session, select
from backend.app.db import session as database
from backend.app.db.models import AppSetting, AuditEvent
sign_in(identity_client, "owner")
original = {
"controller_identity": "unraid-controller",
"bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP",
"trusted_appdata_root": "/srv/example/appdata",
"managed_appdata_backup_acknowledged": False,
"approved_networks": ["itworx-mcp-hub-npm"],
"allowed_domain_suffixes": ["example.com"],
"autonomous_profiles": ["repopulse-authenticated"],
"repopulse_proxy_access_list_id": 42,
"oidc_issuer": None,
"oidc_role_claim": "roles",
"administrator_group": None,
"support_agent_group": None,
"read_only_group": None,
}
with Session(database.engine) as db:
db.add(AppSetting(key="deployment_enrollment:trust", value_json=original, is_secret=False))
db.commit()
headers = {"X-AppOps-Session-CSRF": "1"}
managed_root = "/var/lib/docker/volumes/itworx-managed-appdata/_data"
updated = identity_client.patch(
"/api/enrollments/trust/storage-root",
json={"trusted_appdata_root": managed_root, "managed_appdata_backup_acknowledged": True},
headers=headers,
)
assert updated.status_code == 200
assert updated.json()["trustedAppdataRoot"] == managed_root
assert updated.json()["managedAppdataBackupAcknowledged"] is True
with Session(database.engine) as db:
saved = db.get(AppSetting, "deployment_enrollment:trust")
assert saved is not None
assert saved.value_json == {
**original,
"trusted_appdata_root": managed_root,
"managed_appdata_backup_acknowledged": True,
}
event = db.exec(
select(AuditEvent).where(AuditEvent.action_id == "enrollment.trust.storage_root.update")
).one()
assert event.before_state == {
"trustedAppdataRoot": "/srv/example/appdata",
"managedAppdataBackupAcknowledged": False,
}
assert event.after_state == {
"trustedAppdataRoot": managed_root,
"managedAppdataBackupAcknowledged": True,
}
assert event.redacted_metadata == {
"backupLocation": "dockerRootDir",
"requestSource": "manual",
}
assert "bootstrap_secret_ref" not in str(event.before_state)
assert identity_client.patch(
"/api/enrollments/trust/storage-root",
json={"trusted_appdata_root": managed_root, "managed_appdata_backup_acknowledged": False},
headers=headers,
).status_code == 422
assert identity_client.patch(
"/api/enrollments/trust/storage-root",
json={
"trusted_appdata_root": "/srv/example/other",
"managed_appdata_backup_acknowledged": False,
},
headers=headers,
).status_code == 422
assert identity_client.patch(
"/api/enrollments/trust/storage-root",
json={
"trusted_appdata_root": managed_root,
"managed_appdata_backup_acknowledged": True,
"bootstrap_secret_ref": "LEAKED_SECRET_REFERENCE",
},
headers=headers,
).status_code == 422
legacy = identity_client.patch(
"/api/enrollments/trust/storage-root",
json={"trusted_appdata_root": "/srv/example/appdata", "managed_appdata_backup_acknowledged": False},
headers=headers,
)
assert legacy.status_code == 200
assert legacy.json()["trustedAppdataRoot"] == "/srv/example/appdata"
assert legacy.json()["managedAppdataBackupAcknowledged"] is False
def test_storage_root_patch_returns_conflict_after_concurrent_trust_change(identity_client, monkeypatch):
from sqlmodel import Session, select
from backend.app.core.time import utc_now
from backend.app.db import session as database
from backend.app.db.models import AppSetting, AuditEvent
from backend.app.schemas.enrollment import EnrollmentTrustConfigV1
from backend.app.services.enrollment_plane import EnrollmentPlane
sign_in(identity_client, "owner")
original = {
"controller_identity": "unraid-controller",
"bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP",
"trusted_appdata_root": "/srv/example/appdata",
"managed_appdata_backup_acknowledged": False,
"approved_networks": ["itworx-mcp-hub-npm"],
"allowed_domain_suffixes": ["example.com"],
"autonomous_profiles": ["repopulse-authenticated"],
"repopulse_proxy_access_list_id": 42,
"oidc_issuer": None,
"oidc_role_claim": "roles",
"administrator_group": None,
"support_agent_group": None,
"read_only_group": None,
}
with Session(database.engine) as db:
db.add(AppSetting(key="deployment_enrollment:trust", value_json=original, is_secret=False))
db.commit()
stale_trust = EnrollmentTrustConfigV1.model_validate(original)
def read_stale_after_concurrent_update(_session):
with Session(database.engine) as concurrent_session:
row = concurrent_session.get(AppSetting, "deployment_enrollment:trust")
assert row is not None
row.value_json = {**row.value_json, "approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"]}
row.updated_at = utc_now()
concurrent_session.add(row)
concurrent_session.commit()
return stale_trust
monkeypatch.setattr(
EnrollmentPlane,
"get_trust_config",
staticmethod(read_stale_after_concurrent_update),
)
response = identity_client.patch(
"/api/enrollments/trust/storage-root",
json={
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
"managed_appdata_backup_acknowledged": True,
},
headers={"X-AppOps-Session-CSRF": "1"},
)
assert response.status_code == 409
with Session(database.engine) as db:
saved = db.get(AppSetting, "deployment_enrollment:trust")
assert saved is not None
assert saved.value_json["approved_networks"] == ["itworx-mcp-hub-npm", "itworx-npm"]
assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata"
assert db.exec(
select(AuditEvent).where(AuditEvent.action_id == "enrollment.trust.storage_root.update")
).all() == []
def test_storage_root_patch_cas_rejects_change_after_snapshot(identity_client, monkeypatch):
from sqlmodel import Session, select
from backend.app.api import routes_enrollments
from backend.app.core.time import utc_now
from backend.app.db import session as database
from backend.app.db.models import AppSetting, AuditEvent
sign_in(identity_client, "owner")
original = {
"controller_identity": "unraid-controller",
"bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP",
"trusted_appdata_root": "/srv/example/appdata",
"managed_appdata_backup_acknowledged": False,
"approved_networks": ["itworx-mcp-hub-npm"],
"allowed_domain_suffixes": ["example.com"],
"autonomous_profiles": ["repopulse-authenticated"],
"repopulse_proxy_access_list_id": 42,
"oidc_issuer": None,
"oidc_role_claim": "roles",
"administrator_group": None,
"support_agent_group": None,
"read_only_group": None,
}
with database.engine.connect() as connection:
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
with Session(database.engine) as db:
db.add(AppSetting(key="deployment_enrollment:trust", value_json=original, is_secret=False))
db.commit()
actual_sql_update = routes_enrollments.sql_update
concurrent_write_done = False
def write_concurrently_after_snapshot(model):
nonlocal concurrent_write_done
if model is AppSetting and not concurrent_write_done:
concurrent_write_done = True
with Session(database.engine) as concurrent_session:
row = concurrent_session.get(AppSetting, "deployment_enrollment:trust")
assert row is not None
row.value_json = {
**row.value_json,
"approved_networks": ["itworx-mcp-hub-npm", "itworx-npm"],
}
row.updated_at = utc_now()
concurrent_session.add(row)
concurrent_session.commit()
return actual_sql_update(model)
# The endpoint has already read and validated its selector snapshot when it
# calls sql_update. Commit an independent trust edit at that boundary so the
# route's compare-and-swap must reject the now-stale write.
monkeypatch.setattr(routes_enrollments, "sql_update", write_concurrently_after_snapshot)
response = identity_client.patch(
"/api/enrollments/trust/storage-root",
json={
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
"managed_appdata_backup_acknowledged": True,
},
headers={"X-AppOps-Session-CSRF": "1"},
)
assert concurrent_write_done is True
assert response.status_code == 409
with Session(database.engine) as db:
saved = db.get(AppSetting, "deployment_enrollment:trust")
assert saved is not None
assert saved.value_json["approved_networks"] == ["itworx-mcp-hub-npm", "itworx-npm"]
assert saved.value_json["trusted_appdata_root"] == "/srv/example/appdata"
assert db.exec(
select(AuditEvent).where(AuditEvent.action_id == "enrollment.trust.storage_root.update")
).all() == []
def test_read_only_and_operator_cannot_change_storage_root(identity_client):
reader_id = sign_in(identity_client, "reader")
sign_in(identity_client, "owner")
headers = {"X-AppOps-Session-CSRF": "1"}
assert identity_client.patch(
f"/api/auth/users/{reader_id}",
json={"role": "operator", "enabled": True, "permissions": []},
headers=headers,
).status_code == 200
selection = {
"trusted_appdata_root": "/var/lib/docker/volumes/itworx-managed-appdata/_data",
"managed_appdata_backup_acknowledged": True,
}
sign_in(identity_client, "reader")
assert identity_client.patch(
"/api/enrollments/trust/storage-root", json=selection, headers=headers
).status_code == 403
sign_in(identity_client, "owner")
assert identity_client.patch(
f"/api/auth/users/{reader_id}",
json={"role": "custom", "enabled": True, "permissions": ["settings.manage"]},
headers=headers,
).status_code == 200
sign_in(identity_client, "reader")
assert identity_client.patch(
"/api/enrollments/trust/storage-root", json=selection, headers=headers
).status_code == 403
sign_in(identity_client, "read-only")
assert identity_client.patch(
"/api/enrollments/trust/storage-root", json=selection, headers=headers
).status_code == 403
+76 -54
View File
@@ -5,15 +5,20 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@humanfs/node@>=0.16.6 <0.16.8': 0.16.8
brace-expansion@>=1.0.0 <1.1.21: 1.1.21
brace-expansion@>=2.0.0 <2.0.3: 2.0.3
brace-expansion@>=5.0.0 <5.0.9: 5.0.9
brace-expansion@>=5.0.0 <5.0.12: 5.0.12
browserslist@>=4.0.0 <4.28.7: 4.28.7
flatted@<3.4.2: 3.4.2
glob@>=10.2.0 <10.5.0: 10.5.0
js-yaml@>=4.0.0 <4.3.2: 4.3.2
lodash@<=4.17.23: 4.18.1
minimatch@>=9.0.0 <9.0.7: 9.0.7
nanoid@>=3.0.0 <3.3.18: 3.3.18
picomatch@<2.3.2: 2.3.2
postcss@<8.5.23: 8.5.26
postcss-selector-parser@>=6.1.0 <6.1.3: 6.1.3
yaml@>=2.0.0 <2.8.3: 2.9.0
importers:
@@ -631,22 +636,22 @@ packages:
peerDependencies:
react-hook-form: ^7.0.0
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
'@humanfs/node@0.16.6':
resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==}
'@humanfs/node@0.16.8':
resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
engines: {node: '>=18.18.0'}
'@humanfs/types@0.15.0':
resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
engines: {node: '>=18.18.0'}
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
engines: {node: '>=12.22'}
'@humanwhocodes/retry@0.3.1':
resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==}
engines: {node: '>=18.18'}
'@humanwhocodes/retry@0.4.2':
resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==}
engines: {node: '>=18.18'}
@@ -1912,23 +1917,28 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
baseline-browser-mapping@2.11.26:
resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==}
engines: {node: '>=6.0.0'}
hasBin: true
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
brace-expansion@1.1.21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browserslist@4.24.4:
resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==}
browserslist@4.28.7:
resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -1943,6 +1953,9 @@ packages:
caniuse-lite@1.0.30001805:
resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==}
caniuse-lite@1.0.30001814:
resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -2090,8 +2103,8 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
electron-to-chromium@1.5.138:
resolution: {integrity: sha512-FWlQc52z1dXqm+9cCJ2uyFgJkESd+16j6dBEjsgDNuHjBpuIzL8/lRc0uvh1k8RNI6waGo6tcy2DvwkTBJOLDg==}
electron-to-chromium@1.5.443:
resolution: {integrity: sha512-TDJG36L9A3CWWwZ97HKaE+Iz1sW80pNp6sAU0owzCIV3Zc9eVyRqZK8Vz6NKuMIU8WjP0pIkoqCa0VRf/yFD0Q==}
embla-carousel-react@8.6.0:
resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==}
@@ -2350,8 +2363,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
js-yaml@4.3.2:
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
json-buffer@3.0.1:
@@ -2522,8 +2535,9 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
node-releases@2.0.57:
resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==}
engines: {node: '>=18'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
@@ -2628,8 +2642,8 @@ packages:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
postcss-selector-parser@6.1.3:
resolution: {integrity: sha512-cDoO18VWCIWRsSaws3C23b0HXRxlUknttfdNcbXnf3IGHAPRNLlXPHc6dYiatOkxW0W4uZh524Plaaw5a7WEtQ==}
engines: {node: '>=4'}
postcss-value-parser@4.2.0:
@@ -2930,11 +2944,11 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
update-browserslist-db@1.1.3:
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
update-browserslist-db@1.3.3:
resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
browserslist: 4.28.7
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -3239,7 +3253,7 @@ snapshots:
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.3.0
js-yaml: 4.3.2
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
@@ -3281,17 +3295,20 @@ snapshots:
dependencies:
react-hook-form: 7.55.0(react@19.2.3)
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/core': 0.19.1
'@humanwhocodes/retry': 0.3.1
'@humanfs/types': 0.15.0
'@humanfs/node@0.16.8':
dependencies:
'@humanfs/core': 0.19.2
'@humanfs/types': 0.15.0
'@humanwhocodes/retry': 0.4.2
'@humanfs/types@0.15.0': {}
'@humanwhocodes/module-importer@1.0.1': {}
'@humanwhocodes/retry@0.3.1': {}
'@humanwhocodes/retry@0.4.2': {}
'@isaacs/cliui@8.0.2':
@@ -4488,7 +4505,7 @@ snapshots:
autoprefixer@10.4.21(postcss@8.5.26):
dependencies:
browserslist: 4.24.4
browserslist: 4.28.7
caniuse-lite: 1.0.30001805
fraction.js: 4.3.7
normalize-range: 0.1.2
@@ -4500,14 +4517,16 @@ snapshots:
balanced-match@4.0.4: {}
baseline-browser-mapping@2.11.26: {}
binary-extensions@2.3.0: {}
brace-expansion@1.1.11:
brace-expansion@1.1.21:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@5.0.9:
brace-expansion@5.0.12:
dependencies:
balanced-match: 4.0.4
@@ -4515,12 +4534,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
browserslist@4.24.4:
browserslist@4.28.7:
dependencies:
caniuse-lite: 1.0.30001805
electron-to-chromium: 1.5.138
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.24.4)
baseline-browser-mapping: 2.11.26
caniuse-lite: 1.0.30001814
electron-to-chromium: 1.5.443
node-releases: 2.0.57
update-browserslist-db: 1.3.3(browserslist@4.28.7)
callsites@3.1.0: {}
@@ -4528,6 +4548,8 @@ snapshots:
caniuse-lite@1.0.30001805: {}
caniuse-lite@1.0.30001814: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -4661,7 +4683,7 @@ snapshots:
eastasianwidth@0.2.0: {}
electron-to-chromium@1.5.138: {}
electron-to-chromium@1.5.443: {}
embla-carousel-react@8.6.0(react@19.2.3):
dependencies:
@@ -4770,7 +4792,7 @@ snapshots:
'@eslint/eslintrc': 3.3.6(supports-color@7.2.0)
'@eslint/js': 9.39.5
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.6
'@humanfs/node': 0.16.8
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.2
'@types/estree': 1.0.9
@@ -4954,7 +4976,7 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@4.3.0:
js-yaml@4.3.2:
dependencies:
argparse: 2.0.1
@@ -5058,15 +5080,15 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.12
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.11
brace-expansion: 1.1.21
minimatch@9.0.7:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.12
minipass@7.1.2: {}
@@ -5087,7 +5109,7 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
node-releases@2.0.19: {}
node-releases@2.0.57: {}
normalize-path@3.0.0: {}
@@ -5163,14 +5185,14 @@ snapshots:
postcss-nested@6.2.0(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 6.1.2
postcss-selector-parser: 6.1.3
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-selector-parser@6.1.2:
postcss-selector-parser@6.1.3:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
@@ -5449,7 +5471,7 @@ snapshots:
postcss-js: 4.0.1(postcss@8.5.26)
postcss-load-config: 4.0.2(postcss@8.5.26)
postcss-nested: 6.2.0(postcss@8.5.26)
postcss-selector-parser: 6.1.2
postcss-selector-parser: 6.1.3
resolve: 1.22.10
sucrase: 3.35.0
transitivePeerDependencies:
@@ -5507,9 +5529,9 @@ snapshots:
undici-types@6.21.0: {}
update-browserslist-db@1.1.3(browserslist@4.24.4):
update-browserslist-db@1.3.3(browserslist@4.28.7):
dependencies:
browserslist: 4.24.4
browserslist: 4.28.7
escalade: 3.2.0
picocolors: 1.1.1
+6 -1
View File
@@ -371,13 +371,18 @@ packages:
- .
minimumReleaseAge: 1440
overrides:
"@humanfs/node@>=0.16.6 <0.16.8": 0.16.8
"brace-expansion@>=1.0.0 <1.1.21": 1.1.21
"brace-expansion@>=2.0.0 <2.0.3": 2.0.3
"brace-expansion@>=5.0.0 <5.0.9": 5.0.9
"brace-expansion@>=5.0.0 <5.0.12": 5.0.12
"browserslist@>=4.0.0 <4.28.7": 4.28.7
"flatted@<3.4.2": 3.4.2
"glob@>=10.2.0 <10.5.0": 10.5.0
"js-yaml@>=4.0.0 <4.3.2": 4.3.2
"lodash@<=4.17.23": 4.18.1
"minimatch@>=9.0.0 <9.0.7": 9.0.7
"nanoid@>=3.0.0 <3.3.18": 3.3.18
"picomatch@<2.3.2": 2.3.2
"postcss@<8.5.23": 8.5.26
"postcss-selector-parser@>=6.1.0 <6.1.3": 6.1.3
"yaml@>=2.0.0 <2.8.3": 2.9.0
+5 -1
View File
@@ -4,7 +4,7 @@ import { AISummary, AIRecommendation, AIChatMessage, AIAutopilotRule, AITool } f
import { toIsoString } from '@/lib/backendDateTime';
import type { AiSecurityFinding, AiSecurityRecommendation, AiSecurityReviewResult, AiSecurityScope, AiSecuritySummary, NotificationChannel, NotificationEvaluationResult, NotificationEvent, NotificationRule, NotificationSummary, RemediationApprovalProposal, RemediationDryRunResult, RemediationPlan, RemediationPlanCollection, SafetyDashboard, SafetyFinding, SafetyRecommendation, SafetyScoreBreakdown, SafetySection, SafetySummary, UnraidDisksResponse, UnraidDockerResponse, UnraidSharesResponse, UnraidVmCapabilitiesResponse, UnraidVmsResponse } from '../types/appops';
import type { GiteaActionsStatus, IntegrationStatus, GatewayUser, IdentityProvider, PermissionDefinition, UserAccessUpdate, UserAuthSession, RagQueryResult, RagStatus, RunnerFleetStatus } from '../types/appops';
import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, WorkMeshPromotion } from '../types/appops';
import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, EnrollmentStorageRootSelection, EnrollmentTrustStatus, WorkMeshPromotion } from '../types/appops';
import type { ExposurePreferences, ManagedAiConfig, ManagedAiConfigUpdate, ManagedAiStatus, ManagedAiInference } from '../types/appops';
type HealthStatus = {
@@ -372,6 +372,10 @@ export const createLiveClient = () => ({
},
async getDeliveryStatus(): Promise<DeliveryStatus> { return get<DeliveryStatus>('/api/deployments/delivery-status', defaultConfig); },
async getEnrollmentCapabilities(): Promise<EnrollmentCapabilities> { return get<EnrollmentCapabilities>('/api/enrollments/capabilities', defaultConfig); },
async getEnrollmentTrustStatus(): Promise<EnrollmentTrustStatus> { return get<EnrollmentTrustStatus>('/api/enrollments/trust', defaultConfig); },
async updateEnrollmentStorageRoot(payload: EnrollmentStorageRootSelection): Promise<EnrollmentTrustStatus> {
return patch<EnrollmentTrustStatus>('/api/enrollments/trust/storage-root', payload, defaultConfig);
},
async getEnrollments(): Promise<{ items: Enrollment[] }> { return get<{ items: Enrollment[] }>('/api/enrollments', defaultConfig); },
async getSystemSummary(): Promise<SystemSummary> { return get<SystemSummary>('/api/system/summary', defaultConfig); },
async getHealth(): Promise<HealthStatus> { return get<HealthStatus>('/api/health', defaultConfig); },
+392
View File
@@ -0,0 +1,392 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AlertCircle, CheckCircle2, Database, LoaderCircle, RefreshCw, ShieldAlert } from 'lucide-react';
import { appopsClient } from '@/api/appopsClient';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { formatOperatorError } from '@/lib/operatorError';
import {
buildEnrollmentStorageView,
buildStorageRootSelection,
canSaveStorageRootSelection,
canRequestManagedStorageInitialization,
normalizeManagedStoragePreview,
normalizeManagedStorageActionOutcome,
LEGACY_APPDATA_ROOT,
MANAGED_APPDATA_ROOT,
MANAGED_STORAGE_INITIALIZE_ACTION,
MANAGED_STORAGE_TARGET,
managedStorageBlockerLabel,
managedStorageStatusLabel,
rootReadinessLabel,
} from '@/lib/enrollmentStorageView';
import { useUserSession } from '@/hooks/useUserSession';
import type { ActionPlan, EnrollmentCapabilities, EnrollmentTrustStatus, PreflightPreview, TrustedAppdataRoot } from '@/types/appops';
interface StorageSnapshot {
trust: EnrollmentTrustStatus;
capabilities: EnrollmentCapabilities;
view: ReturnType<typeof buildEnrollmentStorageView>;
preview: PreflightPreview | null;
previewError: string | null;
}
interface StorageDraft {
root: TrustedAppdataRoot;
backupAcknowledged: boolean;
}
const labelForRoot = (root: TrustedAppdataRoot) => (
root === LEGACY_APPDATA_ROOT ? 'Unraid appdata share' : 'Dedicated managed Docker-root volume'
);
const isConflict = (error: unknown): boolean => (
error instanceof Error && /Backend 409:/.test(error.message)
);
export const ManagedStorageSettings = () => {
const session = useUserSession();
const isAdmin = session.ready && session.authenticated && session.user?.role === 'admin';
const canManage = isAdmin && session.permissions.includes('settings.manage');
const [snapshot, setSnapshot] = useState<StorageSnapshot | null>(null);
const [draft, setDraft] = useState<StorageDraft>({ root: LEGACY_APPDATA_ROOT, backupAcknowledged: false });
const [refreshing, setRefreshing] = useState(false);
const [loadError, setLoadError] = useState('');
const [saveError, setSaveError] = useState('');
const [conflict, setConflict] = useState('');
const [saveNotice, setSaveNotice] = useState('');
const [saving, setSaving] = useState(false);
const [initializing, setInitializing] = useState(false);
const [actionNotice, setActionNotice] = useState<{ tone: 'status' | 'alert'; message: string } | null>(null);
const requestSequence = useRef(0);
const snapshotRef = useRef<StorageSnapshot | null>(null);
const draftRef = useRef(draft);
const updateDraft = (next: StorageDraft) => {
draftRef.current = next;
setDraft(next);
};
const load = useCallback(async (options: { preserveDraft?: boolean } = {}) => {
if (!canManage) return;
const sequence = ++requestSequence.current;
setRefreshing(true);
setLoadError('');
try {
const [trust, capabilities] = await Promise.all([
appopsClient.getEnrollmentTrustStatus(),
appopsClient.getEnrollmentCapabilities(),
]);
const view = buildEnrollmentStorageView(trust, capabilities);
let preview: PreflightPreview | null = null;
let previewError: string | null = null;
if (view.managedStatus === 'initialization_required') {
try {
const rawPreview: unknown = await appopsClient.previewAction(MANAGED_STORAGE_INITIALIZE_ACTION, MANAGED_STORAGE_TARGET);
preview = normalizeManagedStoragePreview(rawPreview);
if (!preview) previewError = 'De policy-preview had geen geldig formaat; de actie blijft geblokkeerd.';
} catch (error) {
previewError = formatOperatorError(error, 'De policy-preview is niet beschikbaar.');
}
}
if (sequence !== requestSequence.current) return;
const previous = snapshotRef.current;
const currentDraft = draftRef.current;
const draftChanged = previous !== null && (
currentDraft.root !== previous.view.currentRoot ||
currentDraft.backupAcknowledged !== previous.view.backupAcknowledged
);
const nextSnapshot = { trust, capabilities, view, preview, previewError };
snapshotRef.current = nextSnapshot;
setSnapshot(nextSnapshot);
if (!options.preserveDraft && !draftChanged && view.currentRoot) {
updateDraft({ root: view.currentRoot, backupAcknowledged: view.backupAcknowledged });
}
if (!options.preserveDraft) setConflict('');
} catch (error) {
if (sequence !== requestSequence.current) return;
setLoadError(formatOperatorError(error, 'Opslaginstellingen konden niet worden geladen.'));
} finally {
if (sequence === requestSequence.current) setRefreshing(false);
}
}, [canManage]);
useEffect(() => {
if (canManage) void load();
}, [canManage, load]);
if (!isAdmin) return null;
const view = snapshot?.view;
const selectedDraft = draft.root;
const draftIsManaged = selectedDraft === MANAGED_APPDATA_ROOT;
const dirty = Boolean(view?.currentRoot && (
draft.root !== view.currentRoot ||
(draftIsManaged && draft.backupAcknowledged !== view.backupAcknowledged)
));
const requestPayload = buildStorageRootSelection(draft.root, draft.backupAcknowledged);
const saveDisabled = !canSaveStorageRootSelection({
canManage,
configured: Boolean(snapshot?.view.configured),
payloadAvailable: Boolean(requestPayload),
dirty,
loadError,
refreshing,
saving,
initializing,
});
const initializationGate = {
canManage,
view,
preview: snapshot?.preview,
dirty,
loadError,
conflict,
refreshing,
saving,
initializing,
};
const initializeAllowed = canRequestManagedStorageInitialization(initializationGate);
const save = async () => {
if (!canManage || !requestPayload || saveDisabled) return;
const sequence = ++requestSequence.current;
setSaving(true);
setSaveError('');
setConflict('');
setSaveNotice('');
setActionNotice(null);
try {
const updatedTrust = await appopsClient.updateEnrollmentStorageRoot(requestPayload);
if (sequence !== requestSequence.current) return;
const current = snapshotRef.current;
if (current) {
const updatedView = buildEnrollmentStorageView(updatedTrust, current.capabilities);
const updatedSnapshot = { ...current, trust: updatedTrust, view: updatedView };
snapshotRef.current = updatedSnapshot;
setSnapshot(updatedSnapshot);
if (updatedView.currentRoot) updateDraft({ root: updatedView.currentRoot, backupAcknowledged: updatedView.backupAcknowledged });
}
setSaveNotice('Storagevoorkeur opgeslagen. De controller-readiness wordt opnieuw gecontroleerd; bestaande targets blijven ongewijzigd.');
await load();
} catch (error) {
if (sequence !== requestSequence.current) return;
if (isConflict(error)) {
setConflict('De trustconfiguratie is intussen gewijzigd. De actuele status wordt geladen; jouw keuze blijft als concept bewaard. Controleer de selectie en sla opnieuw op als die nog klopt.');
await load({ preserveDraft: true });
} else {
setSaveError(formatOperatorError(error, 'De storagevoorkeur is niet opgeslagen. Jouw concept blijft behouden.'));
}
} finally {
setSaving(false);
}
};
const initialize = async () => {
if (!snapshot?.preview || !view || !canRequestManagedStorageInitialization(initializationGate)) return;
const preview = snapshot.preview;
const plan: ActionPlan = {
intent: MANAGED_STORAGE_INITIALIZE_ACTION,
target: MANAGED_STORAGE_TARGET,
source: 'manual',
risk: preview.risk,
policy: preview.policyDecision,
reason: preview.auditPreview,
plan: preview.preChecks,
postChecks: preview.postChecks,
};
setInitializing(true);
setActionNotice(null);
try {
const result: unknown = await appopsClient.executeAction(plan);
setActionNotice(normalizeManagedStorageActionOutcome(result));
await load({ preserveDraft: true });
} catch (error) {
setActionNotice({ tone: 'alert', message: formatOperatorError(error, 'Het initialisatieverzoek is niet bevestigd.') });
} finally {
setInitializing(false);
}
};
return (
<Card className="border-slate-700 bg-slate-900/50" aria-busy={refreshing || saving || initializing}>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle className="flex items-center gap-2 text-sm font-bold uppercase tracking-wider text-slate-300">
<Database className="h-4 w-4 shrink-0 text-blue-400" /> Opslag voor nieuwe targets
</CardTitle>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">
Kies de vertrouwde opslagroot voor toekomstige registraties. Dit wijzigt of verplaatst geen bestaande targetregistraties.
</p>
</div>
<Button type="button" variant="outline" className="w-full shrink-0 border-slate-700 bg-slate-950 text-slate-200 sm:w-auto" onClick={() => void load()} disabled={!canManage || refreshing || saving || initializing}>
{refreshing ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <RefreshCw className="mr-2 h-4 w-4" aria-hidden="true" />}
{refreshing ? 'Status vernieuwen…' : 'Vernieuw status'}
</Button>
</CardHeader>
<CardContent className="space-y-5">
{!canManage && (
<p className="text-sm text-slate-400">Alleen een beheerder met de instellingentoestemming kan deze opslagkeuze bekijken of wijzigen.</p>
)}
{canManage && refreshing && !snapshot && <p role="status" aria-live="polite" className="text-sm text-slate-400">Opslagstatus laden…</p>}
{canManage && loadError && (
<div role="alert" className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm text-amber-100">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<div className="min-w-0">
<p>{snapshot ? `De laatste opslagstatus kon niet worden vernieuwd. De waarden hieronder zijn de laatst succesvol gelezen gegevens. ${loadError}` : loadError}</p>
{!snapshot && <Button type="button" variant="outline" className="mt-3 border-amber-500/30" onClick={() => void load()}>Opnieuw proberen</Button>}
</div>
</div>
)}
{snapshot && view && canManage && (
<>
<div className="grid min-w-0 gap-3 rounded-lg border border-slate-800 bg-slate-950/70 p-3 sm:grid-cols-2">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Opgeslagen root</p>
<p className="mt-1 break-all text-sm text-slate-100">{view.currentRoot || 'Niet bevestigd door de API'}</p>
<p className="mt-1 text-xs text-slate-400">{view.currentRoot ? labelForRoot(view.currentRoot) : 'De huidige trustconfiguratie kan niet veilig worden gewijzigd.'}</p>
</div>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Truststatus</p>
<p className="mt-1 text-sm text-slate-100">
{view.trustStatus === 'ready' ? 'Configuratie gelezen' : view.trustStatus === 'blocked' ? 'Trustconfiguratie heeft andere blockers' : view.trustStatus === 'needs_setup' ? 'Trustconfiguratie ontbreekt' : 'Status onbekend'}
</p>
{view.trustStatus === 'needs_setup' && <p className="mt-1 text-xs text-amber-200">Rond de trustconfiguratie eerst af via Setup.</p>}
</div>
</div>
{view.trustStatus === 'needs_setup' && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-sm text-amber-100">Deze beperkte kaart kan geen volledige trustconfiguratie maken.</div>
)}
{snapshot.view.configured && (
<fieldset disabled={saving || refreshing || initializing} className="min-w-0 space-y-3 disabled:opacity-80">
<legend className="text-sm font-semibold text-slate-200">Vertrouwde opslagroot</legend>
<RadioGroup
aria-label="Vertrouwde opslagroot voor toekomstige targetregistraties"
value={draft.root}
onValueChange={(value) => {
if (value !== LEGACY_APPDATA_ROOT && value !== MANAGED_APPDATA_ROOT) return;
setSaveNotice('');
setConflict('');
updateDraft({ root: value, backupAcknowledged: false });
}}
className="grid min-w-0 gap-3 md:grid-cols-2"
>
<label htmlFor="appdata-root-legacy" className="flex min-w-0 cursor-pointer gap-3 rounded-lg border border-slate-700 bg-slate-950/70 p-3 has-[:disabled]:cursor-not-allowed">
<RadioGroupItem id="appdata-root-legacy" value={LEGACY_APPDATA_ROOT} className="mt-1 shrink-0" />
<span className="min-w-0">
<span className="block text-sm font-semibold text-slate-100">{labelForRoot(LEGACY_APPDATA_ROOT)}</span>
<code className="mt-1 block break-all text-xs text-slate-400">{LEGACY_APPDATA_ROOT}</code>
<span className="mt-2 block text-xs text-slate-300">{rootReadinessLabel(view.rootReadiness[LEGACY_APPDATA_ROOT])}</span>
</span>
</label>
<label htmlFor="appdata-root-managed" className="flex min-w-0 cursor-pointer gap-3 rounded-lg border border-slate-700 bg-slate-950/70 p-3 has-[:disabled]:cursor-not-allowed">
<RadioGroupItem id="appdata-root-managed" value={MANAGED_APPDATA_ROOT} className="mt-1 shrink-0" />
<span className="min-w-0">
<span className="block text-sm font-semibold text-slate-100">{labelForRoot(MANAGED_APPDATA_ROOT)}</span>
<code className="mt-1 block break-all text-xs text-slate-400">{MANAGED_APPDATA_ROOT}</code>
<span className="mt-2 block text-xs text-slate-300">{rootReadinessLabel(view.rootReadiness[MANAGED_APPDATA_ROOT])}</span>
</span>
</label>
</RadioGroup>
</fieldset>
)}
{draftIsManaged && snapshot.view.configured && (
<section aria-labelledby="managed-root-backup-title" className="space-y-3 rounded-lg border border-amber-500/25 bg-amber-500/5 p-3">
<div className="flex items-start gap-3">
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-300" aria-hidden="true" />
<div className="min-w-0">
<h3 id="managed-root-backup-title" className="text-sm font-semibold text-amber-100">Back-upverantwoordelijkheid voor DockerRootDir</h3>
<p className="mt-1 break-words text-sm leading-6 text-amber-100/80">
Deze vaste root ligt onder <code className="break-all">/var/lib/docker</code> en kan deel uitmaken van <code>docker.img</code>. Gebruikelijke back-ups van <code>/srv/example/appdata</code> nemen dit niet mee. Beheer en herstel van DockerRootDir vallen onder jouw back-upplan.
</p>
</div>
</div>
<div className="flex min-w-0 items-start gap-3 rounded-md border border-amber-500/20 bg-slate-950/60 p-3">
<Checkbox
id="managed-root-backup-ack"
checked={draft.backupAcknowledged}
onCheckedChange={(checked) => {
setSaveNotice('');
setConflict('');
updateDraft({ ...draftRef.current, backupAcknowledged: checked === true });
}}
disabled={saving || refreshing || initializing}
aria-describedby="managed-root-backup-copy"
className="mt-0.5 shrink-0"
/>
<Label htmlFor="managed-root-backup-ack" id="managed-root-backup-copy" className="cursor-pointer text-sm leading-6 text-slate-200">
Ik bevestig dat reguliere appdata-back-ups DockerRootDir en <code>docker.img</code> niet dekken en dat ik daarvoor afzonderlijk back-up- en herstelbeheer regel.
</Label>
</div>
</section>
)}
<div className="rounded-lg border border-slate-800 bg-slate-950/60 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Controller-readiness van de beheerde volume</p>
<p className="mt-1 text-sm text-slate-100">{managedStorageStatusLabel(view.managedStatus)}</p>
{managedStorageBlockerLabel(view.managedBlocker) && <p className="mt-1 text-xs leading-5 text-amber-200">{managedStorageBlockerLabel(view.managedBlocker)}</p>}
{!view.managedCapabilityValid && <p className="mt-1 text-xs text-amber-200">De controller leverde geen geldige readinessbevestiging; acties blijven geblokkeerd.</p>}
<p className="mt-2 text-xs leading-5 text-slate-400">De rootkeuze geldt voor toekomstige registraties. Bestaande targets blijven aan hun eerder vastgelegde root gekoppeld en worden niet verplaatst.</p>
</div>
{view.managedStatus === 'initialization_required' && (
<div className="space-y-3 rounded-lg border border-slate-800 bg-slate-950/60 p-3">
<div>
<p className="text-sm font-semibold text-slate-100">Eenmalige controlleractie</p>
<p className="mt-1 text-xs leading-5 text-slate-400">Alleen beschikbaar als de exacte handmatige high-risk policy-preview dit toestaat. Een aanvraag is geen bewijs dat de volume klaar is.</p>
</div>
{snapshot.previewError && <p role="alert" className="text-sm text-amber-200">Policy-preview niet beschikbaar: {snapshot.previewError}</p>}
{snapshot.preview && (
<div className="space-y-2 rounded-md border border-slate-800 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Policy: {snapshot.preview.policyDecision === 'allowed' ? 'Toegestaan' : snapshot.preview.policyDecision === 'approval_required' ? 'Approval vereist' : 'Geblokkeerd'}</p>
<p className="break-words text-sm text-slate-200">{snapshot.preview.auditPreview}</p>
{snapshot.preview.preChecks.length > 0 && (
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-400">
{snapshot.preview.preChecks.map((check, index) => <li key={`${index}-${check}`} className="break-words">{check}</li>)}
</ul>
)}
{(snapshot.preview.action !== MANAGED_STORAGE_INITIALIZE_ACTION || snapshot.preview.target !== MANAGED_STORAGE_TARGET) && (
<p role="alert" className="text-xs text-rose-200">De preview bevestigt niet de vaste actie en controllerdoel; uitvoering blijft geblokkeerd.</p>
)}
</div>
)}
{initializeAllowed && (
<Button type="button" onClick={() => void initialize()} disabled={initializing || saving || refreshing} className="w-full sm:w-auto">
{initializing ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <ShieldAlert className="mr-2 h-4 w-4" aria-hidden="true" />}
{initializing ? 'Actieverzoek indienen…' : 'Vraag volume-initialisatie aan'}
</Button>
)}
</div>
)}
{actionNotice && (
<div role={actionNotice.tone === 'alert' ? 'alert' : 'status'} aria-live={actionNotice.tone === 'status' ? 'polite' : undefined} className={`rounded-lg border p-3 text-sm ${actionNotice.tone === 'alert' ? 'border-amber-500/30 bg-amber-500/5 text-amber-100' : 'border-blue-500/25 bg-blue-500/5 text-blue-100'}`}>
{actionNotice.message}
</div>
)}
{conflict && <div role="alert" className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm text-amber-100">{conflict}</div>}
{saveError && <div role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/5 p-3 text-sm text-rose-100">{saveError}</div>}
{saveNotice && <div role="status" aria-live="polite" className="flex items-start gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/5 p-3 text-sm text-emerald-100"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />{saveNotice}</div>}
{view.configured && canManage && (
<div className="flex flex-col gap-3 border-t border-slate-800 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-slate-400">Opslaan wijzigt alleen de trustinstelling voor volgende registraties.</p>
<Button type="button" onClick={() => void save()} disabled={saveDisabled} className="w-full sm:w-auto">
{saving ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : null}
{saving ? 'Opslaan…' : dirty ? 'Opslagkeuze opslaan' : 'Geen wijzigingen'}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
);
};
+216
View File
@@ -0,0 +1,216 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildEnrollmentStorageView,
buildStorageRootSelection,
canInitializeManagedStorage,
canRequestManagedStorageInitialization,
canSaveStorageRootSelection,
LEGACY_APPDATA_ROOT,
MANAGED_APPDATA_ROOT,
MANAGED_STORAGE_INITIALIZE_ACTION,
MANAGED_STORAGE_TARGET,
normalizeManagedStoragePreview,
normalizeManagedStorageActionOutcome,
} from './enrollmentStorageView.ts';
const baseTrust = {
configured: true,
status: 'ready',
trustedAppdataRoot: MANAGED_APPDATA_ROOT,
managedAppdataBackupAcknowledged: true,
};
const baseCapabilities = {
availableTrustedAppdataRoots: [
{ root: LEGACY_APPDATA_ROOT, status: 'ready' },
{ root: MANAGED_APPDATA_ROOT, status: 'needs_initialization' },
],
managedStorage: {
status: 'initialization_required',
blockerCode: 'managed_appdata_initialization_required',
root: MANAGED_APPDATA_ROOT,
backupScope: 'dockerRootDir',
backupAcknowledged: true,
},
};
test('malformed or unknown roots fail closed without echoing untrusted paths', () => {
const view = buildEnrollmentStorageView({ ...baseTrust, trustedAppdataRoot: '/tmp/unknown-secret-path' }, {
...baseCapabilities,
availableTrustedAppdataRoots: [{ root: '/tmp/private', status: 'ready' }, ...baseCapabilities.availableTrustedAppdataRoots],
});
assert.equal(view.currentRoot, null);
assert.equal(view.configured, false);
assert.equal(view.rootReadiness[LEGACY_APPDATA_ROOT], 'unavailable');
assert.equal(view.rootReadiness[MANAGED_APPDATA_ROOT], 'unavailable');
assert.equal(view.managedStatus, 'unavailable');
assert.equal(view.managedCapabilityValid, false);
});
test('readiness and saved selection fail closed when root evidence or acknowledgement contradicts', () => {
const unavailableManagedRoot = buildEnrollmentStorageView(baseTrust, {
...baseCapabilities,
availableTrustedAppdataRoots: [
{ root: LEGACY_APPDATA_ROOT, status: 'ready' },
{ root: MANAGED_APPDATA_ROOT, status: 'unavailable' },
],
managedStorage: { ...baseCapabilities.managedStorage, status: 'ready' },
});
assert.equal(unavailableManagedRoot.managedStatus, 'unavailable');
assert.equal(unavailableManagedRoot.managedCapabilityValid, false);
const contradictoryManagedAcknowledgement = buildEnrollmentStorageView(baseTrust, {
...baseCapabilities,
managedStorage: { ...baseCapabilities.managedStorage, backupAcknowledged: false },
});
assert.equal(contradictoryManagedAcknowledgement.configured, false);
assert.equal(contradictoryManagedAcknowledgement.currentRoot, null);
assert.equal(contradictoryManagedAcknowledgement.trustStatus, 'unknown');
assert.equal(contradictoryManagedAcknowledgement.managedStatus, 'unavailable');
const malformedRootEvidence = buildEnrollmentStorageView(baseTrust, {
...baseCapabilities,
availableTrustedAppdataRoots: [{ root: LEGACY_APPDATA_ROOT, status: 'ready' }],
managedStorage: { ...baseCapabilities.managedStorage, status: 'ready' },
});
assert.equal(malformedRootEvidence.managedStatus, 'unavailable');
});
test('managed-root choice requires explicit backup acknowledgement and sends only the two contract fields', () => {
assert.equal(buildStorageRootSelection(MANAGED_APPDATA_ROOT, false), null);
assert.deepEqual(buildStorageRootSelection(MANAGED_APPDATA_ROOT, true), {
trusted_appdata_root: MANAGED_APPDATA_ROOT,
managed_appdata_backup_acknowledged: true,
});
assert.deepEqual(buildStorageRootSelection(LEGACY_APPDATA_ROOT, true), {
trusted_appdata_root: LEGACY_APPDATA_ROOT,
managed_appdata_backup_acknowledged: false,
});
});
test('a failed refresh disables a dirty storage-root draft from being saved', () => {
const gate = {
canManage: true,
configured: true,
payloadAvailable: true,
dirty: true,
loadError: '',
refreshing: false,
saving: false,
initializing: false,
};
assert.equal(canSaveStorageRootSelection(gate), true);
assert.equal(canSaveStorageRootSelection({ ...gate, loadError: 'Refresh failed; showing last-known configuration.' }), false);
assert.equal(canSaveStorageRootSelection({ ...gate, refreshing: true }), false);
assert.equal(canSaveStorageRootSelection({ ...gate, initializing: true }), false);
});
test('initialization requires selected managed root, capability evidence, exact high-risk action and allowed manual policy', () => {
const view = buildEnrollmentStorageView(baseTrust, baseCapabilities);
const preview = {
action: MANAGED_STORAGE_INITIALIZE_ACTION,
target: MANAGED_STORAGE_TARGET,
risk: 'high',
policyDecision: 'allowed',
};
assert.equal(canInitializeManagedStorage(view, preview), true);
assert.equal(canInitializeManagedStorage(view, { ...preview, policyDecision: 'blocked' }), false);
assert.equal(canInitializeManagedStorage(view, { ...preview, target: 'tower/other' }), false);
assert.equal(canInitializeManagedStorage(view, { ...preview, risk: 'low' }), false);
assert.equal(canInitializeManagedStorage(view, { ...view, policyDecision: 'allowed' }), false);
assert.equal(canInitializeManagedStorage(
buildEnrollmentStorageView({ ...baseTrust, managedAppdataBackupAcknowledged: false }, baseCapabilities),
preview,
), false);
assert.equal(canInitializeManagedStorage(
buildEnrollmentStorageView(baseTrust, { ...baseCapabilities, managedStorage: { ...baseCapabilities.managedStorage, status: 'ready' } }),
preview,
), false);
});
test('initialization stays disabled during stale refresh, conflict recovery, dirty drafts and mutations', () => {
const view = buildEnrollmentStorageView(baseTrust, baseCapabilities);
const gate = {
canManage: true,
view,
preview: {
action: MANAGED_STORAGE_INITIALIZE_ACTION,
target: MANAGED_STORAGE_TARGET,
risk: 'high',
policyDecision: 'allowed',
},
dirty: false,
loadError: '',
conflict: '',
refreshing: false,
saving: false,
initializing: false,
};
assert.equal(canRequestManagedStorageInitialization(gate), true);
assert.equal(canRequestManagedStorageInitialization({ ...gate, loadError: 'Refresh failed; showing last known data.' }), false);
assert.equal(canRequestManagedStorageInitialization({ ...gate, conflict: 'The saved selection changed.' }), false);
assert.equal(canRequestManagedStorageInitialization({ ...gate, dirty: true }), false);
assert.equal(canRequestManagedStorageInitialization({ ...gate, refreshing: true }), false);
assert.equal(canRequestManagedStorageInitialization({ ...gate, saving: true }), false);
assert.equal(canRequestManagedStorageInitialization({ ...gate, initializing: true }), false);
assert.equal(canRequestManagedStorageInitialization({ ...gate, canManage: false }), false);
});
test('malformed runtime policy preview is rejected before UI uses its arrays or text', () => {
assert.equal(normalizeManagedStoragePreview({
action: MANAGED_STORAGE_INITIALIZE_ACTION,
target: MANAGED_STORAGE_TARGET,
risk: 'high',
policyDecision: 'allowed',
auditPreview: 'Initialize managed storage',
affectedApps: [],
dependencies: [],
preChecks: 'not-an-array',
postChecks: [],
}), null);
assert.equal(normalizeManagedStoragePreview({
action: MANAGED_STORAGE_INITIALIZE_ACTION,
target: MANAGED_STORAGE_TARGET,
risk: 'high',
policyDecision: 'allowed',
auditPreview: 42,
affectedApps: [],
dependencies: [],
preChecks: [],
postChecks: [],
}), null);
});
test('malformed initializer results never produce a success notice or stringify arbitrary messages', () => {
const malformedResults: unknown[] = [
{ success: true, jobId: {}, auditId: 'audit-1', message: 'Started' },
{ success: true, jobId: 'job-1', auditId: ' ', message: 'Started' },
{ success: true, jobId: `${' '.repeat(201)}job-1`, auditId: 'audit-1', message: 'Started' },
{ success: true, jobId: 'job-1', auditId: `${' '.repeat(201)}audit-1`, message: 'Started' },
{ success: true, jobId: 'job-1', auditId: 'audit-1', message: { detail: 'Started' } },
{ success: true, jobId: 'job-1', auditId: 'audit-1' },
{ success: 'true', jobId: 'job-1', auditId: 'audit-1', message: 'Started' },
{ success: false, message: { detail: 'Denied' } },
];
for (const result of malformedResults) {
assert.deepEqual(normalizeManagedStorageActionOutcome(result), {
tone: 'alert',
message: 'Het initialisatieverzoek is niet bevestigd met geldige referenties.',
});
}
assert.deepEqual(normalizeManagedStorageActionOutcome({
success: true,
jobId: 'job-123',
auditId: 'audit-456',
message: 'Accepted',
}), {
tone: 'status',
message: 'Initialisatieverzoek geregistreerd. Job job-123; audit audit-456. De opslag wordt pas als gereed getoond nadat de controller dat bevestigt.',
});
});
+277
View File
@@ -0,0 +1,277 @@
import type {
PreflightPreview,
EnrollmentStorageRootSelection,
ManagedStorageStatus,
TrustedAppdataRoot,
TrustedAppdataRootReadinessStatus,
} from '@/types/appops';
export const LEGACY_APPDATA_ROOT: TrustedAppdataRoot = '/srv/example/appdata';
export const MANAGED_APPDATA_ROOT: TrustedAppdataRoot = '/var/lib/docker/volumes/itworx-managed-appdata/_data';
export const MANAGED_STORAGE_INITIALIZE_ACTION = 'controller.managed-storage.initialize';
export const MANAGED_STORAGE_TARGET = 'tower/deployment-controller';
export interface EnrollmentStorageView {
configured: boolean;
trustStatus: 'ready' | 'blocked' | 'needs_setup' | 'unknown';
currentRoot: TrustedAppdataRoot | null;
backupAcknowledged: boolean;
rootReadiness: Record<TrustedAppdataRoot, TrustedAppdataRootReadinessStatus>;
managedStatus: ManagedStorageStatus;
managedBlocker: string | null;
managedCapabilityValid: boolean;
}
const isRecord = (value: unknown): value is Record<string, unknown> => (
typeof value === 'object' && value !== null && !Array.isArray(value)
);
const isRoot = (value: unknown): value is TrustedAppdataRoot => (
value === LEGACY_APPDATA_ROOT || value === MANAGED_APPDATA_ROOT
);
const isRootReadiness = (value: unknown): value is TrustedAppdataRootReadinessStatus => (
value === 'ready' || value === 'needs_initialization' || value === 'unavailable'
);
const isManagedStatus = (value: unknown): value is ManagedStorageStatus => (
value === 'not_selected' || value === 'blocked' || value === 'ready' || value === 'initialization_required' || value === 'unavailable'
);
export const buildEnrollmentStorageView = (trust: unknown, capabilities: unknown): EnrollmentStorageView => {
const safeTrust = isRecord(trust) ? trust : {};
const safeCapabilities = isRecord(capabilities) ? capabilities : {};
const trustedRoot = isRoot(safeTrust.trustedAppdataRoot) ? safeTrust.trustedAppdataRoot : null;
const managed = isRecord(safeCapabilities.managedStorage) ? safeCapabilities.managedStorage : null;
const reportedRoots = safeCapabilities.availableTrustedAppdataRoots;
let rootReadiness: Record<TrustedAppdataRoot, TrustedAppdataRootReadinessStatus> = {
[LEGACY_APPDATA_ROOT]: 'unavailable',
[MANAGED_APPDATA_ROOT]: 'unavailable',
};
let rootsValid = false;
if (Array.isArray(reportedRoots) && reportedRoots.length === 2) {
const entries = new Map<TrustedAppdataRoot, TrustedAppdataRootReadinessStatus>();
const valid = reportedRoots.every((entry) => {
if (!isRecord(entry) || !isRoot(entry.root) || !isRootReadiness(entry.status) || entries.has(entry.root)) return false;
entries.set(entry.root, entry.status);
return true;
});
if (valid && entries.has(LEGACY_APPDATA_ROOT) && entries.has(MANAGED_APPDATA_ROOT)) {
rootsValid = true;
rootReadiness = {
[LEGACY_APPDATA_ROOT]: entries.get(LEGACY_APPDATA_ROOT)!,
[MANAGED_APPDATA_ROOT]: entries.get(MANAGED_APPDATA_ROOT)!,
};
}
}
const backupAcknowledged = safeTrust.managedAppdataBackupAcknowledged;
const trustSelectionConsistent = typeof backupAcknowledged === 'boolean' && (
trustedRoot === MANAGED_APPDATA_ROOT
? backupAcknowledged && managed?.root === MANAGED_APPDATA_ROOT && managed.backupAcknowledged === backupAcknowledged
: trustedRoot === LEGACY_APPDATA_ROOT && !backupAcknowledged
);
const managedStatusConsistent = Boolean(
managed && (
(managed.status !== 'ready' || rootReadiness[MANAGED_APPDATA_ROOT] === 'ready') &&
(managed.status !== 'initialization_required' || rootReadiness[MANAGED_APPDATA_ROOT] === 'needs_initialization') &&
(trustedRoot !== MANAGED_APPDATA_ROOT || managed.status !== 'not_selected') &&
(trustedRoot !== MANAGED_APPDATA_ROOT || managed.backupAcknowledged === backupAcknowledged)
)
);
const managedCapabilityValid = Boolean(
rootsValid &&
trustSelectionConsistent &&
managed &&
managed.root === MANAGED_APPDATA_ROOT &&
managed.backupScope === 'dockerRootDir' &&
typeof managed.backupAcknowledged === 'boolean' &&
isManagedStatus(managed.status) &&
managedStatusConsistent,
);
const rawTrustStatus = safeTrust.status === 'ready' || safeTrust.status === 'blocked' || safeTrust.status === 'needs_setup'
? safeTrust.status
: 'unknown';
const configured = safeTrust.configured === true && trustedRoot !== null && trustSelectionConsistent;
const trustStatus = rawTrustStatus === 'needs_setup' || rawTrustStatus === 'blocked'
? rawTrustStatus
: configured && rawTrustStatus === 'ready' ? 'ready' : 'unknown';
return {
configured,
trustStatus,
currentRoot: configured ? trustedRoot : null,
backupAcknowledged: backupAcknowledged === true,
rootReadiness,
managedStatus: managedCapabilityValid ? managed!.status as ManagedStorageStatus : 'unavailable',
managedBlocker: managedCapabilityValid && typeof managed!.blockerCode === 'string' && managed!.blockerCode.length <= 100
? managed!.blockerCode || null
: null,
managedCapabilityValid,
};
};
const isBoundedStringArray = (value: unknown): value is string[] => (
Array.isArray(value) && value.length <= 100 && value.every((item) => typeof item === 'string' && item.length <= 500)
);
export const normalizeManagedStoragePreview = (value: unknown): PreflightPreview | null => {
if (!isRecord(value)) return null;
const rollbackAvailable = value.rollbackAvailable;
if (
typeof value.action !== 'string' || value.action.length > 120 ||
typeof value.target !== 'string' || value.target.length > 200 ||
(value.risk !== 'low' && value.risk !== 'medium' && value.risk !== 'high' && value.risk !== 'critical') ||
(value.policyDecision !== 'allowed' && value.policyDecision !== 'approval_required' && value.policyDecision !== 'blocked') ||
typeof value.auditPreview !== 'string' || value.auditPreview.length > 2000 ||
!isBoundedStringArray(value.affectedApps) ||
!isBoundedStringArray(value.dependencies) ||
!isBoundedStringArray(value.preChecks) ||
!isBoundedStringArray(value.postChecks) ||
(rollbackAvailable !== undefined && typeof rollbackAvailable !== 'boolean')
) return null;
const normalizedRollbackAvailable: boolean | undefined = typeof rollbackAvailable === 'boolean'
? rollbackAvailable
: undefined;
return {
action: value.action,
target: value.target,
risk: value.risk,
policyDecision: value.policyDecision,
auditPreview: value.auditPreview,
affectedApps: value.affectedApps,
dependencies: value.dependencies,
preChecks: value.preChecks,
postChecks: value.postChecks,
...(normalizedRollbackAvailable === undefined ? {} : { rollbackAvailable: normalizedRollbackAvailable }),
};
};
export const buildStorageRootSelection = (
root: TrustedAppdataRoot,
backupAcknowledged: boolean,
): EnrollmentStorageRootSelection | null => {
if (root === MANAGED_APPDATA_ROOT && !backupAcknowledged) return null;
return {
trusted_appdata_root: root,
managed_appdata_backup_acknowledged: root === MANAGED_APPDATA_ROOT,
};
};
export const canInitializeManagedStorage = (
view: EnrollmentStorageView,
preview: unknown,
): boolean => (
view.configured &&
view.currentRoot === MANAGED_APPDATA_ROOT &&
view.backupAcknowledged &&
view.managedCapabilityValid &&
view.managedStatus === 'initialization_required' &&
isRecord(preview) &&
preview.action === MANAGED_STORAGE_INITIALIZE_ACTION &&
preview.target === MANAGED_STORAGE_TARGET &&
preview.risk === 'high' &&
preview.policyDecision === 'allowed'
);
export interface ManagedStorageInitializationGate {
canManage: boolean;
view: EnrollmentStorageView | null | undefined;
preview: unknown;
dirty: boolean;
loadError: string;
conflict: string;
refreshing: boolean;
saving: boolean;
initializing: boolean;
}
export const canRequestManagedStorageInitialization = (gate: ManagedStorageInitializationGate): boolean => (
gate.canManage &&
Boolean(gate.view && canInitializeManagedStorage(gate.view, gate.preview)) &&
!gate.dirty &&
!gate.loadError &&
!gate.conflict &&
!gate.refreshing &&
!gate.saving &&
!gate.initializing
);
export interface StorageRootSaveGate {
canManage: boolean;
configured: boolean;
payloadAvailable: boolean;
dirty: boolean;
loadError: string;
refreshing: boolean;
saving: boolean;
initializing: boolean;
}
export const canSaveStorageRootSelection = (gate: StorageRootSaveGate): boolean => (
gate.canManage &&
gate.configured &&
gate.payloadAvailable &&
gate.dirty &&
!gate.loadError &&
!gate.refreshing &&
!gate.saving &&
!gate.initializing
);
export interface ManagedStorageActionOutcome {
tone: 'status' | 'alert';
message: string;
}
const UNCONFIRMED_INITIALIZATION_MESSAGE = 'Het initialisatieverzoek is niet bevestigd met geldige referenties.';
const isBoundedIdentifier = (value: unknown): value is string => (
typeof value === 'string' && value.trim().length > 0 && value.length <= 200
);
export const normalizeManagedStorageActionOutcome = (value: unknown): ManagedStorageActionOutcome => {
if (!isRecord(value) || typeof value.message !== 'string' || value.message.trim().length === 0 || value.message.length > 500) {
return { tone: 'alert', message: UNCONFIRMED_INITIALIZATION_MESSAGE };
}
if (value.success === true && isBoundedIdentifier(value.jobId) && isBoundedIdentifier(value.auditId)) {
return {
tone: 'status',
message: `Initialisatieverzoek geregistreerd. Job ${value.jobId.trim()}; audit ${value.auditId.trim()}. De opslag wordt pas als gereed getoond nadat de controller dat bevestigt.`,
};
}
if (value.success === false) {
return { tone: 'alert', message: value.message.trim() };
}
return { tone: 'alert', message: UNCONFIRMED_INITIALIZATION_MESSAGE };
};
export const rootReadinessLabel = (status: TrustedAppdataRootReadinessStatus): string => {
if (status === 'ready') return 'Controller confirms ready';
if (status === 'needs_initialization') return 'Controller reports initialization needed';
return 'Not currently confirmed';
};
export const managedStorageStatusLabel = (status: ManagedStorageStatus): string => {
switch (status) {
case 'not_selected': return 'Not selected for future targets';
case 'blocked': return 'Blocked by a required safety condition';
case 'ready': return 'Controller reports ready';
case 'initialization_required': return 'Controller requires one-time initialization';
default: return 'Unavailable or not confirmed';
}
};
export const managedStorageBlockerLabel = (blocker: string | null): string | null => {
switch (blocker) {
case 'managed_appdata_backup_acknowledgement_required': return 'Confirm the Docker-root backup responsibility before continuing.';
case 'managed_appdata_initialization_required': return 'The controller reports that the managed storage volume needs initialization.';
case 'managed_appdata_volume_unavailable': return 'The controller cannot currently verify the managed storage volume.';
default: return blocker ? 'The controller reports an additional storage blocker.' : null;
}
};
+2
View File
@@ -15,6 +15,7 @@ import { usePermission } from "@/hooks/useUserSession";
import { UserAccessSettings } from "@/components/UserAccessSettings";
import { ExposureSettings } from "@/components/ExposureSettings";
import { ManagedAiSettings } from "@/components/ManagedAiSettings";
import { ManagedStorageSettings } from "@/components/ManagedStorageSettings";
const settingsTabs = ["general", "security", "ai", "notifications", "audit", "voice", "maintenance", "backups"] as const;
type SettingsTab = typeof settingsTabs[number];
@@ -194,6 +195,7 @@ const Settings = () => {
</div>
<TabsContent value="general">
<ManagedStorageSettings />
<Card className="signal-rail border-slate-700 bg-slate-900/50">
<CardHeader>
<CardTitle className="text-sm font-bold uppercase tracking-wider text-slate-400">Runtime Profiel</CardTitle>
+28
View File
@@ -121,9 +121,37 @@ export interface EnrollmentCapabilities {
policyProvisioning: EnrollmentCapability;
rollback: EnrollmentCapability;
verification: EnrollmentCapability;
availableTrustedAppdataRoots?: TrustedAppdataRootReadiness[];
managedStorage?: ManagedStorageCapability;
profiles: Array<{ name: string; version: string; public: boolean; authentication: string }>;
}
export type TrustedAppdataRoot = '/srv/example/appdata' | '/var/lib/docker/volumes/itworx-managed-appdata/_data';
export type TrustedAppdataRootReadinessStatus = 'ready' | 'needs_initialization' | 'unavailable';
export interface TrustedAppdataRootReadiness {
root: TrustedAppdataRoot;
status: TrustedAppdataRootReadinessStatus;
}
export type ManagedStorageStatus = 'not_selected' | 'blocked' | 'ready' | 'initialization_required' | 'unavailable';
export interface ManagedStorageCapability {
status: ManagedStorageStatus;
blockerCode?: string;
root: '/var/lib/docker/volumes/itworx-managed-appdata/_data';
backupScope: 'dockerRootDir';
backupAcknowledged: boolean;
}
export interface EnrollmentTrustStatus {
status: 'ready' | 'blocked' | 'needs_setup' | string;
blockers?: string[];
configured?: boolean;
trustedAppdataRoot: TrustedAppdataRoot | string;
managedAppdataBackupAcknowledged: boolean;
}
export interface EnrollmentStorageRootSelection {
trusted_appdata_root: TrustedAppdataRoot;
managed_appdata_backup_acknowledged: boolean;
}
export interface Enrollment {
id: string;
applicationId: string;