"""create the least-privilege plugin DB role (SPEC §11.1 RLS enforcement) Plugin table access runs as ``webmail_plugin`` — a NOLOGIN, NOSUPERUSER, NOBYPASSRLS role that plugin sessions ``SET LOCAL ROLE`` into per transaction. Row-Level Security is skipped for superusers, table owners, and BYPASSRLS roles, so per-user isolation on ``plg_*`` tables is only real when access runs as this restricted role. The app role is granted membership so it can SET ROLE into it (the dev role happens to be superuser, but this keeps prod parity where it isn't). Per-table SELECT/INSERT/UPDATE/DELETE grants + policies are added by each plugin's own migration via ``app.plugins.rls.enable_rls`` (§11.1); this only provisions the role and schema usage. Revision ID: 0014 Revises: 0013 Create Date: 2026-07-26 """ from __future__ import annotations from collections.abc import Sequence from alembic import op revision: str = "0014" down_revision: str | None = "0013" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: op.execute( """ DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'webmail_plugin') THEN CREATE ROLE webmail_plugin NOLOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; END IF; END $$; """ ) op.execute("GRANT USAGE ON SCHEMA public TO webmail_plugin") # Let the connecting application role drop into the restricted role via SET ROLE. op.execute("GRANT webmail_plugin TO CURRENT_USER") def downgrade() -> None: op.execute( """ DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'webmail_plugin') THEN EXECUTE 'REVOKE webmail_plugin FROM CURRENT_USER'; EXECUTE 'DROP OWNED BY webmail_plugin'; EXECUTE 'DROP ROLE webmail_plugin'; END IF; END $$; """ )