Skip to content

🔒 fix: Exclude Unnecessary fields from Conversation $unset#12501

Merged
danny-avila merged 1 commit into
devfrom
claude/frosty-bouman
Apr 1, 2026
Merged

🔒 fix: Exclude Unnecessary fields from Conversation $unset#12501
danny-avila merged 1 commit into
devfrom
claude/frosty-bouman

Conversation

@danny-avila

Copy link
Copy Markdown
Owner

Summary

  • Adds tenantId to excludedKeys in data-provider/config.ts so BaseClient.saveMessageToDatabase won't include it in $unset when diffing against endpointOptions
  • Fixes tenant isolation plugin throwing assertNoTenantIdMutation when tenantId is present on the conversation document but absent from endpointOptions

Context

BaseClient.js ~L794 iterates all keys of the existing conversation document to build unsetFields. When tenant isolation stamps tenantId on the document, it becomes a candidate for $unset since endpointOptions never includes it. The tenant isolation plugin then rejects the update.

The downstream fix in data-schemas strips tenantId from $unset before findOneAndUpdate, but the proper fix is preventing it from entering unsetFields in the first place — same pattern used for _id, user, conversationId, and other system fields.

Test plan

  • Verify conversations save correctly with tenant isolation enabled
  • Verify no regression in conversation field cleanup when switching endpoint options

Copilot AI review requested due to automatic review settings April 1, 2026 15:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the data-provider configuration to prevent tenantId from being considered a removable conversation field during conversation diffing, avoiding tenant isolation update rejections.

Changes:

  • Add tenantId to excludedKeys so it won’t be included in conversation $unset fields when comparing existing conversation documents to endpointOptions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 55 to 59
'files',
'spec',
'disableParams',
'tenantId',
]);

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change fixes a tenant-isolation regression, but there’s no automated coverage ensuring tenantId is never added to unsetFields when diffing an existing conversation against endpointOptions. Please add a regression test (e.g., in api/app/clients/specs/BaseClient.test.js) where existingConvo contains tenantId and assert saveConvo is called with unsetFields that does not include tenantId.

Copilot uses AI. Check for mistakes.
`BaseClient.js` iterates existing conversation keys to build `unsetFields`
for removal when `endpointOptions` doesn't include them. When tenant
isolation stamps `tenantId` on the document, it gets swept into `$unset`,
triggering `assertNoTenantIdMutation`. Adding `tenantId` to `excludedKeys`
prevents this — it's a system field, not an endpoint option.
@danny-avila danny-avila force-pushed the claude/frosty-bouman branch from c7c9174 to 9f196ab Compare April 1, 2026 16:45
@danny-avila danny-avila changed the title fix: Exclude tenantId from conversation $unset fields 🔒 fix: Exclude Unnecessary fields from Conversation $unset Apr 1, 2026
@danny-avila danny-avila merged commit c4b5ded into dev Apr 1, 2026
12 checks passed
@danny-avila danny-avila deleted the claude/frosty-bouman branch April 1, 2026 17:01
danny-avila added a commit that referenced this pull request Apr 1, 2026
Revert the per-call-site tenantId stripping from #12498 and
the excludedKeys patch from #12501. These are no longer needed
since the self-healing guard handles tenantId in update payloads
at the plugin level.

Reverted patches:
- conversation.ts: delete update.tenantId in saveConvo(),
  tenantId destructuring in bulkSaveConvos()
- message.ts: delete update.tenantId in saveMessage() and
  recordMessage(), tenantId destructuring in bulkSaveMessages()
  and updateMessage()
- config.ts: tenantId in excludedKeys Set
- config.spec.ts: tenantId in excludedKeys test assertion
danny-avila added a commit that referenced this pull request Apr 1, 2026
* refactor: self-healing tenant isolation update guard

Replace the strict throw-on-any-tenantId guard with a
strip-or-throw approach:

- $set/$setOnInsert: strip when value matches current tenant
  or no context is active; throw only on cross-tenant mutations
- $unset/$rename: always strip (unsetting/renaming tenantId
  is never valid)
- Top-level tenantId: same logic as $set

This eliminates the entire class of "tenantId in update payload"
bugs at the plugin level while preserving the cross-tenant
security invariant.

* test: update mutation guard tests for self-healing behavior

- Convert same-tenant $set/$setOnInsert tests to expect silent
  stripping instead of throws
- Convert $unset test to expect silent stripping
- Add cross-tenant throw tests for $set, $setOnInsert, top-level
- Add same-tenant stripping tests for $set, $setOnInsert, top-level
- Add $rename stripping test
- Add no-context stripping test
- Update error message assertions to match new cross-tenant message

* revert: remove call-site tenantId stripping patches

Revert the per-call-site tenantId stripping from #12498 and
the excludedKeys patch from #12501. These are no longer needed
since the self-healing guard handles tenantId in update payloads
at the plugin level.

Reverted patches:
- conversation.ts: delete update.tenantId in saveConvo(),
  tenantId destructuring in bulkSaveConvos()
- message.ts: delete update.tenantId in saveMessage() and
  recordMessage(), tenantId destructuring in bulkSaveMessages()
  and updateMessage()
- config.ts: tenantId in excludedKeys Set
- config.spec.ts: tenantId in excludedKeys test assertion

* fix: strip tenantId from update documents in tenantSafeBulkWrite

Mongoose middleware does not fire for bulkWrite, so the plugin-level
guard never sees update payloads in bulk operations. Extend
injectTenantId() to strip tenantId from update documents for
updateOne/updateMany operations, preventing cross-tenant overwrites.

* refactor: rename guard, add empty-op cleanup and strict-mode warning

- Rename assertNoTenantIdMutation to sanitizeTenantIdMutation
- Remove empty operator objects after stripping to avoid MongoDB errors
- Log warning in strict mode when stripping tenantId without context
- Fix $setOnInsert test to use upsert:true with non-matching filter

* test: fix bulk-save tests and add negative excludedKeys assertion

- Wrap bulkSaveConvos/bulkSaveMessages tests in tenantStorage.run()
  to exercise the actual multi-tenant stripping path
- Assert tenantId equals the real tenant, not undefined
- Add negative assertion: excludedKeys must NOT contain tenantId

* fix: type-safe tenantId stripping in tenantSafeBulkWrite

- Fix TS2345 error: replace conditional type inference with
  UpdateQuery<Record<string, unknown>> for stripTenantIdFromUpdate
- Handle empty updates after stripping (e.g., $set: { tenantId } as
  sole field) by filtering null ops from the bulk array
- Add 4 tests for bulk update tenantId stripping: plain-object update,
  $set stripping, $unset stripping, and sole-field-in-$set edge case

* fix: resolve TS2345 in stripTenantIdFromUpdate parameter type

Use Record<string, unknown> instead of UpdateQuery<> to avoid
type incompatibility with Mongoose's AnyObject-based UpdateQuery
resolution in CI.

* fix: strip tenantId from bulk updates unconditionally

Separate sanitization from injection in tenantSafeBulkWrite:
tenantId is now stripped from all update documents before any
tenant-context checks, closing the gap where no-context and
system-context paths passed caller-supplied tenantId through
to MongoDB unmodified.

* refactor: address review findings in tenant isolation

- Fix early-return gap in stripTenantIdFromUpdate that skipped
  operator-level tenantId when top-level was also present
- Lazy-allocate copy in stripTenantIdFromUpdate (no allocation
  when no tenantId is present)
- Document behavioral asymmetry: plugin throws on cross-tenant,
  bulkWrite strips silently (intentional, documented in JSDoc)
- Remove double JSDoc on injectTenantId
- Remove redundant cast in stripTenantIdFromUpdate
- Use shared frozen EMPTY_BULK_RESULT constant
- Remove Record<string, unknown> annotation in recordMessage
- Isolate bulkSave* tests: pre-create docs then update with
  cross-tenant payload, read via runAsSystem to prove stripping
  is independent of filter injection

* fix: no-op empty updates after tenantId sanitization

When tenantId is the sole field in an update (e.g., { $set: { tenantId } }),
sanitization leaves an empty update object that would fail with
"Update document requires atomic operators." The updateGuard now
detects this and short-circuits the query by adding an unmatchable
filter condition and disabling upsert, matching the bulk-write
handling that filters out null ops.

* refactor: remove dead logger.warn branches, add mixed-case test

- Remove unreachable logger.warn calls in sanitizeTenantIdMutation:
  queryMiddleware throws before updateGuard in strict+no-context,
  and isStrict() is false in non-strict+no-context
- Add test for combined top-level + operator-level tenantId stripping
  to lock in the early-return fix

* feat: ESLint rule to ban raw bulkWrite and collection.* in data-schemas

Add no-restricted-syntax rules to the data-schemas ESLint config that
flag direct Model.bulkWrite() and Model.collection.* calls. These
bypass Mongoose middleware and the tenant isolation plugin — all bulk
writes must use tenantSafeBulkWrite() instead.

Test files are excluded since they intentionally use raw driver calls
for fixture setup.

Also migrate the one remaining raw bulkWrite in seedSystemGrants() to
use tenantSafeBulkWrite() for consistency.

* test: add findByIdAndUpdate coverage to mutation guard tests

* fix: keep tenantSafeBulkWrite in seedSystemGrants, fix ESLint config

- Revert to tenantSafeBulkWrite in seedSystemGrants (always runs
  under runAsSystem, so the wrapper passes through correctly)
- Split data-schemas ESLint config: shared TS rules for all files,
  no-restricted-syntax only for production non-wrapper files
- Fix unused destructure vars to use _tenantId pattern
jcbartle pushed a commit to jcbartle/LibreChat that referenced this pull request May 11, 2026
…vila#12501)

`BaseClient.js` iterates existing conversation keys to build `unsetFields`
for removal when `endpointOptions` doesn't include them. When tenant
isolation stamps `tenantId` on the document, it gets swept into `$unset`,
triggering `assertNoTenantIdMutation`. Adding `tenantId` to `excludedKeys`
prevents this — it's a system field, not an endpoint option.
jcbartle pushed a commit to jcbartle/LibreChat that referenced this pull request May 11, 2026
…12506)

* refactor: self-healing tenant isolation update guard

Replace the strict throw-on-any-tenantId guard with a
strip-or-throw approach:

- $set/$setOnInsert: strip when value matches current tenant
  or no context is active; throw only on cross-tenant mutations
- $unset/$rename: always strip (unsetting/renaming tenantId
  is never valid)
- Top-level tenantId: same logic as $set

This eliminates the entire class of "tenantId in update payload"
bugs at the plugin level while preserving the cross-tenant
security invariant.

* test: update mutation guard tests for self-healing behavior

- Convert same-tenant $set/$setOnInsert tests to expect silent
  stripping instead of throws
- Convert $unset test to expect silent stripping
- Add cross-tenant throw tests for $set, $setOnInsert, top-level
- Add same-tenant stripping tests for $set, $setOnInsert, top-level
- Add $rename stripping test
- Add no-context stripping test
- Update error message assertions to match new cross-tenant message

* revert: remove call-site tenantId stripping patches

Revert the per-call-site tenantId stripping from danny-avila#12498 and
the excludedKeys patch from danny-avila#12501. These are no longer needed
since the self-healing guard handles tenantId in update payloads
at the plugin level.

Reverted patches:
- conversation.ts: delete update.tenantId in saveConvo(),
  tenantId destructuring in bulkSaveConvos()
- message.ts: delete update.tenantId in saveMessage() and
  recordMessage(), tenantId destructuring in bulkSaveMessages()
  and updateMessage()
- config.ts: tenantId in excludedKeys Set
- config.spec.ts: tenantId in excludedKeys test assertion

* fix: strip tenantId from update documents in tenantSafeBulkWrite

Mongoose middleware does not fire for bulkWrite, so the plugin-level
guard never sees update payloads in bulk operations. Extend
injectTenantId() to strip tenantId from update documents for
updateOne/updateMany operations, preventing cross-tenant overwrites.

* refactor: rename guard, add empty-op cleanup and strict-mode warning

- Rename assertNoTenantIdMutation to sanitizeTenantIdMutation
- Remove empty operator objects after stripping to avoid MongoDB errors
- Log warning in strict mode when stripping tenantId without context
- Fix $setOnInsert test to use upsert:true with non-matching filter

* test: fix bulk-save tests and add negative excludedKeys assertion

- Wrap bulkSaveConvos/bulkSaveMessages tests in tenantStorage.run()
  to exercise the actual multi-tenant stripping path
- Assert tenantId equals the real tenant, not undefined
- Add negative assertion: excludedKeys must NOT contain tenantId

* fix: type-safe tenantId stripping in tenantSafeBulkWrite

- Fix TS2345 error: replace conditional type inference with
  UpdateQuery<Record<string, unknown>> for stripTenantIdFromUpdate
- Handle empty updates after stripping (e.g., $set: { tenantId } as
  sole field) by filtering null ops from the bulk array
- Add 4 tests for bulk update tenantId stripping: plain-object update,
  $set stripping, $unset stripping, and sole-field-in-$set edge case

* fix: resolve TS2345 in stripTenantIdFromUpdate parameter type

Use Record<string, unknown> instead of UpdateQuery<> to avoid
type incompatibility with Mongoose's AnyObject-based UpdateQuery
resolution in CI.

* fix: strip tenantId from bulk updates unconditionally

Separate sanitization from injection in tenantSafeBulkWrite:
tenantId is now stripped from all update documents before any
tenant-context checks, closing the gap where no-context and
system-context paths passed caller-supplied tenantId through
to MongoDB unmodified.

* refactor: address review findings in tenant isolation

- Fix early-return gap in stripTenantIdFromUpdate that skipped
  operator-level tenantId when top-level was also present
- Lazy-allocate copy in stripTenantIdFromUpdate (no allocation
  when no tenantId is present)
- Document behavioral asymmetry: plugin throws on cross-tenant,
  bulkWrite strips silently (intentional, documented in JSDoc)
- Remove double JSDoc on injectTenantId
- Remove redundant cast in stripTenantIdFromUpdate
- Use shared frozen EMPTY_BULK_RESULT constant
- Remove Record<string, unknown> annotation in recordMessage
- Isolate bulkSave* tests: pre-create docs then update with
  cross-tenant payload, read via runAsSystem to prove stripping
  is independent of filter injection

* fix: no-op empty updates after tenantId sanitization

When tenantId is the sole field in an update (e.g., { $set: { tenantId } }),
sanitization leaves an empty update object that would fail with
"Update document requires atomic operators." The updateGuard now
detects this and short-circuits the query by adding an unmatchable
filter condition and disabling upsert, matching the bulk-write
handling that filters out null ops.

* refactor: remove dead logger.warn branches, add mixed-case test

- Remove unreachable logger.warn calls in sanitizeTenantIdMutation:
  queryMiddleware throws before updateGuard in strict+no-context,
  and isStrict() is false in non-strict+no-context
- Add test for combined top-level + operator-level tenantId stripping
  to lock in the early-return fix

* feat: ESLint rule to ban raw bulkWrite and collection.* in data-schemas

Add no-restricted-syntax rules to the data-schemas ESLint config that
flag direct Model.bulkWrite() and Model.collection.* calls. These
bypass Mongoose middleware and the tenant isolation plugin — all bulk
writes must use tenantSafeBulkWrite() instead.

Test files are excluded since they intentionally use raw driver calls
for fixture setup.

Also migrate the one remaining raw bulkWrite in seedSystemGrants() to
use tenantSafeBulkWrite() for consistency.

* test: add findByIdAndUpdate coverage to mutation guard tests

* fix: keep tenantSafeBulkWrite in seedSystemGrants, fix ESLint config

- Revert to tenantSafeBulkWrite in seedSystemGrants (always runs
  under runAsSystem, so the wrapper passes through correctly)
- Split data-schemas ESLint config: shared TS rules for all files,
  no-restricted-syntax only for production non-wrapper files
- Fix unused destructure vars to use _tenantId pattern
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