feat(db): pivot to dedicated projax_admin role

Mirrors the mbrian_admin pattern: the binary connects as a role bounded
to the projax schema, so even a compromised projax process cannot read
mai.workers, otto.*, vault.*, etc.

- 0001: switch grants block from postgres → projax_admin (conditional
  on the role existing — bootstrap still works as superuser before the
  role is created). Wrap `create schema` in a guard so the migration
  is idempotent when re-run as a non-superuser app role that lacks
  database-level CREATE.
- 0005_reown_to_projax_admin.sql: enumerate every projax-namespaced
  object via pg_namespace + pg_class / pg_proc and ALTER OWNER to
  projax_admin. Explicitly scoped — no global REASSIGN OWNED that
  would yank ownership from other projects sharing the postgres role.
  Strips residual postgres grants. No-ops with a NOTICE when the role
  is missing.
- README: new "Manual prerequisite" deploy section. Documents the
  CREATE ROLE statement, the cross-schema USAGE + SELECT grants, AND
  the RLS policy `projax_read ON mai.projects` that's required because
  mai.projects has row-level security with policies scoped to `mai`
  and `anon` only. Without the policy, items_unified silently returns
  zero mai-source rows.
- deploy/dokploy.yaml: DSN comment now reflects projax_admin and
  points at the README prereq.

Verified locally against msupabase with a throwaway projax_admin role:
- 13/13 tests green
- mai.workers SELECT → permission denied
- mai.sessions SELECT → permission denied
- mai.projects SELECT → 59 rows (RLS policy in effect)
- projax.items_unified SELECT → 66 rows (7 projax + 59 mai)
This commit is contained in:
mAi
2026-05-15 13:32:56 +02:00
parent 2df9e1b13f
commit 092a56cf24
4 changed files with 153 additions and 14 deletions

View File

@@ -2,7 +2,15 @@
-- projax schema: items + item_links
-- See docs/design.md §3
create schema if not exists projax;
-- Bootstrap-only: a non-superuser app role (projax_admin) lacks CREATE on the
-- database itself, so `create schema if not exists` would trip a privilege
-- check even when the schema already exists. The guard here makes this
-- migration idempotent when re-run as the app role.
do $bootstrap$ begin
if not exists (select 1 from pg_namespace where nspname = 'projax') then
execute 'create schema projax';
end if;
end $bootstrap$;
create table if not exists projax.items (
id uuid primary key default gen_random_uuid(),
@@ -66,16 +74,18 @@ create trigger items_set_updated_at
before update on projax.items
for each row execute function projax.set_updated_at();
-- Grants: the projax service connects as the `postgres` role on msupabase.
-- Tailscale-only, single-user, so we grant freely on this schema.
-- Grants: the projax service connects as the dedicated `projax_admin` role
-- (see migration 0005 + README §0). If the role does not exist yet on this
-- database, this block no-ops; first apply happens before the role exists
-- and a privileged operator runs the migrations once to bootstrap.
do $$ begin
if exists (select 1 from pg_roles where rolname = 'postgres') then
execute 'grant usage on schema projax to postgres';
execute 'grant all on all tables in schema projax to postgres';
execute 'grant all on all sequences in schema projax to postgres';
execute 'grant all on all functions in schema projax to postgres';
execute 'alter default privileges in schema projax grant all on tables to postgres';
execute 'alter default privileges in schema projax grant all on sequences to postgres';
execute 'alter default privileges in schema projax grant all on functions to postgres';
if exists (select 1 from pg_roles where rolname = 'projax_admin') then
execute 'grant usage on schema projax to projax_admin';
execute 'grant all on all tables in schema projax to projax_admin';
execute 'grant all on all sequences in schema projax to projax_admin';
execute 'grant all on all functions in schema projax to projax_admin';
execute 'alter default privileges in schema projax grant all on tables to projax_admin';
execute 'alter default privileges in schema projax grant all on sequences to projax_admin';
execute 'alter default privileges in schema projax grant all on functions to projax_admin';
end if;
end $$;

View File

@@ -0,0 +1,96 @@
-- 0005_reown_to_projax_admin.sql
-- Transfer ownership of every projax-namespaced object to the dedicated
-- `projax_admin` role. Mirrors the `mbrian_admin` pattern: blast-radius
-- bounded to the projax schema; the binary connects as projax_admin so it
-- cannot reach mai.workers, otto.*, vault.*, etc.
--
-- Manual prereq (operator runs once, NOT in a migration — credential concern):
-- CREATE ROLE projax_admin WITH LOGIN PASSWORD '<chosen>';
-- GRANT USAGE ON SCHEMA mai TO projax_admin;
-- GRANT SELECT ON mai.projects TO projax_admin;
-- (The mai grants are required because items_unified executes as the view's
-- owner; without them the view would fail with permission denied.)
--
-- If `projax_admin` does not exist yet the entire migration no-ops with a
-- notice — so the initial bootstrap (as `postgres` or `supabase_admin`) can
-- still apply migrations 00010004 idempotently before the role is created.
-- Once `projax_admin` exists, this migration must be applied while the
-- current owner of the projax schema still has ALTER OWNER rights (the
-- existing owner, supabase_admin, or any role with the projax_admin role).
do $reown$
declare
rec record;
begin
if not exists (select 1 from pg_roles where rolname = 'projax_admin') then
raise notice '0005_reown_to_projax_admin: role projax_admin does not exist — skipping. Create it manually and re-apply.';
return;
end if;
-- Schema
execute 'alter schema projax owner to projax_admin';
-- Tables
for rec in
select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'projax' and c.relkind = 'r'
loop
execute format('alter table projax.%I owner to projax_admin', rec.relname);
end loop;
-- Views
for rec in
select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'projax' and c.relkind = 'v'
loop
execute format('alter view projax.%I owner to projax_admin', rec.relname);
end loop;
-- Sequences (gen_random_uuid means none in v1, but any future serial sequences land here)
for rec in
select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'projax' and c.relkind = 'S'
loop
execute format('alter sequence projax.%I owner to projax_admin', rec.relname);
end loop;
-- Functions (procedures + functions; identity_arguments handles overloads)
for rec in
select p.proname, pg_get_function_identity_arguments(p.oid) as args
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'projax'
loop
execute format('alter function projax.%I(%s) owner to projax_admin', rec.proname, rec.args);
end loop;
-- Strip residual grants that 0001 (pre-pivot) handed to `postgres`.
-- projax_admin is the new owner; postgres has no reason to keep any
-- non-default privileges on this schema.
if exists (select 1 from pg_roles where rolname = 'postgres') then
execute 'revoke all on all tables in schema projax from postgres';
execute 'revoke all on all sequences in schema projax from postgres';
execute 'revoke all on all functions in schema projax from postgres';
execute 'revoke all on schema projax from postgres';
execute 'alter default privileges in schema projax revoke all on tables from postgres';
execute 'alter default privileges in schema projax revoke all on sequences from postgres';
execute 'alter default privileges in schema projax revoke all on functions from postgres';
end if;
-- Explicit grants for projax_admin (redundant with ownership but documents intent
-- and survives future objects via default privileges).
execute 'grant usage on schema projax to projax_admin';
execute 'grant all on all tables in schema projax to projax_admin';
execute 'grant all on all sequences in schema projax to projax_admin';
execute 'grant all on all functions in schema projax to projax_admin';
execute 'alter default privileges for role projax_admin in schema projax grant all on tables to projax_admin';
execute 'alter default privileges for role projax_admin in schema projax grant all on sequences to projax_admin';
execute 'alter default privileges for role projax_admin in schema projax grant all on functions to projax_admin';
end
$reown$;