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>
490 lines
17 KiB
Rust
490 lines
17 KiB
Rust
//! `InvokeServiceImpl` — wires `ScriptRepository` + `RouteTable` +
|
|
//! `OutboxRepo` underneath the `picloud_shared::InvokeService` trait.
|
|
//!
|
|
//! Same-app only. The cross-app guard lives at the service entry point:
|
|
//! every resolved script's `app_id` must match `cx.app_id` — otherwise
|
|
//! `InvokeError::CrossApp` (v1.1.x maintains strict isolation).
|
|
//!
|
|
//! `invoke_async` writes a v1.1.9 `OutboxSourceKind::Invoke` row the
|
|
//! dispatcher consumes (commit 10). Runs once per row — no retries.
|
|
//! Callers wrap in `retry::with(...)` if they want retries.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use picloud_orchestrator_core::routing::{matcher, RouteTable};
|
|
use picloud_shared::{
|
|
ExecutionId, InvokeError, InvokeService, InvokeTarget, ResolvedScript, ScriptId, SdkCallCx,
|
|
};
|
|
|
|
use crate::authz::{self, AuthzRepo, Capability};
|
|
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
|
|
use crate::repo::ScriptRepository;
|
|
|
|
pub struct InvokeServiceImpl {
|
|
scripts: Arc<dyn ScriptRepository>,
|
|
routes: Arc<RouteTable>,
|
|
outbox: Arc<dyn OutboxRepo>,
|
|
/// F-S-012: optional. When set, invoke()/invoke_async() require
|
|
/// AppInvoke for authenticated callers; anonymous callers continue
|
|
/// to skip the check under the script-as-gate convention.
|
|
authz: Option<Arc<dyn AuthzRepo>>,
|
|
}
|
|
|
|
impl InvokeServiceImpl {
|
|
#[must_use]
|
|
pub fn new(
|
|
scripts: Arc<dyn ScriptRepository>,
|
|
routes: Arc<RouteTable>,
|
|
outbox: Arc<dyn OutboxRepo>,
|
|
) -> Self {
|
|
Self {
|
|
scripts,
|
|
routes,
|
|
outbox,
|
|
authz: None,
|
|
}
|
|
}
|
|
|
|
/// F-S-012: attach the authz repo so authenticated callers get
|
|
/// gated on AppInvoke. Without this builder call, the service is
|
|
/// open to any same-app script (the previous behaviour).
|
|
#[must_use]
|
|
pub fn with_authz(mut self, authz: Arc<dyn AuthzRepo>) -> Self {
|
|
self.authz = Some(authz);
|
|
self
|
|
}
|
|
|
|
async fn check_invoke(&self, cx: &SdkCallCx) -> Result<(), InvokeError> {
|
|
let Some(authz_repo) = self.authz.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
authz::script_gate(
|
|
authz_repo.as_ref(),
|
|
cx,
|
|
Capability::AppInvoke(cx.app_id),
|
|
|| InvokeError::Forbidden,
|
|
InvokeError::Backend,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn resolve_id(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
script_id: ScriptId,
|
|
) -> Result<ResolvedScript, InvokeError> {
|
|
let script = self
|
|
.scripts
|
|
.get(script_id)
|
|
.await
|
|
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
|
.ok_or_else(|| InvokeError::NotFound(format!("id {script_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 {
|
|
// §4.3: a disabled script is not invocable via any path. Surface as
|
|
// NotFound (indistinguishable from absent), like the data plane.
|
|
return Err(InvokeError::NotFound(format!("id {script_id}")));
|
|
}
|
|
Ok(ResolvedScript {
|
|
script_id: script.id,
|
|
app_id: cx.app_id,
|
|
source: script.source,
|
|
updated_at: script.updated_at,
|
|
name: script.name,
|
|
})
|
|
}
|
|
|
|
async fn resolve_name(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
name: &str,
|
|
) -> 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
|
|
.scripts
|
|
.get_by_name_inherited(cx.app_id, name)
|
|
.await
|
|
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
|
.ok_or_else(|| InvokeError::NotFound(format!("name {name:?}")))?;
|
|
if !script.enabled {
|
|
return Err(InvokeError::NotFound(format!("name {name:?}")));
|
|
}
|
|
Ok(ResolvedScript {
|
|
script_id: script.id,
|
|
// The execution-context app is always the caller's app — a group
|
|
// script runs under the inheriting app, never its owner.
|
|
app_id: cx.app_id,
|
|
source: script.source,
|
|
updated_at: script.updated_at,
|
|
name: script.name,
|
|
})
|
|
}
|
|
|
|
async fn resolve_path(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
path: &str,
|
|
) -> Result<ResolvedScript, InvokeError> {
|
|
// Run the matcher against this app's routes only. Host bucket
|
|
// is irrelevant for in-app composition — use a synthetic host
|
|
// that matches the wildcard tier. Method assumed POST since
|
|
// most route-resolution invokes target write endpoints; a more
|
|
// exact API would surface method via the SDK in a future bump.
|
|
let app_routes = self.routes.snapshot_for_app(cx.app_id);
|
|
let m = matcher::r#match(&app_routes, "invoke.local", "POST", path)
|
|
.or_else(|| matcher::r#match(&app_routes, "invoke.local", "GET", path));
|
|
let m = m.ok_or_else(|| InvokeError::NotFound(format!("path {path:?}")))?;
|
|
// Resolve the script the matched route bound. Cross-app
|
|
// re-check via the script row (defense in depth — RouteTable
|
|
// partitions by app already, but resolve_id verifies again).
|
|
self.resolve_id(cx, m.matched.script_id).await
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl InvokeService for InvokeServiceImpl {
|
|
async fn resolve(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
target: InvokeTarget,
|
|
) -> Result<ResolvedScript, InvokeError> {
|
|
// F-S-012: AppInvoke gate (skipped for anonymous callers).
|
|
self.check_invoke(cx).await?;
|
|
match target {
|
|
InvokeTarget::Id(id) => self.resolve_id(cx, id).await,
|
|
InvokeTarget::Name(n) => self.resolve_name(cx, &n).await,
|
|
InvokeTarget::Path(p) => self.resolve_path(cx, &p).await,
|
|
}
|
|
}
|
|
|
|
async fn enqueue_async(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
target: InvokeTarget,
|
|
args: serde_json::Value,
|
|
) -> Result<ExecutionId, InvokeError> {
|
|
// F-S-012: gate via resolve() which now calls check_invoke first.
|
|
let resolved = self.resolve(cx, target).await?;
|
|
let execution_id = ExecutionId::new();
|
|
// Payload carries everything the dispatcher's Invoke arm needs
|
|
// to build an ExecRequest at fire time.
|
|
let payload = serde_json::json!({
|
|
"kind": "invoke",
|
|
"script_id": resolved.script_id,
|
|
"script_name": resolved.name,
|
|
"args": args,
|
|
"execution_id": execution_id,
|
|
"caller_request_id": cx.request_id,
|
|
"root_execution_id": cx.root_execution_id,
|
|
"trigger_depth": cx.trigger_depth.saturating_add(1),
|
|
});
|
|
// Caller's principal is recorded as origin_principal (forensic),
|
|
// but the executing script runs with no principal — invoke_async
|
|
// is the fire-and-forget equivalent of a function call, not a
|
|
// re-authentication.
|
|
let origin_principal = cx.principal.as_ref().map(|p| p.user_id);
|
|
self.outbox
|
|
.insert(NewOutboxRow {
|
|
app_id: cx.app_id,
|
|
source_kind: OutboxSourceKind::Invoke,
|
|
trigger_id: None,
|
|
script_id: Some(resolved.script_id),
|
|
reply_to: None,
|
|
payload,
|
|
origin_principal,
|
|
trigger_depth: cx.trigger_depth.saturating_add(1),
|
|
root_execution_id: Some(cx.root_execution_id),
|
|
})
|
|
.await
|
|
.map_err(|e| InvokeError::Backend(e.to_string()))?;
|
|
Ok(execution_id)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::outbox_repo::{OutboxRepoError, OutboxRow};
|
|
use crate::repo::{NewScript, ScriptPatch, ScriptRepositoryError};
|
|
use chrono::Utc;
|
|
use picloud_shared::{AdminUserId, AppId, ExecutionId, RequestId, Script, ScriptSandbox};
|
|
|
|
struct OneScriptRepo {
|
|
script: Script,
|
|
}
|
|
#[async_trait]
|
|
impl ScriptRepository for OneScriptRepo {
|
|
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
|
Ok(if id == self.script.id {
|
|
Some(self.script.clone())
|
|
} else {
|
|
None
|
|
})
|
|
}
|
|
async fn get_by_name(
|
|
&self,
|
|
app_id: AppId,
|
|
name: &str,
|
|
) -> Result<Option<Script>, ScriptRepositoryError> {
|
|
Ok(
|
|
if self.script.app_id == Some(app_id) && name == self.script.name {
|
|
Some(self.script.clone())
|
|
} else {
|
|
None
|
|
},
|
|
)
|
|
}
|
|
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> {
|
|
Ok(vec![self.script.clone()])
|
|
}
|
|
async fn list_for_app(&self, _app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn list_for_group(
|
|
&self,
|
|
_group_id: picloud_shared::GroupId,
|
|
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn list_for_user(
|
|
&self,
|
|
_user_id: AdminUserId,
|
|
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn create(&self, _input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn update(
|
|
&self,
|
|
_id: ScriptId,
|
|
_patch: ScriptPatch,
|
|
) -> Result<Script, ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn delete(&self, _id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
|
unimplemented!()
|
|
}
|
|
async fn count_routes_for_script(
|
|
&self,
|
|
_script_id: ScriptId,
|
|
) -> Result<i64, ScriptRepositoryError> {
|
|
Ok(0)
|
|
}
|
|
async fn count_triggers_for_script(
|
|
&self,
|
|
_script_id: ScriptId,
|
|
) -> Result<i64, ScriptRepositoryError> {
|
|
Ok(0)
|
|
}
|
|
async fn list_imports(
|
|
&self,
|
|
_script_id: ScriptId,
|
|
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
|
Ok(vec![])
|
|
}
|
|
}
|
|
|
|
struct CapturingOutbox {
|
|
last: tokio::sync::Mutex<Option<NewOutboxRow>>,
|
|
}
|
|
#[async_trait]
|
|
impl OutboxRepo for CapturingOutbox {
|
|
async fn insert(&self, row: NewOutboxRow) -> Result<uuid::Uuid, OutboxRepoError> {
|
|
let id = uuid::Uuid::new_v4();
|
|
*self.last.lock().await = Some(row);
|
|
Ok(id)
|
|
}
|
|
async fn claim_due(
|
|
&self,
|
|
_claimed_by: &str,
|
|
_limit: i64,
|
|
) -> Result<Vec<OutboxRow>, OutboxRepoError> {
|
|
Ok(vec![])
|
|
}
|
|
async fn delete(&self, _id: uuid::Uuid) -> Result<(), OutboxRepoError> {
|
|
Ok(())
|
|
}
|
|
async fn reschedule(
|
|
&self,
|
|
_id: uuid::Uuid,
|
|
_attempt_count: u32,
|
|
_next_attempt_at: chrono::DateTime<Utc>,
|
|
) -> Result<(), OutboxRepoError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn make_script(app_id: AppId, name: &str) -> Script {
|
|
Script {
|
|
id: ScriptId::new(),
|
|
app_id: Some(app_id),
|
|
group_id: None,
|
|
name: name.into(),
|
|
description: None,
|
|
version: 1,
|
|
source: "0".into(),
|
|
kind: picloud_shared::ScriptKind::Endpoint,
|
|
timeout_seconds: 30,
|
|
memory_limit_mb: 64,
|
|
sandbox: ScriptSandbox::default(),
|
|
enabled: true,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
}
|
|
}
|
|
|
|
fn anon_cx(app_id: AppId) -> SdkCallCx {
|
|
let execution_id = ExecutionId::new();
|
|
SdkCallCx {
|
|
app_id,
|
|
script_id: ScriptId::new(),
|
|
principal: None,
|
|
execution_id,
|
|
request_id: RequestId::new(),
|
|
trigger_depth: 0,
|
|
root_execution_id: execution_id,
|
|
is_dead_letter_handler: false,
|
|
event: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_by_id_same_app_succeeds() {
|
|
let app_id = AppId::new();
|
|
let script = make_script(app_id, "worker");
|
|
let svc = InvokeServiceImpl::new(
|
|
Arc::new(OneScriptRepo {
|
|
script: script.clone(),
|
|
}),
|
|
Arc::new(RouteTable::new()),
|
|
Arc::new(CapturingOutbox {
|
|
last: tokio::sync::Mutex::new(None),
|
|
}),
|
|
);
|
|
let cx = anon_cx(app_id);
|
|
let resolved = svc.resolve(&cx, InvokeTarget::Id(script.id)).await.unwrap();
|
|
assert_eq!(resolved.script_id, script.id);
|
|
assert_eq!(resolved.app_id, app_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_by_id_cross_app_rejects() {
|
|
let app_a = AppId::new();
|
|
let app_b = AppId::new();
|
|
let script = make_script(app_a, "worker");
|
|
let svc = InvokeServiceImpl::new(
|
|
Arc::new(OneScriptRepo {
|
|
script: script.clone(),
|
|
}),
|
|
Arc::new(RouteTable::new()),
|
|
Arc::new(CapturingOutbox {
|
|
last: tokio::sync::Mutex::new(None),
|
|
}),
|
|
);
|
|
// Caller is in app_b but the script belongs to app_a.
|
|
let cx = anon_cx(app_b);
|
|
let err = svc
|
|
.resolve(&cx, InvokeTarget::Id(script.id))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, InvokeError::CrossApp));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_by_name_finds_script_in_caller_app() {
|
|
let app_id = AppId::new();
|
|
let script = make_script(app_id, "worker");
|
|
let svc = InvokeServiceImpl::new(
|
|
Arc::new(OneScriptRepo {
|
|
script: script.clone(),
|
|
}),
|
|
Arc::new(RouteTable::new()),
|
|
Arc::new(CapturingOutbox {
|
|
last: tokio::sync::Mutex::new(None),
|
|
}),
|
|
);
|
|
let cx = anon_cx(app_id);
|
|
let resolved = svc
|
|
.resolve(&cx, InvokeTarget::Name("worker".into()))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resolved.script_id, script.id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_by_name_unknown_is_not_found() {
|
|
let app_id = AppId::new();
|
|
let script = make_script(app_id, "worker");
|
|
let svc = InvokeServiceImpl::new(
|
|
Arc::new(OneScriptRepo { script }),
|
|
Arc::new(RouteTable::new()),
|
|
Arc::new(CapturingOutbox {
|
|
last: tokio::sync::Mutex::new(None),
|
|
}),
|
|
);
|
|
let cx = anon_cx(app_id);
|
|
let err = svc
|
|
.resolve(&cx, InvokeTarget::Name("ghost".into()))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, InvokeError::NotFound(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enqueue_async_writes_outbox_row_with_invoke_kind() {
|
|
let app_id = AppId::new();
|
|
let script = make_script(app_id, "worker");
|
|
let outbox = Arc::new(CapturingOutbox {
|
|
last: tokio::sync::Mutex::new(None),
|
|
});
|
|
let svc = InvokeServiceImpl::new(
|
|
Arc::new(OneScriptRepo {
|
|
script: script.clone(),
|
|
}),
|
|
Arc::new(RouteTable::new()),
|
|
outbox.clone(),
|
|
);
|
|
let cx = anon_cx(app_id);
|
|
let exec_id = svc
|
|
.enqueue_async(
|
|
&cx,
|
|
InvokeTarget::Id(script.id),
|
|
serde_json::json!({ "x": 1 }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let row = outbox.last.lock().await.clone().unwrap();
|
|
assert_eq!(row.app_id, app_id);
|
|
assert_eq!(row.source_kind, OutboxSourceKind::Invoke);
|
|
assert_eq!(row.script_id, Some(script.id));
|
|
assert_eq!(row.trigger_depth, 1);
|
|
let _ = exec_id;
|
|
}
|
|
}
|