Skip to content

fix(security): scope activity feed and log-stream WS to the caller workspace - #576

Merged
jonwiggins merged 3 commits into
mainfrom
fix/activity-and-ws-tenant-isolation
Aug 8, 2026
Merged

fix(security): scope activity feed and log-stream WS to the caller workspace#576
jonwiggins merged 3 commits into
mainfrom
fix/activity-and-ws-tenant-isolation

Conversation

@jonwiggins

Copy link
Copy Markdown
Owner

SECURITY — cross-tenant data leaks

Two linked HIGH-severity cross-tenant leaks, confirmed by an internal security review. No exploit payloads below.

Leak A — deployment-wide activity feed leaking plaintext-secret params

GET /api/activity returned the whole deployment's activity feed with no workspace filter — its CTEs filtered only on time/user/resourceType. Worse, the action branch emitted optio_actions.params (the logged action params jsonb) verbatim, and optioActionService.logAction spread request bodies into params (...req.body, ...input). So nested plaintext secrets (connection configs, webhook signing secrets, credentials) and other tenants' resource ids leaked to any authenticated user. This feed was also the "id oracle" that made cross-tenant IDORs exploitable. The CTEs additionally interpolated since/userId/resourceType into sql.raw strings.

Leak B — unscoped log-stream WebSockets

ws/workflow-run-log-stream.ts and ws/persistent-agent-stream.ts streamed any workflow-run / persistent-agent log by id with no workspace check, exposing other tenants' agent output live.

Fixes

Data model

  • New migration 1785714869_optio_actions_workspace_id.sql: adds optio_actions.workspace_id (+ index). Existing rows keep workspace_id = NULL, treated as operator/legacy (admin-only in the feed).

Write path (optio-action-service.ts)

  • logAction now records workspaceId and reduces params to an explicit non-secret allowlist (resource ids, human-readable labels/types, a few scalar flags) — request bodies/secrets are dropped at write time, not merely redacted by key name.
  • Stamped workspaceId at ~39 tenant-resource logAction call sites from req.user?.workspaceId ?? null.

Read path (routes/activity.ts)

  • preHandler: [requireRole("member")] — viewers can no longer read the audit trail.
  • Action CTE scoped to oa.workspace_id; task_event CTE scoped via JOIN tasks on tasks.workspace_id. Members see strictly their own workspace; admins additionally see legacy null-workspace rows.
  • auth_event / pod_health_events CTEs have no tenant column (deployment-global) — restricted to admins, omitted for members.
  • since/userId/resourceType now parameterized (no more sql.raw interpolation).
  • Response no longer returns raw params — reduced to the same allowlist, so even legacy rows written before this change can't leak their full jsonb.

WebSockets (ws/ws-authz.ts — new)

  • Shared assertWorkspace(socket, userWorkspaceId, resourceWorkspaceId) closes 4403 unless the null-normalized workspaces match. null == null passes, preserving auth-disabled dev (synthetic null-workspace user) behavior.
  • Applied in the workflow-run, persistent-agent, and pr-review log-stream handlers after resolving the resource's parent workspace (e.g. getWorkflow(run.workflowId)), before any streaming/catch-up.

Tests

  • routes/activity.test.ts: a member is scoped to their own workspace (bound param is the caller's only; no IS NULL branch), an admin additionally sees null-workspace rows, secret-bearing params are stripped from the response, viewers get 403.
  • services/optio-action-service.test.ts: logAction persists workspaceId and stores only allowlisted keys (secrets/bodies dropped).
  • ws/ws-authz.test.ts: helper (match / null==null / cross-workspace 4403 / scoped-vs-null) plus handler-level tests that a cross-workspace socket is closed 4403 and never subscribes.

Verification

  • apps/api typecheck + full workspace typecheck (12 packages) ✓
  • bash scripts/check-migration-prefixes.sh
  • Full apps/api unit suite: 2180 tests ✓
  • Migration applies cleanly to fresh Postgres (integration harness) ✓; ws-log-stream e2e still green (auth-disabled null==null streams) ✓
  • pnpm format:check

Scope note

logAction sites in the connections / workflows / persistent-agents routes are owned by other in-flight work and were intentionally left unstamped. Those rows keep workspace_id = NULL and are therefore admin-only in the feed (deny-by-default) until stamped — no cross-tenant exposure, and secrets are already stripped by the centralized write-time allowlist regardless of the call site.

…rkspace

Two linked cross-tenant leaks found via internal security review (SECURITY).

A) GET /api/activity returned the whole deployment's activity feed with no
workspace filter and emitted the logged action params jsonb verbatim. Since
logAction spread request bodies into params, plaintext secrets and other
tenants' resource ids leaked to any authenticated user (also an id oracle for
cross-tenant IDORs). The feed's CTEs also interpolated since/userId/resourceType
into sql.raw strings.

B) The workflow-run and persistent-agent log-stream WS handlers streamed any
run/agent by id with no workspace check, exposing other tenants' agent output
live.

Fixes:
- Add optio_actions.workspace_id (+ index) via migration
  1785714869_optio_actions_workspace_id; logAction now stamps the caller's
  workspace and reduces params to an explicit non-secret allowlist (ids/labels)
  instead of storing request bodies.
- /api/activity: requireRole(member) (viewers blocked); action + task_event CTEs
  scoped to the caller's workspace (task_event via join to tasks); global
  auth/infra CTEs and legacy null-workspace rows restricted to admins;
  since/userId/resourceType parameterized; response params reduced to the same
  allowlist so legacy rows can't leak.
- New ws/ws-authz.assertWorkspace helper (closes 4403 on null-normalized
  mismatch, preserving auth-disabled null==null); applied to the workflow-run,
  persistent-agent, and pr-review log-stream handlers.
- Tests: activity route (workspace scoping + secret-param stripping + viewer
  403), optio-action-service (workspaceId persistence + allowlist), and
  ws-authz (helper + handler 4403).

logAction sites in the connections/workflows/persistent-agents routes are owned
by other work and left unstamped; those rows keep workspace_id NULL and are
therefore admin-only in the feed (deny-by-default).
@jonwiggins
jonwiggins merged commit 9ae0833 into main Aug 8, 2026
10 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