fix(manager-core): F-S-012 add AppInvoke capability + gate invoke() / invoke_async()

invoke_service::resolve and enqueue_async performed no authz check —
no AppInvoke capability existed. Same-app isolation was preserved
(cross-app guards work), but within one app an anonymous public-HTTP
script could trigger any other script (e.g. an admin-only worker that
hits secrets/files/external HTTP). Worse: invoke_async runs the
callee with principal: None, so the callee could hold capabilities
the original public caller shouldn't.

- Add Capability::AppInvoke(AppId). app_id() / scope_for_capability
  (script:write) / role_satisfies (editor+) are all updated.
- InvokeServiceImpl gains an optional `authz: Option<Arc<dyn AuthzRepo>>`
  + a `with_authz` builder. When set, resolve() runs script_gate on
  AppInvoke before doing the cross-app id check.
- picloud/src/lib.rs wires it: `InvokeServiceImpl::new(...).with_authz(...)`.
- Anonymous callers (cx.principal == None) continue to skip the check
  via script_gate, preserving the public-HTTP convention.

Existing 5 invoke_service unit tests still pass (the tests use the
authz-less constructor, so the gate is a no-op there).

AUDIT.md anchor: F-S-012.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 21:13:45 +02:00
parent 1911691420
commit f2b16da2b5
3 changed files with 52 additions and 3 deletions

View File

@@ -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!(

View File

@@ -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<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 {
@@ -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<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,
@@ -111,6 +140,8 @@ impl InvokeService for InvokeServiceImpl {
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,
@@ -124,6 +155,7 @@ impl InvokeService for InvokeServiceImpl {
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