Publish curated Unraid-AppOps-Gateway source from ad0a390e431166c3198a2ac3bec010c2b44f2dea
This commit is contained in:
1 parent
29c1e3d5a1
commit
98d31477f9
35 files changed
+7264
-185
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user