PostgreSQL: Protect schemas against accidental deletion

There is a risk of accidentally deleting an important schema whenever we interact with a PostgreSQL server.

PostgreSQL: Protect schemas against accidental deletion

If you have any questions, comments, corrections, or suggestions - please contact StJohn Piano on Tela:
tela.app/id/stjohn_piano/7c51a6

Follow Tela Network on LinkedIn:
linkedin.com/company/tela-network

Join Tela Network and become a consultant:
telablog.com/how-to-use-tela-for-consulting


The Problem

There is a risk of accidentally deleting an important schema whenever we interact with a PostgreSQL server.

We interact with a PostgreSQL server whenever we:

  • write statements manually in a client
  • prepare statements in a text file and copypaste them into a client
  • generate SQL statements in another programming language

We want to add a protective guardrail that prevents accidental deletion.

The Solution

We create an event trigger that fires when the DROP command is entered. It calls a function that checks the schema name against a list of protected schema names.

Why not use privileges instead ?

For normal operation, we usually create a second database user with limited privileges that can insert / delete data, but does not own anything, and thus cannot drop a schema.

However, this does not protect against "fat-finger" / "oops" moments when a tired database administrator needs to drop a schema but makes a mistake.

Protecting against this possibility with a simple guardrail is cheaper than restoring from a backup.

Background Material

The commands in these notes can be run in the Postgres client psql.

The article PostgreSQL: Background. It covers:

  • how to connect to Postgres with psql
  • the structure of a Postgres database
  • some details about event triggers

Previous article in this series:

PostgreSQL: Prevent accidental database deletion
When we interact with a PostgreSQL server, there is a risk of accidentally deleting an important database.

The Solution: Actual Code

Create the following function in the database.

Note: A function is an object that belongs to a particular schema in a particular database.

You should be connected as the default superuser to the default postgres database and the default public schema, so that this function is stored here instead of anywhere else.

CREATE OR REPLACE FUNCTION protect_schemas()
  RETURNS event_trigger LANGUAGE plpgsql AS $$
DECLARE
    obj record;
    schema_name_1 text;
BEGIN
    FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects()
    LOOP
        IF obj.object_type = 'schema' THEN

            schema_name_1 := obj.object_name;

            IF EXISTS (SELECT 1 FROM protected_schema WHERE schema_name = schema_name_1) THEN
               RAISE EXCEPTION 'ERROR: Schema ''%'' is in the protected schema list.', schema_name_1;
            END IF;

        END IF;
    END LOOP;
END;
$$;

Create an event trigger that calls this function when a DROP command is used.

DROP EVENT TRIGGER IF EXISTS drop_schema_trigger;

CREATE EVENT TRIGGER drop_schema_trigger
ON sql_drop
EXECUTE FUNCTION protect_schemas();

Create a table that stores the list of protected schemas. Again, this table should be stored on postgres.public.

CREATE TABLE protected_schema (
    schema_name text PRIMARY KEY
);

Now, whenever you have an important schema, insert its name into this table. Example:

INSERT INTO protected_schema (schema_name) VALUES ('my_schema_1');

Worked Example

Document Postgres version.

SELECT version();
postgres=# SELECT version();
                                                            version
--------------------------------------------------------------------------------------------------------------------------------
 PostgreSQL 14.9 (Homebrew) on x86_64-apple-darwin22.4.0, compiled by Apple clang version 14.0.3 (clang-1403.0.22.14.1), 64-bit
(1 row)

See current role.

SELECT CURRENT_ROLE;
postgres=# SELECT CURRENT_ROLE;
 current_role
--------------
 admin
(1 row)

This is the superuser role.

See current database.

SELECT CURRENT_DATABASE();
postgres=# SELECT CURRENT_DATABASE();
 current_database
------------------
 postgres
(1 row)

See current schema.

SELECT CURRENT_SCHEMA;
postgres=# SELECT CURRENT_SCHEMA;
 current_schema
----------------
 public
(1 row)

Create two new test schemas.

CREATE SCHEMA test_schema_1;

CREATE SCHEMA test_schema_2;

List schemas:

SELECT schema_name 
FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_toast', 'pg_catalog', 'information_schema');
postgres=# SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_toast', 'pg_catalog', 'information_schema');
 schema_name
-------------
 public
 test_schema_1
 test_schema_2
(2 rows)

Create the function, trigger, and table shown earlier.

Protect the name of the first test schema.

INSERT INTO protected_schema (schema_name) VALUES ('test_schema_1');

List functions and their characteristics.

\df

List functions and their owners and schemas:

SELECT routine_name AS function, grantee AS owner, routine_schema AS schema
FROM information_schema.routine_privileges
WHERE grantee = 'admin'
AND routine_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY routine_name;

More complex version that also gets the language.

SELECT
  routine_name AS function,
  grantee AS owner,
  routine_schema AS schema,
  pl.lanname AS language
FROM information_schema.routine_privileges AS rp
  INNER JOIN pg_proc AS pp ON pp.proname = rp.routine_name
  INNER JOIN pg_language pl ON (pp.prolang = pl.oid)
WHERE
  rp.grantee = 'admin'
  AND rp.routine_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY function;

List functions, including their language and code.

SELECT 
  pp.proname AS function,
  pn.nspname AS schema,
  pl.lanname AS language,
  pg_get_functiondef(pp.oid) AS function_code
FROM pg_proc pp
INNER JOIN pg_namespace pn ON (pp.pronamespace = pn.oid)
INNER JOIN pg_language pl ON (pp.prolang = pl.oid)
WHERE pl.lanname NOT IN ('c','internal') 
  AND pn.nspname NOT LIKE 'pg_%'
  AND pn.nspname <> 'information_schema';

List triggers:

\dy

We try to drop a schema that doesn't exist.

postgres=# DROP SCHEMA foo;
ERROR:  schema "foo" does not exist

We try to drop a schema that is protected.

postgres=# DROP SCHEMA test_schema_1;
ERROR:  ERROR: Schema 'test_schema_1' is in the protected schema list.
CONTEXT:  PL/pgSQL function protect_schemas() line 15 at RAISE

We try to drop a schema that is protected, using the CASCADE option.

postgres=# DROP SCHEMA test_schema_1 CASCADE;
ERROR:  ERROR: Schema 'test_schema_1' is in the protected schema list.
CONTEXT:  PL/pgSQL function protect_schemas() line 15 at RAISE

We try to drop a schema that is not protected.

postgres=# DROP SCHEMA test_schema_2;
DROP SCHEMA

List schemas.

postgres=# SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_toast', 'pg_catalog', 'information_schema');
  schema_name
---------------
 public
 test_schema_1
(2 rows)

So: We are now prevented from accidentally dropping a schema, if its name is stored in the table postgres.public.protected_schema.

Sources

https://www.postgresql.org/docs/14/event-triggers.html

https://www.postgresql.org/docs/14/event-trigger-definition.html

https://www.postgresql.org/docs/14/sql-createeventtrigger.html

https://stackoverflow.com/questions/16632117/get-all-procedural-user-defined-functions


If you have any questions, comments, or suggestions - please contact StJohn Piano on Tela:
tela.app/id/stjohn_piano/7c51a6

Follow Tela Network on LinkedIn:
linkedin.com/company/tela-network

Follow Tela Network on Twitter:
twitter.com/tela_updates

Join Tela Network and become a consultant:
telablog.com/how-to-use-tela-for-consulting

Join the Tela Social channel on Telegram to get every new update:
t.me/tela_social

Follow Tela Network on Instagram:
instagram.com/tela_updates

Invest in Tela:
tela.network/invest