16 KiB
Authorization & Cross-App Isolation
Audit date: 2026-06-11
Scope: Capability machinery, authz::{can, require, script_gate}, resolve_app, per-handler capability gating, SDK service app_id discipline, dispatcher fan-out, dashboard-supplied app ids, slug-history (redirected) path, public-script anonymous semantics.
Method (what I actually traced)
Two SDK paths traced from Rhai surface to SQL bind:
kv::collection("x").set(k, v)— bridge incrates/executor-core/src/sdk/kv.rs:45capturesArc<SdkCallCx>set atcrates/executor-core/src/engine.rs:218-228(app_id: req.app_id), wherereq.app_id = script.app_idfromcrates/orchestrator-core/src/api.rs:123(the resolver's row). TheKvService::setimpl atcrates/manager-core/src/kv_service.rs:130-187callsself.repo.set(cx.app_id, …)which lands atcrates/manager-core/src/kv_repo.rs:113-127withWHERE app_id = $1 AND collection = $2 AND key = $3. No script-controlledapp_identers the call chain. ✅dead_letters::replay(id)— bridge atcrates/executor-core/src/sdk/dead_letters.rs, implcrates/manager-core/src/dead_letter_service.rs:62-94. Capability check (AppDeadLetterManage(cx.app_id)) runs first; the row is loaded by id, thenrow.app_id != cx.app_idshort-circuits toNotFound(line 67-71). Cross-app id probing is timing-flat with intra-app not-found. ✅
Two admin handlers traced from URL → resolve → require → repo:
DELETE /admin/apps/{slug}/triggers/{trigger_id}—crates/manager-core/src/triggers_api.rs:659-687.resolve_app(line 664) →require(Capability::AppManageTriggers(app_id))against the RESOLVED app id (line 669) → load row → reject iftrigger.app_id != app_id(line 680). No TOCTOU between resolve and authz; both run on the sameapp_idvalue. ✅POST /admin/apps/{slug}/dead-letters/{id}/replay—crates/manager-core/src/dead_letters_api.rs:175-188.resolve_app→ admin-synthSdkCallCxcarrying the resolvedapp_id+ principal (line 208-222) →service.replay(&cx, dl_id). Authz lives inside the service (above). ✅
Service-trait sweep: every *Service trait in crates/shared/src/{kv,docs,files,secrets,email,http,queue,pubsub,dead_letters,invoke,events}.rs takes &SdkCallCx and does NOT accept app_id as a side argument. The only methods with an explicit app_id parameter are admin-mediated UsersService::{verify_session_for_realtime, admin_*, list_invitations, revoke_invitation} — called from the manager-core HTTP layer or the realtime authority, never from a script bridge.
High
Queue dispatcher does not assert script.app_id == claimed.app_id
- Where:
crates/manager-core/src/dispatcher.rs:290-340 - What:
dispatch_one_queueclaims a queue message scoped toconsumer.app_id, resolvesscriptbyconsumer.script_id(line 290), and builds anExecRequestwithapp_id: claimed.app_id(line 334) andscript_id: consumer.script_id— but never cross-checks thatscript.app_id == claimed.app_id. The analogousbuild_invoke_requestdoes (line 752:if script.app_id != row.app_id { return Err(...) }), and the cross-app invoke guard ininvoke_service.rs:83-85does too. Trigger registration (triggers_api.rs:280-303 validate_trigger_target) blocks same-app divergence at create time, so today this is guarded by a write-path invariant only. If a trigger row is hand-edited, restored from a partial backup, or (future) script-move-between-apps is added, the dispatcher would happily execute a script from app B under app A'sSdkCallCx— everykv::*,docs::*,files::*,secrets::*,pubsub::*,email::*SDK call would scope to app A. The script's source belongs to app B but reads/writes app A's data. - Impact: Latent cross-app data read/write if any of the above ever holds. Defense-in-depth is missing on the single most security-critical fan-out path. The same gap is absent in the (sibling)
build_invoke_requestprecisely because it was added there explicitly — the queue arm just wasn't audited the same way. - Recommendation: Mirror the invoke guard. After
self.scripts.get(consumer.script_id)succeeds, checkif script.app_id != consumer.app_id { dead_letter the message + tracing::error!("queue trigger script app mismatch"); return; }. Same fix inbuild_http_request(dispatcher.rs:651-718) — itsscript.app_idis never compared torow.app_id, only used.
Medium
get_script and list_routes_for_script return 404 before authz — cross-app id enumeration
- Where:
crates/manager-core/src/api.rs:184-197(get_script),crates/manager-core/src/route_admin.rs:141-158(list_routes) - What: Both handlers load the resource by id and 404 if absent, before the authz check. A
Memberof app A who guesses ascript_idbelonging to app B observes404for non-existent ids and403for ids that exist in app B (authz denies onAppRead(script.app_id)). The discriminator confirms script-id existence across the whole instance. Same gap on the routes-for-script list. Note this contrasts withapps_api::create_script(line 204-213), which intentionally inverts the order to avoid app-existence enumeration ("checking authz first means a Member trying to create against an unknown app gets 403 (no enumeration of app existence)"). - Impact: Low-grade information leak — a malicious member can map out script-id space across apps.
ScriptIds are UUIDv4, so brute-force enumeration is impractical, but ids leak through logs, error messages, and dashboard URLs (a former member with a stale screenshot can re-confirm validity post-revocation). - Recommendation: Either (a) collapse
NotFoundandForbiddento the same response for these two handlers, or (b) flip the order: do a cheap authz pre-check (you can't, because you need the resource's app_id) — so (a) is the practical fix. Same shape as the membership-on-other-app pattern already in use elsewhere.
Capability check on routes:check / routes:match trusts a body-supplied app_id
- Where:
crates/manager-core/src/route_admin.rs:261-329 - What: Both handlers pull
app_idfrom the JSON body (CheckRouteRequest/MatchRouteRequest) and authz against it. Because authz blocks a Member of app A fromAppRead(B), this is currently safe at runtime. The structural risk: the dashboard sends{ app_id: appId, … }fromdashboard/src/lib/api.ts:709-718and any future code path that strips authz (e.g., a "public health-check" wrapper, a new internal caller) would have no second line of defense — the resource (app) isn't path-bound and isn't loaded. - Impact: Defense-in-depth gap. Inconsistent with every other recently-refactored handler (
queues_api,files_api,secrets_api,topics_api,dead_letters_api,triggers_api) which path-bind the slug and load the app row before authz. - Recommendation: Either move to
POST /admin/apps/{slug}/routes:check(path-bind the slug, thenresolve_app), or loadstate.apps.get_by_id(input.app_id)and fail closed onNonebefore authz. Both restore the resolve-then-authz invariant.
Cron / trigger fan-out runs forever under a deactivated user's principal — PrincipalResolver rejects but the dispatch arm has no recovery
- Where:
crates/manager-core/src/principal_resolver.rs:42-61,crates/manager-core/src/dispatcher.rs:314-318 - What: When the registrant is deactivated (
is_active = false),PrincipalResolver::resolvereturnsInactive(user_id)→ the dispatcher surfacesResolveTrigger(...)and the dispatch loop logs and moves on. The outbox row stays claimed-then-rescheduled forever (or the cron row keeps inserting). There's no "disable the trigger when its owner is gone" path. Perdispatcher.rs:316, the resolver error short-circuits the dispatch but doesn't disable the trigger. This means a deactivated former admin's cron jobs continue to attempt execution indefinitely, building outbox backpressure. - Impact: Not a direct authz break (the script never runs because the principal isn't resolvable), but: (a) operational noise; (b) if the registrant is reactivated later with reduced privileges, every backed-up cron tick suddenly fires under their new (possibly weaker) identity at once; (c) if an attacker who briefly held
InstanceManageUsersregistered crons under a sock-puppet account and was then locked out, the crons remain dormant and re-arm on any future user revival with the same id. - Recommendation: On
Inactivefrom the resolver, mark the triggerenabled = false(with an audit note) and dead-letter any in-flight outbox row that targets it. Mirrors the script-missing arm atdispatcher.rs:292-310which already dead-letters cleanly.
Public/anonymous scripts can call same-app AppDeadLetterManage only because the service explicitly rejects principal: None
- Where:
crates/manager-core/src/dead_letter_service.rs:40-51 - What: For dead letters specifically, the service handles "anonymous caller" with a hard
Err(Forbidden)rather thanscript_gate's default "skip the check forcx.principal == None." That's the correct intent (managing dead letters is an admin act). The risk is that the convention for every other stateful service (kv,docs,files,secrets,pubsub,email,users) is the OPPOSITE: anonymous public-HTTP scripts skip the capability check entirely (seeauthz::script_gateatauthz.rs:327-342). A public, anonymous-callable HTTP script in app A can write tokv::collection("x"), send email viaemail::send, publish onpubsub::*, mutate app-users viausers::*, etc. — all under "script-as-gate" semantics. This is by design (the author owns the script and decides whether to expose it via a public route), but it means a single misconfigured route exposes powerful capabilities. There's no global toggle to require auth on a public route. - Impact: Documented design, but the boundary is one config mistake away from anonymous-internet → email send → SMTP-amplification-of-app-secrets. The "script is the gate" guarantee depends on the script author's discipline.
- Recommendation: Two improvements — (a) document this loudly in the route-create endpoint response ("this route is anonymous-callable; the script you bound holds capabilities X, Y, Z and will execute them un-gated"); (b) consider a per-route
require_principal: booltoggle so app admins can flip a route to "no anonymous calls allowed" without rewriting the script. Defers cleanly to v1.2.
Low
build_http_request does not cross-check script.app_id == row.app_id
- Where:
crates/manager-core/src/dispatcher.rs:651-718 - What: Same shape as the High above. The HTTP outbox arm uses
row.app_idto build theExecRequest(line 686) andscript(resolved byrow.script_idat line 660) is trusted to live in the same app. Routes are guaranteed same-app byroute_admin.rs:179(app_id = script.app_id), so today no row violates the invariant. Listing as Low (not High) because the write-path is tighter for HTTP than for queue triggers — there's novalidate_trigger_targetequivalent because routes carry their ownapp_id. Still worth the belt-and-suspenders check. - Recommendation: One line:
if script.app_id != row.app_id { return Err(DispatcherError::ResolveTrigger("http row app mismatch")); }after the script load.
Email-inbound writes outbox with URL app_id, not target.app_id
- Where:
crates/manager-core/src/email_inbound_api.rs:144-189 - What: After cross-checking
target.app_id != app_idat line 145, the outbox insert at line 177 uses the URL-derivedapp_id. They're equal because of the check, buttarget.app_idis the canonical source of truth (it survives even if the cross-check were ever relaxed). One-character fix; matters only if the cross-check ever gets refactored. - Recommendation:
app_id: target.app_idin theNewOutboxRow.
AppLookup.redirected flag is consumed only in apps_api::get_app
- Where:
crates/manager-core/src/app_repo.rs:25-48,crates/manager-core/src/apps_api.rs:213-217 - What: Every other consumer (
triggers_api,topics_api,files_api,secrets_api,queues_api,dead_letters_api,users_admin_api,app_members_api) silently discards the flag via.map(|l| l.app.id)/.map(|l| l.app). That's fine — historical slugs are valid identifiers for the canonical app. No authz check is bypassed; the resolvedapp.idcarries the same authority as the live-slug path. I traced this exhaustively; listing as Info to confirm no privilege escalation via redirect, but moving to Low because the convention is implicit (each consumer must remember to discard, not skip a check). A clearer API might beresolve_appreturning(AppId, Option<String> /* redirected_from */)so the call site can never accidentally use the slug. - Recommendation: Cosmetic. Consider tightening the type.
RESERVED_MODULE_NAMES is a hard-coded list — drift risk on future SDK module additions
- Where:
crates/manager-core/src/api.rs:261-280 - What: New SDK modules added to executor-core (queue is the most recent) must be remembered to be added to this list. It's not an authz issue per se; the shadow risk is that a user-supplied module of the same name resolves the user copy. Not load-bearing for cross-app isolation (each app's modules are app-scoped anyway), but worth a comment pointing at
crates/executor-core/src/sdk/mod.rsso the next contributor edits both. - Recommendation: Centralize the reserved-name list in
picloud-sharednext to the SDK trait registrations.
Info
SDK trait surface is clean on app_id discipline
- Every
*Servicetrait method that a Rhai bridge can invoke takes&SdkCallCxand never acceptsapp_idas a parameter. Verified by grep acrosscrates/shared/src/{kv,docs,files,secrets,email,http,queue,pubsub,dead_letters,invoke,events}.rs. The onlyapp_id-parameter methods are admin-mediated (UsersService::admin_*,verify_session_for_realtime,list_invitations,revoke_invitation) and live under the HTTP admin layer, not the Rhai bridge.
Capability enum is exhaustively matched
Capability::app_id()andCapability::required_scope()use full match arms with no default branch, so adding a new variant forces every call site to be updated. Testcapability_required_scope_mapping_is_completeatauthz.rs:868-887plus compiler enforcement makes this a self-healing surface.
Bound API keys cannot escape their app
binding_allowsatauthz.rs:449-461denies instance-scoped capabilities for bound keys outright and requirestarget_app == bound_appfor app-scoped ones. Mint-handler also rejects the combination upfront; the runtime layer is defense-in-depth. Tested atauthz.rs:730-767.
Anonymous public scripts are visibly distinguishable in script_gate
authz::script_gateatauthz.rs:327-342explicitly distinguishescx.principal.is_none()("skip the check, script-as-gate") from authenticated callers. Recorded for clarity; the trade-off discussed in the Medium "Public/anonymous scripts" item above.
default slug has no runtime-privileged behavior
- Only
crates/manager-core/src/app_bootstrap.rs:39references the slug at all, and only at fresh-install seed time. An attacker creating a new app and then deleting and recreating it cannot influence anything keyed on the slug — every authz check usesapp_id(UUID), never the slug.
Out of scope (cross-references)
- Capability check on the inbound-email HMAC is in agent 3's lane (crypto).
- Rate limits on the cap-check Argon2 surface — agent 7 (DoS).
- The dashboard's localStorage echo of the bearer token leaking the principal — agent 6 (network/CSRF) and agent 1 (authn).
- The principal-cache TTL window letting a freshly-revoked admin still execute one request — agent 1 (authn) F-P-009 follow-up.