Skip to content

fix(platform): close cross-org isolation leaks (server + release-execution reads)#160

Merged
Pama-Lee merged 6 commits into
mainfrom
fix/platform-cross-org-isolation
Jul 9, 2026
Merged

fix(platform): close cross-org isolation leaks (server + release-execution reads)#160
Pama-Lee merged 6 commits into
mainfrom
fix/platform-cross-org-isolation

Conversation

@Pama-Lee

@Pama-Lee Pama-Lee commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Closes the cross-org read leaks from the 2026-07-09 auth audit. Stacked on #158 (base = fix/platform-release-correctness, because the release-execution fix touches executions.rs which #158 also changes); GitHub will retarget this to main once #158 merges.

Org membership is the isolation boundary the product sells, and server_id = sha256(normalized_url) is derivable — an attacker who guesses a victim engine's URL (http://ordo-server:8080, http://localhost:8080, …) computes its id. Three handlers trusted that id after only a coarse auth check.

1. Server detail / metrics / health — claims ignored (High)

get_server, get_server_metrics, get_server_health took Extension(_claims) and fetched by id with no org check. Any authenticated user (even in zero orgs) could:

  • read any org's engine info (url, version, status, capabilities), and
  • via metrics/health, trigger a control-plane NATS RPC to that engine and read its Prometheus metrics (ruleset names, call volumes).

Fix: an org-owned server now requires the caller to be a member of that org (load_org_and_check_role(Role::Viewer)); a global (unowned) server stays readable by any authenticated user, matching how delete_server already gates only when an org owns the server.

2. Release execution / events — fetched by unscoped id (Medium)

get_release_execution_for_request and list_release_execution_events checked the permission on the path org/project, then fetched by the raw release_id / execution_id. An admin/owner in their own org could read another org's execution detail and event stream (instance/server names, versions, rollout progress) with a guessed id. Both now resolve the release through the org+project-scoped get_release_request (→ 404 on mismatch), and the events handler additionally verifies the execution belongs to that release. Mirrors how list_release_request_history already does it correctly.

3. Engine-proxy fallback routed to the global pool (High)

resolve_engine_url ignored its org_id; resolve_fallback_engine_url picked the most-recently-registered online server from all orgs. The auto-created production env has no bound server, so this fallback is commonly hit — meaning org A's eval traffic (its X-Tenant-ID and, for ruleset writes, the full payload) could be shipped to org B's engine. Fix: the fallback pool is now restricted to the caller's org plus deliberately-global (unowned) servers; the configured engine_url remains the final fallback. A legitimately-shared global engine still works; another org's engine is never borrowed.

Not included (from the same audit, deferred)

  • auth-F4 RBAC create_role/assign_member_role has no permission ceiling / self-assign guard (privilege escalation, needs a policy decision).
  • auth-F5/F6/F7 global-server delete by any user; registration trusts caller org_id under the shared secret; connect tokens never expire.
  • notif-C1 stale/non-member reviewer can read cross-org release bodies.

Verification

cargo check/clippy -p ordo-platform clean. Read-authorization changes; the fallback change is data-plane and should be smoke-tested on a cluster with an unbound-env project before relying on it (per the repo's "data-plane changes get cluster-verified" convention).

Pama-Lee added 5 commits July 8, 2026 23:35
The ReleaseExecutionStatus and ReleaseRequestStatus enums both include
rollback_failed, but the CHECK constraints from 0008 never did. A failed
rollback therefore could not persist its terminal status: the UPDATE hit
the constraint, the error was swallowed, the execution stayed
rollback_in_progress, and the worker re-claimed and re-ran the failing
rollback every 2s poll, forever.

Rebuild both CHECKs with the full status set the code writes.
The 0020 unique index guarantees at most one active execution per
release request, but it cannot see across requests: every direct
publish/redeploy mints a fresh internal request, so two concurrent
publishes of the same ruleset (or a publish racing a governed execute)
would still both roll out.

Add a target-level guard - any active execution for the same
project + ruleset + environment now returns 409 from both
execute_release_request and the direct-publish enqueue path.

Also map the 0020 unique-index violation to 409 instead of 500 for the
loser of two concurrent executes of the same request.
The old safety net (fail_stuck_active_executions) was dead code: both
bootstrap callers passed false, and enabling it would have been wrong
anyway - it had no grace period and would kill in-flight rollouts on
every restart, and its rollback_failed write predates the 0022 CHECK
fix. Remove it.

The poll loop already self-heals claimable executions (the session
advisory lock releases when a dead worker's connection drops), but a
terminal execution is not claimable, so a worker crash between settling
the execution and settling the surrounding state stranded rows forever:

- dispatched deployments whose execution already finished (studio polls
  them indefinitely)
- executing requests whose latest execution already finished
- executing requests that never got an execution row

Add a 60s reconciliation task in the worker that repairs all three,
with grace periods so it never races the worker's own settle writes.
Completed-via-rollback executions are told apart from completed
rollouts by their rollback_in_progress -> completed history row, so
requests settle to rolled_back exactly as the worker would have
written. Repairs are recorded in the request history and are
idempotent, healing rows stranded before this task existed.
A rolling_out / rollback_in_progress execution is worker-claimable, and
build_rolling_context / build_rollback_context hard-errored when a row
they depend on was gone (a bound server pruned as stale >30min or
deleted by an admin, the request, environment, draft, or rollback
snapshot). The spawned worker task only logged that error and dropped
its advisory lock without writing any status, so the execution stayed
claimable and was re-claimed and re-failed every 2s poll forever. The
reconciler could not help — it only repairs executions that already
reached a terminal status.

Distinguish a permanent missing dependency (SetupDependencyMissing, from
a None lookup) from a transient store error (propagated, retried next
poll). On a permanent failure, settle the execution terminally (Failed,
or RollbackFailed for a rollback) along with its request and any
direct-publish deployment row, instead of looping.
Three handlers took an id from the path and fetched by that id alone,
after only a coarse auth check — and server ids are derivable
(sha256 of the engine URL), so the ids are guessable across orgs:

- get_server / get_server_metrics / get_server_health ignored the
  caller's claims entirely. Any authenticated user could read another
  org's engine (url, version, capabilities) and, via metrics/health,
  trigger a control-plane NATS RPC to it. Now an org-owned server
  requires the caller to be a member of that org; global (unowned)
  servers stay readable, matching delete_server's gating.

- get_release_execution_for_request / list_release_execution_events
  checked the permission on the path org/project but then fetched by
  the unscoped release_id / execution_id. An admin in their own org
  could read another org's execution state and event stream with a
  guessed id. Both now resolve the release through the org+project
  scoped getter (404 on mismatch), and the events handler additionally
  verifies the execution belongs to that release.

Also scope the engine-proxy fallback: when a project's environment has
no bound server (the auto-created production env has none, so this is
common), the fallback pool is now restricted to the caller's org plus
deliberately-global servers, never another org's engine — previously it
picked the most-recently-registered server anywhere, routing org A's
eval traffic and ruleset payloads to org B's engine.
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ordo_docs Ready Ready Preview, Comment Jul 9, 2026 11:36am

@Pama-Lee
Pama-Lee merged commit eb605f0 into main Jul 9, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant