"""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