diff --git a/packages/cli/src/lib/services/collections/policies/data-client.ts b/packages/cli/src/lib/services/collections/policies/data-client.ts index 091d0a00..de441d8a 100644 --- a/packages/cli/src/lib/services/collections/policies/data-client.ts +++ b/packages/cli/src/lib/services/collections/policies/data-client.ts @@ -41,8 +41,9 @@ export class PoliciesDataClient extends DataClient { // When role-policy attachments sync is disabled, omit the roles fields // entirely from the dump so they are neither tracked nor diffed. // See https://github.com/tractr/directus-sync/issues/199 + // Include roles.user so we can detect and skip user-attached accesses. const extraFields = this.config.shouldSyncPolicyRoles() - ? ['*', 'roles.role', 'roles.sort'] + ? ['*', 'roles.role', 'roles.user', 'roles.sort'] : ['*']; return readPolicies( deepmerge>(query, { diff --git a/packages/cli/src/lib/services/collections/policies/data-mapper.ts b/packages/cli/src/lib/services/collections/policies/data-mapper.ts index 3cf1e519..99522a17 100644 --- a/packages/cli/src/lib/services/collections/policies/data-mapper.ts +++ b/packages/cli/src/lib/services/collections/policies/data-mapper.ts @@ -1,8 +1,8 @@ -import { DataMapper, Field, IdMappers } from '../base'; +import { DataMapper, Field, IdMappers, WithSyncIdAndWithoutId } from '../base'; import { Container, Service } from 'typedi'; import { LoggerService } from '../../logger'; import { POLICIES_COLLECTION } from './constants'; -import { DirectusPolicy } from './interfaces'; +import { DirectusPolicy, DirectusPolicyAccess } from './interfaces'; import { RolesIdMapperClient } from '../roles'; import { ConfigService } from '../../config'; @@ -27,4 +27,20 @@ export class PoliciesDataMapper extends DataMapper { this.idMappers = {}; } } + + async mapIdsToSyncIdAndRemoveIgnoredFields( + items: WithSyncIdAndWithoutId[], + ): Promise[]> { + const filtered = items.map((item) => + Array.isArray(item.roles) + ? ({ + ...item, + roles: (item.roles as Partial[]).filter( + (a) => !(a.role === null && a.user != null), + ), + } as WithSyncIdAndWithoutId) + : item, + ); + return super.mapIdsToSyncIdAndRemoveIgnoredFields(filtered); + } } diff --git a/packages/cli/src/lib/services/collections/policies/id-mapper-client.ts b/packages/cli/src/lib/services/collections/policies/id-mapper-client.ts index c5fe62ee..ba3cbe7a 100644 --- a/packages/cli/src/lib/services/collections/policies/id-mapper-client.ts +++ b/packages/cli/src/lib/services/collections/policies/id-mapper-client.ts @@ -120,6 +120,9 @@ export class PoliciesIdMapperClient extends IdMapperClient { filter: { _and: [ { roles: { role: { _null: true } } }, + // Exclude user-attached accesses (role=null, user=uuid) so they + // are not mistaken for the public policy access (role=null, user=null). + { roles: { user: { _null: true } } }, { _or: [ { roles: { sort: { _eq: 1 } } }, diff --git a/packages/e2e/dumps/sources/default-updated/collections/policies.json b/packages/e2e/dumps/sources/default-updated/collections/policies.json index 08f22f86..778b9296 100644 --- a/packages/e2e/dumps/sources/default-updated/collections/policies.json +++ b/packages/e2e/dumps/sources/default-updated/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/dependencies-operations-reversed/collections/policies.json b/packages/e2e/dumps/sources/dependencies-operations-reversed/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/dependencies-operations-reversed/collections/policies.json +++ b/packages/e2e/dumps/sources/dependencies-operations-reversed/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/dependencies-operations/collections/policies.json b/packages/e2e/dumps/sources/dependencies-operations/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/dependencies-operations/collections/policies.json +++ b/packages/e2e/dumps/sources/dependencies-operations/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/dependencies-settings-default-folder/collections/policies.json b/packages/e2e/dumps/sources/dependencies-settings-default-folder/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/dependencies-settings-default-folder/collections/policies.json +++ b/packages/e2e/dumps/sources/dependencies-settings-default-folder/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/dependencies-settings-default-role/collections/policies.json b/packages/e2e/dumps/sources/dependencies-settings-default-role/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/dependencies-settings-default-role/collections/policies.json +++ b/packages/e2e/dumps/sources/dependencies-settings-default-role/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/empty-collections/collections/policies.json b/packages/e2e/dumps/sources/empty-collections/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/empty-collections/collections/policies.json +++ b/packages/e2e/dumps/sources/empty-collections/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/group-and-field-names-conflict/collections/policies.json b/packages/e2e/dumps/sources/group-and-field-names-conflict/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/group-and-field-names-conflict/collections/policies.json +++ b/packages/e2e/dumps/sources/group-and-field-names-conflict/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/one-item-per-collection-updated/collections/policies.json b/packages/e2e/dumps/sources/one-item-per-collection-updated/collections/policies.json index 3a4043de..e65af66b 100644 --- a/packages/e2e/dumps/sources/one-item-per-collection-updated/collections/policies.json +++ b/packages/e2e/dumps/sources/one-item-per-collection-updated/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], @@ -42,6 +44,7 @@ "roles": [ { "role": "52183adc-3e8e-4746-abd2-ee8dfc58efd5", + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/one-item-per-collection/collections/policies.json b/packages/e2e/dumps/sources/one-item-per-collection/collections/policies.json index 221072d6..6c7a395b 100644 --- a/packages/e2e/dumps/sources/one-item-per-collection/collections/policies.json +++ b/packages/e2e/dumps/sources/one-item-per-collection/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], @@ -42,6 +44,7 @@ "roles": [ { "role": "52183adc-3e8e-4746-abd2-ee8dfc58efd5", + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/operations-dynamic-flow-id/collections/policies.json b/packages/e2e/dumps/sources/operations-dynamic-flow-id/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/operations-dynamic-flow-id/collections/policies.json +++ b/packages/e2e/dumps/sources/operations-dynamic-flow-id/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/public-permissions/collections/policies.json b/packages/e2e/dumps/sources/public-permissions/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/public-permissions/collections/policies.json +++ b/packages/e2e/dumps/sources/public-permissions/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/seed-basic/collections/policies.json b/packages/e2e/dumps/sources/seed-basic/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/seed-basic/collections/policies.json +++ b/packages/e2e/dumps/sources/seed-basic/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/seed-files/collections/policies.json b/packages/e2e/dumps/sources/seed-files/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/seed-files/collections/policies.json +++ b/packages/e2e/dumps/sources/seed-files/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/seed-users/collections/policies.json b/packages/e2e/dumps/sources/seed-users/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/seed-users/collections/policies.json +++ b/packages/e2e/dumps/sources/seed-users/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/snapshot-with-custom-model/collections/policies.json b/packages/e2e/dumps/sources/snapshot-with-custom-model/collections/policies.json index 163ee4c0..ef27d271 100644 --- a/packages/e2e/dumps/sources/snapshot-with-custom-model/collections/policies.json +++ b/packages/e2e/dumps/sources/snapshot-with-custom-model/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], @@ -42,6 +44,7 @@ "roles": [ { "role": "5acb1b4a-64bb-484a-9e76-76ed9b13251e", + "user": null, "sort": 1 } ], diff --git a/packages/e2e/dumps/sources/snapshot-with-sync-id-map/collections/policies.json b/packages/e2e/dumps/sources/snapshot-with-sync-id-map/collections/policies.json index e9ddc251..c6800145 100644 --- a/packages/e2e/dumps/sources/snapshot-with-sync-id-map/collections/policies.json +++ b/packages/e2e/dumps/sources/snapshot-with-sync-id-map/collections/policies.json @@ -10,6 +10,7 @@ "roles": [ { "role": "_sync_default_admin_role", + "user": null, "sort": null } ], @@ -26,6 +27,7 @@ "roles": [ { "role": null, + "user": null, "sort": 1 } ], diff --git a/packages/e2e/spec/helpers/sdk/interfaces/policy.ts b/packages/e2e/spec/helpers/sdk/interfaces/policy.ts index 1a9e8fdf..f4041877 100644 --- a/packages/e2e/spec/helpers/sdk/interfaces/policy.ts +++ b/packages/e2e/spec/helpers/sdk/interfaces/policy.ts @@ -3,6 +3,7 @@ export type FixPolicy = Omit & { name: string; roles: { role: string; + user: null; sort: number; }[]; }; diff --git a/packages/e2e/spec/helpers/utils/batch.ts b/packages/e2e/spec/helpers/utils/batch.ts index 63e304dc..c9ee6577 100644 --- a/packages/e2e/spec/helpers/utils/batch.ts +++ b/packages/e2e/spec/helpers/utils/batch.ts @@ -110,7 +110,7 @@ export async function createOneItemInEachSystemCollection( const [policy] = (await client.request( readPolicies({ filter: { id: policyRaw.id }, - fields: ['*', 'roles.role', 'roles.sort'], + fields: ['*', 'roles.role', 'roles.user', 'roles.sort'], // Todo: remove this once it is fixed in the SDK } as Query>), )) as unknown as FixPolicy>[]; diff --git a/packages/e2e/spec/pull-diff-push/pull-basic.ts b/packages/e2e/spec/pull-diff-push/pull-basic.ts index b3096b6c..3a4c2caa 100644 --- a/packages/e2e/spec/pull-diff-push/pull-basic.ts +++ b/packages/e2e/spec/pull-diff-push/pull-basic.ts @@ -144,6 +144,7 @@ export const pullBasic = (context: Context) => { policy.roles.map(async (role) => { return { role: (await directus.getByLocalId('roles', role.role)).sync_id, + user: role.user, sort: role.sort, }; }), diff --git a/packages/e2e/spec/pull-diff-push/push-with-user-policy-assignment.ts b/packages/e2e/spec/pull-diff-push/push-with-user-policy-assignment.ts index 8306d7f6..9c65e747 100644 --- a/packages/e2e/spec/pull-diff-push/push-with-user-policy-assignment.ts +++ b/packages/e2e/spec/pull-diff-push/push-with-user-policy-assignment.ts @@ -1,5 +1,11 @@ import { createUser, DirectusPolicy, readUser } from '@directus/sdk'; -import { Context, newPolicy, newRole, Schema } from '../helpers/index.js'; +import { + Context, + getDumpedSystemCollectionsContents, + newPolicy, + newRole, + Schema, +} from '../helpers/index.js'; export const pushWithUserPolicyAssignment = (context: Context) => { it('should preserve user policy assignments after push', async () => { @@ -54,4 +60,43 @@ export const pushWithUserPolicyAssignment = (context: Context) => { ); expect(finalUser.policies.map(extractPolicyId)).toContain(policy.id); }); + + it('should not include user-attached accesses in dump on pull', async () => { + const sync = await context.getSync( + 'temp/push-with-user-policy-assignment-pull-filter', + ); + const directus = context.getDirectus(); + const client = directus.get(); + + // Create a role and policy + const role = await newRole(client); + const policy = await newPolicy(client, role.id); + + // Assign the policy directly to a user (creates role=null, user=uuid access) + await client.request( + createUser({ + email: `user-policy-pull-${Date.now()}@example.com`, + password: 'password123', + role: role.id, + policies: [{ policy: policy.id, role: null, sort: 1 }], + } as Partial>), + ); + + // Pull + await sync.pull(); + + // Read the dump — user-attached accesses (role=null, user=uuid) must be absent + const { policies } = getDumpedSystemCollectionsContents(sync.getDumpPath()); + interface DumpAccess { + role: string | null; + user?: string | null; + } + const userAttachedInDump = (policies ?? []).flatMap( + (p: Record) => + ((p.roles as DumpAccess[] | undefined) ?? []).filter( + (r) => r.role === null && r.user != null, + ), + ); + expect(userAttachedInDump.length).toBe(0); + }); };