mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-11 00:33:40 +00:00
fix: refuse to sweep tenants with principal-scoped retention overrides
The migration evaluates one config per tenant, but runtime requests resolve config with role/user principals, so a deployment using principal-scoped DB config overrides can have users whose effective retention mode or window differs from the tenant default. Sweeping such a tenant with the tenant-level config would skip users who are ephemeral, or worse, force-expire chats of users whose override opts out. Detect active non-base config overrides that touch interface.retentionMode or interface.temporaryChatRetention (including tombstones removing them) and skip the tenant with an explanatory error listing the principals; --force sweeps with the tenant-level config anyway. Skip reasons now surface in the CLI output.
This commit is contained in:
parent
d88182a163
commit
3e2bd05bbd
1 changed files with 63 additions and 3 deletions
|
|
@ -6,6 +6,7 @@ const {
|
|||
createTempChatExpirationDate,
|
||||
forcedRetentionGapFilter,
|
||||
sweepForcedRetention,
|
||||
BASE_CONFIG_PRINCIPAL_ID,
|
||||
} = require('@librechat/data-schemas');
|
||||
const { RetentionMode } = require('librechat-data-provider');
|
||||
|
||||
|
|
@ -13,9 +14,46 @@ require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
|
|||
const connect = require('./connect');
|
||||
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { Conversation, Message, SharedLink, File } = require('~/db/models');
|
||||
const { Conversation, Message, SharedLink, File, Config } = require('~/db/models');
|
||||
const { refreshChatProjectStats } = require('~/models');
|
||||
|
||||
const RETENTION_OVERRIDE_PATHS = ['interface.retentionMode', 'interface.temporaryChatRetention'];
|
||||
|
||||
const overridesTouchRetention = (overrides) => {
|
||||
const interfaceOverrides = overrides?.interface;
|
||||
if (interfaceOverrides == null || typeof interfaceOverrides !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return 'retentionMode' in interfaceOverrides || 'temporaryChatRetention' in interfaceOverrides;
|
||||
};
|
||||
|
||||
const tombstonesTouchRetention = (tombstones) =>
|
||||
(tombstones ?? []).some(
|
||||
(tombstone) => tombstone === 'interface' || RETENTION_OVERRIDE_PATHS.includes(tombstone),
|
||||
);
|
||||
|
||||
/**
|
||||
* Finds active principal-scoped (role/user/group) config overrides that change retention
|
||||
* behavior. The sweep evaluates one config per tenant, so a principal whose effective
|
||||
* retention differs from the tenant default would be skipped or swept with the wrong
|
||||
* deadline; such tenants are refused instead of migrated incorrectly.
|
||||
*/
|
||||
async function findPrincipalRetentionOverrides() {
|
||||
const configs = await Config.find({
|
||||
isActive: true,
|
||||
principalId: { $ne: BASE_CONFIG_PRINCIPAL_ID },
|
||||
})
|
||||
.select('principalType principalId overrides tombstones')
|
||||
.lean();
|
||||
|
||||
return configs
|
||||
.filter(
|
||||
(config) =>
|
||||
overridesTouchRetention(config.overrides) || tombstonesTouchRetention(config.tombstones),
|
||||
)
|
||||
.map((config) => `${config.principalType}:${config.principalId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts one tenant's pre-existing data to the forced (ephemeral) window using that tenant's
|
||||
* own retention config. Runs inside the caller's tenant context, so every query is scoped to the
|
||||
|
|
@ -35,6 +73,23 @@ async function sweepTenant({ tenantId, dryRun, force }) {
|
|||
return { tenantId: tenantId ?? null, skipped: true, retentionMode };
|
||||
}
|
||||
|
||||
const principalOverrides = await findPrincipalRetentionOverrides();
|
||||
if (principalOverrides.length > 0 && !force) {
|
||||
logger.error(
|
||||
`[tenant ${label}] ${principalOverrides.length} principal-scoped config override(s) change ` +
|
||||
`retention behavior (${principalOverrides.join(', ')}). The migration evaluates one ` +
|
||||
'config per tenant, so it cannot safely convert this tenant; remove the overrides or ' +
|
||||
'pass --force to sweep with the tenant-level config anyway.',
|
||||
);
|
||||
return {
|
||||
tenantId: tenantId ?? null,
|
||||
skipped: true,
|
||||
retentionMode,
|
||||
reason: 'principal-scoped retention overrides',
|
||||
principalOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
const forcedExpiredAt = createTempChatExpirationDate(interfaceConfig);
|
||||
const nonConforming = await Conversation.countDocuments(
|
||||
forcedRetentionGapFilter(forcedExpiredAt),
|
||||
|
|
@ -145,7 +200,11 @@ if (require.main === module) {
|
|||
|
||||
if (result.tenants.length > 0 && result.tenants.every((tenant) => tenant.skipped)) {
|
||||
console.log('\n=== NOTHING TO MIGRATE ===');
|
||||
console.log('No tenant has ephemeral retention enabled.');
|
||||
for (const tenant of result.tenants) {
|
||||
const label = tenant.tenantId ?? 'default';
|
||||
const reason = tenant.reason ?? `retentionMode: ${tenant.retentionMode ?? 'unset'}`;
|
||||
console.log(`[${label}] skipped (${reason})`);
|
||||
}
|
||||
console.log('Enable ephemeral retention, or pass --force to run anyway.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -155,7 +214,8 @@ if (require.main === module) {
|
|||
for (const tenant of result.tenants) {
|
||||
const label = tenant.tenantId ?? 'default';
|
||||
if (tenant.skipped) {
|
||||
console.log(`[${label}] skipped (retentionMode: ${tenant.retentionMode ?? 'unset'})`);
|
||||
const reason = tenant.reason ?? `retentionMode: ${tenant.retentionMode ?? 'unset'}`;
|
||||
console.log(`[${label}] skipped (${reason})`);
|
||||
continue;
|
||||
}
|
||||
const expiry = tenant.forcedExpiredAt;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue