feat(scripts): polymorphic script owner (app XOR group) — Phase 4 foundation

Phase 4-lite C1. Make script ownership polymorphic so a script can be owned
by a GROUP (a template inherited by descendant apps) instead of an app —
mirroring vars/secrets (0048/0049), but ON DELETE RESTRICT (code is not data).

Schema (0050): `scripts.group_id` (nullable FK→groups RESTRICT), `app_id` made
nullable, `scripts_owner_exactly_one` CHECK, and the per-app name index split
into two per-owner partial-unique indexes. Existing app-owned rows keep their
exact `(app_id, lower(name))` uniqueness.

Type: `Script.app_id` becomes `Option<AppId>`; add `Script.group_id` +
`ScriptOwner` / `is_owned_by_app()`. `NewScript` gains the same polymorphic
owner. The execution-context app (what a script runs *under*) is supplied by
the invoking route/trigger/caller, never read off the script — group scripts
have no single app.

Behavior is fully preserved for app-owned scripts (the only kind creatable
today): every isolation backstop and authz site now uses `is_owned_by_app`,
which is byte-identical for app owners and **fails closed** for group owners.
A group script therefore can't yet be run, route/trigger-bound, invoked, or
managed via the app-script API — those land in C2 (group-script creation) and
C3 (chain-membership resolution + binding). The `/execute/{id}` bypass 404s a
group script (no app context to run under).

Re-blesses expected_schema.txt; note the golden was last blessed at migration
0044, so this also captures the already-committed 0045–0049 schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 19:53:05 +02:00
parent 27dc04819f
commit 48178c5f60
13 changed files with 298 additions and 51 deletions

View File

@@ -126,8 +126,14 @@ where
if !script.enabled {
return Err(ApiError::NotFound(id));
}
// The direct-execute bypass runs a script under its *owning app's*
// context. A group-owned script (Phase 4) is a template with no single
// app, so it cannot be invoked through this id-addressed bypass — it
// runs only via a descendant app's route/trigger, which supplies the
// execution-context app. Fail closed (404) rather than guess an app.
let app_id = script.app_id.ok_or(ApiError::NotFound(id))?;
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
let mut req = build_exec_request(id, &script.name, &headers, &body, app_id, principal)?;
req.sandbox_overrides = script.sandbox;
let request_id = req.request_id;
let request_path = req.path.clone();
@@ -151,7 +157,7 @@ where
// audit-visible platform — but a sink failure must not mask the
// user-facing result, so we only log a warning if it fails.
let log = build_execution_log(
script.app_id,
app_id,
id,
request_id,
request_path,