From 3e9019879d05e40d0c70fb33e88f531a76450bf5 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 17 Mar 2026 18:11:14 +0100 Subject: [PATCH 1/9] feat: add apparmor profile --- .../postgresql_config/postgresql.service | 1 + .../postgresql_config/sbpostgres_apparmor | 204 ++++++++++++++++++ ansible/tasks/setup-postgres.yml | 14 +- ansible/vars.yml | 6 +- 4 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 ansible/files/postgresql_config/sbpostgres_apparmor 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..7ddd639644 --- /dev/null +++ b/ansible/files/postgresql_config/sbpostgres_apparmor @@ -0,0 +1,204 @@ +#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, + /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/gzip ix, + /usr/bin/cat ix, + /usr/bin/pg_dump ix, + /bin/echo ix, + /bin/bash ix, + /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, + + # 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..60842975cf 100644 --- a/ansible/tasks/setup-postgres.yml +++ b/ansible/tasks/setup-postgres.yml @@ -286,6 +286,18 @@ when: - (is_psql_oriole or is_psql_17) +- name: copy PG apparmor profile + ansible.builtin.copy: + dest: '/etc/apparmor.d/sbpostgres' + mode: '0644' + owner: 'root' + group: 'root' + src: 'files/postgresql_config/sbpostgres_apparmor' + +- name: reload apparmor with sbpostgres profile + ansible.builtin.command: + cmd: '/usr/sbin/apparmor_parser -r /etc/apparmor.d/sbpostgres && /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 +388,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 8d612b970b..3d8b6a1ef8 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.061-orioledb" - postgres17: "17.6.1.104" - postgres15: "15.14.1.104" + postgresorioledb-17: "17.6.0.062-orioledb" + postgres17: "17.6.1.105" + postgres15: "15.14.1.105" # Non Postgres Extensions pgbouncer_release: 1.25.1 From 459024f449175ef6f49b0fe03842e132def06255 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 17 Mar 2026 18:57:35 +0100 Subject: [PATCH 2/9] fix: split commands --- ansible/tasks/setup-postgres.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ansible/tasks/setup-postgres.yml b/ansible/tasks/setup-postgres.yml index 60842975cf..170a329874 100644 --- a/ansible/tasks/setup-postgres.yml +++ b/ansible/tasks/setup-postgres.yml @@ -294,9 +294,13 @@ group: '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/apparmor_parser -r /etc/apparmor.d/sbpostgres && /usr/sbin/aa-enforce sbpostgres' + cmd: '/usr/sbin/aa-enforce sbpostgres' - name: copy PG and optimizations systemd units ansible.builtin.template: From f732281e3924f66c67b67db98eb3ee732c3cbafb Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Wed, 18 Mar 2026 13:21:56 -0400 Subject: [PATCH 3/9] Update ansible/tasks/setup-postgres.yml --- ansible/tasks/setup-postgres.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ansible/tasks/setup-postgres.yml b/ansible/tasks/setup-postgres.yml index 170a329874..87a111c2c8 100644 --- a/ansible/tasks/setup-postgres.yml +++ b/ansible/tasks/setup-postgres.yml @@ -289,9 +289,9 @@ - name: copy PG apparmor profile ansible.builtin.copy: dest: '/etc/apparmor.d/sbpostgres' - mode: '0644' - owner: 'root' group: 'root' + mode: '0644' + owner: 'root' src: 'files/postgresql_config/sbpostgres_apparmor' - name: parse apparmor sbpostgres profile From 0770b5fa6b8595552c847e40b3429873cf418d60 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Thu, 19 Mar 2026 09:01:20 +0100 Subject: [PATCH 4/9] feat: bump version numbers --- ansible/vars.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ansible/vars.yml b/ansible/vars.yml index 3d8b6a1ef8..efca889a87 100644 --- a/ansible/vars.yml +++ b/ansible/vars.yml @@ -10,9 +10,15 @@ postgres_major: # Full version strings for each major version postgres_release: +<<<<<<< HEAD postgresorioledb-17: "17.6.0.062-orioledb" postgres17: "17.6.1.105" postgres15: "15.14.1.105" +======= + postgresorioledb-17: "17.6.0.056-orioledb" + postgres17: "17.6.1.099" + postgres15: "15.14.1.099" +>>>>>>> 41d5a420 (feat: bump version numbers) # Non Postgres Extensions pgbouncer_release: 1.25.1 From da4d37d4918b89006b3f59815854f19829eadf76 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Thu, 19 Mar 2026 10:13:26 +0100 Subject: [PATCH 5/9] fix: correct indent --- ansible/tasks/setup-postgres.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/tasks/setup-postgres.yml b/ansible/tasks/setup-postgres.yml index 87a111c2c8..5584605513 100644 --- a/ansible/tasks/setup-postgres.yml +++ b/ansible/tasks/setup-postgres.yml @@ -291,7 +291,7 @@ dest: '/etc/apparmor.d/sbpostgres' group: 'root' mode: '0644' - owner: 'root' + owner: 'root' src: 'files/postgresql_config/sbpostgres_apparmor' - name: parse apparmor sbpostgres profile From 27fa1baa53a68e14f52d3be9ea07ff88c6b884ec Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 7 Apr 2026 10:28:33 +0200 Subject: [PATCH 6/9] chore: add tests for apparmor --- ansible/vars.yml | 6 ---- testinfra/test_ami_nix.py | 64 +++++++++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/ansible/vars.yml b/ansible/vars.yml index efca889a87..3d8b6a1ef8 100644 --- a/ansible/vars.yml +++ b/ansible/vars.yml @@ -10,15 +10,9 @@ postgres_major: # Full version strings for each major version postgres_release: -<<<<<<< HEAD postgresorioledb-17: "17.6.0.062-orioledb" postgres17: "17.6.1.105" postgres15: "15.14.1.105" -======= - postgresorioledb-17: "17.6.0.056-orioledb" - postgres17: "17.6.1.099" - postgres15: "15.14.1.099" ->>>>>>> 41d5a420 (feat: bump version numbers) # Non Postgres Extensions pgbouncer_release: 1.25.1 diff --git a/testinfra/test_ami_nix.py b/testinfra/test_ami_nix.py index d9063e2c2f..b6c29130e0 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,54 @@ 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_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 -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 -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']}" + ) From 75c4c5f806b6a1b03b4146de997dd7c6f4edd488 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 7 Apr 2026 13:10:42 +0200 Subject: [PATCH 7/9] chore: tests with superuser --- testinfra/test_ami_nix.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testinfra/test_ami_nix.py b/testinfra/test_ami_nix.py index b6c29130e0..aae768a4a7 100644 --- a/testinfra/test_ami_nix.py +++ b/testinfra/test_ami_nix.py @@ -1097,7 +1097,7 @@ def test_apparmor_blocks_disallowed_shell_commands(host): """ result = run_ssh_command( host["ssh"], - "sudo -u postgres psql -c \"COPY (SELECT 1) TO PROGRAM '/usr/bin/id';\" 2>&1 || true", + "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, ( @@ -1115,7 +1115,7 @@ def test_apparmor_permits_allowlisted_commands(host): """ result = run_ssh_command( host["ssh"], - "sudo -u postgres psql -c \"COPY (SELECT 1) TO PROGRAM '/usr/bin/cat';\"", + "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" From 91f227f00e2dab25bf887c844db6daa95806edaa Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 7 Apr 2026 15:49:24 +0200 Subject: [PATCH 8/9] chore: additional apparmor tests --- .../postgresql_config/sbpostgres_apparmor | 8 +- testinfra/test_ami_nix.py | 110 ++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/ansible/files/postgresql_config/sbpostgres_apparmor b/ansible/files/postgresql_config/sbpostgres_apparmor index 7ddd639644..d5cb1de1e3 100644 --- a/ansible/files/postgresql_config/sbpostgres_apparmor +++ b/ansible/files/postgresql_config/sbpostgres_apparmor @@ -66,6 +66,7 @@ profile sbpostgres flags=(attach_disconnected) { /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, @@ -83,12 +84,8 @@ profile sbpostgres flags=(attach_disconnected) { /nix/store/*/bin/bash mr, # Only allow specific commands to be executed from shell - /usr/bin/gzip ix, /usr/bin/cat ix, - /usr/bin/pg_dump ix, - /bin/echo ix, - /bin/bash ix, - /bin/cat ix, + # pgbackrest needs nix and sudo /usr/bin/nix ix, /usr/bin/sudo ix, @@ -99,6 +96,7 @@ profile sbpostgres flags=(attach_disconnected) { /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, diff --git a/testinfra/test_ami_nix.py b/testinfra/test_ami_nix.py index aae768a4a7..fc54f53b48 100644 --- a/testinfra/test_ami_nix.py +++ b/testinfra/test_ami_nix.py @@ -1072,6 +1072,20 @@ def test_postgrest_read_only_session_attrs(host): 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 status 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 @@ -1121,3 +1135,99 @@ def test_apparmor_permits_allowlisted_commands(host): 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") From 41b1d1c3ee155d9d30391fd89aac8951ff7e9d9f Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 7 Apr 2026 18:02:51 +0200 Subject: [PATCH 9/9] chore: fix test for apparmor on service --- testinfra/test_ami_nix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testinfra/test_ami_nix.py b/testinfra/test_ami_nix.py index fc54f53b48..a81bddc1d9 100644 --- a/testinfra/test_ami_nix.py +++ b/testinfra/test_ami_nix.py @@ -1075,7 +1075,7 @@ def test_postgrest_read_only_session_attrs(host): 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 status postgresql | grep -i apparmor" + host["ssh"], "systemctl show postgresql | grep -i apparmor" ) assert result["succeeded"], ( f"Could not find AppArmor info in postgresql service status.\n"