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:
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user