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

@@ -80,7 +80,11 @@ impl InvokeServiceImpl {
.await
.map_err(|e| InvokeError::Backend(e.to_string()))?
.ok_or_else(|| InvokeError::NotFound(format!("id {script_id}")))?;
if script.app_id != cx.app_id {
// Phase 4: group scripts (`app_id: None`) fail closed here — invoke()
// by-id stays app-owned until chain-aware resolution lands. The
// execution context is the *caller's* app (`cx.app_id`), never read
// off the target script.
if !script.is_owned_by_app(cx.app_id) {
return Err(InvokeError::CrossApp);
}
if !script.enabled {
@@ -90,7 +94,7 @@ impl InvokeServiceImpl {
}
Ok(ResolvedScript {
script_id: script.id,
app_id: script.app_id,
app_id: cx.app_id,
source: script.source,
updated_at: script.updated_at,
name: script.name,
@@ -113,7 +117,9 @@ impl InvokeServiceImpl {
}
Ok(ResolvedScript {
script_id: script.id,
app_id: script.app_id,
// Resolved within `cx.app_id`'s own scope (get_by_name filters by
// it), so the execution-context app is the caller's app.
app_id: cx.app_id,
source: script.source,
updated_at: script.updated_at,
name: script.name,
@@ -227,7 +233,7 @@ mod tests {
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
Ok(
if app_id == self.script.app_id && name == self.script.name {
if self.script.app_id == Some(app_id) && name == self.script.name {
Some(self.script.clone())
} else {
None
@@ -312,7 +318,8 @@ mod tests {
fn make_script(app_id: AppId, name: &str) -> Script {
Script {
id: ScriptId::new(),
app_id,
app_id: Some(app_id),
group_id: None,
name: name.into(),
description: None,
version: 1,