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
Conversation
…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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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 realingest()against a migrated schema: a one-record full sync toSystemsdeleted the other two systems.Findings addressed
POST /ingest/systemsruns afullbatch as adelta, logs a warning, and deletes nothing. Both Omada registration calls now senddelta, and the Omada crawler skips the empty batch.scopedDeleterefuses any reconcile that has no system, scope or ownership predicate: it logs the refusal and returnsdeleted: 0. This applies to non-empty batches too.ingest/systemBoundary.js. For a key withsystemIdsset, the API returns 403 when:• a record's
systemId(or a context'sscopeSystemId) is outside the key's systems;• the batch would update an existing row another system owns;
• the batch is a full sync with no
scopeon a table that has nosystemIdcolumn.Ownership is checked with one indexed
jsonb_populate_recordsetlookup. For tables without asystemIdit is derived: a synced context'sscopeSystemId, 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 inextendedAttributeslike any other unknown field. For/ingest/systems, a restricted key can register new systems but cannot update one outside itssystemIds.classify-business-role-assignmentsrequiresrefreshViews. It is limited to an accessiblebody.systemId, or to the key's own systems, and an unrestricted key keeps the tenant-wide pass.• Matview refreshes from classify and
refresh-viewsgo 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-filterrequiresadmin.•
sync-logstampscrawlerIdand an accessiblesystemId, added by migration 064. It also caps text fields and rejects dates that don't parse.•
principals-presenceis limited to the restricted key's systems.•
crawlers/job-progressis worker-only.INGEST_MAX_SESSIONS_PER_CRAWLERandINGEST_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_timeoutof 10 min (PG_IDLE_IN_TRANSACTION_TIMEOUT_MS). A session sets its own 30-min bound withSET LOCAL.extendedAttributesis limited to 500 keys and 512 KB; anything larger gets 400.•
discoverExtendedAttrValuesreturns 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.systemIdon the upsert (report fix 3): not done, by design. Shipped crawlers legitimately write one row under several systems through the built-in worker:SystemNamesystems.A blanket predicate would silently stop those writes. Ownership is enforced by access for restricted keys instead.
Record
systemIdis 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
systemIdcolumn 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.ps1posts 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
full→deltaswitch.systemIds = null, so it skips every ownership check.ingest,refreshViewsandadmin, so classify, matrix-default-filter and job-progress keep working.An older worker image (or an external script) that still sends
syncMode: 'full'to/ingest/systemskeeps 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:
identities,identity-membersorprincipal-activity(these have no scope map), orcontextsorcontext-memberswithout ascope;scopeSystemId;All of these are the boundary working as intended. Unrestricted keys are unaffected.
External keys that only have
ingestcan no longer callclassify-business-role-assignments(needsrefreshViews),matrix-default-filter(needsadmin) orcrawlers/job-progress(worker only). The only shipped callers are the CSV crawler, the demo loader and the dispatcher, all on the worker key. A standaloneIngest-DemoDataset.ps1run 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.jsonundermutationExclusionswith a "not yet measured" reason. Stryker's vitest runner doesn't activate mutants on Windows: the existingfeatureFlags.jsscope 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:coverageis above the aggregate floors, and every touched file is above its per-file floor. New or extended suites:systemBoundary.test.jssessions.limits.test.jsdataPlane.test.jsserializedRunner.test.jsconnectionDefaults.test.jsengine.test.js,engine.ingest.test.jsingest.coverage.test.js,ingest.helpers.test.jscrawlerPresence.test.js,columnCache.test.js,normalization.test.jscrawlers.selfservice.authz.test.jsDiscriminating 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:/ingest/systemsleaves both systems in place;Systemsrow, or full-syncs identities without a scope;deletedIdsand 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, thesync-loginsert andSET LOCAL.Pester:
OmadaCrawlerPhases.Tests.ps1andOmadaCrawlerFunctions.Tests.ps1pass 122/122. The two new Omada tests fail on the old code, which sentfulltwice. PSScriptAnalyzer found no new findings on the touched files, andRegister-OmadaSystemsmeasures 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-importafterAllhook 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
scopeSystemIdon their contexts, send ascopeon full syncs of contexts, and use delta for identities.🤖 Generated with Claude Code