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

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