🧬 fix: Backfill Missing SHARE Permissions and Migrate Legacy SHARED_GLOBAL Fields (#11854)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

* chore: Migrate legacy SHARED_GLOBAL permissions to SHARE and clean up orphaned fields

- Implemented migration logic to convert legacy SHARED_GLOBAL permissions to SHARE for PROMPTS and AGENTS, preserving user intent.
- Added cleanup process to remove orphaned SHARED_GLOBAL fields from the database after the schema change.
- Enhanced unit tests to verify migration and cleanup functionality, ensuring correct behavior for existing roles and permissions.

* fix: Enhance migration of SHARED_GLOBAL to SHARE permissions

- Updated the `updateAccessPermissions` function to ensure that SHARED_GLOBAL values are inherited into SHARE when SHARE is absent from both the database and the update payload.
- Implemented logic to prevent overwriting explicit SHARE values provided in the update, preserving user intent.
- Enhanced unit tests to cover various scenarios, including migration from SHARED_GLOBAL to SHARE and ensuring orphaned SHARED_GLOBAL fields are cleaned up appropriately.
This commit is contained in:
Danny Avila 2026-02-18 12:48:33 -05:00 committed by GitHub
parent 42718faad2
commit 50a48efa43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 370 additions and 19 deletions

View file

@ -114,6 +114,28 @@ async function updateAccessPermissions(roleName, permissionsUpdate, roleData) {
}
}
// Migrate legacy SHARED_GLOBAL → SHARE for PROMPTS and AGENTS.
// SHARED_GLOBAL was removed in favour of SHARE in PR #11283. If the DB still has
// SHARED_GLOBAL but not SHARE, inherit the value so sharing intent is preserved.
const legacySharedGlobalTypes = ['PROMPTS', 'AGENTS'];
for (const legacyPermType of legacySharedGlobalTypes) {
const existingTypePerms = currentPermissions[legacyPermType];
if (
existingTypePerms &&
'SHARED_GLOBAL' in existingTypePerms &&
!('SHARE' in existingTypePerms) &&
updates[legacyPermType] &&
// Don't override an explicit SHARE value the caller already provided
!('SHARE' in updates[legacyPermType])
) {
const inheritedValue = existingTypePerms['SHARED_GLOBAL'];
updates[legacyPermType]['SHARE'] = inheritedValue;
logger.info(
`Migrating '${roleName}' role ${legacyPermType}.SHARED_GLOBAL=${inheritedValue} → SHARE`,
);
}
}
for (const [permissionType, permissions] of Object.entries(updates)) {
const currentTypePermissions = currentPermissions[permissionType] || {};
updatedPermissions[permissionType] = { ...currentTypePermissions };
@ -129,6 +151,32 @@ async function updateAccessPermissions(roleName, permissionsUpdate, roleData) {
}
}
// Clean up orphaned SHARED_GLOBAL fields left in DB after the schema rename.
// Since we $set the full permissions object, deleting from updatedPermissions
// is sufficient to remove the field from MongoDB.
for (const legacyPermType of legacySharedGlobalTypes) {
const existingTypePerms = currentPermissions[legacyPermType];
if (existingTypePerms && 'SHARED_GLOBAL' in existingTypePerms) {
if (!updates[legacyPermType]) {
// permType wasn't in the update payload so the migration block above didn't run.
// Create a writable copy and handle the SHARED_GLOBAL → SHARE inheritance here
// to avoid removing SHARED_GLOBAL without writing SHARE (data loss).
updatedPermissions[legacyPermType] = { ...existingTypePerms };
if (!('SHARE' in existingTypePerms)) {
updatedPermissions[legacyPermType]['SHARE'] = existingTypePerms['SHARED_GLOBAL'];
logger.info(
`Migrating '${roleName}' role ${legacyPermType}.SHARED_GLOBAL=${existingTypePerms['SHARED_GLOBAL']} → SHARE`,
);
}
}
delete updatedPermissions[legacyPermType]['SHARED_GLOBAL'];
hasChanges = true;
logger.info(
`Removed legacy SHARED_GLOBAL field from '${roleName}' role ${legacyPermType} permissions`,
);
}
}
if (hasChanges) {
const updateObj = { permissions: updatedPermissions };

View file

@ -233,6 +233,112 @@ describe('updateAccessPermissions', () => {
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO]).toEqual({ USE: true });
});
it('should inherit SHARED_GLOBAL value into SHARE when SHARE is absent from both DB and update', async () => {
// Simulates the startup backfill path: caller sends SHARE_PUBLIC but not SHARE;
// migration should inherit SHARED_GLOBAL to preserve the deployment's sharing intent.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { USE: true, CREATE: true, SHARED_GLOBAL: true },
[PermissionTypes.AGENTS]: { USE: true, CREATE: true, SHARED_GLOBAL: false },
},
});
await updateAccessPermissions(SystemRoles.USER, {
// No explicit SHARE — migration should inherit from SHARED_GLOBAL
[PermissionTypes.PROMPTS]: { SHARE_PUBLIC: false },
[PermissionTypes.AGENTS]: { SHARE_PUBLIC: false },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
// SHARED_GLOBAL=true → SHARE=true (inherited)
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
// SHARED_GLOBAL=false → SHARE=false (inherited)
expect(updatedRole.permissions[PermissionTypes.AGENTS].SHARE).toBe(false);
// SHARED_GLOBAL cleaned up
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
expect(updatedRole.permissions[PermissionTypes.AGENTS].SHARED_GLOBAL).toBeUndefined();
});
it('should respect explicit SHARE in update payload and not override it with SHARED_GLOBAL', async () => {
// Caller explicitly passes SHARE: false even though SHARED_GLOBAL=true in DB.
// The explicit intent must win; migration must not silently overwrite it.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: { USE: true, SHARED_GLOBAL: true },
},
});
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { SHARE: false }, // explicit false — should be preserved
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(false);
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
});
it('should migrate SHARED_GLOBAL to SHARE even when the permType is not in the update payload', async () => {
// Bug #2 regression: cleanup block removes SHARED_GLOBAL but migration block only
// runs when the permType is in the update payload. Without the fix, SHARE would be
// lost when any other permType (e.g. MULTI_CONVO) is the only thing being updated.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: {
USE: true,
SHARED_GLOBAL: true, // legacy — NO SHARE present
},
[PermissionTypes.MULTI_CONVO]: { USE: false },
},
});
// Only update MULTI_CONVO — PROMPTS is intentionally absent from the payload
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
// SHARE should have been inherited from SHARED_GLOBAL, not silently dropped
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
// SHARED_GLOBAL should be removed
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
// Original USE should be untouched
expect(updatedRole.permissions[PermissionTypes.PROMPTS].USE).toBe(true);
// The actual update should have applied
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(true);
});
it('should remove orphaned SHARED_GLOBAL when SHARE already exists and permType is not in update', async () => {
// Safe cleanup case: SHARE already set, SHARED_GLOBAL is just orphaned noise.
// SHARE must not be changed; SHARED_GLOBAL must be removed.
await Role.collection.insertOne({
name: SystemRoles.USER,
permissions: {
[PermissionTypes.PROMPTS]: {
USE: true,
SHARE: true, // already migrated
SHARED_GLOBAL: true, // orphaned
},
[PermissionTypes.MULTI_CONVO]: { USE: false },
},
});
await updateAccessPermissions(SystemRoles.USER, {
[PermissionTypes.MULTI_CONVO]: { USE: true },
});
const updatedRole = await getRoleByName(SystemRoles.USER);
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARED_GLOBAL).toBeUndefined();
expect(updatedRole.permissions[PermissionTypes.PROMPTS].SHARE).toBe(true);
expect(updatedRole.permissions[PermissionTypes.MULTI_CONVO].USE).toBe(true);
});
it('should not update MULTI_CONVO permissions when no changes are needed', async () => {
await new Role({
name: SystemRoles.USER,