From 6f021fb3ddaa9f1393af469a09b8034568038060 Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:40:49 +0200 Subject: [PATCH 1/2] Open a PostgreSQL service to declared sources over TLS (S-006-8, 10) A team that runs only its database on Cloudfall needs a way in for an app elsewhere. With spec.postgresql.remoteAccess, cloudfall.conf listens on 127.0.0.1 and the declared address with ssl on (the ssl-cert snakeoil pair; a changed file restarts the cluster), pg_hba.conf gets one hostssl SCRAM line per owned database and source between Cloudfall markers, reloaded only after pg_hba_file_rules reports no error, and the host's nftables ruleset gets one source-restricted rule per CIDR, rendered as an allowedInbound entry with a new optional sources list. Closed stays the default: without remoteAccess the listener is loopback only, the block is removed, and no rule is rendered, so an earlier opening never lingers. Entries without sources render as before (golden files). S-006-9 is left for the live proving run on two hosts. Fixes #123 Co-Authored-By: Claude Opus 5.5 --- changelog.d/123-postgresql-remote-access.md | 3 + docs/specs/006-standalone-postgresql.md | 6 + engine/README.md | 11 +- .../meta/argument_specs.yml | 6 + .../roles/cloudfall_firewall/tasks/main.yml | 23 + .../templates/nftables.conf.j2 | 8 + .../cloudfall_postgresql/defaults/main.yml | 3 + .../meta/argument_specs.yml | 22 + .../roles/cloudfall_postgresql/tasks/main.yml | 137 +++++ .../templates/cloudfall.conf.j2 | 7 + .../templates/pg_hba-remote-access.j2 | 5 + .../src/cloudfall_engine/ansible_inventory.py | 56 +- engine/tests/test_postgresql_owner_role.py | 9 +- engine/tests/test_postgresql_remote_access.py | 567 ++++++++++++++++++ 14 files changed, 855 insertions(+), 8 deletions(-) create mode 100644 changelog.d/123-postgresql-remote-access.md create mode 100644 engine/ansible/roles/cloudfall_postgresql/templates/pg_hba-remote-access.j2 create mode 100644 engine/tests/test_postgresql_remote_access.py 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..68a7259 100644 --- a/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml +++ b/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml @@ -12,6 +12,35 @@ 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 + 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 + vars: + cloudfall_postgresql_remote_access: >- + {{ cloudfall_postgresql_service.postgresql.remoteAccess }} + when: cloudfall_postgresql_service.postgresql.remoteAccess is defined + - name: Require a staged password for every owner role ansible.builtin.assert: that: @@ -91,6 +120,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 +181,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..2d88c6f --- /dev/null +++ b/engine/tests/test_postgresql_remote_access.py @@ -0,0 +1,567 @@ +"""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]) -> dict[str, object]: + return { + "id": "postgresql-main", + "postgresql": {"databases": [BILLING], "remoteAccess": access}, + } + + +@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: + that, task_vars = _remote_access_guard() + service = _guard_service(access) + variables: dict[str, object] = {"cloudfall_postgresql_service": service} + alias = Templar(loader=DataLoader(), variables=variables).template( + trust_as_template(str(task_vars["cloudfall_postgresql_remote_access"])) + ) + + assert not _holds( + that, {**variables, "cloudfall_postgresql_remote_access": alias} + ) + + +def test_the_role_accepts_remote_access_narrowed_to_sources() -> None: + that, task_vars = _remote_access_guard() + variables: dict[str, object] = { + "cloudfall_postgresql_service": _guard_service(REMOTE_ACCESS) + } + alias = Templar(loader=DataLoader(), variables=variables).template( + trust_as_template(str(task_vars["cloudfall_postgresql_remote_access"])) + ) + + assert _holds(that, {**variables, "cloudfall_postgresql_remote_access": alias}) + + +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 From 49a9d1dbf46d82e890afa18fcfcef20992f844a6 Mon Sep 17 00:00:00 2001 From: "shipmill-romamo[bot]" <338557544+shipmill-romamo[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:52:57 +0200 Subject: [PATCH 2/2] Refuse an owned database or owner pg_hba.conf reads as a keyword A remoteAccess block line takes the database and owner bare, so a database named all (or sameuser, samerole, samegroup, replication) or an owner named all would open every database or every role to the declared sources, application databases included. The role's remote access guard now refuses those names before anything is written. Co-Authored-By: Claude Opus 5.5 --- .../roles/cloudfall_postgresql/tasks/main.yml | 25 +++++++- engine/tests/test_postgresql_remote_access.py | 59 ++++++++++++------- 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml b/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml index 68a7259..b92972f 100644 --- a/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml +++ b/engine/ansible/roles/cloudfall_postgresql/tasks/main.yml @@ -32,13 +32,36 @@ | 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 + 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 diff --git a/engine/tests/test_postgresql_remote_access.py b/engine/tests/test_postgresql_remote_access.py index 2d88c6f..fe4d395 100644 --- a/engine/tests/test_postgresql_remote_access.py +++ b/engine/tests/test_postgresql_remote_access.py @@ -304,13 +304,29 @@ def _remote_access_guard() -> tuple[list[str], dict[str, object]]: return that, cast("dict[str, object]", task["vars"]) -def _guard_service(access: dict[str, object]) -> dict[str, object]: +def _guard_service( + access: dict[str, object], databases: list[dict[str, object]] | None = None +) -> dict[str, object]: return { "id": "postgresql-main", - "postgresql": {"databases": [BILLING], "remoteAccess": access}, + "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", [ @@ -335,28 +351,31 @@ def _guard_service(access: dict[str, object]) -> dict[str, object]: def test_the_role_refuses_remote_access_open_to_everyone( access: dict[str, object], ) -> None: - that, task_vars = _remote_access_guard() - service = _guard_service(access) - variables: dict[str, object] = {"cloudfall_postgresql_service": service} - alias = Templar(loader=DataLoader(), variables=variables).template( - trust_as_template(str(task_vars["cloudfall_postgresql_remote_access"])) - ) - - assert not _holds( - that, {**variables, "cloudfall_postgresql_remote_access": alias} - ) + assert not _guard_holds(_guard_service(access)) def test_the_role_accepts_remote_access_narrowed_to_sources() -> None: - that, task_vars = _remote_access_guard() - variables: dict[str, object] = { - "cloudfall_postgresql_service": _guard_service(REMOTE_ACCESS) - } - alias = Templar(loader=DataLoader(), variables=variables).template( - trust_as_template(str(task_vars["cloudfall_postgresql_remote_access"])) - ) + 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 _holds(that, {**variables, "cloudfall_postgresql_remote_access": alias}) + assert not _guard_holds(_guard_service(REMOTE_ACCESS, [database])) def _firewall_asserts() -> dict[str, list[str]]: