Commit Graph

3 Commits

Author SHA1 Message Date
Patrick Buckley 4bca60c56c fix(projects): close review-found tenancy leaks + correctness regressions
Max-effort review findings on the visibility feature, worst first:

Leaks — the filter was sound where it ran, but several surfaces never
carried project_id to gate on:
- cluster_snapshot served the raw collector state with no filter at all;
  it now gets the same per-request tenancy treatment as its siblings
- console pseudo-node coordinator rows + emit_console_ws_created,
  the interactive-create ws_created event, and the poll-diff ws_created
  now carry project_id/user_id (parity with their filtered siblings —
  a missing field failed open, and a missing user_id over-hid the
  creator's own workstreams)
- the SSE snapshot's overview total/state histogram is re-derived from
  the filtered rows instead of leaking pre-filter counts

Correctness:
- saved list pages with OFFSET until it fills its 50-row window instead
  of filtering after the LIMIT (a caller's own rows at position 51+
  used to vanish behind other tenants' private rows); scan capped at 20
  pages, logged when hit
- an INHERITED project_id whose project was since deleted no longer
  400s coordinator child spawns — the dangling link is dropped; explicit
  unknown ids still 400, revoked membership still 403s
- the SSE filter keeps a per-connection unresolved map: a storage blip
  suppresses a row without pinning it hidden until reconnect (re-judged
  on later events, rate-limited); definitive verdicts settle as before
- bypass principals (service / admin.cluster.inspect) get payloads
  untouched — no row drops, no overview rewrite

Consistency and robustness:
- dashboard + saved-list visibility checks moved off the event loop
  (executor), matching every sibling site
- list_project_attachments chunks its IN() at 500 ids per statement
- ws_visible/ensure_project_attachable now share one _project_grants
  predicate so the tenancy rule can't diverge
- resolve_workstream_owner's docstring states the deliberate
  fail-closed trade for project-attached rows during DB outages
- the workstreams-for-project ordering test asserts strict order on a
  forced timestamp instead of a vacuous set fallback
2026-07-01 18:01:50 -07:00
Patrick Buckley fbfd170ca6 feat(projects): enforce private-project workstream visibility server-side
Workstreams attached to a private project were listed and reachable for
every authenticated user — only the scope tier was checked. Add a
tenancy predicate (WorkstreamProjectVisibility: private → project
owner/members, the workstream's own creator, service scope, or
admin.cluster.inspect; public/dangling/no project → unchanged
trusted-team visibility; membership itself is the grant — deliberately
NOT gated on the project.read capability, which guards the management
API) and apply it at every surface:

- listings: saved sessions (project_id + owner tail-appended to
  list_workstreams_with_history on both backends), active list, node
  dashboard, console cluster list (pre-pagination via a collector
  row_filter so totals stay honest), node detail
- console tier-1 SSE: per-connection snapshot filtering + a hidden-set
  for sparse follow-up events; ws_created project lookups run on the
  executor, membership changes take effect on reconnect
- row access: resolve_workstream_owner 403s private-project rows for
  non-members, covering every interactive ws-scoped verb via
  tenant_check (console coordinator lane stays on its privileged
  admin.coordinator gate)
- create: ensure_project_attachable gates explicit and parent-inherited
  project_id on both create validators (unknown project 400s instead of
  minting a dangling link)
2026-07-01 18:01:50 -07:00
Patrick Buckley b453ac5bb3 feat(ui): L-shell step 2b — persona-unified dashboard launcher + both-kinds saved list
The dashboard body becomes a persona-unified launcher (start a coordinator OR an interactive session from one composer) and the saved list spans both kinds; the redundant active-coordinators table is dropped (the rail covers it now).

Backend — the console /v1/api/workstreams/saved now returns both kinds: session_routes.py extracts _collect_saved_rows (shared by the refactored, behaviour-preserving make_saved_handler) + adds make_unified_saved_handler (merges per-kind queries — run concurrently via asyncio.gather — sorted by updated desc). The operator gate (admin.coordinator) is applied once; no new exposure (operators already see every session). console/server.py mounts it with [coordinator, interactive] cfgs.

Frontend — a persona toggle routes submit by kind: coordinator -> console-local POST /v1/api/workstreams/new; interactive -> node-proxy POST /v1/api/cluster/workstreams/new (auto placement). Each option is scope-gated (admin.coordinator / workstreams.create); attachments stay coordinator-only. The saved list gains a KIND tag column + kind-routed activation (coordinator -> /open + /coordinator; interactive -> /node/{id}/?ws_id=) and stays operator-gated. The active-coordinators table + _renderHomeView/_activeCoordsFromClusterState are removed.

Tests: make_unified_saved_handler coverage (tests/test_saved_handler_unified.py, synthetic fixtures, no DB) + a console-launcher static guard (tests/test_shell_js.py). Reviewed via the multi-stage pipeline; findings applied (client/server gate match, concurrent queries, chip-CSS dedup, static guards, stale-comment cleanup).
2026-06-08 10:08:30 -07:00