diff --git a/changelog.d/123-postgresql-remote-access.md b/changelog.d/123-postgresql-remote-access.md new file mode 100644 index 0000000..9847f58 --- /dev/null +++ b/changelog.d/123-postgresql-remote-access.md @@ -0,0 +1,3 @@ +### Added + +- A PostgreSQL service with `spec.postgresql.remoteAccess` opens to its declared sources over TLS: `services.yml` sets `listen_addresses = '127.0.0.1,'` and `ssl = on` with the Debian `ssl-cert` snakeoil pair (restarting the cluster), and writes one `hostssl scram-sha-256` line per owned database and source between Cloudfall markers in `pg_hba.conf`, reloading only after `pg_hba_file_rules` reports no error; `baseline.yml` adds one `ip saddr`/`ip6 saddr tcp dport accept` rule per source, rendered as a `cloudfall_firewall.allowedInbound` entry with a new optional `sources` list. Removing `remoteAccess` resets the listener and removes the `pg_hba.conf` block and the firewall rule; databases named by `application` stay socket-only (#123) diff --git a/docs/specs/006-standalone-postgresql.md b/docs/specs/006-standalone-postgresql.md index b89cae8..651e94e 100644 --- a/docs/specs/006-standalone-postgresql.md +++ b/docs/specs/006-standalone-postgresql.md @@ -142,3 +142,9 @@ Touches `config/schemas/v1/service.schema.json`, `sdk/src/cloudfall/validation.p - cloudfall-dev/cloudfall#123: S-006-8, S-006-9, S-006-10 ## Verification + +`remoteAccess` has not run against a live Debian host yet: the files were rendered from a validated project with the role templates, the role's decisions were evaluated with Ansible's templating, and the `pg_hba.conf` tasks ran under `ansible-playbook` on the controller against a copy of Debian's file with a stand-in for `psql`. PostgreSQL's real parse of the file, the restart onto the new listener, TLS with the snakeoil pair, and `nft` loading the rules are unexercised. + +- S-006-8: `test_s006_8_remote_access_renders_listener_tls_hba_and_firewall` and `test_s006_8_the_firewall_entry_carries_its_sources` (`engine/tests/test_postgresql_remote_access.py`) rendered `listen_addresses = '127.0.0.1,10.0.0.2'`, `ssl = on`, the block line `hostssl billing billing 10.0.0.0/24 scram-sha-256` (none for the application database `crm`), and `ip saddr 10.0.0.0/24 tcp dport 5432 accept` as the only rule for port 5432; `test_s006_8_s006_10_the_hba_block_opens_then_closes_leaving_the_rest` wrote exactly that line between the Cloudfall markers, reloaded once, and left Debian's lines unchanged +- S-006-9: not run; it needs the live proving run on two hosts +- S-006-10: `test_s006_10_without_remote_access_the_service_renders_closed` rendered `listen_addresses = '127.0.0.1'`, no rule for port 5432, and the block state `absent`; `test_s006_8_s006_10_the_hba_block_opens_then_closes_leaving_the_rest` then removed the block and the file read byte-identical to Debian's diff --git a/engine/README.md b/engine/README.md index 8b4620a..c084c2e 100644 --- a/engine/README.md +++ b/engine/README.md @@ -115,7 +115,9 @@ The firewall role renders `/etc/nftables.conf` from the rendered SSH port), validates it with `nft --check` before installation, and refuses any ruleset that does not allow the control connection's SSH port. The managed table replaces the complete ruleset; hosts whose profile declares no -firewall are left unmanaged. +firewall are left unmanaged. An entry with a `sources` list (a PostgreSQL +service's `remoteAccess`) renders one `ip saddr`/`ip6 saddr` rule per source +and never counts as the SSH port. The access role installs active environment-scoped `SshPublicKey` resources for root, validates the complete OpenSSH configuration, and only then @@ -159,6 +161,13 @@ of every database restores into a scratch database before dropping it: sudo -u postgres /usr/local/sbin/cloudfall-postgresql-restore-check ``` +A service that declares `postgresql.remoteAccess` also listens on its +`listenAddress` with `ssl = on` (the `ssl-cert` snakeoil pair); a change to +the listener restarts the cluster. Its `pg_hba.conf` gains a block between +Cloudfall markers with one `hostssl` SCRAM line per owned database and source, +applied with `pg_reload_conf()` only after `pg_hba_file_rules` reports no +error; a service without `remoteAccess` has the block removed. + ## Build and deploy component releases `cloudfall-engine artifact build` clones a component's declared repository at diff --git a/engine/ansible/roles/cloudfall_firewall/meta/argument_specs.yml b/engine/ansible/roles/cloudfall_firewall/meta/argument_specs.yml index 623670f..a1d2a63 100644 --- a/engine/ansible/roles/cloudfall_firewall/meta/argument_specs.yml +++ b/engine/ansible/roles/cloudfall_firewall/meta/argument_specs.yml @@ -28,6 +28,12 @@ argument_specs: required: true description: type: str + sources: + type: list + elements: str + description: >- + CIDRs the rule is restricted to, one rule per source; an + entry without sources accepts every address cloudfall_firewall_configuration_path: type: path default: /etc/nftables.conf diff --git a/engine/ansible/roles/cloudfall_firewall/tasks/main.yml b/engine/ansible/roles/cloudfall_firewall/tasks/main.yml index 29ccb7c..3174b92 100644 --- a/engine/ansible/roles/cloudfall_firewall/tasks/main.yml +++ b/engine/ansible/roles/cloudfall_firewall/tasks/main.yml @@ -10,12 +10,35 @@ Cloudfall firewall management requires Debian, systemd, and a declared default-deny ruleset with at least one allowed inbound rule +- name: Refuse a source-restricted rule that opens to every address + ansible.builtin.assert: + that: + - >- + cloudfall_firewall_ruleset.allowedInbound + | selectattr('sources', 'defined') + | map(attribute='sources') + | select('equalto', []) + | list + | length == 0 + - >- + cloudfall_firewall_ruleset.allowedInbound + | selectattr('sources', 'defined') + | map(attribute='sources') + | flatten + | select('in', ['0.0.0.0/0', '::/0']) + | list + | length == 0 + fail_msg: >- + A firewall rule restricted to sources needs at least one, and none may + be 0.0.0.0/0 or ::/0 + - name: Refuse a ruleset that would block the control connection ansible.builtin.assert: that: - >- ansible_port | default(22) | int in ( cloudfall_firewall_ruleset.allowedInbound + | rejectattr('sources', 'defined') | selectattr('protocol', 'equalto', 'tcp') | map(attribute='port') | map('int') diff --git a/engine/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 b/engine/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 index ce6d410..ff0a339 100644 --- a/engine/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 +++ b/engine/ansible/roles/cloudfall_firewall/templates/nftables.conf.j2 @@ -13,7 +13,15 @@ table inet cloudfall { ip protocol icmp accept meta l4proto ipv6-icmp accept {% for rule in cloudfall_firewall_ruleset.allowedInbound %} +{% if rule.sources is defined %} +{% for source in rule.sources %} {% if rule.description is defined %} + {{ 'ip6' if ':' in source else 'ip' }} saddr {{ source }} {{ rule.protocol }} dport {{ rule.port }} accept comment "{{ rule.description }}" +{% else %} + {{ 'ip6' if ':' in source else 'ip' }} saddr {{ source }} {{ rule.protocol }} dport {{ rule.port }} accept +{% endif %} +{% endfor %} +{% elif rule.description is defined %} {{ rule.protocol }} dport {{ rule.port }} accept comment "{{ rule.description }}" {% else %} {{ rule.protocol }} dport {{ rule.port }} accept diff --git a/engine/ansible/roles/cloudfall_postgresql/defaults/main.yml b/engine/ansible/roles/cloudfall_postgresql/defaults/main.yml index 7be4923..9b6c676 100644 --- a/engine/ansible/roles/cloudfall_postgresql/defaults/main.yml +++ b/engine/ansible/roles/cloudfall_postgresql/defaults/main.yml @@ -6,3 +6,6 @@ cloudfall_postgresql_repository_key_path: /etc/apt/keyrings/postgresql.asc cloudfall_postgresql_repository_url: https://apt.postgresql.org/pub/repos/apt cloudfall_postgresql_password_files: {} cloudfall_postgresql_password_path: /var/lib/postgresql/.cloudfall-owner-password +cloudfall_postgresql_ssl_cert_file: /etc/ssl/certs/ssl-cert-snakeoil.pem +cloudfall_postgresql_ssl_key_file: /etc/ssl/private/ssl-cert-snakeoil.key +cloudfall_postgresql_hba_marker: "# {mark} CLOUDFALL MANAGED REMOTE ACCESS" diff --git a/engine/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml b/engine/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml index d02923e..f0230f5 100644 --- a/engine/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml +++ b/engine/ansible/roles/cloudfall_postgresql/meta/argument_specs.yml @@ -67,6 +67,16 @@ argument_specs: key: type: str required: true + remoteAccess: + type: dict + options: + listenAddress: + type: str + required: true + allowedSources: + type: list + elements: str + required: true backup: type: dict required: true @@ -98,6 +108,18 @@ argument_specs: type: path default: /var/lib/postgresql/.cloudfall-owner-password description: Host path an owner role's password is staged at, 0600 + cloudfall_postgresql_ssl_cert_file: + type: path + default: /etc/ssl/certs/ssl-cert-snakeoil.pem + description: Server certificate of remoteAccess, the ssl-cert snakeoil + cloudfall_postgresql_ssl_key_file: + type: path + default: /etc/ssl/private/ssl-cert-snakeoil.key + description: Server key of remoteAccess, the ssl-cert snakeoil + cloudfall_postgresql_hba_marker: + type: str + default: "# {mark} CLOUDFALL MANAGED REMOTE ACCESS" + description: Markers of the remoteAccess block in pg_hba.conf cloudfall_postgresql_metrics_role: type: str default: alloy diff --git a/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml b/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml index 769446e..b92972f 100644 --- a/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml +++ b/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml @@ -12,6 +12,58 @@ PostgreSQL deployment requires its declared Debian server and a loopback-only bind address +- name: Refuse remote access that is not narrowed to declared sources + ansible.builtin.assert: + that: + - cloudfall_postgresql_remote_access.listenAddress | length > 0 + - >- + cloudfall_postgresql_remote_access.listenAddress + not in ['*', '0.0.0.0', '::', 'localhost'] + - "',' not in cloudfall_postgresql_remote_access.listenAddress" + - "\"'\" not in cloudfall_postgresql_remote_access.listenAddress" + - cloudfall_postgresql_remote_access.allowedSources | length > 0 + - >- + cloudfall_postgresql_remote_access.allowedSources + | select('in', ['0.0.0.0/0', '::/0']) + | list + | length == 0 + - >- + cloudfall_postgresql_service.postgresql.databases + | selectattr('passwordRef', 'defined') + | list + | length > 0 + # pg_hba.conf reads these bare words as keywords, not names: a database + # named all would open every database, an owner named all every role. + - >- + cloudfall_postgresql_service.postgresql.databases + | selectattr('passwordRef', 'defined') + | map(attribute='name') + | select('in', cloudfall_postgresql_hba_keywords) + | list + | length == 0 + - >- + cloudfall_postgresql_service.postgresql.databases + | selectattr('passwordRef', 'defined') + | map(attribute='owner') + | select('in', cloudfall_postgresql_hba_keywords) + | list + | length == 0 + fail_msg: >- + remoteAccess of service {{ cloudfall_postgresql_service.id }} needs one + listen address that is not a wildcard, at least one source that is not + 0.0.0.0/0 or ::/0, and a database with an owner, and no owned database + or owner named {{ cloudfall_postgresql_hba_keywords | join(', ') }} + vars: + cloudfall_postgresql_remote_access: >- + {{ cloudfall_postgresql_service.postgresql.remoteAccess }} + cloudfall_postgresql_hba_keywords: + - all + - sameuser + - samerole + - samegroup + - replication + when: cloudfall_postgresql_service.postgresql.remoteAccess is defined + - name: Require a staged password for every owner role ansible.builtin.assert: that: @@ -91,6 +143,26 @@ cache_valid_time: 3600 policy_rc_d: 101 +- name: Install the snakeoil certificate pair for remote access + ansible.builtin.apt: + name: ssl-cert + state: present + update_cache: true + cache_valid_time: 3600 + when: cloudfall_postgresql_service.postgresql.remoteAccess is defined + +- name: Let PostgreSQL read the snakeoil key + ansible.builtin.user: + name: postgres + groups: + - ssl-cert + append: true + # The server reads its key at start, so a new group needs a restart. + notify: Restart PostgreSQL + when: cloudfall_postgresql_service.postgresql.remoteAccess is defined + +# listen_addresses and ssl only change at server start: a changed file +# restarts the cluster through the handler, flushed before readiness. - name: Install the managed cluster configuration ansible.builtin.template: src: cloudfall.conf.j2 @@ -132,6 +204,94 @@ until: cloudfall_postgresql_readiness.rc == 0 when: not ansible_check_mode +# The block is written with remoteAccess and removed without it, so an +# earlier opening never lingers; lines outside the markers stay untouched. +- name: Converge the remote access block of pg_hba.conf + vars: + cloudfall_postgresql_hba_path: >- + /etc/postgresql/{{ + cloudfall_postgresql_service.postgresql.majorVersion + }}/main/pg_hba.conf + block: + - name: Write or remove the Cloudfall block in pg_hba.conf + ansible.builtin.blockinfile: + path: "{{ cloudfall_postgresql_hba_path }}" + marker: "{{ cloudfall_postgresql_hba_marker }}" + block: >- + {{ + lookup('ansible.builtin.template', 'pg_hba-remote-access.j2') + if cloudfall_postgresql_service.postgresql.remoteAccess is defined + else '' + }} + state: >- + {{ + 'present' + if cloudfall_postgresql_service.postgresql.remoteAccess is defined + else 'absent' + }} + register: cloudfall_postgresql_hba + + # pg_hba_file_rules parses the file on disk; reload only a file it reads. + - name: Refuse a pg_hba.conf PostgreSQL cannot read + ansible.builtin.command: + argv: + - /usr/bin/psql + - --port + - "{{ cloudfall_postgresql_service.bind.port }}" + - --tuples-only + - --no-align + - --command + - >- + SELECT line_number || ': ' || error + FROM pg_hba_file_rules WHERE error IS NOT NULL + become: true + become_user: postgres + register: cloudfall_postgresql_hba_errors + changed_when: false + failed_when: >- + cloudfall_postgresql_hba_errors.rc != 0 + or cloudfall_postgresql_hba_errors.stdout_lines | length > 0 + when: + - not ansible_check_mode + - cloudfall_postgresql_hba is changed + + - name: Reload PostgreSQL to apply pg_hba.conf + ansible.builtin.command: + argv: + - /usr/bin/psql + - --port + - "{{ cloudfall_postgresql_service.bind.port }}" + - --tuples-only + - --no-align + - --command + - SELECT pg_reload_conf() + become: true + become_user: postgres + register: cloudfall_postgresql_hba_reload + changed_when: true + failed_when: >- + cloudfall_postgresql_hba_reload.rc != 0 + or cloudfall_postgresql_hba_reload.stdout | trim != 't' + when: + - not ansible_check_mode + - cloudfall_postgresql_hba is changed + rescue: + - name: Close remote access when pg_hba.conf could not be applied + ansible.builtin.blockinfile: + path: "{{ cloudfall_postgresql_hba_path }}" + marker: "{{ cloudfall_postgresql_hba_marker }}" + state: absent + + - name: Stop on a pg_hba.conf that was not applied + ansible.builtin.fail: + msg: >- + pg_hba.conf of service {{ cloudfall_postgresql_service.id }} was not + reloaded and its Cloudfall block was removed: + {{ + cloudfall_postgresql_hba_errors.stdout_lines | default([]) + | join('; ') + }} + - name: Read existing PostgreSQL roles ansible.builtin.command: argv: diff --git a/engine/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 b/engine/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 index 3bfcf99..97935f7 100644 --- a/engine/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 +++ b/engine/ansible/roles/cloudfall_postgresql/templates/cloudfall.conf.j2 @@ -1,3 +1,10 @@ # Managed by Cloudfall. Local changes will be overwritten. +{% if cloudfall_postgresql_service.postgresql.remoteAccess is defined %} +listen_addresses = '{{ cloudfall_postgresql_service.bind.address }},{{ cloudfall_postgresql_service.postgresql.remoteAccess.listenAddress }}' +ssl = on +ssl_cert_file = '{{ cloudfall_postgresql_ssl_cert_file }}' +ssl_key_file = '{{ cloudfall_postgresql_ssl_key_file }}' +{% else %} listen_addresses = '{{ cloudfall_postgresql_service.bind.address }}' +{% endif %} port = {{ cloudfall_postgresql_service.bind.port }} diff --git a/engine/ansible/roles/cloudfall_postgresql/templates/pg_hba-remote-access.j2 b/engine/ansible/roles/cloudfall_postgresql/templates/pg_hba-remote-access.j2 new file mode 100644 index 0000000..40bd070 --- /dev/null +++ b/engine/ansible/roles/cloudfall_postgresql/templates/pg_hba-remote-access.j2 @@ -0,0 +1,5 @@ +{% for database in cloudfall_postgresql_service.postgresql.databases if database.passwordRef is defined %} +{% for source in cloudfall_postgresql_service.postgresql.remoteAccess.allowedSources %} +hostssl {{ database.name }} {{ database.owner }} {{ source }} scram-sha-256 +{% endfor %} +{% endfor %} diff --git a/engine/src/cloudfall_engine/ansible_inventory.py b/engine/src/cloudfall_engine/ansible_inventory.py index 0ceee80..4d0ce78 100644 --- a/engine/src/cloudfall_engine/ansible_inventory.py +++ b/engine/src/cloudfall_engine/ansible_inventory.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING +from cloudfall.domain import NetworkProtocol from cloudfall.inventory import firewall_rules_for_server if TYPE_CHECKING: @@ -18,6 +19,7 @@ PlatformInventory, ServerInventory, ServerTypeInventory, + ServiceInventory, ) @@ -117,7 +119,11 @@ def _host_variables(inventory: PlatformInventory) -> dict[str, object]: if key.environment == server.environment and key.is_active ], **_host_infrastructure(server), - **_host_firewall(server, server_types_by_id[server.server_type_id]), + **_host_firewall( + server, + server_types_by_id[server.server_type_id], + inventory.services, + ), **_host_logging(server, inventory.logging_stacks, inventory.alert_rules), "cloudfall_server_type": server_types_by_id[ server.server_type_id @@ -162,19 +168,63 @@ def _host_infrastructure(server: ServerInventory) -> dict[str, object]: def _host_firewall( - server: ServerInventory, server_type: ServerTypeInventory + server: ServerInventory, + server_type: ServerTypeInventory, + services: tuple[ServiceInventory, ...], ) -> dict[str, object]: + remote_access = _remote_access_rules(server, services) if server_type.firewall is None: + if remote_access: + message = ( + "PostgreSQL remoteAccess needs a firewall on its server: " + f"{server.resource_id.value}" + ) + raise ValueError(message) return {} rules = firewall_rules_for_server(server_type.firewall, server.ssh_port) return { "cloudfall_firewall": { "policy": server_type.firewall.policy.value, - "allowedInbound": [rule.as_dict() for rule in rules], + "allowedInbound": [ + *(rule.as_dict() for rule in rules), + *remote_access, + ], } } +def _remote_access_rules( + server: ServerInventory, services: tuple[ServiceInventory, ...] +) -> list[dict[str, object]]: + """Return one source-restricted inbound entry per remotely reachable service. + + The entry carries ``sources``, so the firewall opens the service port to + the declared networks only; a service without ``remoteAccess`` adds none. + """ + rules: list[dict[str, object]] = [] + for service in services: + if service.server_id != server.resource_id or service.postgresql is None: + continue + remote_access = service.postgresql.remote_access + if remote_access is None: + continue + if any(source.is_unrestricted for source in remote_access.allowed_sources): + message = ( + "PostgreSQL remoteAccess refuses a source of every address: " + f"{service.resource_id.value}" + ) + raise ValueError(message) + rules.append( + { + "port": service.bind.port.value, + "protocol": NetworkProtocol.TCP.value, + "sources": [source.value for source in remote_access.allowed_sources], + "description": f"{service.resource_id.value} remoteAccess", + } + ) + return rules + + def _host_logging( server: ServerInventory, logging_stacks: tuple[LoggingStackInventory, ...], diff --git a/engine/tests/test_postgresql_owner_role.py b/engine/tests/test_postgresql_owner_role.py index f942f88..2f2a807 100644 --- a/engine/tests/test_postgresql_owner_role.py +++ b/engine/tests/test_postgresql_owner_role.py @@ -406,8 +406,9 @@ def test_the_role_argument_spec_takes_an_owned_database() -> None: assert "required" not in database["application"] assert database["passwordRef"]["options"]["key"]["required"] is True - # Remote access is #123's: until it converges, the spec refuses it. - assert "remoteAccess" not in options["cloudfall_postgresql_service"]["options"][ - "postgresql" - ]["options"] + remote_access = options["cloudfall_postgresql_service"]["options"]["postgresql"][ + "options" + ]["remoteAccess"]["options"] + assert remote_access["listenAddress"]["required"] is True + assert remote_access["allowedSources"]["elements"] == "str" assert options["cloudfall_postgresql_password_files"]["type"] == "dict" diff --git a/engine/tests/test_postgresql_remote_access.py b/engine/tests/test_postgresql_remote_access.py new file mode 100644 index 0000000..fe4d395 --- /dev/null +++ b/engine/tests/test_postgresql_remote_access.py @@ -0,0 +1,586 @@ +"""A PostgreSQL service opened to declared sources over TLS (S-006-8, S-006-10). + +The service's files and the host's nftables ruleset are rendered from a +validated project with the templates Ansible uses, the role's decisions are +evaluated with Ansible's own templating, and the ``pg_hba.conf`` tasks run for +real on localhost against a copy of Debian's file with a stand-in for ``psql``. +S-006-9 (``psql`` across two hosts) is left for the live proving run. +""" + +from __future__ import annotations + +import dataclasses +import shutil +import subprocess +import sys +from pathlib import Path +from typing import cast + +import pytest +import yaml +from ansible.parsing.dataloader import DataLoader +from ansible.template import Templar, trust_as_template +from cloudfall.domain import IpNetworkCidr +from cloudfall.inventory import PlatformInventory +from cloudfall.validation import validate_config +from cloudfall_engine.ansible_inventory import render_ansible_inventory +from jinja2 import Environment, FileSystemLoader, StrictUndefined + +ROOT = Path(__file__).parents[2] +EXAMPLES = ROOT / "config" / "examples" +SCHEMAS = ROOT / "config" / "schemas" / "v1" +ROLES = ROOT / "engine" / "ansible" / "roles" +POSTGRESQL = ROLES / "cloudfall_postgresql" +FIREWALL = ROLES / "cloudfall_firewall" +REMOTE_ACCESS: dict[str, object] = { + "listenAddress": "10.0.0.2", + "allowedSources": ["10.0.0.0/24"], +} +BILLING: dict[str, object] = { + "name": "billing", + "owner": "billing", + "passwordRef": { + "environment": "production", + "path": "/billing/postgresql", + "key": "BILLING_PASSWORD", + }, +} +HBA_HEAD = """\ +# Database administrative login by Unix domain socket +local all postgres peer + +# TYPE DATABASE USER ADDRESS METHOD +local all all peer +host all all 127.0.0.1/32 scram-sha-256 +host all all ::1/128 scram-sha-256 +""" +HBA_TAIL = """\ +local replication all peer +host replication all 127.0.0.1/32 scram-sha-256 +""" +DEBIAN_HBA = HBA_HEAD + HBA_TAIL + + +def _project( + tmp_path: Path, + remote_access: dict[str, object] | None, + databases: list[dict[str, object]] | None = None, +) -> Path: + """Copy the example project and give postgresql-main an owned database.""" + project = tmp_path / "project" + if not project.exists(): + shutil.copytree(EXAMPLES, project) + path = project / "services" / "postgresql-main.yaml" + example = EXAMPLES / "services" / "postgresql-main.yaml" + service = yaml.safe_load(example.read_text(encoding="utf-8")) + postgresql = service["spec"]["postgresql"] + if databases is None: + databases = [*postgresql["databases"], BILLING] + postgresql["databases"] = databases + if remote_access is not None: + postgresql["remoteAccess"] = remote_access + path.write_text(yaml.safe_dump(service, sort_keys=False), encoding="utf-8") + return project + + +def _inventory(project: Path) -> PlatformInventory: + return PlatformInventory.from_state(validate_config(project, SCHEMAS)) + + +def _host(inventory: PlatformInventory, host: str = "h1") -> dict[str, object]: + document = render_ansible_inventory(inventory) + children = cast("dict[str, dict[str, object]]", document["all"])["children"] + servers = cast("dict[str, dict[str, dict[str, object]]]", children) + return cast("dict[str, object]", servers["cloudfall_servers"]["hosts"][host]) + + +def _service(host: dict[str, object]) -> dict[str, object]: + services = cast("list[dict[str, object]]", host["cloudfall_services"]) + return next(service for service in services if service["id"] == "postgresql-main") + + +def _environment(role: Path) -> Environment: + """Jinja with the whitespace rules Ansible's template module applies.""" + return Environment( + loader=FileSystemLoader(role / "templates"), + undefined=StrictUndefined, + autoescape=False, # noqa: S701 - configuration files are not HTML. + keep_trailing_newline=True, + trim_blocks=True, + ) + + +def _defaults() -> dict[str, object]: + defaults = yaml.safe_load( + (POSTGRESQL / "defaults" / "main.yml").read_text(encoding="utf-8") + ) + assert isinstance(defaults, dict) + return defaults + + +def _postgresql_file(template: str, service: dict[str, object]) -> str: + return ( + _environment(POSTGRESQL) + .get_template(template) + .render(cloudfall_postgresql_service=service, **_defaults()) + ) + + +def _nftables(host: dict[str, object]) -> list[str]: + rendered = ( + _environment(FIREWALL) + .get_template("nftables.conf.j2") + .render(cloudfall_firewall_ruleset=host["cloudfall_firewall"]) + ) + return [line.strip() for line in rendered.splitlines()] + + +def _port_rules(lines: list[str], port: int) -> list[str]: + return [line for line in lines if f"dport {port} " in line] + + +# proves: S-006-8 +def test_s006_8_remote_access_renders_listener_tls_hba_and_firewall( + tmp_path: Path, +) -> None: + host = _host(_inventory(_project(tmp_path, REMOTE_ACCESS))) + service = _service(host) + + conf = _postgresql_file("cloudfall.conf.j2", service).splitlines() + hba = _postgresql_file("pg_hba-remote-access.j2", service).splitlines() + rules = _port_rules(_nftables(host), 5432) + + assert "listen_addresses = '127.0.0.1,10.0.0.2'" in conf + assert "ssl = on" in conf + assert "ssl_cert_file = '/etc/ssl/certs/ssl-cert-snakeoil.pem'" in conf + assert "ssl_key_file = '/etc/ssl/private/ssl-cert-snakeoil.key'" in conf + # postgresql-main's application database (crm) stays socket-only. + assert hba == ["hostssl billing billing 10.0.0.0/24 scram-sha-256"] + assert rules == [ + 'ip saddr 10.0.0.0/24 tcp dport 5432 accept comment ' + '"postgresql-main remoteAccess"' + ] + assert all(rule.startswith("ip saddr ") for rule in rules) + + +# proves: S-006-8 +def test_s006_8_the_firewall_entry_carries_its_sources(tmp_path: Path) -> None: + host = _host(_inventory(_project(tmp_path, REMOTE_ACCESS))) + firewall = cast("dict[str, list[dict[str, object]]]", host["cloudfall_firewall"]) + + assert firewall["allowedInbound"][-1] == { + "port": 5432, + "protocol": "tcp", + "sources": ["10.0.0.0/24"], + "description": "postgresql-main remoteAccess", + } + unrestricted = [ + rule for rule in firewall["allowedInbound"] if "sources" not in rule + ] + assert [rule["port"] for rule in unrestricted] == [22, 80, 443] + + +def test_one_rule_and_one_hba_line_per_source_and_owned_database( + tmp_path: Path, +) -> None: + sources: dict[str, object] = { + "listenAddress": "10.0.0.2", + "allowedSources": ["10.0.0.0/24", "2001:db8::/64"], + } + reports = {**BILLING, "name": "reports"} + project = _project(tmp_path, sources, databases=[BILLING, reports]) + host = _host(_inventory(project)) + + hba = _postgresql_file("pg_hba-remote-access.j2", _service(host)).splitlines() + rules = _port_rules(_nftables(host), 5432) + + assert hba == [ + "hostssl billing billing 10.0.0.0/24 scram-sha-256", + "hostssl billing billing 2001:db8::/64 scram-sha-256", + "hostssl reports billing 10.0.0.0/24 scram-sha-256", + "hostssl reports billing 2001:db8::/64 scram-sha-256", + ] + assert [rule.split(" accept")[0] for rule in rules] == [ + "ip saddr 10.0.0.0/24 tcp dport 5432", + "ip6 saddr 2001:db8::/64 tcp dport 5432", + ] + + +def test_an_ipv6_listen_address_joins_the_loopback(tmp_path: Path) -> None: + access: dict[str, object] = { + "listenAddress": "2001:db8::2", + "allowedSources": ["2001:db8::/64"], + } + host = _host(_inventory(_project(tmp_path, access))) + + conf = _postgresql_file("cloudfall.conf.j2", _service(host)).splitlines() + + assert "listen_addresses = '127.0.0.1,2001:db8::2'" in conf + + +# proves: S-006-10 +def test_s006_10_without_remote_access_the_service_renders_closed( + tmp_path: Path, +) -> None: + project = _project(tmp_path, REMOTE_ACCESS) + _project(tmp_path, None) + host = _host(_inventory(project)) + service = _service(host) + + conf = _postgresql_file("cloudfall.conf.j2", service).splitlines() + + assert conf == [ + "# Managed by Cloudfall. Local changes will be overwritten.", + "listen_addresses = '127.0.0.1'", + "port = 5432", + ] + assert _port_rules(_nftables(host), 5432) == [] + hba = next( + task + for task in _block_tasks() + if task["name"] == "Write or remove the Cloudfall block in pg_hba.conf" + ) + state = cast("dict[str, str]", hba["ansible.builtin.blockinfile"])["state"] + templar = Templar( + loader=DataLoader(), variables={"cloudfall_postgresql_service": service} + ) + assert templar.template(trust_as_template(state)) == "absent" + + +def test_the_renderer_refuses_a_source_of_every_address(tmp_path: Path) -> None: + inventory = _inventory(_project(tmp_path, REMOTE_ACCESS)) + services = [] + for service in inventory.services: + if service.postgresql is not None and service.postgresql.remote_access: + open_access = dataclasses.replace( + service.postgresql.remote_access, + allowed_sources=(IpNetworkCidr("0.0.0.0/0"),), + ) + service = dataclasses.replace( # noqa: PLW2901 - rebuilt in place. + service, + postgresql=dataclasses.replace( + service.postgresql, remote_access=open_access + ), + ) + services.append(service) + + with pytest.raises(ValueError, match="every address"): + render_ansible_inventory( + dataclasses.replace(inventory, services=tuple(services)) + ) + + +def _main_tasks() -> list[dict[str, object]]: + tasks = DataLoader().load_from_file(str(POSTGRESQL / "tasks" / "main.yml")) + assert isinstance(tasks, list) + return tasks + + +def _block_tasks() -> list[dict[str, object]]: + block = next( + task + for task in _main_tasks() + if task["name"] == "Converge the remote access block of pg_hba.conf" + ) + return cast("list[dict[str, object]]", block["block"]) + + +def _holds(expressions: list[str], variables: dict[str, object]) -> bool: + templar = Templar(loader=DataLoader(), variables=variables) + return all( + templar.evaluate_conditional(trust_as_template(expression)) + for expression in expressions + ) + + +def _remote_access_guard() -> tuple[list[str], dict[str, object]]: + task = next( + task + for task in _main_tasks() + if task["name"] + == "Refuse remote access that is not narrowed to declared sources" + ) + that = cast("dict[str, list[str]]", task["ansible.builtin.assert"])["that"] + return that, cast("dict[str, object]", task["vars"]) + + +def _guard_service( + access: dict[str, object], databases: list[dict[str, object]] | None = None +) -> dict[str, object]: + return { + "id": "postgresql-main", + "postgresql": {"databases": databases or [BILLING], "remoteAccess": access}, + } + + +def _guard_holds(service: dict[str, object]) -> bool: + """Evaluate the remote access guard with its task vars templated.""" + that, task_vars = _remote_access_guard() + variables: dict[str, object] = {"cloudfall_postgresql_service": service} + templar = Templar(loader=DataLoader(), variables=variables) + for name, value in task_vars.items(): + variables[name] = ( + templar.template(trust_as_template(value)) + if isinstance(value, str) + else value + ) + return _holds(that, variables) + + +@pytest.mark.parametrize( + "access", + [ + {"listenAddress": "*", "allowedSources": ["10.0.0.0/24"]}, + {"listenAddress": "0.0.0.0", "allowedSources": ["10.0.0.0/24"]}, # noqa: S104 + {"listenAddress": "::", "allowedSources": ["10.0.0.0/24"]}, + {"listenAddress": "10.0.0.2,0.0.0.0", "allowedSources": ["10.0.0.0/24"]}, + {"listenAddress": "10.0.0.2", "allowedSources": []}, + {"listenAddress": "10.0.0.2", "allowedSources": ["0.0.0.0/0"]}, + {"listenAddress": "10.0.0.2", "allowedSources": ["10.0.0.0/24", "::/0"]}, + ], + ids=[ + "star", + "any-ipv4", + "any-ipv6", + "smuggled-list", + "no-source", + "every-ipv4", + "every-ipv6", + ], +) +def test_the_role_refuses_remote_access_open_to_everyone( + access: dict[str, object], +) -> None: + assert not _guard_holds(_guard_service(access)) + + +def test_the_role_accepts_remote_access_narrowed_to_sources() -> None: + assert _guard_holds(_guard_service(REMOTE_ACCESS)) + + +@pytest.mark.parametrize( + ("name", "owner"), + [ + ("all", "billing"), + ("billing", "all"), + ("sameuser", "billing"), + ("samerole", "billing"), + ("replication", "billing"), + ], +) +def test_the_role_refuses_an_owned_name_pg_hba_reads_as_a_keyword( + name: str, owner: str +) -> None: + # "hostssl all billing ..." would let billing reach every database, + # application databases included; "hostssl billing all ..." every role. + database = {**BILLING, "name": name, "owner": owner} + + assert not _guard_holds(_guard_service(REMOTE_ACCESS, [database])) + + +def _firewall_asserts() -> dict[str, list[str]]: + tasks = DataLoader().load_from_file(str(FIREWALL / "tasks" / "main.yml")) + assert isinstance(tasks, list) + return { + task["name"]: task["ansible.builtin.assert"]["that"] + for task in tasks + if "ansible.builtin.assert" in task + } + + +@pytest.mark.parametrize("sources", [[], ["0.0.0.0/0"], ["10.0.0.0/24", "::/0"]]) +def test_the_firewall_role_refuses_a_rule_restricted_to_everyone( + sources: list[str], +) -> None: + that = _firewall_asserts()[ + "Refuse a source-restricted rule that opens to every address" + ] + ruleset = { + "policy": "default-deny", + "allowedInbound": [ + {"port": 22, "protocol": "tcp"}, + {"port": 5432, "protocol": "tcp", "sources": sources}, + ], + } + + assert not _holds(that, {"cloudfall_firewall_ruleset": ruleset}) + + +def test_a_source_restricted_rule_does_not_count_as_the_ssh_port() -> None: + that = _firewall_asserts()[ + "Refuse a ruleset that would block the control connection" + ] + restricted = { + "policy": "default-deny", + "allowedInbound": [ + {"port": 22, "protocol": "tcp", "sources": ["10.0.0.0/24"]}, + ], + } + open_ssh = { + "policy": "default-deny", + "allowedInbound": [ + {"port": 22, "protocol": "tcp"}, + {"port": 5432, "protocol": "tcp", "sources": ["10.0.0.0/24"]}, + ], + } + + assert not _holds(that, {"cloudfall_firewall_ruleset": restricted}) + assert _holds(that, {"cloudfall_firewall_ruleset": open_ssh}) + + +FAKE_PSQL = """\ +#!{python} +import pathlib, sys +log = pathlib.Path({log!r}) +command = sys.argv[sys.argv.index("--command") + 1] +if "pg_hba_file_rules" in command: + errors = log.joinpath("errors") + if errors.exists(): + print(errors.read_text(), end="") +elif "pg_reload_conf" in command: + with log.joinpath("reloads").open("a") as reloads: + reloads.write("reload\\n") + print("t") +else: + sys.exit("unexpected psql command: " + command) +""" + + +def _hba_play( + tmp_path: Path, service: dict[str, object], hba: Path, log: Path +) -> Path: + """Write the role's pg_hba.conf block as a local play with a stand-in psql.""" + psql = tmp_path / "psql" + psql.write_text(FAKE_PSQL.format(python=sys.executable, log=str(log)), "utf-8") + psql.chmod(0o700) + # Plain YAML, not Ansible's loader: the tasks are written out again. + tasks = yaml.safe_load((POSTGRESQL / "tasks" / "main.yml").read_text("utf-8")) + block = next( + task + for task in tasks + if task["name"] == "Converge the remote access block of pg_hba.conf" + ) + block["vars"]["cloudfall_postgresql_hba_path"] = str(hba) + for task in block["block"]: + command = task.get("ansible.builtin.command") + if command is not None: + command["argv"][0] = str(psql) + del task["become"], task["become_user"] + templates = tmp_path / "templates" + templates.mkdir(exist_ok=True) + shutil.copy(POSTGRESQL / "templates" / "pg_hba-remote-access.j2", templates) + play = [ + { + "name": "Replay the pg_hba.conf tasks of cloudfall_postgresql", + "hosts": "localhost", + "connection": "local", + "gather_facts": False, + "vars": { + "cloudfall_postgresql_service": service, + "cloudfall_postgresql_hba_marker": _defaults()[ + "cloudfall_postgresql_hba_marker" + ], + }, + "tasks": [block], + } + ] + playbook = tmp_path / "pg-hba.yml" + playbook.write_text(yaml.safe_dump(play), encoding="utf-8") + return playbook + + +def _converge_hba( + tmp_path: Path, service: dict[str, object], hba: Path, log: Path +) -> subprocess.CompletedProcess[str]: + playbook = _hba_play(tmp_path, service, hba, log) + binary = shutil.which("ansible-playbook") + assert binary is not None + return subprocess.run( # noqa: S603 - fixed binary and arguments. + [binary, "--inventory", "localhost,", str(playbook)], + check=False, + capture_output=True, + text=True, + timeout=300, + ) + + +def _reloads(log: Path) -> int: + reloads = log / "reloads" + return len(reloads.read_text().splitlines()) if reloads.exists() else 0 + + +# proves: S-006-8 +# proves: S-006-10 +def test_s006_8_s006_10_the_hba_block_opens_then_closes_leaving_the_rest( + tmp_path: Path, +) -> None: + project = _project(tmp_path, REMOTE_ACCESS) + opened = _service(_host(_inventory(project))) + _project(tmp_path, None) + closed = _service(_host(_inventory(project))) + log = tmp_path / "psql-log" + log.mkdir() + hba = tmp_path / "pg_hba.conf" + hba.write_text(DEBIAN_HBA, encoding="utf-8") + + first = _converge_hba(tmp_path, opened, hba, log) + after_open = hba.read_text(encoding="utf-8") + again = _converge_hba(tmp_path, opened, hba, log) + reloads_while_open = _reloads(log) + second = _converge_hba(tmp_path, closed, hba, log) + + assert first.returncode == 0, first.stdout + first.stderr + assert again.returncode == 0, again.stdout + again.stderr + assert second.returncode == 0, second.stdout + second.stderr + assert after_open == ( + DEBIAN_HBA + + "# BEGIN CLOUDFALL MANAGED REMOTE ACCESS\n" + + "hostssl billing billing 10.0.0.0/24 scram-sha-256\n" + + "# END CLOUDFALL MANAGED REMOTE ACCESS\n" + ) + # An unchanged block neither rewrites the file nor reloads. + assert reloads_while_open == 1 + assert hba.read_text(encoding="utf-8") == DEBIAN_HBA + assert _reloads(log) == 2 + + +def test_lines_an_operator_adds_after_the_block_survive_its_removal( + tmp_path: Path, +) -> None: + project = _project(tmp_path, None) + closed = _service(_host(_inventory(project))) + log = tmp_path / "psql-log" + log.mkdir() + hba = tmp_path / "pg_hba.conf" + hba.write_text( + HBA_HEAD + + "# BEGIN CLOUDFALL MANAGED REMOTE ACCESS\n" + + "hostssl billing billing 10.0.0.0/24 scram-sha-256\n" + + "# END CLOUDFALL MANAGED REMOTE ACCESS\n" + + HBA_TAIL, + encoding="utf-8", + ) + + done = _converge_hba(tmp_path, closed, hba, log) + + assert done.returncode == 0, done.stdout + done.stderr + assert hba.read_text(encoding="utf-8") == DEBIAN_HBA + + +def test_a_pg_hba_conf_postgresql_cannot_read_is_never_reloaded( + tmp_path: Path, +) -> None: + opened = _service(_host(_inventory(_project(tmp_path, REMOTE_ACCESS)))) + log = tmp_path / "psql-log" + log.mkdir() + (log / "errors").write_text('3: invalid connection type "hostsssl"\n') + hba = tmp_path / "pg_hba.conf" + hba.write_text(DEBIAN_HBA, encoding="utf-8") + + done = _converge_hba(tmp_path, opened, hba, log) + + assert done.returncode != 0 + assert 'invalid connection type \\"hostsssl\\"' in done.stdout + assert _reloads(log) == 0 + # The opening is closed again, so it never takes effect at a later restart. + assert hba.read_text(encoding="utf-8") == DEBIAN_HBA