diff --git a/ansible/files/postgresql_config/postgresql.service b/ansible/files/postgresql_config/postgresql.service index efb52f18eb..68c37140bd 100644 --- a/ansible/files/postgresql_config/postgresql.service +++ b/ansible/files/postgresql_config/postgresql.service @@ -24,6 +24,7 @@ LimitNOFILE=16384 {% if supabase_internal is defined %} ReadOnlyPaths=/etc InaccessiblePaths=/root -/var/lib/supabase -/var/lib/supabase-admin-agent -/var/cache/supabase-admin-agent -/opt/saltstack -/etc/salt +AppArmorProfile=-sbpostgres {% endif %} [Install] WantedBy=multi-user.target diff --git a/ansible/files/postgresql_config/sbpostgres_apparmor b/ansible/files/postgresql_config/sbpostgres_apparmor new file mode 100644 index 0000000000..d5cb1de1e3 --- /dev/null +++ b/ansible/files/postgresql_config/sbpostgres_apparmor @@ -0,0 +1,202 @@ +#include + +# ============================================ +# POSTGRES PROFILE - With Supabase specific restrictions +# ============================================ +profile sbpostgres flags=(attach_disconnected) { + #include + #include + #include + + # Parent binary access to self + /var/lib/postgresql/.nix-profile/bin/postgres mr, + /nix/store/*/bin/.postgres-wrapped mr, + /nix/store/*/bin/.postgres-wrapped ix, + + # Wide open permissions for parent - to be tuned + # some of this is managed via systemd sandboxing already + /** rw, + owner /** m, + / wr, + /nix/store/** m, + /etc r, + /usr/local r, + # lock permission for pgroonga + /data/pgdata/pgroonga.log k, + + # Systemd notification socket (for Type=notify services) + /run/systemd/notify w, + /{,var/}run/systemd/notify w, + + # Allow disconnected paths (for mount namespaces) + @{run}/systemd/notify w, + /run/systemd/notify w, + + # Full network access + network inet stream, + network inet6 stream, + network unix stream, + + # All capabilities + capability, + + # Libraries + /lib/aarch64-linux-gnu/*.so* mr, + /usr/lib/aarch64-linux-gnu/*.so* mr, + /nix/store/*/lib/*.so* mr, + + # safe commands + /usr/bin/true ix, + /usr/bin/false ix, + # kill needed for postgresql to reload itself + /bin/kill ix, + + # When parent executes shell, transition to restricted profile + # This accounts for popen, which postgres uses for any child process + # Px = discrete profile transition (child gets different profile) + /bin/sh Pix -> postgres_shell, + /bin/bash Pix -> postgres_shell, + /bin/dash Pix -> postgres_shell, + /usr/bin/sh Pix -> postgres_shell, + /usr/bin/dash Pix -> postgres_shell, + /nix/store/*/bin/bash Pix -> postgres_shell, + /usr/lib/postgresql/bin/pgsodium_getkey.sh Pix -> postgres_shell, + /usr/bin/cat Pix -> postgres_shell, + /bin/cat Pix -> postgres_shell, + /usr/bin/admin-mgr Pix -> postgres_shell, + /nix/store/*/bin/wal-g-2 Pix -> postgres_shell, + /nix/store/*/bin/pgbackrest Pix -> postgres_shell, + /nix/store/*/bin/pg_dump ix, + /usr/bin/pgbackrest Pix -> pgbackrest_shell, + /usr/bin/nix Pix -> pgbackrest_shell, + /usr/bin/sudo Pix -> pgbackrest_shell, + + profile postgres_shell { + #include + + /usr/bin/* m, + /bin/* m, + + # Shell binary + /usr/bin/sh mr, + /usr/bin/bash mr, + /usr/bin/dash mr, + /nix/store/*/bin/bash mr, + + # Only allow specific commands to be executed from shell + /usr/bin/cat ix, + + # pgbackrest needs nix and sudo + /usr/bin/nix ix, + /usr/bin/sudo ix, + + # backup things + /usr/lib/postgresql/bin/pgsodium_getkey.sh ix, + /usr/bin/admin-mgr ix, + /nix/store/*/bin/.postgres-wrapped ix, + /nix/store/*/bin/wal-g-2 ix, + /nix/store/*/bin/pgbackrest ix, + /nix/store/*/bin/pg_dump ix, + + # file path permissions + /** r, + /data/wal_fetch_dir/ rw, + /tmp/wal_fetch_dir/ rw, + /var/lib/postgresql/data rw, + /data/pgdata rw, + /data/latest-lsn-checkpoint-v2 rw, + /data/previous-lsn-checkpoint-v2 rw, + /var/lib/postgresql/data/recovery.signal rw, + /var/lib/postgresql/data/standby.signal rw, + /var/lib/pgbackrest rw, + /etc/pgbackrest/conf.d/** rw, + /var/spool/pgbackrest/** rw, + /var/log/pgbackrest/** rw, + /etc/** r, + /usr/local/** r, + + + deny /var/lib/supabase/** rwx, + deny /var/lib/supabase-admin-agent/** rwx, + deny /var/cache/supabase-admin-agent/** rwx, + deny /opt/saltstack/** rwx, + deny /etc/salt/** rwx, + deny /root wr, + deny /home/** wr, + # wal-g needs access to its own home directory + /home/wal-g/* wr, + + # Libraries + /lib/aarch64-linux-gnu/*.so* mr, + /usr/lib/aarch64-linux-gnu/*.so* mr, + /nix/store/*/lib/*.so* mr, + + # full network access + # could further break this up so that only backup + # tools have network access + network, + + # Block everything else + deny /** x, + } + + profile pgbackrest_shell { + #include + + /usr/bin/* m, + /bin/* m, + + # Shell binary + /usr/bin/sh mr, + /usr/bin/bash mr, + /usr/bin/dash mr, + /nix/store/*/bin/bash mr, + + # pgbackrest needs nix and sudo + /usr/bin/nix ix, + /usr/bin/sudo ix, + /bin/bash ix, + + /usr/bin/admin-mgr ix, + /nix/store/*/bin/.postgres-wrapped ix, + /nix/store/*/bin/pgbackrest ix, + + # file path permissions + /** r, + /data/wal_fetch_dir/ rw, + /tmp/wal_fetch_dir/ rw, + /var/lib/postgresql/data rw, + /data/pgdata rw, + /data/latest-lsn-checkpoint-v2 rw, + /data/previous-lsn-checkpoint-v2 rw, + /var/lib/postgresql/data/recovery.signal rw, + /var/lib/postgresql/data/standby.signal rw, + /var/lib/pgbackrest rw, + /etc/pgbackrest/conf.d/** rw, + /var/spool/pgbackrest/** rw, + /var/log/pgbackrest/** rw, + /etc/** r, + /usr/local/** r, + + deny /var/lib/supabase/** rwx, + deny /var/lib/supabase-admin-agent/** rwx, + deny /var/cache/supabase-admin-agent/** rwx, + deny /opt/saltstack/** rwx, + deny /etc/salt/** rwx, + deny /root wr, + deny /home/** wr, + + # Libraries + /lib/aarch64-linux-gnu/*.so* mr, + /usr/lib/aarch64-linux-gnu/*.so* mr, + /nix/store/*/lib/*.so* mr, + + # full network access + # could further break this up so that only backup + # tools have network access + network, + + # Block everything else + deny /** x, + } +} diff --git a/ansible/tasks/setup-postgres.yml b/ansible/tasks/setup-postgres.yml index be6fa0840c..5584605513 100644 --- a/ansible/tasks/setup-postgres.yml +++ b/ansible/tasks/setup-postgres.yml @@ -286,6 +286,22 @@ when: - (is_psql_oriole or is_psql_17) +- name: copy PG apparmor profile + ansible.builtin.copy: + dest: '/etc/apparmor.d/sbpostgres' + group: 'root' + mode: '0644' + owner: 'root' + src: 'files/postgresql_config/sbpostgres_apparmor' + +- name: parse apparmor sbpostgres profile + ansible.builtin.command: + cmd: '/usr/sbin/apparmor_parser -r /etc/apparmor.d/sbpostgres' + +- name: reload apparmor with sbpostgres profile + ansible.builtin.command: + cmd: '/usr/sbin/aa-enforce sbpostgres' + - name: copy PG and optimizations systemd units ansible.builtin.template: dest: "/etc/systemd/system/{{ systemd_svc_item | basename }}" @@ -376,7 +392,7 @@ dest: '/var/lib/postgresql/.bashrc' line: "{{ lang_item }}" become: true - loop: + loop: - 'export LOCALE_ARCHIVE=/usr/lib/locale/locale-archive' - 'export LANG="en_US.UTF-8"' - 'export LANGUAGE="en_US.UTF-8"' diff --git a/ansible/vars.yml b/ansible/vars.yml index a706b52ad2..1b2f62a4e7 100644 --- a/ansible/vars.yml +++ b/ansible/vars.yml @@ -10,9 +10,9 @@ postgres_major: # Full version strings for each major version postgres_release: - postgresorioledb-17: "17.6.0.063-orioledb" - postgres17: "17.6.1.106" - postgres15: "15.14.1.106" + postgresorioledb-17: "17.6.0.064-orioledb" + postgres17: "17.6.1.107" + postgres15: "15.14.1.107" # Non Postgres Extensions pgbouncer_release: 1.25.1 diff --git a/testinfra/test_ami_nix.py b/testinfra/test_ami_nix.py index d9063e2c2f..a81bddc1d9 100644 --- a/testinfra/test_ami_nix.py +++ b/testinfra/test_ami_nix.py @@ -1,16 +1,17 @@ import base64 -import boto3 import gzip import logging import os -import pytest -import requests import socket -from ec2instanceconnectcli.EC2InstanceConnectLogger import EC2InstanceConnectLogger -from ec2instanceconnectcli.EC2InstanceConnectKey import EC2InstanceConnectKey +from pathlib import Path from time import sleep + +import boto3 import paramiko -from pathlib import Path +import pytest +import requests +from ec2instanceconnectcli.EC2InstanceConnectKey import EC2InstanceConnectKey +from ec2instanceconnectcli.EC2InstanceConnectLogger import EC2InstanceConnectLogger # if EXECUTION_ID is not set, use a default value that includes the user and hostname RUN_ID = os.environ.get( @@ -1069,3 +1070,164 @@ def test_postgrest_read_only_session_attrs(host): print("Warning: Failed to restart PostgreSQL after restoring config") else: print("Warning: Failed to restore PostgreSQL configuration") + + +def test_apparmor_postgresql_service_uses_profile(host): + """Verify the PostgreSQL systemd service is running under the sbpostgres AppArmor profile.""" + result = run_ssh_command( + host["ssh"], "systemctl show postgresql | grep -i apparmor" + ) + assert result["succeeded"], ( + f"Could not find AppArmor info in postgresql service status.\n" + f"stderr: {result['stderr']}" + ) + assert "sbpostgres" in result["stdout"], ( + f"Expected 'sbpostgres' in postgresql AppArmor status but got:\n{result['stdout']}" + ) + + +def test_apparmor_sbpostgres_profile_enforced(host): + """Verify the sbpostgres AppArmor profile is loaded and in enforce mode.""" + import json + + result = run_ssh_command(host["ssh"], "sudo aa-status --json") + assert result["succeeded"], f"aa-status failed: {result['stderr']}" + status = json.loads(result["stdout"]) + enforced = status.get("profiles", {}) + assert "sbpostgres" in enforced, "sbpostgres profile not found in AppArmor" + assert enforced["sbpostgres"] == "enforce", ( + f"sbpostgres profile is not in enforce mode: {enforced['sbpostgres']}" + ) + + +def test_apparmor_blocks_disallowed_shell_commands(host): + """Verify AppArmor's postgres_shell sub-profile blocks execution of + commands not on the allowlist (e.g. /usr/bin/id). + + COPY TO PROGRAM causes postgres to fork /bin/sh, which transitions to the + postgres_shell sub-profile via the 'Pix -> postgres_shell' rule. /usr/bin/id + is not on the allowlist so AppArmor denies the exec, and PostgreSQL surfaces + this as 'command not executable'. + """ + result = run_ssh_command( + host["ssh"], + "sudo -u postgres psql -U supabase_admin -h localhost -d postgres -c \"COPY (SELECT 1) TO PROGRAM '/usr/bin/id';\" 2>&1 || true", + ) + combined = result["stdout"] + result["stderr"] + assert "command not executable" in combined, ( + f"Expected AppArmor to block /usr/bin/id with 'command not executable' " + f"but got:\nstdout: {result['stdout']}\nstderr: {result['stderr']}" + ) + + +def test_apparmor_permits_allowlisted_commands(host): + """Verify allowlisted commands are not blocked by the postgres_shell profile. + + /usr/bin/cat is explicitly listed as 'ix' in postgres_shell with a canonical + path (avoiding the /bin -> /usr/bin symlink issue on Ubuntu 22.04+), and + writes only to the pipe so no file-write permissions are needed. + """ + result = run_ssh_command( + host["ssh"], + "sudo -u postgres psql -U supabase_admin -h localhost -d postgres -c \"COPY (SELECT 1) TO PROGRAM '/usr/bin/cat';\"", + ) + assert result["succeeded"], ( + f"AppArmor unexpectedly blocked /usr/bin/cat.\n" + f"stdout: {result['stdout']}\nstderr: {result['stderr']}" + ) + + +def test_apparmor_allows_basic_sql_and_extensions(host): + """Verify basic SQL and extension availability are unaffected by AppArmor.""" + result = run_ssh_command( + host["ssh"], + "sudo -u postgres psql -U supabase_admin -h localhost -d postgres -c " + "\"SELECT name FROM pg_available_extensions WHERE name IN ('pgcrypto', 'pg_stat_statements') ORDER BY name;\"", + ) + assert result["succeeded"], ( + f"SQL query failed under AppArmor.\nstdout: {result['stdout']}\nstderr: {result['stderr']}" + ) + assert "pgcrypto" in result["stdout"], ( + "pgcrypto extension not available under AppArmor" + ) + assert "pg_stat_statements" in result["stdout"], ( + "pg_stat_statements extension not available under AppArmor" + ) + + +def test_apparmor_allows_pg_dump(host): + """Verify pg_dump executes from postgres_shell under AppArmor. + + /usr/bin/pg_dump is explicitly listed as 'ix' in postgres_shell. + """ + result = run_ssh_command( + host["ssh"], + "sudo -u postgres psql -U supabase_admin -h localhost -d postgres -c " + "\"COPY (SELECT 1) TO PROGRAM '/usr/bin/pg_dump --version';\"", + ) + assert result["succeeded"], ( + f"pg_dump was blocked by AppArmor.\n" + f"stdout: {result['stdout']}\nstderr: {result['stderr']}" + ) + + +def test_apparmor_allows_walg(host): + """Verify wal-g-2 can be executed under the sbpostgres AppArmor profile. + + /nix/store/*/bin/wal-g-2 is listed as 'ix' in postgres_shell. We locate the + binary at runtime since the Nix store hash is not known ahead of time. + """ + find_result = run_ssh_command( + host["ssh"], + "find /nix/store -maxdepth 3 -name 'wal-g-2' -type f 2>/dev/null | head -1", + ) + walg_path = find_result["stdout"].strip() + if not walg_path: + print("wal-g-2 not found in Nix store, skipping") + return + + result = run_ssh_command( + host["ssh"], + f"sudo -u postgres psql -U supabase_admin -h localhost -d postgres -c " + f"\"COPY (SELECT 1) TO PROGRAM '{walg_path} --version';\"", + ) + assert result["succeeded"], ( + f"wal-g-2 was blocked by AppArmor.\n" + f"stdout: {result['stdout']}\nstderr: {result['stderr']}" + ) + + +def test_apparmor_denies_access_to_sensitive_paths(host): + """Verify postgres_shell deny rules block access to sensitive system paths. + + The profile has 'deny /var/lib/supabase/** rwx', 'deny /opt/saltstack/** rwx', + and 'deny /etc/salt/** rwx'. Files are created world-readable so that the + only reason cat fails is AppArmor, not OS file permissions. + """ + denied_paths = [ + "/var/lib/supabase", + "/opt/saltstack", + "/etc/salt", + ] + for base in denied_paths: + run_ssh_command( + host["ssh"], + f"sudo mkdir -p {base} && echo 'restricted' | sudo tee {base}/apparmor_test.txt > /dev/null " + f"&& sudo chmod 644 {base}/apparmor_test.txt", + ) + + for base in denied_paths: + test_file = f"{base}/apparmor_test.txt" + result = run_ssh_command( + host["ssh"], + f"sudo -u postgres psql -U supabase_admin -h localhost -d postgres -c " + f"\"COPY (SELECT 1) TO PROGRAM '/usr/bin/cat {test_file}';\" 2>&1 || true", + ) + combined = result["stdout"] + result["stderr"] + assert ( + "failed" in combined.lower() or "child process exited" in combined.lower() + ), ( + f"Expected AppArmor to deny access to {test_file} but the command appears " + f"to have succeeded.\nstdout: {result['stdout']}\nstderr: {result['stderr']}" + ) + print(f"Confirmed: access to {test_file} denied by AppArmor")