Skip to content

feat(merge-users) Scalar user references and public sharing user rewrite (#17495) - #17577

Draft
Ludovic Bouges (ludovic) wants to merge 7 commits into
issue/17494from
issue/17495
Draft

feat(merge-users) Scalar user references and public sharing user rewrite (#17495)#17577
Ludovic Bouges (ludovic) wants to merge 7 commits into
issue/17494from
issue/17495

Conversation

@ludovic

@ludovic Ludovic Bouges (ludovic) commented Aug 6, 2026

Copy link
Copy Markdown
Member

Proposed changes

  • Adds the scalar user reference handler, the first one that writes. Its targets are derived from the schema: every attribute declared with format id and pointing at User. Discovery walks the entity types one by one rather than through getIdAttributes(), which deduplicates by attribute name and would collapse the several entities declaring user_id into a single entry.
  • Adds a table alongside supplying what the schema cannot say. Dispositions: whether a discovered attribute is rewritten here or left to another chunk — History, Activity and PirHistory belong to the history chunk, platform_ip_whitelist_exclusion_ids is invalidated rather than transferred, and User-Account.user_id is the STIX observable property holding an account identifier on the observed system, declared as a user reference by mistake. Lifecycle splits: work.status, BackgroundTask.completed and Notification.is_read each split one attribute into two register rows with different dispositions. Complements: nine paths the declarations do not expose, each recording why it is missing.
  • Reports a discovered attribute with no disposition as unassigned, failing a test rather than rewriting it silently.
  • Shares one query builder between counting and updating, so what the dry run reported is what the real pass writes. creator_id is multiple, so the script removes the source before appending the target, which makes a replay a no-op.
  • Adds the public sharing handler. feed_public_user_id, taxii_public_user_id and stream_public_user_id are transferred, and the handler reports what each endpoint exposes anonymously before and after the transfer.
  • Adds a blocking flag on UserMergeRightsAlert, raised by the public sharing handler when the target user carries markings or organizations the source did not have. The engine checks it between the two passes and refuses the real pass until acknowledgeExposureChange is set.
  • Moves handler registration from the userMerge module to the end of modules/index.ts, since the scalar handler reads the schema and userMerge is imported before the workflow and custom field modules.

The nine complements exist because connector_user_id, NewsFeedItem.user_id and initiator_id are declared with format short, PublicDashboard.user_id is not declared at all, InternalFile.metaData.creator_id sits under an object with no attribute definitions, and the workflow version createdBy fields are nested children declared without entityTypes. Fixing those declarations is out of scope: format: 'id' drives filter representative resolution, the filter key schema and history diff rendering, so changing it has effects well beyond this feature. A test fails once the schema starts yielding a complement, so the entry gets dropped rather than silently duplicating a discovered target.

Filters, JSON manifests, STIX relations and RBAC have their own chunks. Hardening resolveValidUser against a disabled account serving a public endpoint is tracked separately; transferring *_public_user_id means no endpoint points at the merged-away source, so that check is defence in depth.

Related issues

How to test this PR

Stacked on #17535, so review the last 7 commits only. Enable the flag and start the platform:

export APP__ENABLED_DEV_FEATURES='["MERGE_USERS"]'
yarn start

Create some data owned by a source user — a few entities, an ingestion, a public feed — then run a dry merge. Each target reports a count, including the ones at zero, so the report names what was examined and not only what moved:

curl -s http://localhost:4000/graphql \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $OPENCTI_TOKEN" \
  -d '{"query":"mutation { userMerge(sourceId:\"<source>\", targetId:\"<target>\", options:{dryRun:true}) { merge_id status report { changes { register_row_id entity_type count exact detail } alerts { register_row_id kind message blocking } coverage { covered_count is_complete } } } }"}'

With a target user holding markings the source does not have, the public sharing handler must raise a blocking alert, and the real run must refuse:

curl -s http://localhost:4000/graphql \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $OPENCTI_TOKEN" \
  -d '{"query":"mutation { userMerge(sourceId:\"<source>\", targetId:\"<target>\", options:{dryRun:false}) { status message } }"}'

The same call with acknowledgeExposureChange: true proceeds. Replaying it must report zero updates, since the rewrite is idempotent — worth checking specifically on creator_id, which is a multi-valued field.

Automated coverage:

npx vitest run --config vitest.config.ci-unit.ts tests/01-unit/modules/userMerge
npx vitest run --config vitest.config.dev.ts tests/03-integration/10-modules/userMerge

Checklist

  • I consider the submitted work as finished
  • I tested the code for its functionality
  • I wrote test cases for the relevant use cases (coverage and e2e)
  • I added/updated the relevant documentation (either on GitHub or on Notion)
  • Where necessary, I refactored code to improve the overall quality

Further comments

72 unit tests and 27 integration tests. Discovery resolves to 17 targets, 12 motivated exclusions and no unassigned attribute — the same coverage an enumerated list would give, derived rather than written down, so a properly declared attribute added later is covered without touching this code.

The blocking refusal sits in the engine rather than in the handler. Raising it in apply() would stop the merge after earlier handlers already wrote, and raising it in compute() would keep the difference out of the dry run report, which is the one place the operator can read it before deciding.

The schema declares which attributes point at a User, but not what the merge should do with
them: it says nothing about the register, about the lifecycle states splitting one attribute
into two register rows, and it is silent on a few fields whose declarations are missing or
wrong. Discovery walks the types one by one rather than through getIdAttributes(), which
deduplicates by name and would collapse the several entities declaring user_id into one entry.

A table alongside gives every discovered attribute a disposition, and a discovered attribute
without one is reported as unassigned rather than silently rewritten.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both the count and the update run off the same query builder, so what the dry run reported is
what the real pass writes. A term filter goes to the keyword sub-field, booleans stay on the
bare path, and a negated condition goes to must_not so documents missing the field are also
selected. On a multiple field the script removes before appending, which is what makes a
replay a no-op.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Some differences a handler reports change the security posture and should not be applied on
the operator's behalf. The refusal sits in the engine, between the two passes: raising it in
apply() would stop the merge after earlier handlers already wrote, and raising it in compute()
would keep the difference out of the dry run report, which is the one place the operator can
read it before deciding.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
One count per target, and a change emitted even at zero so the report names what was examined
and not only what moved. Targets flagged as unexpected on an idle platform raise an alert when
they match: a work still running means something was left on.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A feed, a taxii collection and a stream published under the source user become readable with
the target user's markings and organizations. The handler reports the difference per endpoint
and blocks the real pass when the target sees more than the source did.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The scalar handler reads the schema to build its targets, so registering it from the userMerge
module would only see the modules imported before it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added the filigran team Item from the Filigran team. label Aug 6, 2026
@ludovic Ludovic Bouges (ludovic) changed the title [backend] Scalar user references and public sharing user rewrite feat(merge-users) Scalar user references and public sharing user rewrite Aug 6, 2026
@ludovic Ludovic Bouges (ludovic) changed the title feat(merge-users) Scalar user references and public sharing user rewrite feat(merge-users) Scalar user references and public sharing user rewrite (#17495) Aug 6, 2026
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.04072% with 64 lines in your changes missing coverage. Please review.
✅ Project coverage is 26.16%. Comparing base (63ccabc) to head (f231b14).

Files with missing lines Patch % Lines
...odules/userMerge/userMerge-publicSharingHandler.ts 23.52% 39 Missing ⚠️
...l/src/modules/userMerge/userMerge-scalarHandler.ts 32.35% 23 Missing ⚠️
...l/src/modules/userMerge/userMerge-scalarTargets.ts 96.22% 2 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (63ccabc) and HEAD (f231b14). Click for more details.

HEAD has 2 uploads less than BASE
Flag BASE (63ccabc) HEAD (f231b14)
opencti-graphql 6 4
Additional details and impacted files
@@               Coverage Diff               @@
##           issue/17494   #17577      +/-   ##
===============================================
- Coverage        34.18%   26.16%   -8.02%     
===============================================
  Files             3386     3394       +8     
  Lines           137814   138034     +220     
  Branches         37256    37310      +54     
===============================================
- Hits             47109    36114   -10995     
- Misses           90705   101920   +11215     
Flag Coverage Δ
opencti-client-python 48.37% <ø> (ø)
opencti-front 10.98% <ø> (ø)
opencti-graphql 45.87% <71.04%> (-23.39%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

filigran team Item from the Filigran team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PR3 — Scalar root-ID attributes rewrite (creator_id, user_id, …)

1 participant