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

@@ -181,6 +181,15 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
Ok(lookup.app.id)
}
/// The owning app of a script, for app-scoped admin authz. Every script
/// before Phase 4 is app-owned and yields its app. A group-owned script
/// (Phase 4) is not addressable through the app-script admin API — it is
/// managed via the group-script API — so it 404s here, indistinguishable
/// from an absent id.
fn script_app(script: &Script) -> Result<AppId, ApiError> {
script.app_id.ok_or(ApiError::NotFound(script.id))
}
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
@@ -190,7 +199,7 @@ async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script.app_id),
Capability::AppRead(script_app(&script)?),
)
.await?;
Ok(Json(script))
@@ -234,7 +243,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
let created = state
.repo
.create(NewScript {
app_id: input.app_id,
app_id: Some(input.app_id),
group_id: None,
name: input.name,
description: input.description,
source: input.source,
@@ -291,7 +301,7 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(script.app_id),
Capability::AppWriteScript(script_app(&script)?),
)
.await?;
@@ -367,7 +377,7 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppAdmin(script.app_id),
Capability::AppAdmin(script_app(&script)?),
)
.await?;
state.repo.delete(id).await?;
@@ -408,7 +418,7 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
require(
state.authz.as_ref(),
&principal,
Capability::AppLogRead(script.app_id),
Capability::AppLogRead(script_app(&script)?),
)
.await?;
// Cap to keep the dashboard responsive; the data plane writes are