Authorization


https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Appendix.PostgreSQL.CommonDBATasks.Roles.rds_superuser.html
https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/database-authentication.html
https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/UsingWithRDS.IAMDBAuth.html
https://www.postgresql.org/docs/current/user-manag.html
https://www.postgresql.org/docs/current/sql-createrole.html
https://www.postgresql.org/docs/current/sql-alterrole.html
https://www.postgresql.org/docs/current/sql-grant.html
https://www.postgresql.org/docs/current/sql-revoke.html
https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html
https://www.postgresql.org/docs/current/ddl-priv.html

1. Scenario#

original requirement, normalized#

environment:
    AWS Aurora PostgreSQL cluster identifier:
        my-pg

    database:
        my-pg-db

    application schema:
        my-pg-db-schema

goal:
    create a production-grade role model for:
        database / schema ownership
        migration DDL
        application runtime DML
        firefighter readonly incident access
        firefighter readwrite incident access
        temporary 2-hour firefighter passwords issued by Lambda

important naming note:
    my-pg-db and my-pg-db-schema contain hyphen
    PostgreSQL SQL must quote them:
        "my-pg-db"
        "my-pg-db-schema"

    recommended production naming:
        my_pg_db
        my_pg_db_schema

optimized role model#

Role LOGIN Purpose Privilege Level
my_pg_admin yes DBA automation role, create roles/databases, own admin functions high
my_pg_owner no database/schema/object owner high, no direct login
my_pg_migrator yes CI/CD migration login, SET ROLE my_pg_owner for DDL DDL through owner
my_pg_app yes application runtime connection SELECT/INSERT/UPDATE only, no DELETE
my_pg_firefighter_ro yes incident readonly access SELECT only
my_pg_firefighter_rw yes incident readwrite access SELECT/INSERT/UPDATE/DELETE only
my_pg_password_broker yes Lambda login that can only execute password rotation function narrow
why add my_pg_migrator:
    direct login as object owner is convenient but risky
    better model:
        owner role owns database/schema/tables/functions/indexes
        owner role is NOLOGIN
        CI/CD login role sets role to owner during migration

why add my_pg_password_broker:
    do not give Lambda broad CREATEROLE / rds_password if it only needs firefighter passwords
    Lambda should call one restricted SECURITY DEFINER function
    function only rotates my_pg_firefighter_ro and my_pg_firefighter_rw

privilege decision#

admin role:
    CREATEDB
    CREATEROLE
    on Aurora: rds_password when password management is required
    on standard PostgreSQL: no built-in rds_password role exists
    should not be application runtime user
    should not get rds_superuser unless there is a real DBA break-glass reason

owner role:
    owns database, schema, and objects
    can perform DDL because ownership includes ALTER / DROP
    should be NOLOGIN

migrator role:
    login role used by Flyway / Liquibase / migration job / Jenkins / GitHub Actions
    member of owner role
    runs SET ROLE my_pg_owner before DDL

app role:
    CONNECT to database
    USAGE on schema
    SELECT / INSERT / UPDATE on tables
    USAGE / SELECT on sequences
    no CREATE on schema
    no ownership
    no DELETE / DROP / ALTER / TRUNCATE / CREATE INDEX

firefighter readonly:
    temporary incident login
    SELECT only
    no data modification

firefighter readwrite:
    temporary incident login
    SELECT / INSERT / UPDATE / DELETE
    no DDL
    no TRUNCATE
    no CREATE
    no ownership

2. Aurora Notes#

Aurora PostgreSQL is managed PostgreSQL:
    the master user is NOSUPERUSER
    AWS provides rds_superuser for many DBA tasks
    do not assume real PostgreSQL SUPERUSER exists
    grant rds_superuser only to real DBA break-glass users

rds_password:
    Aurora predefined role for password management
    grant it carefully
    prefer a restricted function for Lambda instead of granting Lambda broad password power

CONNECT:
    newer Aurora PostgreSQL versions require explicit CONNECT privilege
    even highly privileged roles should be granted CONNECT to the target database
temporary password caveat:
    VALID UNTIL expires the password
    it does not disable the role itself
    it is not enforced for non-password authentication

if using IAM DB authentication:
    grant rds_iam to the database role
    use IAM auth token instead of storing a password
    do not mix rds_iam / rds_ad / password auth casually for the same role

3. Bootstrap Roles#

Run this as the Aurora master user or another trusted DBA role with enough privileges.

connect to:
    postgres database or another existing maintenance database

do not run CREATE DATABASE inside a transaction block

create admin and owner roles#

CREATE ROLE my_pg_admin
  LOGIN
  CREATEDB
  CREATEROLE
  INHERIT
  PASSWORD NULL;

-- Aurora only: allow password management for non-rds_superuser users.
GRANT rds_password TO my_pg_admin;

-- Standard PostgreSQL:
-- no predefined rds_password role exists.
-- Use this admin role, or a SECURITY DEFINER helper owned by a trusted DBA,
-- to run ALTER ROLE ... PASSWORD ... for the target roles it is allowed to manage.

CREATE ROLE my_pg_owner
  NOLOGIN
  INHERIT;

-- Admin must be able to SET ROLE my_pg_owner
-- so it can create a database owned by my_pg_owner.
GRANT my_pg_owner TO my_pg_admin WITH ADMIN OPTION;
password note:
    PASSWORD NULL means password login fails
    set real admin authentication through IAM DB auth or a controlled secret workflow

create runtime roles#

CREATE ROLE my_pg_migrator
  LOGIN
  INHERIT
  PASSWORD NULL;

CREATE ROLE my_pg_app
  LOGIN
  INHERIT
  CONNECTION LIMIT 100
  PASSWORD NULL;

CREATE ROLE my_pg_firefighter_ro
  LOGIN
  INHERIT
  CONNECTION LIMIT 5
  PASSWORD NULL;

CREATE ROLE my_pg_firefighter_rw
  LOGIN
  INHERIT
  CONNECTION LIMIT 3
  PASSWORD NULL;

CREATE ROLE my_pg_password_broker
  LOGIN
  INHERIT
  CONNECTION LIMIT 3
  PASSWORD NULL;

optional IAM DB auth#

If Aurora IAM DB authentication is enabled and you want Lambda to connect without a stored DB password:

GRANT rds_iam TO my_pg_password_broker;

If application or migration also uses IAM DB authentication:

GRANT rds_iam TO my_pg_app;
GRANT rds_iam TO my_pg_migrator;
do not grant rds_iam to firefighter roles if your emergency workflow is password-based:
    firefighter password expiry uses VALID UNTIL
    IAM auth token does not use the PostgreSQL password

grant owner membership to migrator#

GRANT my_pg_owner TO my_pg_migrator;
migration job pattern:
    connect as my_pg_migrator
    SET ROLE my_pg_owner
    run DDL
    RESET ROLE

4. Create Database#

Run as my_pg_admin or the bootstrap DBA user. If connected as bootstrap DBA, make sure my_pg_admin and/or the executing role can SET ROLE my_pg_owner.

CREATE DATABASE "my-pg-db"
  OWNER my_pg_owner
  TEMPLATE template0
  ENCODING 'UTF8'
  CONNECTION LIMIT 200;

Harden default database privileges:

REVOKE ALL ON DATABASE "my-pg-db" FROM PUBLIC;

GRANT CONNECT ON DATABASE "my-pg-db" TO my_pg_admin;
GRANT CONNECT ON DATABASE "my-pg-db" TO my_pg_owner;
GRANT CONNECT ON DATABASE "my-pg-db" TO my_pg_migrator;
GRANT CONNECT ON DATABASE "my-pg-db" TO my_pg_app;
GRANT CONNECT ON DATABASE "my-pg-db" TO my_pg_firefighter_ro;
GRANT CONNECT ON DATABASE "my-pg-db" TO my_pg_firefighter_rw;
GRANT CONNECT ON DATABASE "my-pg-db" TO my_pg_password_broker;
do not grant:
    CREATE ON DATABASE to app
    CREATE ON DATABASE to firefighter roles

database CREATE privilege means:
    can create schema in this database
    can create publications
    can install trusted extensions

5. Create Schema#

Connect to "my-pg-db" before running schema grants.

SET ROLE my_pg_owner;

CREATE SCHEMA "my-pg-db-schema" AUTHORIZATION my_pg_owner;

RESET ROLE;

Harden default public schema:

REVOKE CREATE ON SCHEMA public FROM PUBLIC;

-- Optional stricter baseline when application objects do not use public schema.
REVOKE USAGE ON SCHEMA public FROM PUBLIC;

Harden application schema:

REVOKE ALL ON SCHEMA "my-pg-db-schema" FROM PUBLIC;

GRANT USAGE ON SCHEMA "my-pg-db-schema" TO my_pg_app;
GRANT USAGE ON SCHEMA "my-pg-db-schema" TO my_pg_firefighter_ro;
GRANT USAGE ON SCHEMA "my-pg-db-schema" TO my_pg_firefighter_rw;

-- Password broker does not need access to application tables.
-- It will only use db_admin schema created later.
schema rule:
    USAGE lets a role look up objects in the schema
    CREATE lets a role create objects in the schema
    application and firefighter roles should not get CREATE

6. Existing Object Grants#

Run after the schema and objects already exist. If there are no tables yet, keep this section for future re-runs.

app role#

GRANT SELECT, INSERT, UPDATE
ON ALL TABLES IN SCHEMA "my-pg-db-schema"
TO my_pg_app;

GRANT USAGE, SELECT
ON ALL SEQUENCES IN SCHEMA "my-pg-db-schema"
TO my_pg_app;

GRANT EXECUTE
ON ALL FUNCTIONS IN SCHEMA "my-pg-db-schema"
TO my_pg_app;
app role intentionally does not get:
    CREATE on schema
    DELETE on tables
    TRUNCATE on tables
    REFERENCES unless the app creates foreign keys at runtime
    TRIGGER
    MAINTAIN
    ownership

firefighter readonly#

GRANT SELECT
ON ALL TABLES IN SCHEMA "my-pg-db-schema"
TO my_pg_firefighter_ro;

GRANT SELECT
ON ALL SEQUENCES IN SCHEMA "my-pg-db-schema"
TO my_pg_firefighter_ro;

firefighter readwrite#

GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA "my-pg-db-schema"
TO my_pg_firefighter_rw;

GRANT USAGE, SELECT
ON ALL SEQUENCES IN SCHEMA "my-pg-db-schema"
TO my_pg_firefighter_rw;
firefighter readwrite intentionally does not get:
    TRUNCATE
    CREATE
    ALTER
    DROP
    CREATE INDEX
    ownership

reason:
    production incident data fixes should be narrow DML
    DDL changes should go through migration process

7. Future Object Grants#

ALTER DEFAULT PRIVILEGES only affects objects created after the command. Run it for the role that will create future objects, normally my_pg_owner.

ALTER DEFAULT PRIVILEGES
FOR ROLE my_pg_owner
IN SCHEMA "my-pg-db-schema"
GRANT SELECT, INSERT, UPDATE ON TABLES TO my_pg_app;

ALTER DEFAULT PRIVILEGES
FOR ROLE my_pg_owner
IN SCHEMA "my-pg-db-schema"
GRANT USAGE, SELECT ON SEQUENCES TO my_pg_app;

ALTER DEFAULT PRIVILEGES
FOR ROLE my_pg_owner
IN SCHEMA "my-pg-db-schema"
GRANT EXECUTE ON FUNCTIONS TO my_pg_app;
ALTER DEFAULT PRIVILEGES
FOR ROLE my_pg_owner
IN SCHEMA "my-pg-db-schema"
GRANT SELECT ON TABLES TO my_pg_firefighter_ro;

ALTER DEFAULT PRIVILEGES
FOR ROLE my_pg_owner
IN SCHEMA "my-pg-db-schema"
GRANT SELECT ON SEQUENCES TO my_pg_firefighter_ro;
ALTER DEFAULT PRIVILEGES
FOR ROLE my_pg_owner
IN SCHEMA "my-pg-db-schema"
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO my_pg_firefighter_rw;

ALTER DEFAULT PRIVILEGES
FOR ROLE my_pg_owner
IN SCHEMA "my-pg-db-schema"
GRANT USAGE, SELECT ON SEQUENCES TO my_pg_firefighter_rw;

Remove default function execution from PUBLIC for objects created by owner:

ALTER DEFAULT PRIVILEGES
FOR ROLE my_pg_owner
IN SCHEMA "my-pg-db-schema"
REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
important:
    default privileges are based on the actual creating role
    if migration creates objects as my_pg_migrator without SET ROLE my_pg_owner,
    these FOR ROLE my_pg_owner defaults will not apply

8. Migration DDL Pattern#

Migration tools should connect as my_pg_migrator, then set role to my_pg_owner.

SELECT current_user, current_role;

SET ROLE my_pg_owner;

CREATE TABLE "my-pg-db-schema".orders (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_no text NOT NULL UNIQUE,
  status text NOT NULL DEFAULT 'created',
  amount numeric(12, 2) NOT NULL CHECK (amount >= 0),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX orders_status_created_at_idx
ON "my-pg-db-schema".orders (status, created_at DESC);

RESET ROLE;

Verify owner:

SELECT
  schemaname,
  tablename,
  tableowner
FROM pg_tables
WHERE schemaname = 'my-pg-db-schema'
ORDER BY tablename;
expected:
    tableowner should be my_pg_owner

9. Temporary Firefighter Passwords#

preferred model#

Lambda should:
    authenticate as my_pg_password_broker
    generate a random 32+ character password
    call db_admin.set_firefighter_password(...)
    return/store the password through an approved secret channel
    write audit logs:
        requester
        ticket/incident id
        target role
        expiry timestamp
        approver if required

PostgreSQL should:
    allow broker to execute only one function
    function should only rotate:
        my_pg_firefighter_ro
        my_pg_firefighter_rw
    password validity should be capped at 2 hours

create admin schema#

Run this while connected to "my-pg-db" as my_pg_admin or bootstrap DBA.

CREATE SCHEMA IF NOT EXISTS db_admin AUTHORIZATION my_pg_admin;

REVOKE ALL ON SCHEMA db_admin FROM PUBLIC;

GRANT USAGE ON SCHEMA db_admin TO my_pg_password_broker;

create restricted password function#

Create the function as my_pg_admin, or create it and then change owner to my_pg_admin.

CREATE OR REPLACE FUNCTION db_admin.set_firefighter_password(
  p_role name,
  p_password text,
  p_hours integer DEFAULT 2
)
RETURNS timestamptz
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $$
DECLARE
  v_expires_at timestamptz;
BEGIN
  IF p_role NOT IN ('my_pg_firefighter_ro', 'my_pg_firefighter_rw') THEN
    RAISE EXCEPTION 'role % is not allowed for firefighter password rotation', p_role;
  END IF;

  IF p_password IS NULL OR length(p_password) < 32 THEN
    RAISE EXCEPTION 'temporary password must be at least 32 characters';
  END IF;

  IF p_hours IS NULL OR p_hours < 1 OR p_hours > 2 THEN
    RAISE EXCEPTION 'temporary password validity must be between 1 and 2 hours';
  END IF;

  v_expires_at := clock_timestamp() + (p_hours * interval '1 hour');

  EXECUTE format(
    'ALTER ROLE %I PASSWORD %L VALID UNTIL %L',
    p_role,
    p_password,
    v_expires_at
  );

  RETURN v_expires_at;
END;
$$;

ALTER FUNCTION db_admin.set_firefighter_password(name, text, integer)
OWNER TO my_pg_admin;

REVOKE ALL ON FUNCTION db_admin.set_firefighter_password(name, text, integer) FROM PUBLIC;

GRANT EXECUTE
ON FUNCTION db_admin.set_firefighter_password(name, text, integer)
TO my_pg_password_broker;
security definer rules:
    always set search_path
    do not use unqualified application objects
    validate target role allowlist
    validate max validity
    keep function owner highly controlled

issue temporary readonly password#

SELECT db_admin.set_firefighter_password(
  'my_pg_firefighter_ro',
  'replace-with-lambda-generated-32-plus-char-password',
  2
) AS expires_at;

issue temporary readwrite password#

SELECT db_admin.set_firefighter_password(
  'my_pg_firefighter_rw',
  'replace-with-lambda-generated-32-plus-char-password',
  2
) AS expires_at;

expire firefighter password now#

Run as my_pg_admin or another role that can alter these roles:

ALTER ROLE my_pg_firefighter_ro PASSWORD NULL VALID UNTIL '1970-01-01 00:00:00+00';

ALTER ROLE my_pg_firefighter_rw PASSWORD NULL VALID UNTIL '1970-01-01 00:00:00+00';
operational note:
    password expiry blocks new password logins
    it does not kill existing sessions
    terminate active firefighter sessions separately if needed

Terminate active firefighter sessions:

SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE usename IN ('my_pg_firefighter_ro', 'my_pg_firefighter_rw')
  AND pid <> pg_backend_pid();

10. Session Guardrails#

Set conservative defaults for firefighter roles.

ALTER ROLE my_pg_firefighter_ro IN DATABASE "my-pg-db"
  SET statement_timeout = '5min';

ALTER ROLE my_pg_firefighter_ro IN DATABASE "my-pg-db"
  SET idle_in_transaction_session_timeout = '2min';

ALTER ROLE my_pg_firefighter_rw IN DATABASE "my-pg-db"
  SET statement_timeout = '5min';

ALTER ROLE my_pg_firefighter_rw IN DATABASE "my-pg-db"
  SET lock_timeout = '5s';

ALTER ROLE my_pg_firefighter_rw IN DATABASE "my-pg-db"
  SET idle_in_transaction_session_timeout = '2min';

Set app default schema:

ALTER ROLE my_pg_app IN DATABASE "my-pg-db"
  SET search_path = '"my-pg-db-schema"', public;

ALTER ROLE my_pg_migrator IN DATABASE "my-pg-db"
  SET search_path = '"my-pg-db-schema"', public;
application recommendation:
    still qualify production SQL with schema name when possible
    search_path is convenience, not a security boundary

11. Verification#

role attributes#

SELECT
  rolname,
  rolcanlogin,
  rolcreatedb,
  rolcreaterole,
  rolreplication,
  rolbypassrls,
  rolconnlimit,
  rolvaliduntil
FROM pg_roles
WHERE rolname LIKE 'my_pg_%'
ORDER BY rolname;

database privileges#

SELECT
  role_name,
  has_database_privilege(role_name, 'my-pg-db', 'CONNECT') AS can_connect,
  has_database_privilege(role_name, 'my-pg-db', 'CREATE') AS can_create_in_database
FROM (
  VALUES
    ('my_pg_admin'),
    ('my_pg_owner'),
    ('my_pg_migrator'),
    ('my_pg_app'),
    ('my_pg_firefighter_ro'),
    ('my_pg_firefighter_rw'),
    ('my_pg_password_broker')
) AS r(role_name)
ORDER BY role_name;

Expected:

can_connect:
    true for all roles that need to connect

can_create_in_database:
    false for app / firefighter / password_broker

schema privileges#

SELECT
  role_name,
  has_schema_privilege(role_name, '"my-pg-db-schema"', 'USAGE') AS schema_usage,
  has_schema_privilege(role_name, '"my-pg-db-schema"', 'CREATE') AS schema_create
FROM (
  VALUES
    ('my_pg_app'),
    ('my_pg_firefighter_ro'),
    ('my_pg_firefighter_rw'),
    ('my_pg_password_broker')
) AS r(role_name)
ORDER BY role_name;

Expected:

my_pg_app:
    schema_usage = true
    schema_create = false

my_pg_firefighter_ro:
    schema_usage = true
    schema_create = false

my_pg_firefighter_rw:
    schema_usage = true
    schema_create = false

my_pg_password_broker:
    schema_usage = false
    schema_create = false

table privileges#

SELECT
  grantee,
  table_schema,
  table_name,
  privilege_type
FROM information_schema.role_table_grants
WHERE table_schema = 'my-pg-db-schema'
  AND grantee IN (
    'my_pg_app',
    'my_pg_firefighter_ro',
    'my_pg_firefighter_rw'
  )
ORDER BY grantee, table_name, privilege_type;

default privileges#

SELECT
  defaclrole::regrole AS owner_role,
  defaclnamespace::regnamespace AS schema_name,
  defaclobjtype AS object_type,
  defaclacl AS acl
FROM pg_default_acl
WHERE defaclrole = 'my_pg_owner'::regrole
ORDER BY object_type;

negative tests#

Connect as my_pg_app:

CREATE TABLE "my-pg-db-schema".should_fail (id bigint);

Expected:

permission denied for schema my-pg-db-schema

Connect as my_pg_app:

DELETE FROM "my-pg-db-schema".orders
WHERE id = 1;

Expected:

permission denied for table orders

Connect as my_pg_firefighter_ro:

UPDATE "my-pg-db-schema".orders
SET status = 'cancelled'
WHERE id = 1;

Expected:

permission denied for table orders

Connect as my_pg_firefighter_rw:

DROP TABLE "my-pg-db-schema".orders;

Expected:

must be owner of table orders

12. Create A New Database Later#

This is the pattern for your requirement:

admin role creates a new database
admin role creates a new owner role
admin grants itself membership in owner role
admin creates database owned by that owner role
admin connects to new database
admin creates schema owned by owner role
admin grants app / firefighter privileges

Example:

CREATE ROLE other_pg_owner NOLOGIN INHERIT;

GRANT other_pg_owner TO my_pg_admin WITH ADMIN OPTION;

CREATE DATABASE other_pg_db
  OWNER other_pg_owner
  TEMPLATE template0
  ENCODING 'UTF8';

Then connect to other_pg_db:

SET ROLE other_pg_owner;

CREATE SCHEMA app AUTHORIZATION other_pg_owner;

RESET ROLE;
why membership matters:
    to create a database owned by another role,
    the executing role must be able to SET ROLE to that owner role

13. Incident Workflow#

readonly firefighter:
    use first for diagnosis
    SELECT only
    no data changes

readwrite firefighter:
    use only after approved incident/ticket
    write exact SQL before execution
    run SELECT preview first
    run DML in transaction
    use RETURNING where possible
    record changed primary keys

DDL:
    do not use firefighter role for DDL
    use migration process with my_pg_migrator -> SET ROLE my_pg_owner

Example readwrite fix:

BEGIN;

SELECT id, order_no, status
FROM "my-pg-db-schema".orders
WHERE order_no = 'ORD-1001';

UPDATE "my-pg-db-schema".orders
SET status = 'cancelled',
    updated_at = now()
WHERE order_no = 'ORD-1001'
  AND status = 'created'
RETURNING id, order_no, status, updated_at;

COMMIT;

Rollback if result is wrong before commit:

ROLLBACK;

14. Production Checklist#

role design:
    owner is NOLOGIN
    app is not owner
    firefighter roles are not owner
    Lambda broker is not admin

database / schema:
    PUBLIC database privileges reviewed
    public schema CREATE revoked
    app schema CREATE not granted to runtime roles

existing objects:
    app has SELECT/INSERT/UPDATE only
    firefighter_ro has SELECT only
    firefighter_rw has DML only
    sequences granted separately

future objects:
    ALTER DEFAULT PRIVILEGES configured for my_pg_owner
    migration job uses SET ROLE my_pg_owner

temporary access:
    firefighter passwords default to NULL or expired
    Lambda generated passwords are 32+ characters
    validity capped at 2 hours
    active sessions terminated after incident if needed

audit:
    log Lambda caller, target role, expiry, incident id
    enable database connection logs where appropriate
    keep CloudTrail / Lambda logs / database logs retained by policy