Close all thirteen app functions in the migration, not by hand

B4 was recorded as open against the deployment. It was not open there - the ten
original functions had been closed by hand - but it was open everywhere else. A
database restored from a backup of the deployment comes back with 10 of 13
PUBLIC-executable, because a hand-applied GRANT is a property of one database and
does not survive a restore. Observed on scratch, built from the pre-0003 dump.

So 0003 now revokes from PUBLIC on all thirteen, by full signature taken from
pg_proc.oid::regprocedure: a bare () does not resolve for a function taking one
argument, and REVOKE on a nonexistent name aborts the whole migration.

Three findings while doing it, none of which would have shown up without running the
tests:

- My own three guard functions went in PUBLIC-executable, reproducing the exact
  defect B4 is about.
- Revoking PUBLIC without regranting broke five tenancy tests with a permission
  denied error on the platform-admin helper. Every RLS policy calls it, so a missing
  grant here looks like a broken policy rather than a missing grant. Regranted to the
  roles that actually read and write, guarded by EXISTS so a role added later is
  covered by the next migration rather than by luck.
- The revoke block originally sat before the functions were created, so it failed on
  a fresh restore and passed on the already-migrated database - passing by accident,
  the failure mode this project keeps hitting. Both revoke blocks now sit after the
  triggers, and the fresh-restore path is the one verified.

Verified by restoring the pre-0003 backup into scratch, applying 0003, and running
the suite: 0 of 13 PUBLIC-executable, 4 guards, 474 passed, 27 skipped. Re-applying
is a no-op. Deployment data unchanged (users=2 orgs=3 audit=43 perms=20); health
200, heartbeat 401 without a credential and 200 with one, so the revoke broke
nothing.

G2 is still open: 26 tables have RLS enabled but not forced, and the application
connects as the owner.
This commit is contained in:
umbra audio
2026-10-02 20:25:12 -07:00
parent c0a1971a52
commit 2ff322dcd2
2 changed files with 171 additions and 12 deletions

View File

@@ -776,8 +776,8 @@ that is a finding and you should report it as a blocker rather than proceeding.
**Stage 1 needs no schema change and no new endpoint.** `node_heartbeats.raw` is an open
`jsonb`, so the node report rides inside the heartbeat that already exists. Your report is
already read-only, already secret-free, and already emits per-project schema fingerprints —
it was built for exactly this. Send it under a `raw.node_report` key alongside the usual
fields.
it was built for exactly this. Send it as the **top-level `measurements` object** (§10.6 gives
the exact shape, which is flat — there is no `raw` key and no `agent` object).
### 10.3 Why it starts read-only
@@ -844,26 +844,88 @@ second credential.
### 10.6 What your heartbeat should carry
`node_heartbeats.raw` is an open `jsonb`. Your existing
`scripts/umbra_node_report.py --json` output rides inside the heartbeat as-is — no migration
on this side, no new endpoint, no new schema on yours. Send:
> **Corrected 2026-10-02, after this document was already in your hands.** The payload shape
> originally given here was `{"agent": {"version": ...}, "raw": {...}}`. That shape is wrong.
> It returns `200 OK` and stores nothing — verified, not assumed: the row came back with
> `agent_version=NULL` and `raw={}`. An earlier version of this section was wrong in exactly
> the way that is hardest to notice, so read the correction rather than trusting the shape you
> were first given. If you have already been sending the old shape, stop; nothing you have sent
> has been recorded.
The request body is **flat**. There is no `agent` object and no `raw` key:
```json
{"agent": {"version": "<your version>"}, "raw": { ...your existing report... }}
{
"agent_version": "umbra-audio-0.1.0",
"active_jobs": 0,
"resident_models": [],
"devices": [],
"measurements": { ...your existing report... }
}
```
Two things the response tells you, both earned rather than declared:
Everything except `measurements` is optional and defaults to empty. **`measurements` is an open
`jsonb` and is written verbatim into the `raw` column**, so your existing
`scripts/umbra_node_report.py --json` output rides inside it as-is — no migration on this side,
no new endpoint, no new schema on yours.
Keys that have a name of their own are lifted out into real columns; everything else stays in
`raw` and is queryable as JSON. Recognised: `cpu_busy_pct`, `ram_free_mb`, `vram_free_mb`,
`vram_total_mb`, `disk_free_mb`, `load_avg_1m`.
The working command:
```sh
curl -sS -X POST https://umbratrans.bdmurphy.me/api/node/heartbeat \
-H 'Authorization: Bearer <KEY>' \
-H 'Content-Type: application/json' \
-d '{
"agent_version": "umbra-audio-0.1.0",
"active_jobs": 0,
"resident_models": [],
"measurements": {
"cpu_busy_pct": 0,
"ram_free_mb": 0,
"disk_free_mb": 0,
"sql_tables": {"books": 4, "tracks": 11, "speakers": 461},
"speaker_name_agreements": {"last_dwarf": [93, 68, 71], "dealhunter": [61, 64, 97]}
}
}'
```
Arbitrary nested JSON survives intact. The example above stores
`raw->speaker_name_agreements` as `{"dealhunter": [61, 64, 97], "last_dwarf": [93, 68, 71]}` —
checked, not assumed.
**Transport note.** Use the hostname and no `Host` header is needed. If you reach the
controller by IP address instead, you must send `Host: umbratrans.bdmurphy.me` or every
request returns `400` — including `/api/health`, which makes a healthy service look dead.
### 10.7 What the response tells you
```json
{"node_id":"...","name":"umbraaudio-m4","status":"online","schedulable":false,
"status_reason":"online: no devices reported yet","devices":[],"heartbeat_interval_seconds":10}
```
- **`status`** is `provisioning` until your first heartbeat, `online` after. It is never set
by configuration.
- **`schedulable`** is separate from `status` and will read `false` until you report at least
one device. That is the honest answer: a node with no devices cannot be given work, and it
is better that it says so than that it looks available.
- **`status`** is `provisioning` until your first heartbeat, `online` after. Never set by
configuration. A node an administrator has set to `draining` or `retired` is *not* promoted
back by heartbeating.
- **`schedulable`** is separate from `status` and reads `false` until you report at least one
device that is not busy. That is the honest answer: a node with no devices cannot be given
work, and it is better that it says so than that it looks available.
- **`heartbeat_interval_seconds`** is `10`. Miss that window and the node goes `offline`.
To become schedulable, report a device. `kind` must be exactly one of `cpu`, `gpu_discrete`,
`gpu_integrated`, `npu`, `tpu` — any other value is a `422`. `throughput_factor` is measured by
the node rather than declared by a human: a declared factor is a guess that goes stale the
moment quantisation or context length changes, and a stale factor sends work to the wrong
machine.
```json
"devices": [{"kind":"cpu","label":"M4","ram_total_mb":16384,"ram_free_mb":8192,
"throughput_factor":1.0,"supports_accelerators":[]}]
```
---

View File

@@ -581,6 +581,33 @@ ON CONFLICT (key) DO NOTHING;
-- they should not have to do while holding an incident.
--
-- DELETE and UPDATE only. INSERT is how these tables are populated.
-- EXECUTE is revoked from PUBLIC on all three, matching every other function in
-- `app` (the ten that predate this migration were closed by hand; nothing in a
-- migration did it, which is why it needs doing here explicitly).
--
-- A trigger function runs as SECURITY INVOKER, so the guard is only reached if the
-- *calling* role may execute it. Revoking PUBLIC without granting the roles that
-- actually write would therefore turn the guard into an outage rather than a
-- protection - and an outage at least announces itself, whereas a guard that never
-- fires does not. So the grants follow the revoke, to every role that writes these
-- tables, and are re-issued on every apply so a role added later is covered by the
-- next migration rather than by luck.
--
-- Verified after applying: 13 of 13 `app` functions non-PUBLIC-executable, and a
-- non-owner role can still insert into `artifacts` and be refused on update.
--
-- The ten functions that predate this migration were closed by hand against the
-- deployment, and nothing in any migration does it. That is why a database restored
-- from a backup of the deployment comes back with all ten PUBLIC-executable again -
-- observed, on the scratch database built from the pre-0003 dump, where 10 of 13 were
-- open while the deployment itself had 3. A hand-applied grant is not a property of
-- the schema, it is a property of one database, and it does not survive a restore.
--
-- So this closes all thirteen, not just the three added above. B4 was recorded as
-- open against the deployment, where it was already fixed; the defect was never only
-- about the deployment.
--
--
-- Note what this does *not* do: it does not stop the owner role from bypassing RLS
-- (G2), and it does not stop a superuser. It stops an ordinary mistake from being
@@ -641,6 +668,76 @@ BEGIN
END
$$;
DO $$
DECLARE
target text;
BEGIN
EXECUTE 'REVOKE ALL ON FUNCTION app.prevent_artifact_mutation() FROM PUBLIC';
EXECUTE 'REVOKE ALL ON FUNCTION app.prevent_job_artifact_mutation() FROM PUBLIC';
EXECUTE 'REVOKE ALL ON FUNCTION app.prevent_job_event_mutation() FROM PUBLIC';
--
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_api') THEN
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_artifact_mutation() TO umbracontrol_api';
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_job_artifact_mutation() TO umbracontrol_api';
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_job_event_mutation() TO umbracontrol_api';
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_worker') THEN
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_artifact_mutation() TO umbracontrol_worker';
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_job_artifact_mutation() TO umbracontrol_worker';
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_job_event_mutation() TO umbracontrol_worker';
END IF;
-- The read-only roles need EXECUTE on the policy helpers, not only on the guards.
-- Every RLS policy calls app.is_platform_admin() or app.is_organization_member(),
-- so revoking PUBLIC without this turns every SELECT into
-- "permission denied for function is_platform_admin" - which looks like a
-- permissions bug in the policy rather than a missing grant here. Found by
-- running the tenancy tests as a non-owner role immediately after the revoke:
-- five of them failed that way.
FOREACH target IN ARRAY ARRAY[
'app.can_access_project(uuid)',
'app.can_view_user(uuid)',
'app.current_user_id()',
'app.is_organization_admin(uuid)',
'app.is_organization_admin_for_project(uuid)',
'app.is_organization_member(uuid)',
'app.is_platform_admin()'
] LOOP
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_api') THEN
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO umbracontrol_api', target);
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_readonly') THEN
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO umbracontrol_readonly', target);
END IF;
-- rls_probe is the non-owner role the tenancy tests use. It is not a
-- deployment role; it exists so a policy can be observed refusing something,
-- and it needs the helpers for exactly that.
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rls_probe') THEN
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO rls_probe', target);
END IF;
END LOOP;
-- All thirteen, for the reason given above.
FOREACH target IN ARRAY ARRAY[
'app.can_access_project(uuid)',
'app.can_view_user(uuid)',
'app.current_user_id()',
'app.is_organization_admin(uuid)',
'app.is_organization_admin_for_project(uuid)',
'app.is_organization_member(uuid)',
'app.is_platform_admin()',
'app.prevent_artifact_mutation()',
'app.prevent_audit_mutation()',
'app.prevent_job_artifact_mutation()',
'app.prevent_job_event_mutation()',
'app.set_updated_at()',
'app.validate_user_role_scope()'
] LOOP
EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC', target);
END LOOP;
END
$$;
-- ---------------------------------------------------------------------
-- 4. Row-level security
-- ---------------------------------------------------------------------