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>
Brings QueueError / PubsubError / InvokeError in line with sibling
shape used by KvError / DocsError / FilesError / SecretsError:
- QueueError gains an explicit Forbidden variant (previously authz
denial was squashed into Rejected("forbidden") in queue_service.rs:68,
losing the structured variant a 403-translation layer would need).
- QueueError::Unavailable renamed → Backend.
- PubsubError::Unavailable renamed → Backend.
- InvokeError::Unavailable renamed → Backend.
- Call sites updated (queue_service, pubsub_service, invoke_service,
Noop* stubs, From<PubsubRepoError> impl, one test assertion).
- New unit test verifying authed-denied → QueueError::Forbidden.
AUDIT.md anchor: F-Q-004.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
manager-core/invoke_service.rs implements InvokeService over:
- ScriptRepository (for Id + Name resolution)
- RouteTable (for Path resolution via the orchestrator's matcher)
- OutboxRepo (for invoke_async)
Same-app guard runs at the service entry point: resolve_id() always
verifies resolved.app_id == cx.app_id and returns InvokeError::CrossApp
otherwise. resolve_name() reads cx.app_id when querying (so a wrong
app_id from somewhere else couldn't slip through), but defense-in-depth
won't hurt — future hardening if it surfaces.
resolve_path() runs the matcher against this app's routes only
(RouteTable::snapshot_for_app). Method assumed POST→GET fallback for
v1.1.9 — surfacing method via the SDK is a future addition.
enqueue_async() resolves the target, then writes one outbox row with
source_kind = 'invoke'. The payload carries script_id, script_name,
args, fresh execution_id, root_execution_id (inherited from caller),
trigger_depth + 1. caller principal recorded as origin_principal
(forensic only — the executing script runs with no principal). One
shot — no retry policy on the row; users wrap in retry::with() if
they want retries.
picloud/lib.rs:
- route_table construction moved BEFORE Services::new so the invoke
service can hold it. populates from route_repo as before.
- InvokeServiceImpl wired into Services::new in place of the
NoopInvokeService placeholder.
Unit tests cover: resolve by Id same-app, cross-app rejected, resolve
by Name finds + not-found, enqueue_async writes Invoke outbox row with
trigger_depth=1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>