376 lines
16 KiB
Python
376 lines
16 KiB
Python
"""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
|