diff --git a/changelog.d/121-owned-postgresql-validation.md b/changelog.d/121-owned-postgresql-validation.md new file mode 100644 index 0000000..8cfdbbb --- /dev/null +++ b/changelog.d/121-owned-postgresql-validation.md @@ -0,0 +1,3 @@ +### Added + +- A PostgreSQL `Service` database may name an `owner` role and its `passwordRef` (`environment`, `path`, `key` in the secrets store) instead of an `application`, so a project of a server and its database alone validates; `spec.postgresql.remoteAccess` declares a listen address and the client networks allowed in. `config validate` refuses an entry with both or neither of `application` and `owner`, an `owner` without `passwordRef` or a `passwordRef` next to `application`, a reserved owner (`service_database_owner_reserved`), and a `remoteAccess` with no owner database, no firewall, the port already open, or a source of every address. Converging these declarations comes later: until then the PostgreSQL role refuses them before any task runs (#121) diff --git a/config/schemas/v1/service.schema.json b/config/schemas/v1/service.schema.json index f13ca7f..f0cc4c0 100644 --- a/config/schemas/v1/service.schema.json +++ b/config/schemas/v1/service.schema.json @@ -10,6 +10,72 @@ "maxLength": 63, "pattern": "^[a-z][a-z0-9_]*$" }, + "postgresRoleName": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z][a-z0-9_]*$" + }, + "secretValueReference": { + "type": "object", + "properties": { + "environment": { + "$ref": "common.schema.json#/$defs/resourceId" + }, + "path": { + "$ref": "common.schema.json#/$defs/absolutePath" + }, + "key": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "required": [ + "environment", + "path", + "key" + ], + "additionalProperties": false + }, + "remoteAccess": { + "type": "object", + "properties": { + "listenAddress": { + "type": "string", + "anyOf": [ + { + "format": "ipv4" + }, + { + "format": "ipv6" + } + ], + "not": { + "enum": [ + "0.0.0.0", + "::" + ] + } + }, + "allowedSources": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 49 + }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": [ + "listenAddress", + "allowedSources" + ], + "additionalProperties": false + }, "systemdCalendar": { "type": "string", "minLength": 1, @@ -83,16 +149,50 @@ }, "application": { "$ref": "common.schema.json#/$defs/resourceId" + }, + "owner": { + "$ref": "#/$defs/postgresRoleName" + }, + "passwordRef": { + "$ref": "#/$defs/secretValueReference" } }, "required": [ - "name", - "application" + "name" + ], + "additionalProperties": false, + "oneOf": [ + { + "required": [ + "application" + ] + }, + { + "required": [ + "owner" + ] + } ], - "additionalProperties": false + "dependentRequired": { + "owner": [ + "passwordRef" + ] + }, + "dependentSchemas": { + "application": { + "not": { + "required": [ + "passwordRef" + ] + } + } + } }, "minItems": 1, "uniqueItems": true + }, + "remoteAccess": { + "$ref": "#/$defs/remoteAccess" } }, "required": [ diff --git a/sdk/src/cloudfall/domain.py b/sdk/src/cloudfall/domain.py index 59f0b8c..c979e34 100644 --- a/sdk/src/cloudfall/domain.py +++ b/sdk/src/cloudfall/domain.py @@ -9,7 +9,7 @@ from enum import StrEnum from ipaddress import IPv4Address as StandardIPv4Address from ipaddress import IPv6Network as StandardIPv6Network -from ipaddress import ip_address +from ipaddress import ip_address, ip_network from pathlib import PurePosixPath from typing import TYPE_CHECKING @@ -246,6 +246,11 @@ def __post_init__(self) -> None: message = f"IP address is not canonical: {self.value!r}" raise ValueError(message) + @property + def is_unspecified(self) -> bool: + """Return whether the address is the wildcard of its family.""" + return ip_address(self.value).is_unspecified + @classmethod def from_boundary(cls, value: object) -> IpAddress: """Coerce a boundary value into an IP address.""" @@ -693,6 +698,66 @@ def from_boundary(cls, value: object) -> PostgresDatabaseName: return cls(_required_string(value, "PostgreSQL database name")) +@dataclass(frozen=True, slots=True) +class PostgresRoleName: + """Validated PostgreSQL role name safe for quoted identifiers.""" + + value: str + + def __post_init__(self) -> None: + """Enforce the database-name pattern state uses for roles.""" + if not re.fullmatch(r"[a-z][a-z0-9_]{0,62}", self.value): + message = f"invalid PostgreSQL role name: {self.value!r}" + raise ValueError(message) + + @classmethod + def from_boundary(cls, value: object) -> PostgresRoleName: + """Coerce a boundary value into a role name.""" + return cls(_required_string(value, "PostgreSQL role name")) + + +@dataclass(frozen=True, slots=True) +class SecretKeyName: + """Validated key of one ``KEY=VALUE`` line in a secret source.""" + + value: str + + def __post_init__(self) -> None: + """Enforce the dotenv key syntax the secrets reader accepts.""" + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,254}", self.value): + message = f"invalid secret key name: {self.value!r}" + raise ValueError(message) + + @classmethod + def from_boundary(cls, value: object) -> SecretKeyName: + """Coerce a boundary value into a secret key name.""" + return cls(_required_string(value, "secret key name")) + + +@dataclass(frozen=True, slots=True) +class IpNetworkCidr: + """Validated canonical IPv4 or IPv6 network in CIDR notation.""" + + value: str + + def __post_init__(self) -> None: + """Enforce a canonical network with no host bits set.""" + parsed = ip_network(self.value, strict=True) + if str(parsed) != self.value: + message = f"IP network is not canonical: {self.value!r}" + raise ValueError(message) + + @property + def is_unrestricted(self) -> bool: + """Return whether the network matches every address of its family.""" + return ip_network(self.value).prefixlen == 0 + + @classmethod + def from_boundary(cls, value: object) -> IpNetworkCidr: + """Coerce a boundary value into an IP network.""" + return cls(_required_string(value, "IP network")) + + @dataclass(frozen=True, slots=True) class ReleaseId: """Validated component release identifier.""" diff --git a/sdk/src/cloudfall/inventory.py b/sdk/src/cloudfall/inventory.py index ad6a354..22de64c 100644 --- a/sdk/src/cloudfall/inventory.py +++ b/sdk/src/cloudfall/inventory.py @@ -26,6 +26,7 @@ HttpScheme, HttpStatusCode, IpAddress, + IpNetworkCidr, Ipv4Address, Ipv6NetworkCidr, LinuxUser, @@ -40,6 +41,7 @@ PositiveCount, PostgresDatabaseName, PostgresMajorVersion, + PostgresRoleName, PromqlExpression, ProviderServerId, RaidLevel, @@ -52,6 +54,7 @@ RuntimePackageManager, RuntimeType, RuntimeVersion, + SecretKeyName, SecretScope, ServerLifecycle, ServiceCommand, @@ -637,13 +640,63 @@ def as_dict(self) -> dict[str, object]: } +@dataclass(frozen=True, slots=True) +class SecretValueReference: + """Pointer to one key of one secret source; never carries the value.""" + + environment: ResourceId + path: AbsolutePath + key: SecretKeyName + + def as_dict(self) -> dict[str, object]: + """Serialize the reference itself (references are not secret).""" + return { + "environment": self.environment.value, + "path": self.path.value, + "key": self.key.value, + } + + +@dataclass(frozen=True, slots=True) +class OwnedPostgresDatabase: + """One PostgreSQL database owned by a declared role, not an application.""" + + name: PostgresDatabaseName + owner: PostgresRoleName + password_ref: SecretValueReference + + def as_dict(self) -> dict[str, object]: + """Serialize the database with its role and password reference.""" + return { + "name": self.name.value, + "owner": self.owner.value, + "passwordRef": self.password_ref.as_dict(), + } + + +@dataclass(frozen=True, slots=True) +class PostgresRemoteAccess: + """Listener and client networks for outside apps reaching owned roles.""" + + listen_address: IpAddress + allowed_sources: tuple[IpNetworkCidr, ...] + + def as_dict(self) -> dict[str, object]: + """Serialize the remote access declaration.""" + return { + "listenAddress": self.listen_address.value, + "allowedSources": [source.value for source in self.allowed_sources], + } + + @dataclass(frozen=True, slots=True) class PostgresqlService: """PostgreSQL-specific service contract.""" major_version: PostgresMajorVersion package_version: PackageVersion | None - databases: tuple[PostgresDatabase, ...] + databases: tuple[PostgresDatabase | OwnedPostgresDatabase, ...] + remote_access: PostgresRemoteAccess | None = None @dataclass(frozen=True, slots=True) @@ -725,6 +778,10 @@ def as_dict(self) -> dict[str, object]: postgresql["packageVersion"] = ( self.postgresql.package_version.value ) + if self.postgresql.remote_access is not None: + postgresql["remoteAccess"] = ( + self.postgresql.remote_access.as_dict() + ) result["postgresql"] = postgresql if self.redis is not None: redis: dict[str, object] = { @@ -1615,6 +1672,11 @@ def _service_inventory( raw_postgresql.get("databases"), "service databases" ) ), + remote_access=( + _postgres_remote_access(_mapping(raw_postgresql, "remoteAccess")) + if "remoteAccess" in raw_postgresql + else None + ), ) redis = None if service_kind is ServiceKind.REDIS: @@ -1727,7 +1789,18 @@ def _service_metrics_enabled( def _postgres_database( content: Mapping[str, object], applications_by_id: Mapping[ResourceId, ApplicationInventory], -) -> PostgresDatabase: +) -> PostgresDatabase | OwnedPostgresDatabase: + if "owner" in content: + password_ref = _mapping(content, "passwordRef") + return OwnedPostgresDatabase( + name=PostgresDatabaseName.from_boundary(content.get("name")), + owner=PostgresRoleName.from_boundary(content.get("owner")), + password_ref=SecretValueReference( + environment=ResourceId.from_boundary(password_ref.get("environment")), + path=AbsolutePath.from_boundary(password_ref.get("path")), + key=SecretKeyName.from_boundary(password_ref.get("key")), + ), + ) application_id = ResourceId.from_boundary(content.get("application")) application = applications_by_id.get(application_id) if application is None: @@ -1742,6 +1815,19 @@ def _postgres_database( ) +def _postgres_remote_access(content: Mapping[str, object]) -> PostgresRemoteAccess: + raw_sources = content.get("allowedSources") + if not isinstance(raw_sources, list) or not raw_sources: + message = "validated remote access sources are not a non-empty list" + raise ValueError(message) + return PostgresRemoteAccess( + listen_address=IpAddress.from_boundary(content.get("listenAddress")), + allowed_sources=tuple( + IpNetworkCidr.from_boundary(source) for source in raw_sources + ), + ) + + def _ssh_public_key_inventory( document: ResourceDocument, ) -> SshPublicKeyInventory: diff --git a/sdk/src/cloudfall/validation.py b/sdk/src/cloudfall/validation.py index ef5d1d2..6a3e51c 100644 --- a/sdk/src/cloudfall/validation.py +++ b/sdk/src/cloudfall/validation.py @@ -19,6 +19,8 @@ from referencing import Registry, Resource from cloudfall.domain import ( + IpAddress, + IpNetworkCidr, OpenSshPublicKey, RawDocument, ResourceDocument, @@ -611,8 +613,16 @@ def _validate_service_references( ) raise ConfigValidationError(issue) - if spec.get("serviceKind") != "postgresql": - return + if spec.get("serviceKind") == "postgresql": + _validate_postgresql_service(document, spec, server, index) + + +def _validate_postgresql_service( + document: ResourceDocument, + spec: Mapping[str, object], + server: ResourceDocument, + index: Mapping[ResourceKey, ResourceDocument], +) -> None: postgresql = _required_mapping(spec, "postgresql", document.source) raw_databases = postgresql.get("databases") if not isinstance(raw_databases, list): @@ -624,6 +634,7 @@ def _validate_service_references( ) raise ConfigValidationError(issue) seen_names: set[object] = set() + databases: list[tuple[int, Mapping[str, object]]] = [] for position, raw_database in enumerate(raw_databases): if not isinstance(raw_database, dict) or not all( isinstance(key, str) for key in raw_database @@ -646,14 +657,201 @@ def _validate_service_references( ) raise ConfigValidationError(issue) seen_names.add(name) + databases.append((position, database)) + application_users: set[str] = set() + for position, database in databases: + if "application" not in database: + continue application_id = _resource_id_value(database, "application", document.source) - _require_resource( + application = _require_resource( index, ResourceKind.APPLICATION, application_id, document.source, ("spec", "postgresql", "databases", position, "application"), ) + application_spec = _required_mapping( + application.content, "spec", application.source + ) + linux_user = application_spec.get("linuxUser") + if isinstance(linux_user, str): + application_users.add(linux_user) + owners = _validate_service_database_owners( + document, databases, frozenset(application_users) + ) + if "remoteAccess" in postgresql: + _validate_service_remote_access( + document, + spec, + _required_mapping(postgresql, "remoteAccess", document.source), + server, + index, + has_owner=bool(owners), + ) + + +_RESERVED_POSTGRES_ROLES = frozenset({"postgres", "alloy"}) + + +def _validate_service_database_owners( + document: ResourceDocument, + databases: Sequence[tuple[int, Mapping[str, object]]], + application_users: frozenset[str], +) -> tuple[str, ...]: + """Refuse an owner role Cloudfall or an application on the service holds.""" + owners: list[str] = [] + for position, database in databases: + owner = database.get("owner") + if owner is None: + continue + if not isinstance(owner, str): + issue = ValidationIssue( + code="internal_state_shape_invalid", + message="validated service database owner is not a string", + source=document.source, + field_path=("spec", "postgresql", "databases", position, "owner"), + ) + raise ConfigValidationError(issue) + if owner in _RESERVED_POSTGRES_ROLES or owner in application_users: + holder = ( + "a role Cloudfall reserves" + if owner in _RESERVED_POSTGRES_ROLES + else "the Linux user of an Application with a database on " + "this service" + ) + issue = ValidationIssue( + code="service_database_owner_reserved", + message=f"database owner {owner!r} is {holder}", + source=document.source, + field_path=("spec", "postgresql", "databases", position, "owner"), + ) + raise ConfigValidationError(issue) + owners.append(owner) + return tuple(owners) + + +def _validate_service_remote_access( # noqa: PLR0913 + document: ResourceDocument, + spec: Mapping[str, object], + remote_access: Mapping[str, object], + server: ResourceDocument, + index: Mapping[ResourceKey, ResourceDocument], + *, + has_owner: bool, +) -> None: + """Refuse a way in that serves no owner role or opens the port to all.""" + field_path: tuple[str | int, ...] = ("spec", "postgresql", "remoteAccess") + if not has_owner: + issue = ValidationIssue( + code="service_remote_access_unused", + message=( + "remoteAccess needs a database with an owner: databases named " + "by an application stay socket-only" + ), + source=document.source, + field_path=field_path, + ) + raise ConfigValidationError(issue) + _validate_remote_access_listen_address(document, remote_access, field_path) + server_spec = _required_mapping(server.content, "spec", server.source) + server_type_id = _resource_id_value(server_spec, "serverType", server.source) + server_type = _require_resource( + index, + ResourceKind.HOST_PROFILE, + server_type_id, + server.source, + ("spec", "serverType"), + ) + server_type_spec = _required_mapping( + server_type.content, "spec", server_type.source + ) + if "firewall" not in server_type_spec: + issue = ValidationIssue( + code="service_remote_access_unfirewalled", + message=( + f"ServerType/{server_type_id} of Server/{server.key.resource_id} " + "declares no firewall to restrict remoteAccess to its sources" + ), + source=document.source, + field_path=field_path, + ) + raise ConfigValidationError(issue) + bind = _required_mapping(spec, "bind", document.source) + port = bind.get("port") + firewall = _required_mapping(server_type_spec, "firewall", server_type.source) + raw_rules = firewall.get("allowedInbound") + rules = raw_rules if isinstance(raw_rules, list) else [] + for rule in rules: + if ( + isinstance(rule, dict) + and rule.get("port") == port + and rule.get("protocol") == "tcp" + ): + issue = ValidationIssue( + code="service_remote_access_port_open", + message=( + f"ServerType/{server_type_id} already opens tcp port {port} " + "to every address; remoteAccess would not restrict it" + ), + source=document.source, + field_path=field_path, + ) + raise ConfigValidationError(issue) + raw_sources = remote_access.get("allowedSources") + sources = raw_sources if isinstance(raw_sources, list) else [] + for position, raw_source in enumerate(sources): + source_path = (*field_path, "allowedSources", position) + try: + source = IpNetworkCidr.from_boundary(raw_source) + except (TypeError, ValueError) as error: + issue = ValidationIssue( + code="service_remote_access_source_invalid", + message=f"allowed source is not a canonical CIDR: {error}", + source=document.source, + field_path=source_path, + ) + raise ConfigValidationError(issue) from error + if source.is_unrestricted: + issue = ValidationIssue( + code="service_remote_access_open", + message=( + f"allowed source {source.value} matches every address; " + "name the networks the outside app connects from" + ), + source=document.source, + field_path=source_path, + ) + raise ConfigValidationError(issue) + + +def _validate_remote_access_listen_address( + document: ResourceDocument, + remote_access: Mapping[str, object], + field_path: tuple[str | int, ...], +) -> None: + """Refuse a listen address that is a wildcard or not canonical.""" + listen_path = (*field_path, "listenAddress") + try: + listen_address = IpAddress.from_boundary(remote_access.get("listenAddress")) + except (TypeError, ValueError) as error: + issue = ValidationIssue( + code="service_remote_access_listen_invalid", + message=f"listen address is not one canonical IP address: {error}", + source=document.source, + field_path=listen_path, + ) + raise ConfigValidationError(issue) from error + if listen_address.is_unspecified: + issue = ValidationIssue( + code="service_remote_access_listen_invalid", + message=( + f"listen address {listen_address.value} is a wildcard; name one " + "address of the server" + ), + source=document.source, + field_path=listen_path, + ) + raise ConfigValidationError(issue) def _validate_service_uniqueness(documents: Sequence[ResourceDocument]) -> None: diff --git a/sdk/tests/test_owned_postgresql.py b/sdk/tests/test_owned_postgresql.py new file mode 100644 index 0000000..78e34d6 --- /dev/null +++ b/sdk/tests/test_owned_postgresql.py @@ -0,0 +1,336 @@ +"""Validation of PostgreSQL databases owned by a declared role (S-006).""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest +import yaml +from cloudfall.cli import main +from cloudfall.domain import ResourceId +from cloudfall.inventory import OwnedPostgresDatabase, PlatformInventory +from cloudfall.validation import ConfigValidationError, validate_config + +if TYPE_CHECKING: + from collections.abc import Mapping + +ROOT = Path(__file__).parents[2] +EXAMPLES = ROOT / "config" / "examples" +SCHEMAS = ROOT / "config" / "schemas" / "v1" +PASSWORD_REF = { + "environment": "production", + "path": "/billing/postgresql", + "key": "BILLING_PASSWORD", +} + + +def _owned_service( + databases: list[dict[str, object]], + remote_access: Mapping[str, object] | None = None, +) -> dict[str, object]: + postgresql: dict[str, object] = {"majorVersion": "17", "databases": databases} + if remote_access is not None: + postgresql["remoteAccess"] = remote_access + return { + "apiVersion": "cloudfall/v1", + "kind": "Service", + "metadata": {"id": "postgresql-billing"}, + "spec": { + "serviceKind": "postgresql", + "environment": "production", + "server": "h1", + "bind": {"address": "127.0.0.1", "port": 5432}, + "postgresql": postgresql, + "backup": { + "directory": "/var/backups/cloudfall/postgresql-billing", + "onCalendar": "*-*-* 02:00:00 UTC", + "retentionDays": 14, + }, + }, + } + + +def _billing(**extra: object) -> dict[str, object]: + return {"name": "billing", "owner": "billing", "passwordRef": PASSWORD_REF, **extra} + + +def _standalone_project(tmp_path: Path, service: dict[str, object]) -> Path: + """Write a project of one Server, its server type, and one Service.""" + project = tmp_path / "project" + (project / "servers").mkdir(parents=True) + (project / "server-types").mkdir() + (project / "services").mkdir() + shutil.copy(EXAMPLES / "servers" / "h1.yaml", project / "servers" / "h1.yaml") + shutil.copy( + EXAMPLES / "server-types" / "debian-application.yaml", + project / "server-types" / "debian-application.yaml", + ) + _write_service(project, service) + return project + + +def _write_service(project: Path, service: dict[str, object]) -> None: + (project / "services" / "postgresql-billing.yaml").write_text( + yaml.safe_dump(service, sort_keys=False), encoding="utf-8" + ) + + +def _validate( + project: Path, capsys: pytest.CaptureFixture[str] +) -> tuple[int, dict[str, dict[str, Any]]]: + exit_code = main( + ["config", "validate", "--project", str(project), "--schemas", str(SCHEMAS)] + ) + document: dict[str, dict[str, Any]] = json.loads(capsys.readouterr().out) + return exit_code, document + + +def _refusal(project: Path) -> ConfigValidationError: + with pytest.raises(ConfigValidationError) as error: + validate_config(project, SCHEMAS) + return error.value + + +def test_s006_1_owner_database_validates_without_application( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project = _standalone_project(tmp_path, _owned_service([_billing()])) + + exit_code, document = _validate(project, capsys) + + assert exit_code == 0 + assert document["data"]["by_kind"] == { + "Server": 1, + "ServerType": 1, + "Service": 1, + } + + +def test_s006_1_owner_database_renders_reference_without_application( + tmp_path: Path, +) -> None: + project = _standalone_project(tmp_path, _owned_service([_billing()])) + + inventory = PlatformInventory.from_state(validate_config(project, SCHEMAS)) + + (service,) = inventory.services + assert service.postgresql is not None + (database,) = service.postgresql.databases + assert isinstance(database, OwnedPostgresDatabase) + assert service.as_dict()["postgresql"] == { + "majorVersion": "17", + "databases": [ + {"name": "billing", "owner": "billing", "passwordRef": PASSWORD_REF} + ], + } + + +@pytest.mark.parametrize( + "database", + [ + _billing(application="crm"), + {"name": "billing"}, + ], + ids=["both", "neither"], +) +def test_s006_2_database_names_exactly_one_of_application_or_owner( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + database: dict[str, object], +) -> None: + project = _standalone_project(tmp_path, _owned_service([database])) + + exit_code, document = _validate(project, capsys) + + assert exit_code == 80 + assert document["error"]["code"] == "CONFIG_INVALID" + assert document["error"]["context"]["field"] == "spec.postgresql.databases.0" + + +@pytest.mark.parametrize( + "database", + [ + {"name": "billing", "owner": "billing"}, + {"name": "crm", "application": "crm", "passwordRef": PASSWORD_REF}, + ], + ids=["owner-without-ref", "ref-next-to-application"], +) +def test_s006_3_password_ref_goes_with_owner_only( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + database: dict[str, object], +) -> None: + project = _standalone_project(tmp_path, _owned_service([database])) + + exit_code, document = _validate(project, capsys) + + assert exit_code == 80 + assert document["error"]["code"] == "CONFIG_INVALID" + assert document["error"]["context"]["field"] == "spec.postgresql.databases.0" + assert "passwordRef" in document["error"]["message"] + + +@pytest.mark.parametrize("owner", ["postgres", "alloy"]) +def test_s006_4_owner_refuses_cloudfall_roles( + tmp_path: Path, capsys: pytest.CaptureFixture[str], owner: str +) -> None: + project = _standalone_project( + tmp_path, _owned_service([_billing(owner=owner)]) + ) + + exit_code, document = _validate(project, capsys) + + assert exit_code == 80 + assert document["error"]["code"] == "CONFIG_INVALID" + assert document["error"]["context"]["code"] == "service_database_owner_reserved" + + +def test_s006_4_owner_refuses_an_application_user_on_the_service( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + service_path = project / "services" / "postgresql-main.yaml" + service = yaml.safe_load(service_path.read_text(encoding="utf-8")) + service["spec"]["postgresql"]["databases"].append(_billing(owner="crm")) + service_path.write_text(yaml.safe_dump(service), encoding="utf-8") + + error = _refusal(project) + + assert error.issue.code == "service_database_owner_reserved" + assert error.issue.field_path == ("spec", "postgresql", "databases", 1, "owner") + + +def test_owner_may_share_a_name_with_an_application_elsewhere( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + service = _owned_service([_billing(owner="crm")]) + service["spec"]["server"] = "h2" # type: ignore[index] + _write_service(project, service) + + config = validate_config(project, SCHEMAS) + + assert config.resource_count == 13 + + +REMOTE_ACCESS = {"listenAddress": "10.0.0.2", "allowedSources": ["10.0.0.0/24"]} + + +def test_remote_access_with_an_owner_validates_and_renders(tmp_path: Path) -> None: + project = _standalone_project( + tmp_path, _owned_service([_billing()], remote_access=REMOTE_ACCESS) + ) + + inventory = PlatformInventory.from_state(validate_config(project, SCHEMAS)) + + (service,) = inventory.services + assert service.resource_id == ResourceId("postgresql-billing") + postgresql = service.as_dict()["postgresql"] + assert isinstance(postgresql, dict) + assert postgresql["remoteAccess"] == REMOTE_ACCESS + + +def test_s006_11_remote_access_needs_an_owner_database(tmp_path: Path) -> None: + project = tmp_path / "project" + shutil.copytree(EXAMPLES, project) + service_path = project / "services" / "postgresql-main.yaml" + service = yaml.safe_load(service_path.read_text(encoding="utf-8")) + service["spec"]["postgresql"]["remoteAccess"] = REMOTE_ACCESS + service_path.write_text(yaml.safe_dump(service), encoding="utf-8") + + assert _refusal(project).issue.code == "service_remote_access_unused" + + +def test_s006_11_remote_access_needs_a_firewall(tmp_path: Path) -> None: + project = _standalone_project( + tmp_path, _owned_service([_billing()], remote_access=REMOTE_ACCESS) + ) + server_type_path = project / "server-types" / "debian-application.yaml" + server_type = yaml.safe_load(server_type_path.read_text(encoding="utf-8")) + del server_type["spec"]["firewall"] + server_type_path.write_text(yaml.safe_dump(server_type), encoding="utf-8") + + assert _refusal(project).issue.code == "service_remote_access_unfirewalled" + + +def test_s006_11_remote_access_refuses_a_port_already_open(tmp_path: Path) -> None: + project = _standalone_project( + tmp_path, _owned_service([_billing()], remote_access=REMOTE_ACCESS) + ) + server_type_path = project / "server-types" / "debian-application.yaml" + server_type = yaml.safe_load(server_type_path.read_text(encoding="utf-8")) + server_type["spec"]["firewall"]["allowedInbound"].append( + {"port": 5432, "protocol": "tcp", "description": "postgresql"} + ) + server_type_path.write_text(yaml.safe_dump(server_type), encoding="utf-8") + + assert _refusal(project).issue.code == "service_remote_access_port_open" + + +@pytest.mark.parametrize("source", ["0.0.0.0/0", "::/0"]) +def test_s006_11_remote_access_refuses_every_address( + tmp_path: Path, capsys: pytest.CaptureFixture[str], source: str +) -> None: + remote_access = {"listenAddress": "10.0.0.2", "allowedSources": [source]} + project = _standalone_project( + tmp_path, _owned_service([_billing()], remote_access=remote_access) + ) + + exit_code, document = _validate(project, capsys) + + assert exit_code == 80 + assert document["error"]["code"] == "CONFIG_INVALID" + assert document["error"]["context"]["code"] == "service_remote_access_open" + + +def test_remote_access_refuses_a_non_canonical_source(tmp_path: Path) -> None: + remote_access = {"listenAddress": "10.0.0.2", "allowedSources": ["10.0.0.1/24"]} + project = _standalone_project( + tmp_path, _owned_service([_billing()], remote_access=remote_access) + ) + + assert _refusal(project).issue.code == "service_remote_access_source_invalid" + + +@pytest.mark.parametrize( + "address", + ["0.0.0.0", "::", "not-an-address"], # noqa: S104 - refused wildcards +) +def test_remote_access_listen_address_is_one_address( + tmp_path: Path, address: str +) -> None: + remote_access = {"listenAddress": address, "allowedSources": ["10.0.0.0/24"]} + project = _standalone_project( + tmp_path, _owned_service([_billing()], remote_access=remote_access) + ) + + assert _refusal(project).issue.code == "schema_validation_failed" + + +@pytest.mark.parametrize( + "address", + ["::0", "0:0:0:0:0:0:0:0", "fd00::0002"], + ids=["wildcard-short", "wildcard-long", "non-canonical"], +) +def test_remote_access_listen_address_is_one_canonical_address( + tmp_path: Path, address: str +) -> None: + remote_access = {"listenAddress": address, "allowedSources": ["10.0.0.0/24"]} + project = _standalone_project( + tmp_path, _owned_service([_billing()], remote_access=remote_access) + ) + + error = _refusal(project) + + assert error.issue.code == "service_remote_access_listen_invalid" + assert error.issue.field_path == ( + "spec", + "postgresql", + "remoteAccess", + "listenAddress", + )