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:
40
crates/manager-core/migrations/0050_group_scripts.sql
Normal file
40
crates/manager-core/migrations/0050_group_scripts.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
-- Phase 4 (v1.2 Hierarchies): group-owned scripts.
|
||||||
|
--
|
||||||
|
-- Until now every script was owned by exactly one app (`app_id NOT NULL`,
|
||||||
|
-- ON DELETE RESTRICT). Phase 4 lets a script be owned by a GROUP instead, so
|
||||||
|
-- it is shared by every descendant app — resolved by name, nearest-owner
|
||||||
|
-- wins (CoW: an app's own script of the same name shadows the inherited one),
|
||||||
|
-- exactly like `vars`/`secrets` (0048/0049).
|
||||||
|
--
|
||||||
|
-- Phase 4-LITE scope: group-owned ENDPOINT scripts only. Group modules and
|
||||||
|
-- the origin-aware (lexical) import resolver are deferred to Phase 4b, so a
|
||||||
|
-- group-owned script must be self-contained (enforced in the service layer).
|
||||||
|
--
|
||||||
|
-- Reshape (mirrors vars/secrets, but RESTRICT not CASCADE — code is not data,
|
||||||
|
-- a group can't be deleted out from under the scripts it owns, matching the
|
||||||
|
-- existing app_id RESTRICT and the §5.6 delete=RESTRICT rule):
|
||||||
|
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
|
||||||
|
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
|
||||||
|
-- enforces "owned by an app XOR a group".
|
||||||
|
-- * the per-app unique name index becomes two partial indexes, one per owner.
|
||||||
|
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ALTER COLUMN app_id DROP NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE scripts
|
||||||
|
ADD CONSTRAINT scripts_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||||
|
|
||||||
|
-- Per-owner, case-insensitive name uniqueness. Partial so each owner column
|
||||||
|
-- only constrains its own rows; existing app rows (group_id NULL) keep the
|
||||||
|
-- exact same (app_id, LOWER(name)) uniqueness they had under the old index.
|
||||||
|
DROP INDEX scripts_name_uidx;
|
||||||
|
CREATE UNIQUE INDEX scripts_app_name_uidx
|
||||||
|
ON scripts (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX scripts_group_name_uidx
|
||||||
|
ON scripts (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX scripts_group_id_idx ON scripts (group_id) WHERE group_id IS NOT NULL;
|
||||||
@@ -181,6 +181,15 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
|
|||||||
Ok(lookup.app.id)
|
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>(
|
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||||
State(state): State<AdminState<R, L>>,
|
State(state): State<AdminState<R, L>>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
@@ -190,7 +199,7 @@ async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppRead(script.app_id),
|
Capability::AppRead(script_app(&script)?),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(script))
|
Ok(Json(script))
|
||||||
@@ -234,7 +243,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
let created = state
|
let created = state
|
||||||
.repo
|
.repo
|
||||||
.create(NewScript {
|
.create(NewScript {
|
||||||
app_id: input.app_id,
|
app_id: Some(input.app_id),
|
||||||
|
group_id: None,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
@@ -291,7 +301,7 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppWriteScript(script.app_id),
|
Capability::AppWriteScript(script_app(&script)?),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -367,7 +377,7 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppAdmin(script.app_id),
|
Capability::AppAdmin(script_app(&script)?),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
state.repo.delete(id).await?;
|
state.repo.delete(id).await?;
|
||||||
@@ -408,7 +418,7 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppLogRead(script.app_id),
|
Capability::AppLogRead(script_app(&script)?),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
// Cap to keep the dashboard responsive; the data plane writes are
|
// Cap to keep the dashboard responsive; the data plane writes are
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ async fn seed_into(
|
|||||||
) -> Result<(), ScriptRepositoryError> {
|
) -> Result<(), ScriptRepositoryError> {
|
||||||
let script = scripts
|
let script = scripts
|
||||||
.create(NewScript {
|
.create(NewScript {
|
||||||
app_id: default.id,
|
app_id: Some(default.id),
|
||||||
|
group_id: None,
|
||||||
name: "hello".to_string(),
|
name: "hello".to_string(),
|
||||||
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||||
source: HELLO_RHAI_SOURCE.to_string(),
|
source: HELLO_RHAI_SOURCE.to_string(),
|
||||||
|
|||||||
@@ -519,7 +519,8 @@ impl ApplyService {
|
|||||||
Op::Create => {
|
Op::Create => {
|
||||||
let bs = bundle_scripts[&key];
|
let bs = bundle_scripts[&key];
|
||||||
let new = NewScript {
|
let new = NewScript {
|
||||||
app_id,
|
app_id: Some(app_id),
|
||||||
|
group_id: None,
|
||||||
name: bs.name.clone(),
|
name: bs.name.clone(),
|
||||||
description: bs.description.clone(),
|
description: bs.description.clone(),
|
||||||
source: bs.source.clone(),
|
source: bs.source.clone(),
|
||||||
@@ -1990,7 +1991,8 @@ mod tests {
|
|||||||
fn script(name: &str, source: &str) -> Script {
|
fn script(name: &str, source: &str) -> Script {
|
||||||
Script {
|
Script {
|
||||||
id: ScriptId::from(uuid::Uuid::new_v4()),
|
id: ScriptId::from(uuid::Uuid::new_v4()),
|
||||||
app_id: AppId::from(uuid::Uuid::nil()),
|
app_id: Some(AppId::from(uuid::Uuid::nil())),
|
||||||
|
group_id: None,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
description: None,
|
description: None,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -359,9 +359,14 @@ impl Dispatcher {
|
|||||||
// existing consumers. Dead-letter the message so the misfire is
|
// existing consumers. Dead-letter the message so the misfire is
|
||||||
// observable rather than executing one app's script under
|
// observable rather than executing one app's script under
|
||||||
// another app's SdkCallCx.
|
// another app's SdkCallCx.
|
||||||
if script.app_id != claimed.app_id {
|
// Phase 4: a group-owned script (`app_id: None`) is not yet
|
||||||
|
// dispatchable here — the chain-membership check that authorizes
|
||||||
|
// inherited scripts lands with group-script binding. Until then
|
||||||
|
// `is_owned_by_app` is the exact app-owned guard as before and a
|
||||||
|
// group script fails closed (dead-lettered, not silently run).
|
||||||
|
if !script.is_owned_by_app(claimed.app_id) {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
script_app = %script.app_id,
|
script_app = ?script.app_id,
|
||||||
claim_app = %claimed.app_id,
|
claim_app = %claimed.app_id,
|
||||||
script_id = %consumer.script_id,
|
script_id = %consumer.script_id,
|
||||||
"queue consumer script belongs to a different app; dead-lettering"
|
"queue consumer script belongs to a different app; dead-lettering"
|
||||||
@@ -786,7 +791,9 @@ impl Dispatcher {
|
|||||||
// boundary. Not reachable via the trigger-create or `apply` paths
|
// boundary. Not reachable via the trigger-create or `apply` paths
|
||||||
// (both resolve the script within the app's own scope), so this is
|
// (both resolve the script within the app's own scope), so this is
|
||||||
// the runtime backstop the other arms already carry.
|
// the runtime backstop the other arms already carry.
|
||||||
if script.app_id != row.app_id {
|
// Phase 4: group scripts (`app_id: None`) fail closed here until the
|
||||||
|
// chain-membership check lands with group-script binding.
|
||||||
|
if !script.is_owned_by_app(row.app_id) {
|
||||||
return Err(DispatcherError::ResolveTrigger(
|
return Err(DispatcherError::ResolveTrigger(
|
||||||
"trigger outbox target belongs to a different app".into(),
|
"trigger outbox target belongs to a different app".into(),
|
||||||
));
|
));
|
||||||
@@ -876,7 +883,8 @@ impl Dispatcher {
|
|||||||
// build_invoke_request and dispatch_one_queue. A hand-edited
|
// build_invoke_request and dispatch_one_queue. A hand-edited
|
||||||
// outbox row could otherwise execute one app's script under
|
// outbox row could otherwise execute one app's script under
|
||||||
// another app's SdkCallCx.
|
// another app's SdkCallCx.
|
||||||
if script.app_id != row.app_id {
|
// Phase 4: group scripts fail closed here (see queue/trigger arms).
|
||||||
|
if !script.is_owned_by_app(row.app_id) {
|
||||||
return Err(DispatcherError::ResolveTrigger(
|
return Err(DispatcherError::ResolveTrigger(
|
||||||
"http outbox target belongs to a different app".into(),
|
"http outbox target belongs to a different app".into(),
|
||||||
));
|
));
|
||||||
@@ -969,7 +977,8 @@ impl Dispatcher {
|
|||||||
})?;
|
})?;
|
||||||
// Same-app guard — the script could have been re-assigned or
|
// Same-app guard — the script could have been re-assigned or
|
||||||
// the row could have been hand-edited. Reject mismatches.
|
// the row could have been hand-edited. Reject mismatches.
|
||||||
if script.app_id != row.app_id {
|
// Phase 4: group scripts fail closed here (see queue/trigger arms).
|
||||||
|
if !script.is_owned_by_app(row.app_id) {
|
||||||
return Err(DispatcherError::ResolveTrigger(
|
return Err(DispatcherError::ResolveTrigger(
|
||||||
"invoke target belongs to a different app".into(),
|
"invoke target belongs to a different app".into(),
|
||||||
));
|
));
|
||||||
@@ -2097,7 +2106,8 @@ mod tests {
|
|||||||
pub(super) fn disabled_script(app_id: AppId) -> Script {
|
pub(super) fn disabled_script(app_id: AppId) -> Script {
|
||||||
Script {
|
Script {
|
||||||
id: ScriptId::new(),
|
id: ScriptId::new(),
|
||||||
app_id,
|
app_id: Some(app_id),
|
||||||
|
group_id: None,
|
||||||
name: "worker".into(),
|
name: "worker".into(),
|
||||||
description: None,
|
description: None,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -80,7 +80,11 @@ impl InvokeServiceImpl {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
||||||
.ok_or_else(|| InvokeError::NotFound(format!("id {script_id}")))?;
|
.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);
|
return Err(InvokeError::CrossApp);
|
||||||
}
|
}
|
||||||
if !script.enabled {
|
if !script.enabled {
|
||||||
@@ -90,7 +94,7 @@ impl InvokeServiceImpl {
|
|||||||
}
|
}
|
||||||
Ok(ResolvedScript {
|
Ok(ResolvedScript {
|
||||||
script_id: script.id,
|
script_id: script.id,
|
||||||
app_id: script.app_id,
|
app_id: cx.app_id,
|
||||||
source: script.source,
|
source: script.source,
|
||||||
updated_at: script.updated_at,
|
updated_at: script.updated_at,
|
||||||
name: script.name,
|
name: script.name,
|
||||||
@@ -113,7 +117,9 @@ impl InvokeServiceImpl {
|
|||||||
}
|
}
|
||||||
Ok(ResolvedScript {
|
Ok(ResolvedScript {
|
||||||
script_id: script.id,
|
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,
|
source: script.source,
|
||||||
updated_at: script.updated_at,
|
updated_at: script.updated_at,
|
||||||
name: script.name,
|
name: script.name,
|
||||||
@@ -227,7 +233,7 @@ mod tests {
|
|||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
Ok(
|
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())
|
Some(self.script.clone())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -312,7 +318,8 @@ mod tests {
|
|||||||
fn make_script(app_id: AppId, name: &str) -> Script {
|
fn make_script(app_id: AppId, name: &str) -> Script {
|
||||||
Script {
|
Script {
|
||||||
id: ScriptId::new(),
|
id: ScriptId::new(),
|
||||||
app_id,
|
app_id: Some(app_id),
|
||||||
|
group_id: None,
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: None,
|
description: None,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, RequestId, Script,
|
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, GroupId, RequestId, Script,
|
||||||
ScriptId, ScriptKind, ScriptSandbox,
|
ScriptId, ScriptKind, ScriptSandbox,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -142,7 +142,13 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
|
|||||||
/// constraints; the repo enforces them in the DB regardless.
|
/// constraints; the repo enforces them in the DB regardless.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewScript {
|
pub struct NewScript {
|
||||||
pub app_id: AppId,
|
/// App owner (the common case). Exactly one of `app_id`/`group_id` must
|
||||||
|
/// be `Some` — the DB CHECK is the backstop, but callers should uphold
|
||||||
|
/// it. App-owned creation continues to pass `Some(app_id)`, `group_id:
|
||||||
|
/// None`; group-owned creation (Phase 4) inverts that.
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
/// Group owner (Phase 4). See [`NewScript::app_id`].
|
||||||
|
pub group_id: Option<GroupId>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
@@ -206,7 +212,7 @@ impl PostgresScriptRepository {
|
|||||||
/// Columns selected from `scripts` everywhere — kept in one constant so
|
/// Columns selected from `scripts` everywhere — kept in one constant so
|
||||||
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
|
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
|
||||||
/// one query.
|
/// one query.
|
||||||
const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \
|
const SCRIPT_SELECT_COLS: &str = "id, app_id, group_id, name, description, version, source, kind, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, enabled, \
|
timeout_seconds, memory_limit_mb, sandbox, enabled, \
|
||||||
created_at, updated_at";
|
created_at, updated_at";
|
||||||
|
|
||||||
@@ -397,12 +403,13 @@ pub(crate) async fn insert_script_tx(
|
|||||||
.unwrap_or_else(|_| serde_json::json!({}));
|
.unwrap_or_else(|_| serde_json::json!({}));
|
||||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
"INSERT INTO scripts ( \
|
"INSERT INTO scripts ( \
|
||||||
app_id, name, description, source, kind, \
|
app_id, group_id, name, description, source, kind, \
|
||||||
timeout_seconds, memory_limit_mb, sandbox, enabled \
|
timeout_seconds, memory_limit_mb, sandbox, enabled \
|
||||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8, $9) \
|
) VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 30), COALESCE($8, 256), $9, $10) \
|
||||||
RETURNING {SCRIPT_SELECT_COLS}"
|
RETURNING {SCRIPT_SELECT_COLS}"
|
||||||
))
|
))
|
||||||
.bind(input.app_id.into_inner())
|
.bind(input.app_id.map(AppId::into_inner))
|
||||||
|
.bind(input.group_id.map(GroupId::into_inner))
|
||||||
.bind(&input.name)
|
.bind(&input.name)
|
||||||
.bind(input.description.as_deref())
|
.bind(input.description.as_deref())
|
||||||
.bind(&input.source)
|
.bind(&input.source)
|
||||||
@@ -417,13 +424,18 @@ pub(crate) async fn insert_script_tx(
|
|||||||
Ok(row) => row.into(),
|
Ok(row) => row.into(),
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
return Err(ScriptRepositoryError::Conflict(format!(
|
return Err(ScriptRepositoryError::Conflict(format!(
|
||||||
"a script named {:?} already exists in this app",
|
"a script named {:?} already exists in this owner",
|
||||||
input.name
|
input.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
replace_imports_tx(tx, script.id, script.app_id, &input.imports).await?;
|
// Module imports are resolved within the owning app's script set. Group
|
||||||
|
// scripts must be self-contained in Phase 4-lite (the origin-aware import
|
||||||
|
// resolver is Phase 4b), so only app-owned scripts wire up import edges.
|
||||||
|
if let Some(app_id) = script.app_id {
|
||||||
|
replace_imports_tx(tx, script.id, app_id, &input.imports).await?;
|
||||||
|
}
|
||||||
Ok(script)
|
Ok(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +488,9 @@ pub(crate) async fn update_script_tx(
|
|||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
if let Some(imports) = patch.imports.as_deref() {
|
if let Some(imports) = patch.imports.as_deref() {
|
||||||
replace_imports_tx(tx, script.id, script.app_id, imports).await?;
|
if let Some(app_id) = script.app_id {
|
||||||
|
replace_imports_tx(tx, script.id, app_id, imports).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(script)
|
Ok(script)
|
||||||
}
|
}
|
||||||
@@ -501,7 +515,10 @@ pub(crate) async fn delete_script_tx(
|
|||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ScriptRow {
|
struct ScriptRow {
|
||||||
id: uuid::Uuid,
|
id: uuid::Uuid,
|
||||||
app_id: uuid::Uuid,
|
/// Polymorphic owner (Phase 4): exactly one of `app_id`/`group_id` is
|
||||||
|
/// non-NULL (DB CHECK). App-owned rows keep `app_id` set as before.
|
||||||
|
app_id: Option<uuid::Uuid>,
|
||||||
|
group_id: Option<uuid::Uuid>,
|
||||||
name: String,
|
name: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
version: i32,
|
version: i32,
|
||||||
@@ -531,7 +548,8 @@ impl From<ScriptRow> for Script {
|
|||||||
let kind = ScriptKind::parse_str(&r.kind).unwrap_or(ScriptKind::Endpoint);
|
let kind = ScriptKind::parse_str(&r.kind).unwrap_or(ScriptKind::Endpoint);
|
||||||
Self {
|
Self {
|
||||||
id: r.id.into(),
|
id: r.id.into(),
|
||||||
app_id: r.app_id.into(),
|
app_id: r.app_id.map(Into::into),
|
||||||
|
group_id: r.group_id.map(Into::into),
|
||||||
name: r.name,
|
name: r.name,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
version: r.version,
|
version: r.version,
|
||||||
|
|||||||
@@ -148,10 +148,16 @@ async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
.get(script_id)
|
.get(script_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
|
// Phase 4: routes bind to app-owned scripts; a group-owned script
|
||||||
|
// (`app_id: None`) is not addressable here (binding lands in C3), so it
|
||||||
|
// reads as a missing script.
|
||||||
|
let app_id = script
|
||||||
|
.app_id
|
||||||
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppRead(script.app_id),
|
Capability::AppRead(app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||||
@@ -176,7 +182,11 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
.get(script_id)
|
.get(script_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
let app_id = script.app_id;
|
// Phase 4: only app-owned scripts are route-bindable for now (group
|
||||||
|
// binding is C3); a group script reads as missing here.
|
||||||
|
let app_id = script
|
||||||
|
.app_id
|
||||||
|
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
|
|||||||
@@ -289,7 +289,10 @@ async fn validate_trigger_target(
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
TriggersApiError::Invalid(format!("script {script_id} not found in this app"))
|
TriggersApiError::Invalid(format!("script {script_id} not found in this app"))
|
||||||
})?;
|
})?;
|
||||||
if script.app_id != app_id {
|
// Phase 4: trigger targets are app-owned scripts; a group-owned script
|
||||||
|
// (`app_id: None`) fails closed here until group-script trigger binding
|
||||||
|
// lands in C3.
|
||||||
|
if !script.is_owned_by_app(app_id) {
|
||||||
return Err(TriggersApiError::Invalid(format!(
|
return Err(TriggersApiError::Invalid(format!(
|
||||||
"script {script_id} does not belong to this app"
|
"script {script_id} does not belong to this app"
|
||||||
)));
|
)));
|
||||||
@@ -1348,7 +1351,8 @@ mod tests {
|
|||||||
script_id,
|
script_id,
|
||||||
picloud_shared::Script {
|
picloud_shared::Script {
|
||||||
id: script_id,
|
id: script_id,
|
||||||
app_id,
|
app_id: Some(app_id),
|
||||||
|
group_id: None,
|
||||||
name: format!(
|
name: format!(
|
||||||
"{}_{}",
|
"{}_{}",
|
||||||
match kind {
|
match kind {
|
||||||
@@ -1393,7 +1397,7 @@ mod tests {
|
|||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.values()
|
.values()
|
||||||
.find(|s| s.app_id == app_id && s.name == name)
|
.find(|s| s.app_id == Some(app_id) && s.name == name)
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
async fn list(
|
async fn list(
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ table: apps
|
|||||||
description: text NULL
|
description: text NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_at: timestamp with time zone NOT NULL default=now()
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
group_id: uuid NOT NULL
|
||||||
|
environment: text NULL
|
||||||
|
|
||||||
table: cron_trigger_details
|
table: cron_trigger_details
|
||||||
trigger_id: uuid NOT NULL
|
trigger_id: uuid NOT NULL
|
||||||
@@ -212,6 +214,23 @@ table: files_trigger_details
|
|||||||
collection_glob: text NOT NULL
|
collection_glob: text NOT NULL
|
||||||
ops: ARRAY NOT NULL
|
ops: ARRAY NOT NULL
|
||||||
|
|
||||||
|
table: group_members
|
||||||
|
group_id: uuid NOT NULL
|
||||||
|
user_id: uuid NOT NULL
|
||||||
|
role: text NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: groups
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
parent_id: uuid NULL
|
||||||
|
slug: text NOT NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
description: text NULL
|
||||||
|
structure_version: bigint NOT NULL default=1
|
||||||
|
owner_project: uuid NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: kv_entries
|
table: kv_entries
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
collection: text NOT NULL
|
collection: text NOT NULL
|
||||||
@@ -277,6 +296,7 @@ table: routes
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
dispatch_mode: text NOT NULL default='sync'::text
|
dispatch_mode: text NOT NULL default='sync'::text
|
||||||
|
enabled: boolean NOT NULL default=true
|
||||||
|
|
||||||
table: script_imports
|
table: script_imports
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
@@ -295,17 +315,21 @@ table: scripts
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_at: timestamp with time zone NOT NULL default=now()
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NULL
|
||||||
kind: text NOT NULL default='endpoint'::text
|
kind: text NOT NULL default='endpoint'::text
|
||||||
|
enabled: boolean NOT NULL default=true
|
||||||
|
group_id: uuid NULL
|
||||||
|
|
||||||
table: secrets
|
table: secrets
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NULL
|
||||||
name: text NOT NULL
|
name: text NOT NULL
|
||||||
encrypted_value: bytea NOT NULL
|
encrypted_value: bytea NOT NULL
|
||||||
nonce: bytea NOT NULL
|
nonce: bytea NOT NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_at: timestamp with time zone NOT NULL default=now()
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
version: smallint NOT NULL default=0
|
version: smallint NOT NULL default=0
|
||||||
|
group_id: uuid NULL
|
||||||
|
environment_scope: text NOT NULL default='*'::text
|
||||||
|
|
||||||
table: topics
|
table: topics
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
@@ -328,6 +352,18 @@ table: triggers
|
|||||||
registered_by_principal: uuid NOT NULL
|
registered_by_principal: uuid NOT NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_at: timestamp with time zone NOT NULL default=now()
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
name: text NOT NULL default=(gen_random_uuid())::text
|
||||||
|
|
||||||
|
table: vars
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
group_id: uuid NULL
|
||||||
|
app_id: uuid NULL
|
||||||
|
environment_scope: text NOT NULL default='*'::text
|
||||||
|
key: text NOT NULL
|
||||||
|
value: jsonb NOT NULL
|
||||||
|
is_tombstone: boolean NOT NULL default=false
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
## indexes
|
## indexes
|
||||||
|
|
||||||
@@ -395,6 +431,7 @@ indexes on app_users:
|
|||||||
idx_app_users_app_email_lower: public.app_users USING btree (app_id, lower(email))
|
idx_app_users_app_email_lower: public.app_users USING btree (app_id, lower(email))
|
||||||
|
|
||||||
indexes on apps:
|
indexes on apps:
|
||||||
|
apps_group_id_idx: public.apps USING btree (group_id)
|
||||||
apps_pkey: public.apps USING btree (id)
|
apps_pkey: public.apps USING btree (id)
|
||||||
apps_slug_key: public.apps USING btree (slug)
|
apps_slug_key: public.apps USING btree (slug)
|
||||||
|
|
||||||
@@ -433,6 +470,15 @@ indexes on files:
|
|||||||
indexes on files_trigger_details:
|
indexes on files_trigger_details:
|
||||||
files_trigger_details_pkey: public.files_trigger_details USING btree (trigger_id)
|
files_trigger_details_pkey: public.files_trigger_details USING btree (trigger_id)
|
||||||
|
|
||||||
|
indexes on group_members:
|
||||||
|
group_members_pkey: public.group_members USING btree (group_id, user_id)
|
||||||
|
group_members_user_id_idx: public.group_members USING btree (user_id)
|
||||||
|
|
||||||
|
indexes on groups:
|
||||||
|
groups_parent_id_idx: public.groups USING btree (parent_id)
|
||||||
|
groups_pkey: public.groups USING btree (id)
|
||||||
|
groups_slug_key: public.groups USING btree (slug)
|
||||||
|
|
||||||
indexes on kv_entries:
|
indexes on kv_entries:
|
||||||
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
|
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
|
||||||
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
|
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
|
||||||
@@ -473,12 +519,16 @@ indexes on script_imports:
|
|||||||
indexes on scripts:
|
indexes on scripts:
|
||||||
idx_scripts_app_kind: public.scripts USING btree (app_id, kind)
|
idx_scripts_app_kind: public.scripts USING btree (app_id, kind)
|
||||||
scripts_app_id_idx: public.scripts USING btree (app_id)
|
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||||
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
scripts_app_name_uidx: public.scripts USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
|
||||||
|
scripts_group_id_idx: public.scripts USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||||
|
scripts_group_name_uidx: public.scripts USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
|
||||||
scripts_pkey: public.scripts USING btree (id)
|
scripts_pkey: public.scripts USING btree (id)
|
||||||
|
|
||||||
indexes on secrets:
|
indexes on secrets:
|
||||||
idx_secrets_app: public.secrets USING btree (app_id)
|
idx_secrets_app: public.secrets USING btree (app_id)
|
||||||
secrets_pkey: public.secrets USING btree (app_id, name)
|
idx_secrets_group: public.secrets USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||||
|
secrets_app_uidx: public.secrets USING btree (app_id, environment_scope, name) WHERE (app_id IS NOT NULL)
|
||||||
|
secrets_group_uidx: public.secrets USING btree (group_id, environment_scope, name) WHERE (group_id IS NOT NULL)
|
||||||
|
|
||||||
indexes on topics:
|
indexes on topics:
|
||||||
topics_pkey: public.topics USING btree (app_id, name)
|
topics_pkey: public.topics USING btree (app_id, name)
|
||||||
@@ -487,8 +537,16 @@ indexes on triggers:
|
|||||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
||||||
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
||||||
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
|
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
|
||||||
|
triggers_app_name_uniq: public.triggers USING btree (app_id, name)
|
||||||
triggers_pkey: public.triggers USING btree (id)
|
triggers_pkey: public.triggers USING btree (id)
|
||||||
|
|
||||||
|
indexes on vars:
|
||||||
|
vars_app_id_idx: public.vars USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||||
|
vars_app_uidx: public.vars USING btree (app_id, environment_scope, key) WHERE (app_id IS NOT NULL)
|
||||||
|
vars_group_id_idx: public.vars USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||||
|
vars_group_uidx: public.vars USING btree (group_id, environment_scope, key) WHERE (group_id IS NOT NULL)
|
||||||
|
vars_pkey: public.vars USING btree (id)
|
||||||
|
|
||||||
## constraints
|
## constraints
|
||||||
|
|
||||||
constraints on abandoned_executions:
|
constraints on abandoned_executions:
|
||||||
@@ -561,6 +619,7 @@ constraints on app_users:
|
|||||||
[PRIMARY KEY] app_users_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] app_users_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on apps:
|
constraints on apps:
|
||||||
|
[FOREIGN KEY] apps_group_id_fk: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||||
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||||
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||||
|
|
||||||
@@ -605,6 +664,17 @@ constraints on files_trigger_details:
|
|||||||
[FOREIGN KEY] files_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
[FOREIGN KEY] files_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||||
|
|
||||||
|
constraints on group_members:
|
||||||
|
[CHECK] group_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||||
|
[FOREIGN KEY] group_members_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] group_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] group_members_pkey: PRIMARY KEY (group_id, user_id)
|
||||||
|
|
||||||
|
constraints on groups:
|
||||||
|
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||||
|
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
||||||
|
[UNIQUE] groups_slug_key: UNIQUE (slug)
|
||||||
|
|
||||||
constraints on kv_entries:
|
constraints on kv_entries:
|
||||||
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
|
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
|
||||||
@@ -648,13 +718,16 @@ constraints on scripts:
|
|||||||
[CHECK] scripts_kind_check: CHECK ((kind = ANY (ARRAY['endpoint'::text, 'module'::text])))
|
[CHECK] scripts_kind_check: CHECK ((kind = ANY (ARRAY['endpoint'::text, 'module'::text])))
|
||||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||||
[CHECK] scripts_module_name_shape: CHECK (((kind <> 'module'::text) OR (name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'::text)))
|
[CHECK] scripts_module_name_shape: CHECK (((kind <> 'module'::text) OR (name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'::text)))
|
||||||
|
[CHECK] scripts_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||||
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||||
|
[FOREIGN KEY] scripts_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on secrets:
|
constraints on secrets:
|
||||||
|
[CHECK] secrets_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||||
[FOREIGN KEY] secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
[FOREIGN KEY] secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] secrets_pkey: PRIMARY KEY (app_id, name)
|
[FOREIGN KEY] secrets_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
|
|
||||||
constraints on topics:
|
constraints on topics:
|
||||||
[CHECK] topics_auth_mode_check: CHECK ((auth_mode = ANY (ARRAY['public'::text, 'token'::text, 'session'::text])))
|
[CHECK] topics_auth_mode_check: CHECK ((auth_mode = ANY (ARRAY['public'::text, 'token'::text, 'session'::text])))
|
||||||
@@ -670,6 +743,12 @@ constraints on triggers:
|
|||||||
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
|
constraints on vars:
|
||||||
|
[CHECK] vars_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||||
|
[FOREIGN KEY] vars_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] vars_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] vars_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
## applied migrations
|
## applied migrations
|
||||||
0001: init
|
0001: init
|
||||||
0002: sandbox
|
0002: sandbox
|
||||||
@@ -715,3 +794,9 @@ constraints on triggers:
|
|||||||
0042: secrets envelope version
|
0042: secrets envelope version
|
||||||
0043: execution logs source
|
0043: execution logs source
|
||||||
0044: delete reserved path routes
|
0044: delete reserved path routes
|
||||||
|
0045: scripts routes enabled
|
||||||
|
0046: trigger name
|
||||||
|
0047: groups
|
||||||
|
0048: vars
|
||||||
|
0049: group secrets
|
||||||
|
0050: group scripts
|
||||||
|
|||||||
@@ -126,8 +126,14 @@ where
|
|||||||
if !script.enabled {
|
if !script.enabled {
|
||||||
return Err(ApiError::NotFound(id));
|
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;
|
req.sandbox_overrides = script.sandbox;
|
||||||
let request_id = req.request_id;
|
let request_id = req.request_id;
|
||||||
let request_path = req.path.clone();
|
let request_path = req.path.clone();
|
||||||
@@ -151,7 +157,7 @@ where
|
|||||||
// audit-visible platform — but a sink failure must not mask the
|
// audit-visible platform — but a sink failure must not mask the
|
||||||
// user-facing result, so we only log a warning if it fails.
|
// user-facing result, so we only log a warning if it fails.
|
||||||
let log = build_execution_log(
|
let log = build_execution_log(
|
||||||
script.app_id,
|
app_id,
|
||||||
id,
|
id,
|
||||||
request_id,
|
request_id,
|
||||||
request_path,
|
request_path,
|
||||||
|
|||||||
@@ -40,10 +40,15 @@ pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
|||||||
let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
|
let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
|
||||||
let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
|
let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
|
||||||
for s in scripts {
|
for s in scripts {
|
||||||
let app_slug = slug_by_id
|
// Group-owned scripts (Phase 4) have no app; show a marker rather
|
||||||
.get(&s.app_id)
|
// than a slug. App-owned scripts resolve their slug as before.
|
||||||
.cloned()
|
let app_slug = match s.app_id {
|
||||||
.unwrap_or_else(|| "-".to_string());
|
Some(app_id) => slug_by_id
|
||||||
|
.get(&app_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
None => "(group)".to_string(),
|
||||||
|
};
|
||||||
table.row([
|
table.row([
|
||||||
s.id.to_string(),
|
s.id.to_string(),
|
||||||
app_slug,
|
app_slug,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{AppId, ScriptId, ScriptSandbox};
|
use crate::{AppId, GroupId, ScriptId, ScriptSandbox};
|
||||||
|
|
||||||
/// Semantic role of a script (v1.1.3).
|
/// Semantic role of a script (v1.1.3).
|
||||||
///
|
///
|
||||||
@@ -94,10 +94,26 @@ mod kind_tests {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Script {
|
pub struct Script {
|
||||||
pub id: ScriptId,
|
pub id: ScriptId,
|
||||||
/// Owning app. Set on create, immutable thereafter — a "move to
|
/// Owning app, when app-owned. Set on create, immutable thereafter — a
|
||||||
/// another app" is a copy+delete, not an in-place edit (snapshot
|
/// "move to another app" is a copy+delete, not an in-place edit
|
||||||
/// semantics — see blueprint §11.5).
|
/// (snapshot semantics — see blueprint §11.5).
|
||||||
pub app_id: AppId,
|
///
|
||||||
|
/// Phase 4 (v1.2 Hierarchies) made script ownership **polymorphic**:
|
||||||
|
/// exactly one of `app_id` / `group_id` is set (DB CHECK + [`ScriptOwner`]).
|
||||||
|
/// A group-owned script (`app_id: None`, `group_id: Some`) is a template
|
||||||
|
/// inherited by every descendant app — it has no single app, so the
|
||||||
|
/// **execution context** app is supplied by the route/trigger/caller that
|
||||||
|
/// invoked it, never read off the script. Reading `app_id` to mean "the
|
||||||
|
/// app this runs under" is therefore a bug for group scripts; use the
|
||||||
|
/// invoking surface's app_id instead.
|
||||||
|
#[serde(default)]
|
||||||
|
pub app_id: Option<AppId>,
|
||||||
|
/// Owning group, when group-owned (Phase 4). Mutually exclusive with
|
||||||
|
/// `app_id`. The script is resolved by name, nearest-owner-wins, down the
|
||||||
|
/// `apps.group_id → groups.parent_id` chain (CoW: an app's own script of
|
||||||
|
/// the same name shadows the inherited one).
|
||||||
|
#[serde(default)]
|
||||||
|
pub group_id: Option<GroupId>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
@@ -131,3 +147,36 @@ pub struct Script {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Who owns a script (Phase 4). Exactly one owner — the DB enforces it with
|
||||||
|
/// a `CHECK ((group_id IS NULL) <> (app_id IS NULL))`; this enum is the
|
||||||
|
/// in-memory witness of that invariant so call sites can `match` exhaustively
|
||||||
|
/// instead of juggling two `Option`s.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ScriptOwner {
|
||||||
|
App(AppId),
|
||||||
|
Group(GroupId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Script {
|
||||||
|
/// The script's owner, reconstructed from the polymorphic columns.
|
||||||
|
/// A row that violates the exactly-one invariant (both/neither set —
|
||||||
|
/// impossible under the CHECK) is reported as whichever is present,
|
||||||
|
/// preferring the app, so a corrupt row never panics on the hot path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn owner(&self) -> Option<ScriptOwner> {
|
||||||
|
match (self.app_id, self.group_id) {
|
||||||
|
(Some(a), _) => Some(ScriptOwner::App(a)),
|
||||||
|
(None, Some(g)) => Some(ScriptOwner::Group(g)),
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff this script is owned by `app` directly (not via a group).
|
||||||
|
/// The cheap, app-owned-only ownership check — group inheritance is
|
||||||
|
/// resolved separately (chain-membership), never by this method.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_owned_by_app(&self, app: AppId) -> bool {
|
||||||
|
self.app_id == Some(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user