-- ============================================================================ -- Database least-privilege provisioning — receipt scanner app -- ============================================================================ -- Creates the runtime role `receipt_app` and grants it ONLY the privileges the -- application needs at runtime: -- -- * CONNECT on the database -- * USAGE on the public schema — explicitly NO CREATE (no DDL of any kind) -- * SELECT / INSERT / UPDATE / DELETE on all tables in public -- * USAGE / SELECT on all sequences in public -- * the same DML grants as DEFAULT PRIVILEGES, so tables/sequences created -- by the owner during later migrations are covered automatically -- -- The role is deliberately NOT a superuser and has no CREATEDB / CREATEROLE / -- CREATE-on-schema rights: if the application is compromised, the attacker's -- database blast radius is limited to reading and modifying rows. They cannot -- create/drop tables, alter the schema, or grant themselves more rights. -- -- ---------------------------------------------------------------------------- -- Who must run this? The database owner or a superuser. -- * `receipt_user` (docker-compose's POSTGRES_USER / the owner URL) KEEPS all -- of its current rights — including DDL for migrations and schema init — -- and is the only role this script ever grants privileges to (besides the -- new runtime role). Nothing here weakens the owner. -- * REVOKE CREATE ON SCHEMA public FROM PUBLIC requires the schema owner or -- a superuser. The official postgres image's /docker-entrypoint-initdb.d -- scripts run as POSTGRES_USER, so that path satisfies this. -- -- How is it executed? -- * Fresh database / volume: docker-compose mounts this file into -- /docker-entrypoint-initdb.d/10-db-permissions.sql; the postgres image -- runs it once, before the app starts, as POSTGRES_USER. (Init scripts do -- NOT run on an existing volume.) -- * Existing database (e.g. the local dev DB): `node scripts/apply-db-permissions.mjs` -- -- Idempotent: safe to run any number of times. CREATE ROLE is guarded by an -- existence check; every GRANT / REVOKE / ALTER DEFAULT PRIVILEGES is a no-op -- when already applied. -- -- Runtime role password: -- The default below (receipt_app_secure_password) matches the docker-compose -- default APP_DATABASE_PASSWORD and is for LOCAL DEVELOPMENT only. For -- production, choose a strong password: -- * docker init path: edit the CREATE ROLE statement below before first -- volume creation (this file runs as-is under psql), or -- * apply-script path: set APP_DATABASE_PASSWORD when running -- scripts/apply-db-permissions.mjs — the script substitutes that value -- and rotates the password of an already-existing role (ALTER ROLE). -- ============================================================================ -- 1. Create the least-privilege runtime role (guarded -> re-runnable). DO $dbpermissions$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'receipt_app') THEN CREATE ROLE receipt_app LOGIN PASSWORD 'receipt_app_secure_password' -- documented default, see header NOSUPERUSER NOCREATEDB NOCREATEROLE; END IF; END $dbpermissions$; -- 2. Allow the app to connect. The database name matches POSTGRES_DB / -- the DATABASE_URL database (receipt_scanner); adjust if you use a -- different database name. GRANT CONNECT ON DATABASE receipt_scanner TO receipt_app; -- 3. Schema access: strip anything pre-existing, then grant USAGE only. -- NOTE: CREATE is deliberately NOT granted — receipt_app can never create, -- alter or drop schema objects (no DDL). REVOKE ALL ON SCHEMA public FROM receipt_app; GRANT USAGE ON SCHEMA public TO receipt_app; -- 4. DML on the tables / sequences that exist right now. (On a fresh database -- this grants nothing yet — step 5 covers the objects the owner creates -- during migrations.) GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO receipt_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO receipt_app; -- 5. Future objects: when the owner (receipt_user) creates tables / sequences -- during later migrations, the runtime role gets the same DML automatically. ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO receipt_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO receipt_app; -- 6. Harden the schema: take CREATE away from the PUBLIC pseudo-role so no -- other database user can create objects in public either. Must be run by -- the schema owner / a superuser (see header). The owner (receipt_user) is -- unaffected — it keeps full rights through pg_database_owner — so -- migrations and schema init keep working. REVOKE CREATE ON SCHEMA public FROM PUBLIC;