Files
webmail-client/backend/migrations/versions/0014_plugin_role.py
T

62 lines
2.0 KiB
Python
Raw Normal View History

"""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
$$;
"""
)