diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90ae0f2..b35643d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,3 +88,24 @@ jobs: - if: ${{ failure() }} run: cat regression.diffs + validate-sql-snapshot: + runs-on: macos-15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install Nix + uses: cachix/install-nix-action@c134e4c9e34bac6cab09cf239815f9339aaaf84e # v31.5.1 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Use Cachix Cache + uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + with: + name: nxpg + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Run snapshot + run: nix develop --command "./scripts/dump_extension.sh" + + - name: Check for diffs in supabase_vault.sql snapshot + run: nix-shell --run "git diff --exit-code ./supabase_vault.sql" diff --git a/scripts/dump_extension.sh b/scripts/dump_extension.sh new file mode 100755 index 0000000..6cda091 --- /dev/null +++ b/scripts/dump_extension.sh @@ -0,0 +1,35 @@ +#! /usr/bin/env bash +xpg psql \ + --file=<(cat << 'EOF' +DO $$ +DECLARE + extension_objects text[]; + extension_object text; +BEGIN + +SELECT array_agg(pg_catalog.pg_describe_object(classid, objid, 0)) into extension_objects +FROM pg_catalog.pg_depend +INNER JOIN ( + SELECT e.oid + FROM pg_catalog.pg_extension e + WHERE e.extname OPERATOR(pg_catalog.=) 'supabase_vault' + COLLATE pg_catalog.default + ORDER BY 1 +) AS extension_oid +ON extension_oid.oid = refobjid +WHERE refclassid = 'pg_catalog.pg_extension'::pg_catalog.regclass + AND deptype = 'e' +ORDER BY 1; + +FOR extension_object IN select unnest(extension_objects) LOOP + BEGIN + EXECUTE 'alter extension supabase_vault drop ' || extension_object; + EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'failed on %: %', extension_object, sqlerrm; + END; +END LOOP; + +END; +$$; +EOF) \ + --command="\\! pg_dump --restrict-key=0 --schema=vault --user=postgres --schema-only | grep -v '^\\\\restrict 0$' | grep -v '^\\\\unrestrict 0$' | grep -v '^SET .*;' | grep -v 'SELECT pg_catalog.set_config' | pgpp > supabase_vault.sql" diff --git a/shell.nix b/shell.nix index b46044e..c28d90a 100644 --- a/shell.nix +++ b/shell.nix @@ -48,6 +48,7 @@ pkgs.mkShell { }; }) pkgs.libsodium + pkgs.python313Packages.pglast style styleCheck ]; diff --git a/supabase_vault.sql b/supabase_vault.sql new file mode 100644 index 0000000..b327262 --- /dev/null +++ b/supabase_vault.sql @@ -0,0 +1,134 @@ +CREATE SCHEMA vault; + +ALTER SCHEMA vault OWNER TO postgres; + +CREATE FUNCTION vault._crypto_aead_det_decrypt(message bytea + , additional bytea + , key_id bigint + , context bytea = CAST('\x7067736f6469756d' AS bytea) + , nonce bytea = CAST(NULL AS bytea)) +RETURNS bytea +LANGUAGE c IMMUTABLE AS '$libdir/supabase_vault' + , 'pgsodium_crypto_aead_det_decrypt_by_id'; + +ALTER FUNCTION vault._crypto_aead_det_decrypt (message bytea, additional bytea, key_id bigint, context bytea, nonce bytea) OWNER TO postgres; + +CREATE FUNCTION vault._crypto_aead_det_encrypt(message bytea + , additional bytea + , key_id bigint + , context bytea = CAST('\x7067736f6469756d' AS bytea) + , nonce bytea = CAST(NULL AS bytea)) +RETURNS bytea +LANGUAGE c IMMUTABLE AS '$libdir/supabase_vault' + , 'pgsodium_crypto_aead_det_encrypt_by_id'; + +ALTER FUNCTION vault._crypto_aead_det_encrypt (message bytea, additional bytea, key_id bigint, context bytea, nonce bytea) OWNER TO postgres; + +CREATE FUNCTION vault._crypto_aead_det_noncegen() +RETURNS bytea +LANGUAGE c IMMUTABLE AS '$libdir/supabase_vault' + , 'pgsodium_crypto_aead_det_noncegen'; + +ALTER FUNCTION vault._crypto_aead_det_noncegen () OWNER TO postgres; + +CREATE FUNCTION vault.create_secret(new_secret text + , new_name text = CAST(NULL AS text) + , new_description text = CAST('' AS text) + , new_key_id uuid = CAST(NULL AS uuid)) +RETURNS uuid +LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'pg_catalog', 'pg_temp' +AS $$ +DECLARE + rec record; +BEGIN + INSERT INTO vault.secrets (secret, name, description) + VALUES ( + new_secret, + new_name, + new_description + ) + RETURNING * INTO rec; + UPDATE vault.secrets s + SET secret = encode(vault._crypto_aead_det_encrypt( + message := convert_to(rec.secret, 'utf8'), + additional := convert_to(s.id::text, 'utf8'), + key_id := 0, + context := 'pgsodium'::bytea, + nonce := rec.nonce + ), 'base64') + WHERE id = rec.id; + RETURN rec.id; +END +$$; + +ALTER FUNCTION vault.create_secret (new_secret text, new_name text, new_description text, new_key_id uuid) OWNER TO postgres; + +CREATE FUNCTION vault.update_secret(secret_id uuid + , new_secret text = CAST(NULL AS text) + , new_name text = CAST(NULL AS text) + , new_description text = CAST(NULL AS text) + , new_key_id uuid = CAST(NULL AS uuid)) +RETURNS void +LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'pg_catalog', 'pg_temp' +AS $$ +DECLARE + decrypted_secret text := (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE id = secret_id); +BEGIN + UPDATE vault.secrets s + SET + secret = CASE WHEN new_secret IS NULL THEN s.secret + ELSE encode(vault._crypto_aead_det_encrypt( + message := convert_to(new_secret, 'utf8'), + additional := convert_to(s.id::text, 'utf8'), + key_id := 0, + context := 'pgsodium'::bytea, + nonce := s.nonce + ), 'base64') END, + name = coalesce(new_name, s.name), + description = coalesce(new_description, s.description), + updated_at = now() + WHERE s.id = secret_id; +END +$$; + +ALTER FUNCTION vault.update_secret (secret_id uuid, new_secret text, new_name text, new_description text, new_key_id uuid) OWNER TO postgres; + +GRANT USAGE + ON SCHEMA vault + TO bob + WITH GRANT OPTION; + +REVOKE ALL PRIVILEGES + ON FUNCTION vault._crypto_aead_det_decrypt (message bytea, additional bytea, key_id bigint, context bytea, nonce bytea) + FROM PUBLIC; + +GRANT ALL PRIVILEGES + ON FUNCTION vault._crypto_aead_det_decrypt (message bytea, additional bytea, key_id bigint, context bytea, nonce bytea) + TO bob + WITH GRANT OPTION; + +REVOKE ALL PRIVILEGES + ON FUNCTION vault._crypto_aead_det_encrypt (message bytea, additional bytea, key_id bigint, context bytea, nonce bytea) + FROM PUBLIC; + +REVOKE ALL PRIVILEGES + ON FUNCTION vault._crypto_aead_det_noncegen () + FROM PUBLIC; + +REVOKE ALL PRIVILEGES + ON FUNCTION vault.create_secret (new_secret text, new_name text, new_description text, new_key_id uuid) + FROM PUBLIC; + +GRANT ALL PRIVILEGES + ON FUNCTION vault.create_secret (new_secret text, new_name text, new_description text, new_key_id uuid) + TO bob + WITH GRANT OPTION; + +REVOKE ALL PRIVILEGES + ON FUNCTION vault.update_secret (secret_id uuid, new_secret text, new_name text, new_description text, new_key_id uuid) + FROM PUBLIC; + +GRANT ALL PRIVILEGES + ON FUNCTION vault.update_secret (secret_id uuid, new_secret text, new_name text, new_description text, new_key_id uuid) + TO bob + WITH GRANT OPTION