Skip to content

fix(ingest): enforce the per-system boundary on crawler ingest (SEC-2026-09 C-01, H-04, M-05, M-07, L-16) - #1200

Merged
WimvandenHeijkant merged 8 commits into
mainfrom
bugfixes/sec-ingest-system-boundary
Sep 16, 2026
Merged

WimvandenHeijkant merged 8 commits into
mainfrom
bugfixes/sec-ingest-system-boundary

Conversation

@WimvandenHeijkant

@WimvandenHeijkant WimvandenHeijkant commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Restores the per-system boundary on crawler ingest and hardens the crawler data plane, following the SEC-2026-09 white-box review. Four commits, one per concern:

  1. C-01 + H-04: systems are never reconciled, the reconcile delete always needs a bound, and a key restricted to specific systems stays inside them.
  2. M-07: caps on open ingest sessions, session ownership, and an idle-in-transaction timeout.
  3. M-05: the five data-plane endpoints are gated and scoped.
  4. L-16: bounds on extendedAttributes, and a cap on the attribute keys that column discovery returns.

I re-checked every finding against main @ 086a1a322. All five still reproduced before this change. C-01 was reproduced by running the real ingest() against a migrated schema: a one-record full sync to Systems deleted the other two systems.

Findings addressed

ID Severity What changed
C-01 Critical POST /ingest/systems runs a full batch as a delta, logs a warning, and deletes nothing. Both Omada registration calls now send delta, and the Omada crawler skips the empty batch. scopedDelete refuses any reconcile that has no system, scope or ownership predicate: it logs the refusal and returns deleted: 0. This applies to non-empty batches too.
H-04 High New module ingest/systemBoundary.js. For a key with systemIds set, the API returns 403 when:
• a record's systemId (or a context's scopeSystemId) is outside the key's systems;
• the batch would update an existing row another system owns;
• the batch is a full sync with no scope on a table that has no systemId column.
Ownership is checked with one indexed jsonb_populate_recordset lookup. For tables without a systemId it is derived: a synced context's scopeSystemId, a context member's context, the principal of an identity member or activity row, and an identity's linked principals.
deletedIds, the full-sync reconcile and the session reconcile only reach owned rows. For every caller, server-managed columns (deletedAt, riskScore, riskTier, linkConfidence, analystOverride, analystVerified, analystNotes, createdAt, updatedAt, …) are dropped from the writable column set. A field with one of those names lands in extendedAttributes like any other unknown field. For /ingest/systems, a restricted key can register new systems but cannot update one outside its systemIds.
M-05 Medium classify-business-role-assignments requires refreshViews. It is limited to an accessible body.systemId, or to the key's own systems, and an unrestricted key keeps the tenant-wide pass.
• Matview refreshes from classify and refresh-views go through a serialised runner. Only one refresh runs at a time, and callers that arrive mid-refresh share one follow-up. That follow-up starts at least 5 s after the run it waited for (MATRIX_REFRESH_MIN_INTERVAL_MS). A call that finds nothing in flight runs immediately.
matrix-default-filter requires admin.
sync-log stamps crawlerId and an accessible systemId, added by migration 064. It also caps text fields and rejects dates that don't parse.
principals-presence is limited to the restricted key's systems.
crawlers/job-progress is worker-only.
M-07 Medium • Open sessions are capped at 3 per crawler and 7 in total, configurable with INGEST_MAX_SESSIONS_PER_CRAWLER and INGEST_MAX_SESSIONS_GLOBAL. A start past either cap gets 429.
• The slot is reserved before the first await, so concurrent starts can't slip past the cap.
• A session that fails to open now releases its connection. It used to leak it.
• A session is only visible to the crawler that opened it.
• Every pooled connection gets idle_in_transaction_session_timeout of 10 min (PG_IDLE_IN_TRANSACTION_TIMEOUT_MS). A session sets its own 30-min bound with SET LOCAL.
L-16 Low • A record's packed extendedAttributes is limited to 500 keys and 512 KB; anything larger gets 400.
discoverExtendedAttrValues returns only the 300 most frequent safe keys, and unsafe names are filtered in SQL before the cap.

Not addressed / deferred

  • statement_timeout: deliberately not set. Matview refreshes, the full-sync reconcile on large tenants and migration index builds (one took about 11 min) legitimately run for many minutes. A statement cap tight enough to stop abuse would break them. The M-07 exposure is covered by the session caps and the idle-in-transaction timeout instead.

  • systemId = EXCLUDED.systemId on the upsert (report fix 3): not done, by design. Shipped crawlers legitimately write one row under several systems through the built-in worker:

    • Azure RM principal stubs are existing Entra principal rows.
    • Built-in Entra directory-role ids are the same in every tenant.
    • CSV deterministic ids are shared across SystemName systems.

    A blanket predicate would silently stop those writes. Ownership is enforced by access for restricted keys instead.

  • Record systemId is checked, not overwritten from the envelope. The demo dataset loader sends per-record system ids that match its envelope, so checking by access changes the least.

  • Reconcile of tables without a systemId column by unrestricted keys is unchanged. Entra, Omada, CSV and the demo loader full-sync Identities, IdentityMembers and ContextMembers without a scope and rely on that reconcile. Restricting it would leave departed users' identities in place forever.

  • Out of my lane, noticed: setup/docker/Build-FGContexts.ps1 posts to /api/ingest/refresh-contexts, which has no route. The 404 is swallowed. This is not a security issue.

Compatibility

  • Built-in worker and every shipped crawler: no behaviour change beyond the Omada fulldelta switch.

    • The worker has systemIds = null, so it skips every ownership check.
    • The worker holds ingest, refreshViews and admin, so classify, matrix-default-filter and job-progress keep working.
    • No shipped producer sends any server-managed column.
  • An older worker image (or an external script) that still sends syncMode: 'full' to /ingest/systems keeps working. The batch is run as a delta rather than rejected.

  • External connectors with a key restricted to specific systems may now get 403 where they previously succeeded. That happens when they:

    • write rows another system owns;
    • full-sync identities, identity-members or principal-activity (these have no scope map), or contexts or context-members without a scope;
    • update contexts they created without setting scopeSystemId;
    • update identities that have no linked principals of their own.

    All of these are the boundary working as intended. Unrestricted keys are unaffected.

  • External keys that only have ingest can no longer call classify-business-role-assignments (needs refreshViews), matrix-default-filter (needs admin) or crawlers/job-progress (worker only). The only shipped callers are the CSV crawler, the demo loader and the dispatcher, all on the worker key. A standalone Ingest-DemoDataset.ps1 run with a non-worker key will fail to seed the default matrix filter.

  • Session caps: shipped chunking is sequential (one session at a time). SCIM user buckets and midPoint per-resource streams can overlap, and they run on the worker key, which is only bound by the global cap of 7. Beyond 7 concurrent streams, the pool of 10 was already exhausted before this change.

  • Migration 064 adds two nullable columns to GraphSyncLog (ADD COLUMN IF NOT EXISTS) and needs no backfill. Its hash is added to .ci/migration-hashes.json.

  • Mutation-scope guard: the four new modules are recorded in stryker.features.config.json under mutationExclusions with a "not yet measured" reason. Stryker's vitest runner doesn't activate mutants on Windows: the existing featureFlags.js scope scored 18.5% locally against 100% in CI, so no honest score could be produced. They should be measured on Linux and moved into a scope.

Tests

  • API unit (Vitest): 3135 passed, 238 files. npm run lint: 0 errors, and the 5 existing warnings are unchanged. npm run test:coverage is above the aggregate floors, and every touched file is above its per-file floor. New or extended suites:

    • systemBoundary.test.js
    • sessions.limits.test.js
    • dataPlane.test.js
    • serializedRunner.test.js
    • connectionDefaults.test.js
    • engine.test.js, engine.ingest.test.js
    • ingest.coverage.test.js, ingest.helpers.test.js
    • crawlerPresence.test.js, columnCache.test.js, normalization.test.js
    • crawlers.selfservice.authz.test.js
  • Discriminating check: with the source changes stashed, all 19 new route-level tests failed. The new session-cap concurrency test lets all 5 starts through without the synchronous slot reservation.

  • Contract (contract-tests/ingestSystemBoundary.integration.test.js): real app, real crawler keys, two seeded systems. Covers:

    • a one-record full sync to /ingest/systems leaves both systems in place;
    • a restricted key is refused when it forges a system, overwrites by id, updates a foreign Systems row, or full-syncs identities without a scope;
    • deletedIds and the full-sync reconcile only reach the key's own system.

    Docker isn't available here, so I ran the same file through vitest against the real app on a migrated in-process Postgres (PGlite, desktop mode). It passed 7/7, and all 7 fail with the source changes stashed. I also checked the SQL against PGlite for the ownership predicates on every table, scoped deletedIds, the restricted reconcile, the presence filter, the key-cap query, the sync-log insert and SET LOCAL.

  • Pester: OmadaCrawlerPhases.Tests.ps1 and OmadaCrawlerFunctions.Tests.ps1 pass 122/122. The two new Omada tests fail on the old code, which sent full twice. PSScriptAnalyzer found no new findings on the touched files, and Register-OmadaSystems measures cc 13 / cog 14.

  • Complexity (the ratchet's ESLint rules, run locally): every touched JS unit is under cc 20 and cognitive 15. jscpd found no clones involving the changed files.

Also in this PR

  • tools/crawlers/demo/ConfigWizard.e2e.mjs: the demo re-import afterAll hook polls for up to 2 minutes, but it ran under Playwright's default 30 s hook timeout. It now sets its own timeout. This was a latent flake that an earlier revision of this PR made reproducible.

Manual follow-ups

  • None outside the repo.
  • Operators who give external connectors keys restricted to specific systems should confirm those connectors set scopeSystemId on their contexts, send a scope on full syncs of contexts, and use delta for identities.

🤖 Generated with Claude Code

WimvandenHeijkant and others added 4 commits September 13, 2026 16:24
…6-09 C-01, H-04)

C-01: a full sync to ingest/systems reconciled the whole Systems table, so a
one-record batch removed every other system and cascaded through its data.
Systems are registered, never reconciled: a full batch there now runs as a
delta, the Omada crawler sends delta for both of its registration calls, and
the engine refuses any reconcile that has no system, scope or ownership bound
(logged, deleted: 0). Tables without a systemId column keep their whole-table
reconcile for unrestricted keys, which the built-in worker's crawlers rely on.

H-04: for a crawler key restricted to specific systems, every batch is now
checked against the key's systems: records naming another system, updates of
rows another system owns (ownership derived per table where there is no
systemId column), and unscoped full syncs of tables without a systemId column
are refused with 403; deletedIds and the reconcile only reach owned rows.
Server-managed columns (deletedAt, risk scores, analyst decisions, timestamps)
are no longer writable from crawler records for any caller. Unrestricted keys,
including the built-in worker, skip the ownership checks so shipped crawlers
that legitimately write one row under several systems behave as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…C-2026-09 M-07)

Every multi-batch ingest session pins a pooled connection until it ends or its
30-minute timeout, and nothing limited how many a crawler key could open, so a
single key could hold the whole pool. Opening a session is now capped at 3 per
crawler and 7 in total (INGEST_MAX_SESSIONS_PER_CRAWLER / _GLOBAL); a start
past the cap returns 429. The slot is reserved before the first await so
concurrent starts cannot all pass the check, and a session that fails to open
releases its connection. Worker-class keys are only subject to the global cap,
because the SCIM and midPoint crawlers keep several buckets open at once.

A session now belongs to the crawler that opened it: another key presenting
its syncId gets "Invalid or expired syncId".

Every pooled connection gets idle_in_transaction_session_timeout (10 minutes,
PG_IDLE_IN_TRANSACTION_TIMEOUT_MS); sessions keep their 30-minute bound for their
own transaction. No pool-wide statement_timeout: matview refreshes, large
reconciles and migration index builds legitimately run for many minutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…6-09 M-05)

- classify-business-role-assignments needs the refreshViews permission, flags
  only the systems the caller may access (an explicit systemId must be
  accessible), and its matview refresh goes through a serialised runner: one
  refresh at a time, callers mid-refresh share one follow-up, runs spaced by
  MATRIX_REFRESH_MIN_INTERVAL_MS (default 5 s). refresh-views uses it too.
- matrix-default-filter is worker-only (its sole caller is the demo loader).
- sync-log stamps the authenticated crawler and, when named, an accessible
  system (migration 064 adds the two nullable columns); text fields are capped
  and unparseable dates are rejected.
- principals-presence limits a restricted key to its own Entra systems.
- crawlers/job-progress is part of the worker protocol (job rows carry no
  owning crawler).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ys (SEC-2026-09 L-16)

Every distinct extendedAttributes key becomes a candidate filter column, and
column discovery issued one UNION ALL branch per key, so a crawler writing many
unique keys made every column-list request expensive. A record's packed
extendedAttributes is now limited to 500 keys and 512 KB (400 otherwise), and
discovery surfaces at most the 300 most frequent safe keys, filtering unsafe
names in SQL before the cap. Both limits are far above any shipped crawler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
if (!allowed) return null;
const denial = await systemBoundaryDenial({ ...ctx, records: ctx.normalized, syncMode: body.syncMode, allowed });
if (!denial) return null;
console.warn(`Ingest denied for crawler ${req.crawler?.id}: ${denial}`);
…(SEC-2026-09 M-05)

The serialised matrix refresh also delayed a call that found nothing in flight
when the previous refresh had finished less than the minimum interval ago. A
crawler that refreshes twice in a row (the demo loader does) paid that delay on
every run, which pushed the demo re-import in the Playwright suite past its hook
timeout. The spacing now applies only to the shared follow-up run of callers
that arrived while a refresh was in flight; a lone call runs immediately. At
most one refresh still runs at a time.

Also give that demo re-import hook the timeout its own 2-minute poll assumes;
it ran under Playwright's default 30 s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TaekeK pushed a commit that referenced this pull request Sep 14, 2026
Adds a public, sanitised record of the September 2026 white-box
re-assessment: scope, method, all 53 findings by severity with their
remediation pull requests (#1193-#1200), upgrade notes for operators,
the regression check against June 2026, and confirmed strengths.
Linked from the docs navigation and from the June assessment page.
Exploit detail is left out, matching the June page.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolved the one textual conflict in .ci/migration-hashes.json by keeping both
new entries: 064_sync_log_source.sql from this branch and
066_auth_role_change_log_oid.sql from main. No existing entry changed.
Everything else merged cleanly (engine.js comment, Omada phases/tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WimvandenHeijkant and others added 2 commits September 15, 2026 12:19
Conflict in .ci/migration-hashes.json resolved by keeping both sides: this branch's 064_sync_log_source.sql entry plus main's 066 and 067 entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ystem-boundary

# Conflicts:
#	.ci/migration-hashes.json
@WimvandenHeijkant
WimvandenHeijkant merged commit d35a364 into main Sep 16, 2026
34 checks passed
@WimvandenHeijkant
WimvandenHeijkant deleted the bugfixes/sec-ingest-system-boundary branch September 16, 2026 09:32
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.

2 participants