Skip to content

fix(security): pre-onboarding hardening — OLTP statement ceiling, GraphQL off-loop, read-path audit, async op lifecycle - #1215

Merged
jfrench9 merged 8 commits into
mainfrom
bugfix/pre-onboarding-hardening
Aug 19, 2026
Merged

fix(security): pre-onboarding hardening — OLTP statement ceiling, GraphQL off-loop, read-path audit, async op lifecycle#1215
jfrench9 merged 8 commits into
mainfrom
bugfix/pre-onboarding-hardening

Conversation

@jfrench9

@jfrench9 jfrench9 commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

Pre-onboarding hardening pass over the extensions data plane — the availability, detectability and async-correctness follow-up to the multi-tenancy resilience review (#1211#1213). No authorization semantics change; every fix makes the shared plane hold under load, leave a trail on denial, and keep background operations retryable. Design record and residuals: local/RoboSystems/specs/security/pre-onboarding-hardening.md.

  • Extensions OLTP: per-statement ceiling on interactive sessions. extensions_session binds SET LOCAL statement_timeout with the search_path (SSM database/EXTENSIONS_STATEMENT_TIMEOUT_MS, default 30s); loader / Dagster / migration paths opt out. A cancelled statement is a 504 + Retry-After on REST, statement_timeout on MCP, STATEMENT_TIMEOUT on GraphQL.
  • GraphQL: resolvers off the event loop; unexpected errors masked. Sync resolvers now run under the shared runner limiter (OffloadSyncResolvers); only deliberate GraphQLErrors reach errors[] verbatim (MaskUnexpectedErrors, with requestId).
  • Ingest: row counting off the loop, per-file row cap, signed Content-Length. Parquet is footer-only (ranged GET), CSV is streamed record by record, JSON arrays are walked element by element over a text held once (bounded by the size gate — not streamed); the S3 body is closed on every exit. The upload presign signs ContentLength when declared and the S3 client is pinned to SigV4 (verified locally that content-length joins X-Amz-SignedHeaders; the HEAD-measured size in ingest stays authoritative).
  • Read-path audit + attribution. GraphQL and MCP read denials on user graphs emit AuthorizationDenied (same alarm as the write gates). Every auth success publishes a request-scoped principal; the access log now records the caller (it was user_id=None on every line); audit lines and security events carry request_id, auth_method, api_key_prefix.
  • Async idempotency: reserve on miss, release on failed dispatch, evict the pending envelope on the transition into FAILED/CANCELLED (incl. the reaper's DLQ path, now a compare-and-set) — a failed backup/subgraph/materialize is retryable again under the same key. The first terminal status is final: a late/duplicate error after a completion neither flips the record nor evicts the key.
  • Materialization lock: fail closed (503/409), one lock over both build paths, compare-token extend per table, compare-and-delete release, materialize_db tag on tenant sensor runs.

Deploy notes

  • New SSM tunable database/EXTENSIONS_STATEMENT_TIMEOUT_MS (defaults in code; 0 disables).
  • S3 presigned URLs are now SigV4 — same-region buckets only (true today).
  • Materialization now refuses to run when Valkey is unreachable rather than proceeding unlocked; the staleness sensor retries.

Test plan

  • just test-code clean
  • just test — 13,362 passed (incl. real-DB tests for the session ceiling)
  • Staging: a GraphQL read against a tenant graph while a pg_sleep(60) runs on another tenant's session; confirm the API stays responsive and the sleeping session gets 504/STATEMENT_TIMEOUT
  • Staging: probe a non-member graph via GraphQL/MCP with a valid key; confirm AuthorizationDenied in the security stream with api_key_prefix + request_id

…ssion ceiling

The extensions engine is one pool for every tenant and deliberately set no
statement_timeout, so a statement left to run held a shared connection for
its whole duration. Interactive sessions now bind SET LOCAL statement_timeout
alongside the search_path on every transaction (SSM-tunable, 30s default);
loader syncs, Dagster jobs, migration-time resync and dev scripts opt out
explicitly. A cancelled statement surfaces as a 504 with Retry-After on REST
and a statement_timeout answer on MCP, never a bare 500 or the SQL.
…ed errors

Every extensions resolver is a plain def that opens an OLTP session, and
Strawberry executes those inline on the single uvicorn worker's event loop —
one slow read stalled every request on the task. User-written sync resolvers
now run through run_off_loop under the shared runner limiter; default
attribute resolvers and introspection stay inline. Resolver exceptions that
are not deliberate GraphQL errors are replaced with a fixed message plus the
request id, and a cancelled statement gets its own code.
…the credential

The write gates already emitted AuthorizationDenied; a user-graph read
refused at the GraphQL or MCP gate did not, so systematic probing through
the read surfaces left no trail. Both now emit the same event. Every
authentication success publishes a request-scoped principal (user, method,
API-key prefix), the logging middleware binds the request id before the
route and reads the caller after it, and security events read both at write
time — the access log was recording user_id=None on every request.
…minal failure

The synchronous envelope reserved its key with SET NX and released it on
failure; the async path fronting create-backup, create-subgraph, materialize,
change-tier, ingest-file, sync-connection, auto-map-elements and create-graph
was lookup-only and cached an optimistic pending envelope that nothing ever
cleared — a failed op replayed pending for 24h under its Idempotency-Key.
check_idempotency now reserves after a miss, idempotent_dispatch releases the
reservation when dispatch fails, and the SSE store (plus the worker reaper's
DLQ path) evicts the bound envelope on FAILED/CANCELLED. The operation audit
line also carries request id and credential attribution.
…oth build paths, extend it mid-run

The blue-green rebuild proceeded unlocked when the lock module or Valkey
client failed to construct, and the first-build path took no lock at all —
a stale-sensor run and a user materialize could COPY into the same fresh
graph. Both build paths now run under one per-graph lock that fails closed
(503 + Retry-After at the API when the lock service is unavailable, 409 when
held); the lock is compare-token extended per table so a run longer than its
TTL aborts before the swap instead of carrying a stale token; the worker's
outer lock release is compare-and-delete; tenant sensor runs carry the
materialize_db tag so Dagster's per-graph concurrency limit covers them.
…, under a row cap

ingest_file read the whole object and fully parsed it just to count rows —
on the API event loop, before the small/large routing — so a 100 MB parquet
or JSON payload became a multi-GB allocation that stalled every tenant.
Parquet is now counted from the footer alone (ranged GET), CSV is streamed
record by record, JSON arrays are walked element by element, all in a runner
thread behind a body reader bounded by the HEAD-measured size; a per-file
row cap (min of the constant and the tier's per-table limit) rejects with
413. The upload presign signs the declared Content-Length and the S3 client
is pinned to SigV4 so a mismatched PUT is refused by S3.
@jfrench9

Copy link
Copy Markdown
Member Author

@claude can you review?

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Claude finished @jfrench9's task in 7m 37s —— View job


Review of PR #1215

Todo list

  • Gather context (PR description, changed files, CLAUDE.md)
  • Read key changed files (extensions.py, materialize.py, graphql/execution.py, security/request_context.py, etc.)
  • Review OLTP statement-timeout ceiling implementation
  • Review GraphQL off-loop / error masking changes
  • Review async idempotency lifecycle changes
  • Review materialization lock hardening
  • Review read-path audit/attribution changes
  • Post final review feedback

Summary

This is a large, well-scoped hardening pass and the core mechanisms are solid: the statement-timeout ceiling is correctly SET LOCAL-scoped (transaction-local, can't leak across pooled connections) with sensible opt-outs for bulk paths; the GraphQL offload/error-masking layer is a real implementation (not a no-op) with genuinely useful tests; the materialization lock's compare-token extend / compare-and-delete release are atomic Lua ops with correct fail-closed behavior on Valkey unavailability; and the read-path audit/attribution work fixes a real pre-existing bug (user_id=None on every access-log line). Test coverage across the diff is unusually strong — several tests hit real Postgres sessions and inspect actual pooled connection state rather than mocking.

I did find two real correctness gaps in the async-idempotency eviction path (below), both narrow races but directly relevant to the PR's own "retryable under the same key" guarantee.

Issues (should fix, or explicitly accept before merge)

  1. worker_reaper.py:188-210 (_fail_operation_sync) can clobber a genuinely-completed operation and wrongly evict its idempotency key. The reaper snapshots an operation's status once per sensor tick (worker_reaper.py:~108) before deciding staleness/DLQ. If the worker actually finishes and writes COMPLETED in the window between that snapshot and the DLQ branch's call to _fail_operation_sync — most plausible for long-running ops (backup/materialize) finishing right near their timeout — _fail_operation_sync re-reads metadata but overwrites status to "failed" unconditionally, bypassing the WATCH/CAS status-priority guard that event_storage.py otherwise uses, and then calls invalidate_operation_idempotency_sync(task_id). A client retry with the same Idempotency-Key then dispatches a new duplicate operation even though the original succeeded. tests/dagster/test_worker_reaper.py only exercises "running"→"failed", not "completed"→"failed" racing the DLQ path.

  2. event_storage.py:373-392 / :515-534status_priority gives COMPLETED, FAILED, and CANCELLED the same priority (2), and the transition guard uses >=. This predates this PR, but this PR newly wires idempotency eviction to any terminal-failure transition (terminal_failure = new_status in _TERMINAL_FAILURE_STATUSES, lines ~395/537). A late/duplicate OPERATION_ERROR event arriving after OPERATION_COMPLETED (plausible under at-least-once delivery) now passes the 2 >= 2 guard, flips status to FAILED, and evicts the idempotency key for an already-succeeded operation — same duplicate-dispatch-on-retry outcome as Inital open source release of robosystems #1. Consider > for same-tier transitions, or an explicit "already terminal, don't re-transition" short-circuit before the eviction call.

Both are narrow-window races, not likely to fire often, but they undercut the PR's headline idempotency guarantee specifically for the long-running operations (backup/materialize) it calls out. Worth at least a follow-up ticket if not fixed here.

Suggestions (non-blocking)

  1. Shared offload limiter sizing (middleware/operations.py:884-903) — OffloadSyncResolvers and the new ingest row-counting work (ingest_file.py) share the same CapacityLimiter, sized around DB-session-duration assumptions. Large concurrent ingest row-counts could starve GraphQL/REST resolver offloading under load. No test exercises the limiter under real concurrent contention (only sequential cases) — worth a load-style test if this pool sizing hasn't been validated in staging.
  2. JSON row-counting still fully decodes the file into a Python string rather than streaming, despite the "footer-only/streamed" framing in the PR description — contrast with the genuinely footer-only Parquet path and streamed CSV path. Low risk since it's still bounded by the size gate, but worth calling out as scope-narrower than described.
  3. CSV row-cap early-exit doesn't close the underlying stream (ingest_file.py) — under sustained cap-rejection traffic this could contribute to connection-pool exhaustion. Minor resource-leak risk.
  4. SigV4 Content-Length-binding claim is asserted but not verified by testtests/operations/graph/commands/test_create_file_upload_size_gate.py checks the parameter reaches a mocked generate_presigned_url, not that S3 actually rejects a size-mismatched PUT. Low impact since ingest_file_cmd independently re-measures the real object via HEAD as the authoritative check, but the "signed length binds the PUT" claim in the PR body is stronger than what's tested.
  5. request_context.py: reset_principal is exported and tested but never called in production code (unlike reset_request_id, which is reset in a finally in middleware/logging.py). Harmless under the current per-request-Task ASGI model, but latent risk if any future path reuses a task/thread across principals — consider resetting it symmetrically for defense-in-depth.
  6. db/extensions.py:200/262int(statement_timeout_ms) silently truncates a non-int rather than raising, and the -1 sentinel for "resolve from tuning" is a magic value a stray explicit -1 call would silently misinterpret as "use default." Both purely theoretical (no current call site triggers either), just flagging for hygiene.
  7. Out-of-diff, informational: graph_api/routers/databases/swap.py:79-81 still has a "proceed without lock if Valkey unavailable" fallback for callers that don't pass X-Materialization-Lock-Token. Doesn't affect this PR's own caller (which always passes a token now), but the "fail closed" guarantee isn't end-to-end at that endpoint for any other/future caller.

Verdict

No blockers on the headline hardening mechanisms (statement timeout, GraphQL offload/masking, materialization lock) — those are correctly implemented and well-tested. I'd want items #1 and #2 addressed or consciously accepted before merge, since they're real gaps in the async-idempotency guarantee the PR explicitly advertises for backup/subgraph/materialize retries; everything else is non-blocking polish.
· Branch: bugfix/pre-onboarding-hardening

…re cannot evict a completed operation's key

Review follow-up on #1215. COMPLETED, FAILED and CANCELLED shared one
priority tier under a >= guard, so a late or duplicate OPERATION_ERROR after
OPERATION_COMPLETED flipped a finished operation to failed and, with the new
eviction hook, freed its Idempotency-Key for a duplicate dispatch. The SSE
store now refuses any move between distinct terminal statuses and evicts
only when its own write is the transition into failure (a lost CAS evicts
nothing). The worker reaper's DLQ write is a WATCH/MULTI compare-and-set
that leaves an already-terminal operation alone. Ingest closes the S3 body
on every exit, including the row-cap early stop.
@jfrench9

Copy link
Copy Markdown
Member Author

Review follow-up in 2c26c20:

  • Inital open source release of robosystems #1 reaper race_fail_operation_sync is now a WATCH/MULTI compare-and-set: it only moves pending/running to failed, leaves an already-terminal operation alone, and evicts the idempotency envelope only when its own write landed. Tests cover completed-before-read and completed-between-read-and-EXEC.
  • Fix MCP tools query results to include units and improve dimensions #2 terminal-tier guard — the SSE store now refuses any move between distinct terminal statuses (same-status re-writes still merge result data) and evicts only on the transition into failure, after the CAS succeeds — a lost CAS evicts nothing. Tests: late error after completion, repeated error, lost CAS, both async and sync branches.
  • CSV/JSON: the S3 body is closed on every exit (incl. the row-cap early stop). PR body corrected: JSON is walked element-by-element over a size-bounded text, not streamed; the SigV4 binding is verified as a signed header, with the HEAD size staying authoritative.
  • Not changed, recorded in the vault queue: swap.py no-token fallback (Q5), reset_principal symmetry (per-request task context makes it moot today), the -1 sentinel / int() hygiene.

@jfrench9
jfrench9 merged commit 8def717 into main Aug 19, 2026
7 checks passed
@jfrench9
jfrench9 deleted the bugfix/pre-onboarding-hardening branch August 19, 2026 14:42
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