Files
webmail-client/backend/migrations/versions/0014_plugin_role.py
T
lukasandClaude Opus 4.8 4e077f1a37 M3 plugin platform: SDK + loader, RLS-isolated tables, migrations, jobs
Backend foundation for the plugin platform (SPEC §11), Phases 1-3.

Phase 1 — SDK + loader:
- app/plugins/types.py: frozen Plugin/UiManifest/Slot/Page/JobSpec contract.
- app/plugins/sdk.py: sdk(name) facade — the only app.* import allowed for plugins
  (scoped session, plugin.{name}.* publish, blob, outbox, settings, SSRF http_get, KV, enqueue).
- app/plugins/loader.py: entry-point discovery (webmail.plugins), hook forwarding
  (API + worker), router mounting at /api/plugins/{name}, ui-manifest aggregation.
- plugin_kv core table (migration 0013) + GET /api/plugins/ui-manifest.

Phase 2 — non-removable core plugins + DB-enforced privacy:
- builtin flag (calendar/contacts will be builtin=True; uninstall refuses them).
- RLS per-user isolation on plg_* tables: least-privilege webmail_plugin role
  (migration 0014), plugin_session(user_id) sets LOCAL ROLE + app.current_user_id,
  rls.enable_rls() (ENABLE+FORCE RLS + USING/WITH CHECK policy). Mail untouched.
- Per-plugin Alembic runner (shared env, dedicated version table) + install/uninstall
  --drop-data CLI; skeleton fixture proves install -> run -> uninstall --drop-data
  leaves zero residue (§12 M3 acceptance).

Phase 3 — plugin jobs (§11.2):
- JobSpec registered as arq function plg.{plugin}.{func} + cron (5-field parser);
  singleton=True guarded by a per-(plugin,user) Redis lock; wired onto the shared
  interactive worker.

Gates: ruff, mypy --strict (94 files + tests), full suite 326 passed. RLS isolation,
migration round-trip, and singleton lock covered by tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
2026-07-26 21:42:35 +02:00

62 lines
2.0 KiB
Python

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