feat(scripts): inherited resolution for invoke() + chain-aware dispatch guards
Phase 4-lite C3. A descendant app can now resolve and run a group-owned script
inherited down its ownership chain — the core of group-script inheritance.
Resolver (repo, reusing config_resolver's CHAIN_LEVELS_CTE):
* `get_by_name_inherited(app_id, name)` — walk app → ancestor groups
nearest-first, return the nearest script of that name (an app's own script
shadows an inherited group one: CoW).
* `is_invocable_by_app(script_id, app_id)` — EXISTS over the same chain; the
cross-app isolation predicate generalized to the hierarchy.
invoke(): `invoke("name", …)` resolves via `get_by_name_inherited`, so a script
can call a shared group endpoint by name. A group script always runs under the
*caller's* app context, never its owner.
Dispatch guards (the isolation backstops touched in C1) are now chain-aware via
a `Dispatcher::script_invocable` helper and the chain-aware
`validate_trigger_target`: app-owned scripts keep the in-memory fast path (no
query, behavior byte-identical to the pre-Phase-4 same-app guard); only a
group-owned candidate pays one chain query, and a non-member / query error
fails closed (dead-letter or reject — never run under another app's SdkCallCx).
The orchestrator HTTP route path needs no change: its per-app route trie
already scopes routes to one app, and it dispatches under the route's app
without reading script.app_id — a route bound to a group script just works.
Live-validated against the dev DB: an app under group G invoking the group's
`shared` returns its output; an app NOT under G cannot resolve it (isolation);
and after the app deploys its own `shared`, the same call returns the app's
version (CoW shadowing). Route/trigger binding of inherited scripts via the
declarative engine is C4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,9 +29,9 @@ use picloud_executor_core::{
|
|||||||
};
|
};
|
||||||
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
|
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
|
AppId, DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
|
||||||
HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult,
|
HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult,
|
||||||
RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
RequestId, Script, ScriptId, ScriptSandbox, TriggerEvent,
|
||||||
};
|
};
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -191,6 +191,32 @@ impl ExecLogContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Dispatcher {
|
impl Dispatcher {
|
||||||
|
/// Phase 4 isolation backstop: may `script` run under `app_id`'s context?
|
||||||
|
/// True when the script is owned by `app_id` directly, or by a group on
|
||||||
|
/// its ancestor chain (an inherited group script). App-owned is the
|
||||||
|
/// in-memory fast path — only a group-owned script pays the chain query,
|
||||||
|
/// and a query error fails closed (not invocable). This generalizes the
|
||||||
|
/// pre-Phase-4 same-app guard (`script.app_id == row.app_id`) to the
|
||||||
|
/// hierarchy without changing behavior for app-owned scripts.
|
||||||
|
async fn script_invocable(&self, script: &Script, app_id: AppId) -> bool {
|
||||||
|
if script.is_owned_by_app(app_id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if script.group_id.is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match self.scripts.is_invocable_by_app(script.id, app_id).await {
|
||||||
|
Ok(ok) => ok,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e, script_id = %script.id, %app_id,
|
||||||
|
"chain-membership check failed; treating script as not invocable"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn the dispatcher loop as a detached `tokio::task`. Also
|
/// Spawn the dispatcher loop as a detached `tokio::task`. Also
|
||||||
/// spawns the v1.1.9 queue visibility-timeout reclaim task. Both
|
/// spawns the v1.1.9 queue visibility-timeout reclaim task. Both
|
||||||
/// run for the process lifetime; returned `JoinHandle`s are
|
/// run for the process lifetime; returned `JoinHandle`s are
|
||||||
@@ -359,12 +385,12 @@ 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.
|
||||||
// Phase 4: a group-owned script (`app_id: None`) is not yet
|
// Phase 4: the target must be invocable in the claimed message's app
|
||||||
// dispatchable here — the chain-membership check that authorizes
|
// context — owned by that app or an inherited group script on its
|
||||||
// inherited scripts lands with group-script binding. Until then
|
// chain. App-owned scripts take the in-memory fast path; only a group
|
||||||
// `is_owned_by_app` is the exact app-owned guard as before and a
|
// script pays the chain query, and a non-member fails closed
|
||||||
// group script fails closed (dead-lettered, not silently run).
|
// (dead-lettered, not silently run under another app's SdkCallCx).
|
||||||
if !script.is_owned_by_app(claimed.app_id) {
|
if !self.script_invocable(&script, claimed.app_id).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
script_app = ?script.app_id,
|
script_app = ?script.app_id,
|
||||||
claim_app = %claimed.app_id,
|
claim_app = %claimed.app_id,
|
||||||
@@ -791,9 +817,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.
|
||||||
// Phase 4: group scripts (`app_id: None`) fail closed here until the
|
// Phase 4: invocable in the outbox row's app context (app-owned or an
|
||||||
// chain-membership check lands with group-script binding.
|
// inherited group script on the chain); a non-member fails closed.
|
||||||
if !script.is_owned_by_app(row.app_id) {
|
if !self.script_invocable(&script, row.app_id).await {
|
||||||
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(),
|
||||||
));
|
));
|
||||||
@@ -883,8 +909,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.
|
||||||
// Phase 4: group scripts fail closed here (see queue/trigger arms).
|
// Phase 4: invocable in this app's context (see queue/trigger arms).
|
||||||
if !script.is_owned_by_app(row.app_id) {
|
if !self.script_invocable(&script, row.app_id).await {
|
||||||
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(),
|
||||||
));
|
));
|
||||||
@@ -977,8 +1003,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.
|
||||||
// Phase 4: group scripts fail closed here (see queue/trigger arms).
|
// Phase 4: invocable in this app's context (app-owned or inherited).
|
||||||
if !script.is_owned_by_app(row.app_id) {
|
if !self.script_invocable(&script, row.app_id).await {
|
||||||
return Err(DispatcherError::ResolveTrigger(
|
return Err(DispatcherError::ResolveTrigger(
|
||||||
"invoke target belongs to a different app".into(),
|
"invoke target belongs to a different app".into(),
|
||||||
));
|
));
|
||||||
@@ -1649,6 +1675,20 @@ mod tests {
|
|||||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
unimplemented!("not used by this test")
|
unimplemented!("not used by this test")
|
||||||
}
|
}
|
||||||
|
async fn get_by_name_inherited(
|
||||||
|
&self,
|
||||||
|
_app_id: AppId,
|
||||||
|
_name: &str,
|
||||||
|
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
|
unimplemented!("not used by this test")
|
||||||
|
}
|
||||||
|
async fn is_invocable_by_app(
|
||||||
|
&self,
|
||||||
|
_script_id: ScriptId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<bool, ScriptRepositoryError> {
|
||||||
|
Ok(self.script.is_owned_by_app(app_id))
|
||||||
|
}
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
unimplemented!("not used by this test")
|
unimplemented!("not used by this test")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,9 +106,13 @@ impl InvokeServiceImpl {
|
|||||||
cx: &SdkCallCx,
|
cx: &SdkCallCx,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<ResolvedScript, InvokeError> {
|
) -> Result<ResolvedScript, InvokeError> {
|
||||||
|
// Phase 4: resolve down the app's chain — an inherited group script
|
||||||
|
// of this name is reachable, nearest-owner-wins (an app's own script
|
||||||
|
// shadows it). The chain only contains the app + its ancestors, so the
|
||||||
|
// resolved script is invocable by `cx.app_id` by construction.
|
||||||
let script = self
|
let script = self
|
||||||
.scripts
|
.scripts
|
||||||
.get_by_name(cx.app_id, name)
|
.get_by_name_inherited(cx.app_id, name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
||||||
.ok_or_else(|| InvokeError::NotFound(format!("name {name:?}")))?;
|
.ok_or_else(|| InvokeError::NotFound(format!("name {name:?}")))?;
|
||||||
@@ -117,8 +121,8 @@ impl InvokeServiceImpl {
|
|||||||
}
|
}
|
||||||
Ok(ResolvedScript {
|
Ok(ResolvedScript {
|
||||||
script_id: script.id,
|
script_id: script.id,
|
||||||
// Resolved within `cx.app_id`'s own scope (get_by_name filters by
|
// The execution-context app is always the caller's app — a group
|
||||||
// it), so the execution-context app is the caller's app.
|
// script runs under the inheriting app, never its owner.
|
||||||
app_id: cx.app_id,
|
app_id: cx.app_id,
|
||||||
source: script.source,
|
source: script.source,
|
||||||
updated_at: script.updated_at,
|
updated_at: script.updated_at,
|
||||||
@@ -240,6 +244,21 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
async fn get_by_name_inherited(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
|
// This one-script repo has no hierarchy; inherited == own-scope.
|
||||||
|
self.get_by_name(app_id, name).await
|
||||||
|
}
|
||||||
|
async fn is_invocable_by_app(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
_app_id: AppId,
|
||||||
|
) -> Result<bool, ScriptRepositoryError> {
|
||||||
|
Ok(script_id == self.script.id)
|
||||||
|
}
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
Ok(vec![self.script.clone()])
|
Ok(vec![self.script.clone()])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,26 @@ pub trait ScriptRepository: Send + Sync {
|
|||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<Option<Script>, ScriptRepositoryError>;
|
) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||||
|
/// Phase 4: resolve `name → Script` down `app_id`'s ownership chain —
|
||||||
|
/// the app itself, then its ancestor groups nearest-first. Nearest owner
|
||||||
|
/// wins (an app's own script shadows an inherited group one of the same
|
||||||
|
/// name: CoW). Returns `None` if no script in the chain has that name.
|
||||||
|
/// Backs inherited `invoke("name")` and declarative name→id binding.
|
||||||
|
async fn get_by_name_inherited(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||||
|
/// Phase 4: is `script_id` invocable in `app_id`'s context? True iff the
|
||||||
|
/// script is owned by `app_id` directly, OR owned by a group on `app_id`'s
|
||||||
|
/// ancestor chain. The cross-app isolation predicate generalized to the
|
||||||
|
/// hierarchy — the runtime backstop for trigger/queue/invoke dispatch and
|
||||||
|
/// trigger-bind validation. A missing script returns `false` (fail closed).
|
||||||
|
async fn is_invocable_by_app(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<bool, ScriptRepositoryError>;
|
||||||
/// Every script across all apps. Mostly for tests and admin
|
/// Every script across all apps. Mostly for tests and admin
|
||||||
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||||
@@ -97,6 +117,20 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
|
|||||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
(**self).get_by_name(app_id, name).await
|
(**self).get_by_name(app_id, name).await
|
||||||
}
|
}
|
||||||
|
async fn get_by_name_inherited(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
|
(**self).get_by_name_inherited(app_id, name).await
|
||||||
|
}
|
||||||
|
async fn is_invocable_by_app(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<bool, ScriptRepositoryError> {
|
||||||
|
(**self).is_invocable_by_app(script_id, app_id).await
|
||||||
|
}
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
(**self).list().await
|
(**self).list().await
|
||||||
}
|
}
|
||||||
@@ -253,6 +287,58 @@ impl ScriptRepository for PostgresScriptRepository {
|
|||||||
Ok(row.map(Into::into))
|
Ok(row.map(Into::into))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_by_name_inherited(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||||
|
// Walk app → ancestor groups (CHAIN_LEVELS_CTE binds $1 = app_id),
|
||||||
|
// join scripts owned at each level, and take the nearest (lowest
|
||||||
|
// depth) row named `name`. Case-insensitive to match the per-owner
|
||||||
|
// `LOWER(name)` unique indexes. Mirrors the vars/secrets resolvers.
|
||||||
|
let cols = SCRIPT_SELECT_COLS
|
||||||
|
.split(", ")
|
||||||
|
.map(|c| format!("s.{c}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let row = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
|
"{cte} SELECT {cols} FROM scripts s \
|
||||||
|
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||||
|
WHERE LOWER(s.name) = LOWER($2) \
|
||||||
|
ORDER BY c.depth ASC \
|
||||||
|
LIMIT 1",
|
||||||
|
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
|
||||||
|
))
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_invocable_by_app(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<bool, ScriptRepositoryError> {
|
||||||
|
// EXISTS over the same chain: the script's owner (app or group) must
|
||||||
|
// appear on `app_id`'s app→ancestor-group chain. CHAIN_LEVELS_CTE
|
||||||
|
// binds $1 = app_id; $2 = script_id.
|
||||||
|
let exists: (bool,) = sqlx::query_as(&format!(
|
||||||
|
"{cte} SELECT EXISTS ( \
|
||||||
|
SELECT 1 FROM scripts s \
|
||||||
|
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||||
|
WHERE s.id = $2 \
|
||||||
|
)",
|
||||||
|
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
|
||||||
|
))
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(script_id.into_inner())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(exists.0)
|
||||||
|
}
|
||||||
|
|
||||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||||
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||||
"SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name"
|
"SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name"
|
||||||
|
|||||||
@@ -289,10 +289,17 @@ 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"))
|
||||||
})?;
|
})?;
|
||||||
// Phase 4: trigger targets are app-owned scripts; a group-owned script
|
// Phase 4: a trigger target must be invocable in this app's context —
|
||||||
// (`app_id: None`) fails closed here until group-script trigger binding
|
// owned by the app, or by a group on the app's ancestor chain (inherited).
|
||||||
// lands in C3.
|
// App-owned is the in-memory fast path (no query); only a group-owned
|
||||||
if !script.is_owned_by_app(app_id) {
|
// candidate pays the chain lookup.
|
||||||
|
let invocable = script.is_owned_by_app(app_id)
|
||||||
|
|| (script.group_id.is_some()
|
||||||
|
&& scripts
|
||||||
|
.is_invocable_by_app(script_id, app_id)
|
||||||
|
.await
|
||||||
|
.map_err(map_script_repo_err)?);
|
||||||
|
if !invocable {
|
||||||
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"
|
||||||
)));
|
)));
|
||||||
@@ -1400,6 +1407,26 @@ mod tests {
|
|||||||
.find(|s| s.app_id == Some(app_id) && s.name == name)
|
.find(|s| s.app_id == Some(app_id) && s.name == name)
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
|
async fn get_by_name_inherited(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||||
|
// No hierarchy in this in-memory repo; inherited == own-scope.
|
||||||
|
self.get_by_name(app_id, name).await
|
||||||
|
}
|
||||||
|
async fn is_invocable_by_app(
|
||||||
|
&self,
|
||||||
|
script_id: ScriptId,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<bool, crate::repo::ScriptRepositoryError> {
|
||||||
|
Ok(self
|
||||||
|
.existing
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&script_id)
|
||||||
|
.is_some_and(|s| s.is_owned_by_app(app_id)))
|
||||||
|
}
|
||||||
async fn list(
|
async fn list(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||||
|
|||||||
Reference in New Issue
Block a user