diff --git a/crates/manager-core/src/authz.rs b/crates/manager-core/src/authz.rs index 1f8b0b5..5fc5c21 100644 --- a/crates/manager-core/src/authz.rs +++ b/crates/manager-core/src/authz.rs @@ -137,6 +137,15 @@ pub enum Capability { /// seven-scope commitment); the additional gate vs `Write` lives in /// the per-app role chain (`app_admin`+ only). AppUsersAdmin(AppId), + /// F-S-012 (v1.1.9+): `invoke()` / `invoke_async()` synchronously + /// trigger another script in the same app. Same-app isolation is + /// already enforced (cross-app calls are rejected), but within one + /// app an anonymous public-HTTP script could otherwise trigger any + /// other script — including ones that hold capabilities the + /// original caller shouldn't. Gate authenticated callers on + /// AppInvoke; anonymous callers continue to skip the check under + /// the script-as-gate convention. + AppInvoke(AppId), } impl Capability { @@ -171,7 +180,8 @@ impl Capability { | Self::AppTopicManage(id) | Self::AppUsersRead(id) | Self::AppUsersWrite(id) - | Self::AppUsersAdmin(id) => Some(id), + | Self::AppUsersAdmin(id) + | Self::AppInvoke(id) => Some(id), } } @@ -202,7 +212,8 @@ impl Capability { | Self::AppSecretsWrite(_) | Self::AppEmailSend(_) | Self::AppUsersWrite(_) - | Self::AppUsersAdmin(_) => Scope::ScriptWrite, + | Self::AppUsersAdmin(_) + | Self::AppInvoke(_) => Scope::ScriptWrite, Self::AppWriteRoute(_) => Scope::RouteWrite, Self::AppManageDomains(_) => Scope::DomainManage, Self::AppAdmin(_) @@ -401,6 +412,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool { | Capability::AppSecretsWrite(_) | Capability::AppEmailSend(_) | Capability::AppUsersWrite(_) + | Capability::AppInvoke(_) ); let in_app_admin = in_editor || matches!( diff --git a/crates/manager-core/src/invoke_service.rs b/crates/manager-core/src/invoke_service.rs index 6dfb88f..faa979a 100644 --- a/crates/manager-core/src/invoke_service.rs +++ b/crates/manager-core/src/invoke_service.rs @@ -17,6 +17,7 @@ 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; @@ -24,6 +25,10 @@ pub struct InvokeServiceImpl { scripts: Arc, routes: Arc, outbox: Arc, + /// 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>, } impl InvokeServiceImpl { @@ -37,9 +42,33 @@ impl InvokeServiceImpl { 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) -> 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, @@ -111,6 +140,8 @@ impl InvokeService for InvokeServiceImpl { cx: &SdkCallCx, target: InvokeTarget, ) -> Result { + // 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, @@ -124,6 +155,7 @@ impl InvokeService for InvokeServiceImpl { target: InvokeTarget, args: serde_json::Value, ) -> Result { + // 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 diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 04b3680..6aded7e 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -302,7 +302,12 @@ pub async fn build_app( script_repo.clone(), route_table.clone(), outbox_repo.clone(), - ), + ) + // F-S-012: gate authenticated invoke() callers on AppInvoke so + // an editor-role principal can't trigger admin-only worker + // scripts in the same app. Anonymous callers skip the check + // under script-as-gate semantics. + .with_authz(authz.clone()), ); let services = Services::new( kv,