Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .ai_security import Confidence, Severity
|
||||
|
||||
|
||||
RemediationPlanStatus = Literal['draft', 'reviewed', 'approved_for_manual_execution', 'dismissed']
|
||||
RemediationSourceType = Literal['security_finding', 'security_recommendation', 'media_issue', 'inventory', 'connector', 'policy', 'secret', 'unknown']
|
||||
RemediationStepType = Literal[
|
||||
'open_page',
|
||||
'configure_secret',
|
||||
'bind_connector',
|
||||
'review_policy',
|
||||
'request_approval',
|
||||
'run_readonly_check',
|
||||
'manual_external_change',
|
||||
'blocked_action',
|
||||
'documentation',
|
||||
]
|
||||
DryRunStatus = Literal['pass', 'warning', 'blocked']
|
||||
|
||||
|
||||
class RemediationEvidence(BaseModel):
|
||||
source: str
|
||||
label: str
|
||||
value: Any = None
|
||||
redacted: bool = False
|
||||
|
||||
|
||||
class RemediationApprovalRequirement(BaseModel):
|
||||
actionId: str
|
||||
target: str
|
||||
reason: str
|
||||
risk: str
|
||||
policyDecision: str
|
||||
|
||||
|
||||
class RemediationVerificationCheck(BaseModel):
|
||||
label: str
|
||||
endpoint: str
|
||||
expectedResult: str
|
||||
source: str
|
||||
readOnly: bool = True
|
||||
|
||||
|
||||
class RemediationStep(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
description: str
|
||||
type: RemediationStepType
|
||||
actionId: str | None = None
|
||||
target: str | None = None
|
||||
href: str | None = None
|
||||
policyDecision: str = 'allowed'
|
||||
risk: str = 'low'
|
||||
requiresApproval: bool = False
|
||||
canExecute: bool = False
|
||||
evidence: list[RemediationEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RemediationPlan(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
sourceType: RemediationSourceType
|
||||
sourceId: str
|
||||
severity: Severity
|
||||
confidence: Confidence
|
||||
status: RemediationPlanStatus = 'draft'
|
||||
affectedResources: list[str] = Field(default_factory=list)
|
||||
prerequisites: list[str] = Field(default_factory=list)
|
||||
steps: list[RemediationStep] = Field(default_factory=list)
|
||||
verificationChecks: list[RemediationVerificationCheck] = Field(default_factory=list)
|
||||
blockedActions: list[RemediationStep] = Field(default_factory=list)
|
||||
approvalRequirements: list[RemediationApprovalRequirement] = Field(default_factory=list)
|
||||
riskAssessment: str
|
||||
estimatedImpact: str
|
||||
rollbackNotes: str
|
||||
auditPreview: str
|
||||
createdAt: str
|
||||
updatedAt: str
|
||||
executable: bool = False
|
||||
|
||||
|
||||
class RemediationPlanCollection(BaseModel):
|
||||
status: str
|
||||
generatedAt: str
|
||||
total: int
|
||||
plans: list[RemediationPlan]
|
||||
|
||||
|
||||
class RemediationDryRunStepResult(BaseModel):
|
||||
stepId: str
|
||||
label: str
|
||||
actionId: str | None = None
|
||||
target: str | None = None
|
||||
policyDecision: str
|
||||
risk: str
|
||||
status: DryRunStatus
|
||||
reason: str
|
||||
canExecute: bool = False
|
||||
|
||||
|
||||
class RemediationDryRunResult(BaseModel):
|
||||
planId: str
|
||||
status: DryRunStatus
|
||||
executed: bool = False
|
||||
reasons: list[str] = Field(default_factory=list)
|
||||
prerequisites: list[str] = Field(default_factory=list)
|
||||
unresolvedBlockers: list[str] = Field(default_factory=list)
|
||||
blockedActions: list[RemediationApprovalRequirement] = Field(default_factory=list)
|
||||
approvalRequirements: list[RemediationApprovalRequirement] = Field(default_factory=list)
|
||||
verificationChecks: list[RemediationVerificationCheck] = Field(default_factory=list)
|
||||
stepResults: list[RemediationDryRunStepResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RemediationApprovalProposal(BaseModel):
|
||||
planId: str
|
||||
created: bool
|
||||
executed: bool = False
|
||||
approvalId: str | None = None
|
||||
proposals: list[RemediationApprovalRequirement] = Field(default_factory=list)
|
||||
message: str
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
Severity = Literal['info', 'low', 'medium', 'high', 'critical']
|
||||
Confidence = Literal['low', 'medium', 'high']
|
||||
ReviewStatus = Literal['healthy', 'warning', 'critical', 'partial', 'unknown']
|
||||
FindingStatus = Literal['open', 'acknowledged', 'dismissed', 'resolved']
|
||||
RecommendationStatus = Literal['open', 'snoozed', 'dismissed', 'accepted']
|
||||
SecurityReviewScope = Literal['all', 'exposure', 'auth', 'secrets', 'policies', 'connectors', 'audit', 'unraid', 'media']
|
||||
|
||||
|
||||
class SecurityEvidence(BaseModel):
|
||||
source: str
|
||||
label: str
|
||||
value: Any = None
|
||||
observed: Any = None
|
||||
expected: Any = None
|
||||
referenceId: str | None = None
|
||||
redacted: bool = False
|
||||
|
||||
|
||||
class SecuritySuggestedAction(BaseModel):
|
||||
label: str
|
||||
type: Literal['link', 'policy_draft', 'approval_request', 'open_page', 'run_readonly_check']
|
||||
actionId: str | None = None
|
||||
target: str | None = None
|
||||
policyDecision: str = 'allowed'
|
||||
risk: str = 'low'
|
||||
href: str | None = None
|
||||
executable: bool = False
|
||||
|
||||
|
||||
class SecurityFinding(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
severity: Severity
|
||||
confidence: Confidence
|
||||
category: Literal['exposure', 'auth_coverage', 'coverage', 'connector_config', 'secrets', 'policies', 'connectors', 'audit', 'inventory', 'unraid', 'media']
|
||||
affectedResources: list[str] = Field(default_factory=list)
|
||||
evidence: list[SecurityEvidence] = Field(default_factory=list)
|
||||
suggestedActions: list[SecuritySuggestedAction] = Field(default_factory=list)
|
||||
status: FindingStatus = 'open'
|
||||
createdAt: str
|
||||
riskReason: str
|
||||
links: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SecurityRecommendation(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
severity: Severity
|
||||
confidence: Confidence
|
||||
evidence: list[SecurityEvidence] = Field(default_factory=list)
|
||||
affectedResources: list[str] = Field(default_factory=list)
|
||||
suggestedAction: str
|
||||
suggestedActions: list[SecuritySuggestedAction] = Field(default_factory=list)
|
||||
policyDecision: str = 'allowed'
|
||||
actionTarget: str | None = None
|
||||
canAutoApply: bool = False
|
||||
status: RecommendationStatus = 'open'
|
||||
|
||||
|
||||
class SecurityReviewSummary(BaseModel):
|
||||
status: ReviewStatus
|
||||
score: int
|
||||
generatedAt: str
|
||||
dataCompleteness: dict[str, Any]
|
||||
criticalFindings: int
|
||||
highFindings: int = 0
|
||||
mediumFindings: int = 0
|
||||
warningFindings: int
|
||||
infoFindings: int
|
||||
recommendationsCount: int
|
||||
connectedSources: list[str]
|
||||
scoreBreakdown: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SecurityReviewResult(BaseModel):
|
||||
summary: SecurityReviewSummary
|
||||
findings: list[SecurityFinding]
|
||||
recommendations: list[SecurityRecommendation]
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Strict, secret-free contracts for managed Unraid deployments.
|
||||
|
||||
This module intentionally models a small deployment vocabulary. It is not a
|
||||
Compose parser, a Docker API wrapper, or a generic remote-command interface.
|
||||
The controller derives filesystem paths and runtime arguments from this plan
|
||||
after it has accepted the immutable repository revision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
_APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
||||
_REPOSITORY = re.compile(r"^jens/[a-z0-9][a-z0-9_.-]{0,96}$")
|
||||
_REVISION = re.compile(r"^[0-9a-f]{40}$")
|
||||
_VERSION = re.compile(r"^v?(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){2}(?:-[0-9A-Za-z.-]+)?$")
|
||||
_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$")
|
||||
_DOMAIN = re.compile(
|
||||
r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$"
|
||||
)
|
||||
_SECRET_REFERENCE = re.compile(r"^[A-Z][A-Z0-9_]{2,120}$")
|
||||
_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"
|
||||
|
||||
|
||||
class _StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
|
||||
|
||||
|
||||
class DeploymentTopologyV1(_StrictModel):
|
||||
"""The only runtime shape accepted by V1.
|
||||
|
||||
V1 is deliberately compose-only and always private behind the existing
|
||||
proxy network. A public host binding, Docker socket or host networking is
|
||||
outside this contract.
|
||||
"""
|
||||
|
||||
kind: Literal["compose"] = "compose"
|
||||
compose_file: str = ".itworx/deployment.yml"
|
||||
compose_project: str = Field(min_length=2, max_length=63)
|
||||
containers: tuple[str, ...] = Field(min_length=1, max_length=8)
|
||||
proxy_network: str = Field(min_length=2, max_length=63)
|
||||
internal_port: int = Field(ge=1, le=65535)
|
||||
restart_policy: Literal["unless-stopped"] = "unless-stopped"
|
||||
build: RepositoryBuildV1 | None = None
|
||||
|
||||
@field_validator("compose_file")
|
||||
@classmethod
|
||||
def safe_compose_file(cls, value: str) -> str:
|
||||
if not _SAFE_PATH.fullmatch(value) or not value.endswith((".yml", ".yaml")):
|
||||
raise ValueError("compose_file must be a safe repository-relative YAML path")
|
||||
return value
|
||||
|
||||
@field_validator("compose_project", "proxy_network", "containers")
|
||||
@classmethod
|
||||
def safe_runtime_name(cls, value: str | tuple[str, ...]) -> str | tuple[str, ...]:
|
||||
values = value if isinstance(value, tuple) else (value,)
|
||||
if any(not _NAME.fullmatch(item) for item in values):
|
||||
raise ValueError("runtime names must use the bounded deployment identifier format")
|
||||
if len(set(values)) != len(values):
|
||||
raise ValueError("container names must be unique")
|
||||
return value
|
||||
|
||||
|
||||
class RepositoryBuildV1(_StrictModel):
|
||||
"""A controller-revalidated repository build, never a caller shell step."""
|
||||
|
||||
context: str = "."
|
||||
dockerfile: str = "Dockerfile"
|
||||
|
||||
@field_validator("context")
|
||||
@classmethod
|
||||
def safe_context(cls, value: str) -> str:
|
||||
if value != "." and not _SAFE_PATH.fullmatch(value):
|
||||
raise ValueError("build context must be a safe repository-relative path")
|
||||
return value
|
||||
|
||||
@field_validator("dockerfile")
|
||||
@classmethod
|
||||
def safe_dockerfile(cls, value: str) -> str:
|
||||
if not _SAFE_PATH.fullmatch(value) or not value.endswith("Dockerfile"):
|
||||
raise ValueError("dockerfile must be a safe repository-relative Dockerfile path")
|
||||
return value
|
||||
|
||||
|
||||
DeploymentTopologyV1.model_rebuild()
|
||||
|
||||
|
||||
class ProxyHostV1(_StrictModel):
|
||||
hostname: str
|
||||
certificate_mode: Literal["existing", "managed"] = "managed"
|
||||
force_ssl: bool = True
|
||||
http2: bool = True
|
||||
upload_limit_mb: int = Field(default=256, ge=1, le=2048)
|
||||
read_timeout_seconds: int = Field(default=180, ge=15, le=900)
|
||||
|
||||
@field_validator("hostname")
|
||||
@classmethod
|
||||
def safe_hostname(cls, value: str) -> str:
|
||||
normalized = value.lower().rstrip(".")
|
||||
if not _DOMAIN.fullmatch(normalized) or normalized.startswith("-"):
|
||||
raise ValueError("hostname must be a canonical DNS hostname")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_https_redirect(self) -> ProxyHostV1:
|
||||
if not self.force_ssl:
|
||||
raise ValueError("managed proxy hosts must force HTTPS")
|
||||
return self
|
||||
|
||||
|
||||
class OidcRoleMappingV1(_StrictModel):
|
||||
administrator_group: str = Field(min_length=1, max_length=120)
|
||||
support_agent_group: str = Field(min_length=1, max_length=120)
|
||||
read_only_group: str = Field(min_length=1, max_length=120)
|
||||
|
||||
@field_validator("administrator_group", "support_agent_group", "read_only_group")
|
||||
@classmethod
|
||||
def safe_group_name(cls, value: str) -> str:
|
||||
if any(character in value for character in "\r\n\x00"):
|
||||
raise ValueError("role group names may not contain control characters")
|
||||
return value
|
||||
|
||||
|
||||
class OidcProvisioningV1(_StrictModel):
|
||||
provider: Literal["authentik"] = "authentik"
|
||||
application_slug: str = Field(min_length=2, max_length=63)
|
||||
redirect_path: str = "/signin-oidc"
|
||||
role_claim: str = Field(default="groups", min_length=1, max_length=80)
|
||||
client_secret_ref: str
|
||||
roles: OidcRoleMappingV1
|
||||
|
||||
@field_validator("application_slug")
|
||||
@classmethod
|
||||
def safe_application_slug(cls, value: str) -> str:
|
||||
if not _APP_ID.fullmatch(value):
|
||||
raise ValueError("application_slug must be a bounded lower-case identifier")
|
||||
return value
|
||||
|
||||
@field_validator("client_secret_ref")
|
||||
@classmethod
|
||||
def secret_reference_only(cls, value: str) -> str:
|
||||
if not _SECRET_REFERENCE.fullmatch(value):
|
||||
raise ValueError("client_secret_ref must reference a stored AppOps secret, never a value")
|
||||
return value
|
||||
|
||||
@field_validator("redirect_path")
|
||||
@classmethod
|
||||
def safe_redirect_path(cls, value: str) -> str:
|
||||
if not value.startswith("/") or "//" in value or any(character in value for character in "?#\\\r\n"):
|
||||
raise ValueError("redirect_path must be a relative HTTPS callback path")
|
||||
return value
|
||||
|
||||
|
||||
class DeploymentPlanV1(_StrictModel):
|
||||
"""A validated, immutable deployment intent for one managed application."""
|
||||
|
||||
api_version: Literal["itworx.deployment/v1"] = "itworx.deployment/v1"
|
||||
application_id: str
|
||||
repository: str
|
||||
revision: str
|
||||
release_version: str
|
||||
image_repository: str
|
||||
image_tag: str
|
||||
topology: DeploymentTopologyV1
|
||||
proxy: ProxyHostV1 | None = None
|
||||
oidc: OidcProvisioningV1 | None = None
|
||||
health_path: str = "/healthz"
|
||||
health_timeout_seconds: int = Field(default=180, ge=15, le=900)
|
||||
broker_secret_ref: str
|
||||
rollback_enabled: bool = True
|
||||
|
||||
@field_validator("application_id")
|
||||
@classmethod
|
||||
def safe_app_id(cls, value: str) -> str:
|
||||
if not _APP_ID.fullmatch(value):
|
||||
raise ValueError("application_id must be a bounded lower-case identifier")
|
||||
return value
|
||||
|
||||
@field_validator("repository")
|
||||
@classmethod
|
||||
def safe_repository(cls, value: str) -> str:
|
||||
normalized = value.lower()
|
||||
if not _REPOSITORY.fullmatch(normalized):
|
||||
raise ValueError("repository must be a canonical Jens repository")
|
||||
return normalized
|
||||
|
||||
@field_validator("revision")
|
||||
@classmethod
|
||||
def immutable_revision(cls, value: str) -> str:
|
||||
normalized = value.lower()
|
||||
if not _REVISION.fullmatch(normalized):
|
||||
raise ValueError("revision must be a full lower-case 40-character Git SHA")
|
||||
return normalized
|
||||
|
||||
@field_validator("release_version")
|
||||
@classmethod
|
||||
def semantic_version(cls, value: str) -> str:
|
||||
if not _VERSION.fullmatch(value):
|
||||
raise ValueError("release_version must be a semantic version")
|
||||
return value.removeprefix("v")
|
||||
|
||||
@field_validator("image_repository")
|
||||
@classmethod
|
||||
def safe_image_repository(cls, value: str) -> str:
|
||||
normalized = value.lower()
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9._/-]{1,180}", normalized):
|
||||
raise ValueError("image_repository is not a safe image identifier")
|
||||
return normalized
|
||||
|
||||
@field_validator("image_tag")
|
||||
@classmethod
|
||||
def immutable_image_tag(cls, value: str) -> str:
|
||||
if value.lower() == "latest" or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", value):
|
||||
raise ValueError("image_tag must be an explicit non-latest tag")
|
||||
return value
|
||||
|
||||
@field_validator("broker_secret_ref")
|
||||
@classmethod
|
||||
def broker_secret_reference_only(cls, value: str) -> str:
|
||||
if not _SECRET_REFERENCE.fullmatch(value):
|
||||
raise ValueError("broker_secret_ref must reference a stored AppOps secret, never a value")
|
||||
return value
|
||||
|
||||
@field_validator("health_path")
|
||||
@classmethod
|
||||
def safe_health_path(cls, value: str) -> str:
|
||||
if not value.startswith("/") or "//" in value or any(character in value for character in "?#\\\r\n"):
|
||||
raise ValueError("health_path must be a relative path")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_cross_field_contract(self) -> DeploymentPlanV1:
|
||||
if self.application_id not in self.topology.containers:
|
||||
raise ValueError("application_id must be one of the managed container names")
|
||||
if self.topology.compose_project != self.application_id:
|
||||
raise ValueError("compose_project must equal application_id in DeploymentPlanV1")
|
||||
if self.image_tag not in {self.release_version, self.revision[:12]}:
|
||||
raise ValueError("image_tag must be the release version or immutable revision prefix")
|
||||
if self.oidc and not self.proxy:
|
||||
raise ValueError("OIDC provisioning requires a managed HTTPS proxy host")
|
||||
return self
|
||||
|
||||
@property
|
||||
def appdata_path(self) -> str:
|
||||
return f"{TRUSTED_APPCONFIG_ROOT}/{self.application_id}"
|
||||
|
||||
@property
|
||||
def public_url(self) -> str | None:
|
||||
return f"https://{self.proxy.hostname}" if self.proxy else None
|
||||
|
||||
|
||||
def validate_compose_runtime_contract(plan: DeploymentPlanV1, raw_yaml: str) -> dict[str, Any]:
|
||||
"""Validate a repository compose artifact against the narrow V1 policy.
|
||||
|
||||
The result is suitable for evidence/audit storage and deliberately contains
|
||||
no Compose environment values. It rejects mounts, privileged flags and
|
||||
runtime capabilities that would turn a product deployment into a host
|
||||
control interface.
|
||||
"""
|
||||
|
||||
try:
|
||||
document = yaml.safe_load(raw_yaml)
|
||||
except yaml.YAMLError as exc:
|
||||
raise ValueError("deployment compose YAML could not be parsed") from exc
|
||||
if not isinstance(document, dict) or set(document) - {"services", "networks", "name"}:
|
||||
raise ValueError("deployment compose only permits name, services and networks")
|
||||
services = document.get("services")
|
||||
if not isinstance(services, dict) or not services:
|
||||
raise ValueError("deployment compose must define services")
|
||||
|
||||
expected = set(plan.topology.containers)
|
||||
observed = set(services)
|
||||
if observed != expected:
|
||||
raise ValueError("deployment compose services must exactly match the typed plan containers")
|
||||
|
||||
for service_name, service in services.items():
|
||||
if not isinstance(service, dict):
|
||||
raise TypeError("deployment service must be an object")
|
||||
forbidden = {
|
||||
"privileged",
|
||||
"devices",
|
||||
"pid",
|
||||
"ipc",
|
||||
"cap_add",
|
||||
"dockerfile",
|
||||
}
|
||||
present = forbidden.intersection(service)
|
||||
if present:
|
||||
raise ValueError(f"deployment service {service_name} uses forbidden runtime fields")
|
||||
build = service.get("build")
|
||||
if plan.topology.build is None:
|
||||
if build:
|
||||
raise ValueError("deployment service build is not declared by the typed plan")
|
||||
else:
|
||||
if isinstance(build, str):
|
||||
observed_build = {"context": build, "dockerfile": "Dockerfile"}
|
||||
elif isinstance(build, dict):
|
||||
observed_build = {
|
||||
"context": str(build.get("context") or "."),
|
||||
"dockerfile": str(build.get("dockerfile") or "Dockerfile"),
|
||||
}
|
||||
if set(build) - {"context", "dockerfile"}:
|
||||
raise ValueError("deployment service build contains unsupported fields")
|
||||
else:
|
||||
raise ValueError("deployment service build must be declared by the typed plan")
|
||||
expected_build = plan.topology.build.model_dump()
|
||||
if observed_build != expected_build:
|
||||
raise ValueError("deployment service build does not match the typed plan")
|
||||
if service.get("network_mode") in {"host", "service:host"}:
|
||||
raise ValueError("host networking is forbidden")
|
||||
if service.get("ports"):
|
||||
raise ValueError("published host ports are forbidden for managed proxy deployments")
|
||||
security_options = service.get("security_opt") or []
|
||||
if set(security_options) - {"no-new-privileges:true"}:
|
||||
raise ValueError("deployment service uses unsupported security options")
|
||||
if service.get("read_only") is not True:
|
||||
raise ValueError("deployment services must use a read-only root filesystem")
|
||||
if service.get("cap_drop") != ["ALL"]:
|
||||
raise ValueError("deployment services must drop every Linux capability")
|
||||
if service.get("restart") != "unless-stopped":
|
||||
raise ValueError("deployment services must use restart: unless-stopped")
|
||||
image = str(service.get("image") or "")
|
||||
if image != f"{plan.image_repository}:{plan.image_tag}":
|
||||
raise ValueError("deployment service image must equal the immutable plan image")
|
||||
owned_bind_mounts = 0
|
||||
for mount in service.get("volumes", []) or []:
|
||||
if isinstance(mount, dict) and mount.get("type") not in {None, "bind"}:
|
||||
raise ValueError("deployment services permit only application-owned bind mounts")
|
||||
source = mount.get("source") if isinstance(mount, dict) else str(mount).split(":", 1)[0]
|
||||
source_text = str(source or "")
|
||||
normalized_source = posixpath.normpath(source_text)
|
||||
if "docker.sock" in source_text:
|
||||
raise ValueError("docker socket and host-control mounts are forbidden")
|
||||
if (
|
||||
not source_text.startswith("/")
|
||||
or normalized_source != source_text
|
||||
or source_text in {"/", "/proc", "/sys"}
|
||||
):
|
||||
raise ValueError("only normalized application-owned bind mounts are permitted")
|
||||
if source_text != plan.appdata_path and not source_text.startswith(plan.appdata_path + "/"):
|
||||
raise ValueError("bind mounts must stay below the plan's owned appdata path")
|
||||
owned_bind_mounts += 1
|
||||
if not owned_bind_mounts:
|
||||
raise ValueError("deployment services require an application-owned persistent bind mount")
|
||||
networks = service.get("networks")
|
||||
if networks != [plan.topology.proxy_network] and networks != {plan.topology.proxy_network: None}:
|
||||
raise ValueError("deployment services must use only the typed proxy network")
|
||||
|
||||
networks = document.get("networks")
|
||||
if networks != {plan.topology.proxy_network: {"external": True}}:
|
||||
raise ValueError("deployment compose must reference exactly one existing proxy network")
|
||||
return {
|
||||
"services": sorted(observed),
|
||||
"proxyNetwork": plan.topology.proxy_network,
|
||||
"appdataPath": plan.appdata_path,
|
||||
"image": f"{plan.image_repository}:{plan.image_tag}",
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Strict, secret-free contract for managed application enrollment.
|
||||
|
||||
Enrollment establishes an application identity and its operating boundaries. It
|
||||
is deliberately separate from a deployment: the caller cannot submit Compose,
|
||||
host paths, shell commands, callback origins, proxy snippets, or secret values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
_APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
||||
_REPOSITORY = re.compile(r"^jens/[a-z0-9][a-z0-9._-]{1,80}$")
|
||||
_REVISION = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HOSTNAME = re.compile(r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$")
|
||||
_RELATIVE_PATH = re.compile(r"^/[a-z0-9][a-z0-9/_-]{0,120}$")
|
||||
_SUBPATH = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$")
|
||||
_IMAGE = re.compile(r"^[a-z0-9][a-z0-9._/-]{1,180}$")
|
||||
_IMAGE_TAG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
||||
_VERSION = re.compile(r"^v?[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||
|
||||
DeploymentProfileName = Literal[
|
||||
"internal-webapp",
|
||||
"internal-webapp-oidc",
|
||||
"internal-service",
|
||||
"repopulse-authenticated",
|
||||
]
|
||||
|
||||
|
||||
class _StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class EnrollmentTargetV1(_StrictModel):
|
||||
target_type: Literal["gitea-compose-controller"] = "gitea-compose-controller"
|
||||
target_identity: str = Field(min_length=3, max_length=80)
|
||||
container_name: str = Field(min_length=2, max_length=63)
|
||||
# A small reviewed manifest allow-list keeps source locations explicit while
|
||||
# allowing the established AppOps artifact layout as well as the default
|
||||
# contract for future projects. It is intentionally not a free path.
|
||||
compose_file: Literal[".itworx/deployment.yml", "deploy/appops/compose.yml"] = ".itworx/deployment.yml"
|
||||
ownership_label: Literal["tech.itworx.enrollment"] = "tech.itworx.enrollment"
|
||||
|
||||
@field_validator("target_identity")
|
||||
@classmethod
|
||||
def safe_target_identity(cls, value: str) -> str:
|
||||
if not re.fullmatch(r"[a-z][a-z0-9-]{2,79}", value):
|
||||
raise ValueError("target_identity must be a bounded lower-case identifier")
|
||||
return value
|
||||
|
||||
@field_validator("container_name")
|
||||
@classmethod
|
||||
def safe_container_name(cls, value: str) -> str:
|
||||
if not _APP_ID.fullmatch(value):
|
||||
raise ValueError("container_name must be a bounded lower-case identifier")
|
||||
return value
|
||||
|
||||
|
||||
class EnrollmentStorageV1(_StrictModel):
|
||||
# The v1 profiles own the data layout. Future profiles may add reviewed
|
||||
# alternatives, but enrollment must not turn this into a free host path.
|
||||
appdata_subpath: Literal["data"] = "data"
|
||||
runtime_uid: int = Field(ge=1, le=65535)
|
||||
runtime_gid: int = Field(ge=1, le=65535)
|
||||
mount_role: Literal["application-data"] = "application-data"
|
||||
|
||||
@field_validator("appdata_subpath")
|
||||
@classmethod
|
||||
def safe_subpath(cls, value: str) -> str:
|
||||
if not _SUBPATH.fullmatch(value) or value in {".", ".."}:
|
||||
raise ValueError("appdata_subpath must be one bounded relative directory")
|
||||
return value
|
||||
|
||||
|
||||
class EnrollmentNetworkV1(_StrictModel):
|
||||
docker_network: str = Field(min_length=2, max_length=80)
|
||||
internal_port: int = Field(ge=1, le=65535)
|
||||
host_publish: Literal[False] = False
|
||||
|
||||
@field_validator("docker_network")
|
||||
@classmethod
|
||||
def safe_network(cls, value: str) -> str:
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{1,79}", value):
|
||||
raise ValueError("docker_network must be a bounded Docker network name")
|
||||
return value
|
||||
|
||||
|
||||
class EnrollmentExposureV1(_StrictModel):
|
||||
hostname: str
|
||||
tls_required: Literal[True] = True
|
||||
upstream_service: str
|
||||
websocket: bool = False
|
||||
upload_profile: Literal["standard", "large-upload"] = "standard"
|
||||
timeout_profile: Literal["standard", "long-running"] = "standard"
|
||||
|
||||
@field_validator("hostname")
|
||||
@classmethod
|
||||
def safe_hostname(cls, value: str) -> str:
|
||||
normalized = value.lower().rstrip(".")
|
||||
if not _HOSTNAME.fullmatch(normalized) or "*" in normalized:
|
||||
raise ValueError("hostname must be a concrete DNS hostname")
|
||||
return normalized
|
||||
|
||||
@field_validator("upstream_service")
|
||||
@classmethod
|
||||
def safe_upstream(cls, value: str) -> str:
|
||||
if not _APP_ID.fullmatch(value):
|
||||
raise ValueError("upstream_service must be a bounded application identifier")
|
||||
return value
|
||||
|
||||
|
||||
class EnrollmentRolesV1(_StrictModel):
|
||||
administrator: str = "Administrator"
|
||||
support_agent: str = "SupportAgent"
|
||||
read_only: str = "ReadOnly"
|
||||
|
||||
@field_validator("administrator", "support_agent", "read_only")
|
||||
@classmethod
|
||||
def safe_role(cls, value: str) -> str:
|
||||
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{1,63}", value):
|
||||
raise ValueError("role names must be bounded identifiers")
|
||||
return value
|
||||
|
||||
|
||||
class EnrollmentAuthenticationV1(_StrictModel):
|
||||
mode: Literal["none", "native-oidc"] = "none"
|
||||
redirect_path: str | None = None
|
||||
logout_path: str | None = None
|
||||
roles: EnrollmentRolesV1 | None = None
|
||||
|
||||
@field_validator("redirect_path", "logout_path")
|
||||
@classmethod
|
||||
def safe_relative_path(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
if not _RELATIVE_PATH.fullmatch(value) or "//" in value or any(item in value for item in ("?", "#", "\\")):
|
||||
raise ValueError("OIDC paths must be relative HTTPS application paths")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode(self) -> EnrollmentAuthenticationV1:
|
||||
if self.mode == "native-oidc" and (not self.redirect_path or self.roles is None):
|
||||
raise ValueError("native-oidc requires a redirect_path and a complete role model")
|
||||
if self.mode == "none" and any((self.redirect_path, self.logout_path, self.roles)):
|
||||
raise ValueError("authentication details are only valid for native-oidc")
|
||||
return self
|
||||
|
||||
|
||||
class EnrollmentArtifactV1(_StrictModel):
|
||||
release_version: str
|
||||
image_repository: str
|
||||
image_tag: str
|
||||
health_path: str = "/health"
|
||||
|
||||
@field_validator("release_version")
|
||||
@classmethod
|
||||
def valid_version(cls, value: str) -> str:
|
||||
if not _VERSION.fullmatch(value):
|
||||
raise ValueError("release_version must be a semantic version")
|
||||
return value.removeprefix("v")
|
||||
|
||||
@field_validator("image_repository")
|
||||
@classmethod
|
||||
def valid_image(cls, value: str) -> str:
|
||||
normalized = value.lower()
|
||||
if not _IMAGE.fullmatch(normalized):
|
||||
raise ValueError("image_repository must be a bounded image name")
|
||||
return normalized
|
||||
|
||||
@field_validator("image_tag")
|
||||
@classmethod
|
||||
def valid_tag(cls, value: str) -> str:
|
||||
if value.lower() == "latest" or not _IMAGE_TAG.fullmatch(value):
|
||||
raise ValueError("image_tag must be explicit and cannot be latest")
|
||||
return value
|
||||
|
||||
@field_validator("health_path")
|
||||
@classmethod
|
||||
def valid_health_path(cls, value: str) -> str:
|
||||
if not _RELATIVE_PATH.fullmatch(value) or "//" in value or any(item in value for item in ("?", "#", "\\")):
|
||||
raise ValueError("health_path must be a bounded relative path")
|
||||
return value
|
||||
|
||||
|
||||
class EnrollmentSecretsV1(_StrictModel):
|
||||
oidc_client_secret_required: bool = False
|
||||
additional_secret_refs: tuple[str, ...] = ()
|
||||
|
||||
@field_validator("additional_secret_refs")
|
||||
@classmethod
|
||||
def references_only(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if len(value) > 8 or len(set(value)) != len(value):
|
||||
raise ValueError("additional_secret_refs must contain up to eight unique references")
|
||||
for reference in value:
|
||||
if not re.fullmatch(r"[A-Z][A-Z0-9_]{2,95}", reference):
|
||||
raise ValueError("secret requirements must be references, never values")
|
||||
return value
|
||||
|
||||
|
||||
class EnrollmentPolicyV1(_StrictModel):
|
||||
deployment_apply: Literal[True] = True
|
||||
deployment_rollback: Literal[True] = True
|
||||
autonomous_profile: Literal[True] = True
|
||||
|
||||
|
||||
class EnrollmentPlanV1(_StrictModel):
|
||||
api_version: Literal["itworx.enrollment/v1"] = "itworx.enrollment/v1"
|
||||
application_id: str
|
||||
display_name: str = Field(min_length=2, max_length=100)
|
||||
environment: Literal["production", "staging"] = "production"
|
||||
repository: str
|
||||
revision: str
|
||||
profile: DeploymentProfileName
|
||||
target: EnrollmentTargetV1
|
||||
storage: EnrollmentStorageV1
|
||||
network: EnrollmentNetworkV1
|
||||
artifact: EnrollmentArtifactV1
|
||||
exposure: EnrollmentExposureV1 | None = None
|
||||
authentication: EnrollmentAuthenticationV1 = EnrollmentAuthenticationV1()
|
||||
secrets: EnrollmentSecretsV1 = EnrollmentSecretsV1()
|
||||
policy: EnrollmentPolicyV1 = EnrollmentPolicyV1()
|
||||
|
||||
@field_validator("application_id")
|
||||
@classmethod
|
||||
def safe_application_id(cls, value: str) -> str:
|
||||
if not _APP_ID.fullmatch(value):
|
||||
raise ValueError("application_id must be a bounded lower-case identifier")
|
||||
return value
|
||||
|
||||
@field_validator("display_name")
|
||||
@classmethod
|
||||
def safe_display_name(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized or any(character in normalized for character in "\r\n<>"):
|
||||
raise ValueError("display_name contains unsupported characters")
|
||||
return normalized
|
||||
|
||||
@field_validator("repository")
|
||||
@classmethod
|
||||
def safe_repository(cls, value: str) -> str:
|
||||
normalized = value.lower()
|
||||
if not _REPOSITORY.fullmatch(normalized):
|
||||
raise ValueError("repository must be a canonical Jens repository")
|
||||
return normalized
|
||||
|
||||
@field_validator("revision")
|
||||
@classmethod
|
||||
def immutable_revision(cls, value: str) -> str:
|
||||
normalized = value.lower()
|
||||
if not _REVISION.fullmatch(normalized):
|
||||
raise ValueError("revision must be a full lower-case 40-character Git SHA")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_profile_contract(self) -> EnrollmentPlanV1:
|
||||
if self.target.container_name != self.application_id:
|
||||
raise ValueError("target.container_name must equal application_id")
|
||||
if self.profile == "internal-service":
|
||||
if self.exposure is not None or self.authentication.mode != "none":
|
||||
raise ValueError("internal-service cannot request public exposure or OIDC")
|
||||
elif self.exposure is None:
|
||||
raise ValueError("web application profiles require managed HTTPS exposure")
|
||||
if self.exposure and self.exposure.upstream_service != self.application_id:
|
||||
raise ValueError("exposure.upstream_service must equal application_id")
|
||||
expects_oidc = self.profile == "internal-webapp-oidc"
|
||||
if expects_oidc != (self.authentication.mode == "native-oidc"):
|
||||
raise ValueError("profile and authentication mode must match")
|
||||
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"
|
||||
or self.repository != "jens/repopulse"
|
||||
or self.environment != "production"
|
||||
or self.target.container_name != "repopulse"
|
||||
or self.target.compose_file != ".itworx/deployment.yml"
|
||||
or self.storage.appdata_subpath != "data"
|
||||
or self.storage.runtime_uid != 10001
|
||||
or self.storage.runtime_gid != 10001
|
||||
or self.network.docker_network != "itworx-mcp-hub-npm"
|
||||
or self.network.internal_port != 8000
|
||||
or self.artifact.image_repository != "repopulse-managed"
|
||||
or self.artifact.image_tag != "production"
|
||||
or self.artifact.health_path != "/health"
|
||||
or self.exposure is None
|
||||
or self.exposure.hostname != "repopulse.example.com"
|
||||
or self.exposure.upstream_service != "repopulse"
|
||||
or self.authentication.mode != "none"
|
||||
or self.secrets.additional_secret_refs != ("REPOPULSE_GITEA_TOKEN",)
|
||||
):
|
||||
raise ValueError("repopulse-authenticated must use the fixed RepoPulse managed target contract")
|
||||
return self
|
||||
|
||||
@property
|
||||
def appdata_path(self) -> str:
|
||||
return f"/srv/example/appdata/{self.application_id}"
|
||||
|
||||
@property
|
||||
def data_path(self) -> str:
|
||||
return f"{self.appdata_path}/{self.storage.appdata_subpath}"
|
||||
|
||||
@property
|
||||
def public_url(self) -> str | None:
|
||||
return f"https://{self.exposure.hostname}" if self.exposure else None
|
||||
|
||||
|
||||
class EnrollmentTrustConfigV1(_StrictModel):
|
||||
"""Root-owned boundaries; secret values remain in the encrypted store."""
|
||||
|
||||
controller_identity: str
|
||||
bootstrap_secret_ref: str
|
||||
trusted_appdata_root: Literal["/srv/example/appdata"] = "/srv/example/appdata"
|
||||
approved_networks: tuple[str, ...] = ()
|
||||
allowed_domain_suffixes: tuple[str, ...] = ()
|
||||
autonomous_profiles: tuple[DeploymentProfileName, ...] = ()
|
||||
repopulse_proxy_access_list_id: int | None = Field(default=None, strict=True, ge=1, le=2_147_483_647)
|
||||
oidc_issuer: str | None = None
|
||||
oidc_role_claim: Literal["roles"] = "roles"
|
||||
administrator_group: str | None = None
|
||||
support_agent_group: str | None = None
|
||||
read_only_group: str | None = None
|
||||
|
||||
@field_validator("controller_identity")
|
||||
@classmethod
|
||||
def trusted_controller(cls, value: str) -> str:
|
||||
if not re.fullmatch(r"[a-z][a-z0-9-]{2,79}", value):
|
||||
raise ValueError("controller_identity must be a bounded lower-case identifier")
|
||||
return value
|
||||
|
||||
@field_validator("bootstrap_secret_ref")
|
||||
@classmethod
|
||||
def trusted_secret_ref(cls, value: str) -> str:
|
||||
if not re.fullmatch(r"[A-Z][A-Z0-9_]{2,95}", value):
|
||||
raise ValueError("bootstrap_secret_ref must be a secret reference")
|
||||
return value
|
||||
|
||||
@field_validator("approved_networks")
|
||||
@classmethod
|
||||
def trusted_networks(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if not value or len(value) > 8 or len(set(value)) != len(value):
|
||||
raise ValueError("approved_networks must contain one to eight unique networks")
|
||||
for item in value:
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{1,79}", item):
|
||||
raise ValueError("approved network name is invalid")
|
||||
return value
|
||||
|
||||
@field_validator("allowed_domain_suffixes")
|
||||
@classmethod
|
||||
def trusted_suffixes(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if len(value) > 16 or len(set(value)) != len(value):
|
||||
raise ValueError("allowed_domain_suffixes must be unique")
|
||||
for item in value:
|
||||
normalized = item.lower().lstrip(".")
|
||||
if not _HOSTNAME.fullmatch(normalized) or "*" in normalized:
|
||||
raise ValueError("allowed domain suffix must be a concrete DNS suffix")
|
||||
return tuple(item.lower().lstrip(".") for item in value)
|
||||
|
||||
@field_validator("oidc_issuer")
|
||||
@classmethod
|
||||
def issuer_is_https(cls, value: str | None) -> str | None:
|
||||
if value is not None and not re.fullmatch(r"https://[a-z0-9.-]+(?:/[a-z0-9._~/-]*)?", value):
|
||||
raise ValueError("oidc_issuer must be an HTTPS URL without query or fragment")
|
||||
return value.rstrip("/") if value else value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_oidc_groups(self) -> EnrollmentTrustConfigV1:
|
||||
groups = (self.administrator_group, self.support_agent_group, self.read_only_group)
|
||||
if any(groups) and not all(groups):
|
||||
raise ValueError("all three configured OIDC role groups are required together")
|
||||
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")
|
||||
return self
|
||||
Reference in new issue
Block a user