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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user