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:
33
README.md
33
README.md
@@ -41,11 +41,39 @@ Covers: migration idempotency, path-trigger semantics (nest, rename, re-parent,
|
||||
|
||||
## Deploy (Dokploy on mlake)
|
||||
|
||||
### 0. Manual prerequisite — create the dedicated DB role (run **once**)
|
||||
|
||||
The binary connects as a dedicated `projax_admin` role so its blast radius is bounded to the projax schema (cannot reach `mai.workers`, `otto.*`, `vault.*`, etc.). The role lives outside the migrations because it carries credentials.
|
||||
|
||||
As a superuser on msupabase (e.g. via the Supabase SQL editor):
|
||||
|
||||
```sql
|
||||
CREATE ROLE projax_admin WITH LOGIN PASSWORD '<choose-strong-pw>';
|
||||
|
||||
-- Cross-schema read of mai.projects (consumed by projax.items_unified):
|
||||
GRANT USAGE ON SCHEMA mai TO projax_admin;
|
||||
GRANT SELECT ON mai.projects TO projax_admin;
|
||||
|
||||
-- mai.projects has RLS enabled; standard SELECT grants aren't enough.
|
||||
-- Add an explicit policy so projax_admin sees every row:
|
||||
CREATE POLICY projax_read ON mai.projects FOR SELECT TO projax_admin USING (true);
|
||||
```
|
||||
|
||||
Then store the credential in `.env.age` and surface it to Dokploy as the secret `PROJAX_DB_URL`:
|
||||
|
||||
```
|
||||
PROJAX_DB_URL=postgres://projax_admin:<pw>@<msupabase-tailscale-host>:6789/postgres?sslmode=disable
|
||||
```
|
||||
|
||||
After this, migration `0005_reown_to_projax_admin.sql` will detect the role on the next deploy and transfer ownership of every projax-namespaced object. Migrations before/after that point are idempotent.
|
||||
|
||||
### 1. Dokploy app
|
||||
|
||||
`deploy/dokploy.yaml` is a reference manifest. Translate to the Dokploy UI:
|
||||
|
||||
1. Create an app `projax` with `Dockerfile` build context = repo root.
|
||||
2. Set domain `projax.msbls.de` (Tailscale-only — do **not** publish through public reverse proxy).
|
||||
3. Secret `PROJAX_DB_URL` pointing at msupabase's Tailscale address on port 6789 with the `postgres` user.
|
||||
3. Secret `PROJAX_DB_URL` from step 0.
|
||||
4. Health check path `/healthz`.
|
||||
5. Single replica.
|
||||
|
||||
@@ -53,11 +81,12 @@ The image is a distroless static container running as `nonroot`. Total image siz
|
||||
|
||||
## Trust model (v1)
|
||||
|
||||
Single-user, Tailscale-only. No authentication layer. The deployment relies on:
|
||||
Single-user, Tailscale-only. No HTTP-side authentication layer. The deployment relies on:
|
||||
|
||||
- Dokploy app exposed only to Tailscale (no public DNS / reverse proxy outside Tailscale).
|
||||
- msupabase reachable only inside the same Tailscale network.
|
||||
- `PROJAX_DB_URL` is a Dokploy secret, not in the repo.
|
||||
- DB role is `projax_admin` — full rights on `projax.*`, read-only on `mai.projects` via an explicit RLS policy, blocked on every other schema (see deploy step 0).
|
||||
|
||||
If projax later needs auth (multi-device, shared with people, etc.), the natural fit is the same Supabase auth used by flexsiebels — defer until projax has actually outgrown the Tailscale fence.
|
||||
|
||||
|
||||
@@ -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 $$;
|
||||
|
||||
96
db/migrations/0005_reown_to_projax_admin.sql
Normal file
96
db/migrations/0005_reown_to_projax_admin.sql
Normal 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 0001–0004 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$;
|
||||
@@ -4,9 +4,13 @@
|
||||
# Tailscale-only; no public exposure. Single replica, single tenant (m).
|
||||
#
|
||||
# Environment expected (set via Dokploy secrets, NEVER commit):
|
||||
# PROJAX_DB_URL postgres://postgres:<pw>@<msupabase-tailscale-ip>:6789/postgres?sslmode=disable
|
||||
# PROJAX_DB_URL postgres://projax_admin:<pw>@<msupabase-tailscale-ip>:6789/postgres?sslmode=disable
|
||||
# PROJAX_LISTEN_ADDR :8080 (default; Dokploy maps to public port)
|
||||
# PROJAX_AUTO_MIGRATE on (default; set "off" to bypass embedded migrations on boot)
|
||||
#
|
||||
# README §"Deploy / 0. Manual prerequisite" documents the one-time CREATE ROLE
|
||||
# projax_admin + cross-schema grants + RLS policy on mai.projects. The
|
||||
# migrations themselves are credential-free.
|
||||
|
||||
name: projax
|
||||
service: projax
|
||||
|
||||
Reference in New Issue
Block a user