From a682c8fa6222a2a3f2797e94a657ad15e985c7c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sun, 21 Jun 2026 22:09:19 +0200 Subject: [PATCH] feat(sdk): declare row-level permission predicates in the role manifest (#21919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Apps can declare object and field permissions on a role via `defineRole`, but **not row-level security**. The RLS engine and the metadata-sync machinery already support predicates fully — they're first-class universal flat entities, the `FlatRole` already carries `rowLevelPermissionPredicateUniversalIdentifiers`, and the workspace-migration layer has builders/validators/handlers for them. The only gap was the **manifest layer**: `RoleManifest` had no field for predicates, so the sync converter always left them empty. As a result, the only way to ship RLS with an app was a post-install script that pushed predicates through the `upsertRowLevelPermissionPredicates` mutation. That mutation assigns predicates to the workspace's **generic custom application**, not the app that owns the role — so a single role's definition ends up split across two applications and drifts on every upgrade (you have to remember to re-run the script). The Partner app does exactly this today via `configure-partner-rls.ts`. ## What Adds `rowLevelPermissionPredicates` and `rowLevelPermissionPredicateGroups` to `RoleManifest` / `RoleConfig`, mirroring how `objectPermissions` / `fieldPermissions` already flow end-to-end: - **twenty-shared** — predicate + predicate-group manifest types on `RoleManifest` (referencing objects/fields by `universalIdentifier`, operand/logical-operator from the existing GraphQL enums). - **twenty-sdk** — `defineRole` accepts and validates them; the build derives deterministic predicate `universalIdentifier`s (groups keep an explicit one so predicates can reference them). - **twenty-server** — two converters turn manifest predicates/groups into universal flat entities during application-manifest sync, so they are created/updated/deleted together with the role and **owned by the app that ships it**. ### Bug fix found along the way The migration build order ran the `rowLevelPermissionPredicate(Group)` builders **before** the `role` builder, so a predicate declared alongside a brand-new role failed validation with `ROLE_NOT_FOUND`. They now run **after** the role builder, exactly like object/field permissions. ## Partner app (second commit) Converts `partner.role.ts` to declare its five predicates inline and **deletes `configure-partner-rls.ts`** + the `rls:configure` scripts — the workaround this PR is meant to retire. The predicates are byte-for-byte the same semantics as the script produced. > Live-deployment note: the existing script-created predicates are owned by the *custom* application, so the Partner app sync won't touch them. Clear them once (e.g. an empty upsert on the Partner role) around deploy to avoid duplicates. Kept as a **separate commit** so it can be split out if reviewers prefer. ## Testing - **Integration (full app):** new `successful-manifest-sync-row-level-permission-predicate.integration-spec.ts` — installs an app whose role declares a predicate and asserts the predicate row is created (and **owned by the app**, not the custom app), updated in place on re-sync, removed when dropped from the manifest, and removed on uninstall. Ran locally against a seeded test DB ✅. - Re-ran the existing cross-app permission + view-field manifest suites to confirm the build-order change doesn't regress object/field-permission sync (13/13 ✅). - **Unit (utils only):** `defineRole` validation and `fromRoleConfigToRoleManifest` deterministic-id derivation. - Docs: new "Row-level security" section in `apps/config/roles.mdx`. ## Scope notes / possible follow-ups - Surfacing RLS in the app-install permission summary UI was intentionally left out (predicates *restrict* rather than grant, and typically live on a non-default role) — easy follow-up if wanted. - The `upsertRowLevelPermissionPredicates` mutation still homes out-of-band predicates on the custom app for app-owned roles; making that consistent (or rejecting it, like field permissions already do) is a sensible follow-up. https://claude.ai/code/session_01MipAis9z9okd4oCm9HCKEf --- _Generated by [Claude Code](https://claude.ai/code/session_01MipAis9z9okd4oCm9HCKEf)_ Review in cubic --- .../developers/extend/apps/config/roles.mdx | 118 +++++++++ .../app-dev/expected-manifest.ts | 2 + .../app-dev/expected-manifest.ts | 4 + ...stub-twenty-sdk-define.plugin.spec.ts.snap | 2 + .../from-role-config-to-role-manifest.spec.ts | 70 ++++++ .../from-role-config-to-role-manifest.ts | 4 + packages/twenty-sdk/src/sdk/define/index.ts | 8 + .../roles/__tests__/define-role.spec.ts | 223 +++++++++++++++++ .../src/sdk/define/roles/define-role.ts | 79 ++++++ ...w-level-permission-predicate-group.util.ts | 36 +++ ...lat-row-level-permission-predicate.util.ts | 43 ++++ ...-all-universal-flat-entity-maps.service.ts | 36 +++ ...ce-migration-build-orchestrator.service.ts | 16 +- ...l-permission-predicate.integration-spec.ts | 226 ++++++++++++++++++ .../twenty-shared/src/application/index.ts | 2 + .../src/application/roleManifestType.ts | 26 ++ 16 files changed, 887 insertions(+), 8 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/from-role-config-to-role-manifest.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-group-manifest-to-universal-flat-row-level-permission-predicate-group.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-manifest-to-universal-flat-row-level-permission-predicate.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-sync-row-level-permission-predicate.integration-spec.ts diff --git a/packages/twenty-docs/developers/extend/apps/config/roles.mdx b/packages/twenty-docs/developers/extend/apps/config/roles.mdx index a780600a7e..1c738c584b 100644 --- a/packages/twenty-docs/developers/extend/apps/config/roles.mdx +++ b/packages/twenty-docs/developers/extend/apps/config/roles.mdx @@ -50,6 +50,124 @@ export default defineRole({ }); ``` +## Row-level security + +Object and field permissions decide _which objects and fields_ a role can touch. **Row-level +permission predicates** go further and decide _which records_ a role can see and act on — for +example, a self-service role where each external user sees only their own records. + +Declare predicates with `rowLevelPermissionPredicates` on the role. Like the rest of the manifest, +each predicate carries its own `universalIdentifier`, and references an object and a field by their +`universalIdentifier`, an `operand`, and (optionally) a workspaceMember field whose value is injected +at query time — so you can express "the record's owner relation **is** the current workspace member": + +```ts src/roles/partner-role.ts +import { + defineRole, + RowLevelPermissionPredicateOperand, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/account-owner.field'; + +export default defineRole({ + universalIdentifier: 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343', + label: 'Partner', + description: 'External partner — sees only its own records', + canBeAssignedToUsers: true, + objectPermissions: [ + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: true, + }, + ], + rowLevelPermissionPredicates: [ + { + universalIdentifier: 'd0f0c1a2-3b4c-4d5e-8f60-111111111111', + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + fieldUniversalIdentifier: ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER, + operand: RowLevelPermissionPredicateOperand.IS, + workspaceMemberFieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id + .universalIdentifier, + }, + ], +}); +``` + +Because predicates ship in the manifest, they are created, updated, and removed together with the +role on every install and upgrade — there is no separate post-install step to keep in sync. + +### Combining predicates with groups + +By default a role's predicates are combined with `AND`. To combine some of them with `OR` (or to +nest logic), declare a `rowLevelPermissionPredicateGroups` entry and point each predicate at it via +`predicateGroupUniversalIdentifier`. This role lets a partner see an Opportunity it either **owns** +or is the **point of contact** for: + +```ts src/roles/partner-opportunities-role.ts +import { + defineRole, + RowLevelPermissionPredicateGroupLogicalOperator, + RowLevelPermissionPredicateOperand, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +const OPPORTUNITY = STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity; +const CURRENT_MEMBER = + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id + .universalIdentifier; + +export default defineRole({ + universalIdentifier: 'b2a1c0d9-8e7f-4a6b-9c5d-222222222222', + label: 'Partner (opportunities)', + canBeAssignedToUsers: true, + objectPermissions: [ + { + objectUniversalIdentifier: OPPORTUNITY.universalIdentifier, + canReadObjectRecords: true, + }, + ], + rowLevelPermissionPredicateGroups: [ + { + universalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333', + objectUniversalIdentifier: OPPORTUNITY.universalIdentifier, + logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator.OR, + }, + ], + rowLevelPermissionPredicates: [ + { + universalIdentifier: 'd4c3b2a1-0e9f-4c8b-9e7d-444444444444', + objectUniversalIdentifier: OPPORTUNITY.universalIdentifier, + fieldUniversalIdentifier: OPPORTUNITY.fields.owner.universalIdentifier, + operand: RowLevelPermissionPredicateOperand.IS, + workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER, + predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333', + }, + { + universalIdentifier: 'e5d4c3b2-1f0e-4d9c-8f8e-555555555555', + objectUniversalIdentifier: OPPORTUNITY.universalIdentifier, + fieldUniversalIdentifier: + OPPORTUNITY.fields.pointOfContact.universalIdentifier, + operand: RowLevelPermissionPredicateOperand.IS, + workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER, + predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333', + }, + ], +}); +``` + +Notes: + +- Give every predicate and group a stable `universalIdentifier` (any uuid) — it keys the entity + across upgrades, and predicates reference groups by it. +- Predicates can reference objects and fields owned by your app or by Twenty's standard objects. +- Row-level security is enforced for workspaces on plans that include it; the predicates still sync + on other plans, they are simply not enforced. + ## The default function role When you scaffold a new app, the CLI creates a default role file declared with `defineApplicationRole()`: diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts index 4e765c26ec..1da69c1e3c 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts @@ -333,6 +333,8 @@ export const EXPECTED_MANIFEST: Manifest = { canBeAssignedToApiKeys: false, fieldPermissions: [], objectPermissions: [], + rowLevelPermissionPredicateGroups: [], + rowLevelPermissionPredicates: [], permissionFlagUniversalIdentifiers: [], }, ], diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts index 26332a2579..9a4f8f0a35 100644 --- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts +++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts @@ -1399,6 +1399,8 @@ export const EXPECTED_MANIFEST: Manifest = { universalIdentifier: 'c0c1c2c3-c4c5-4000-8000-000000000001', fieldPermissions: [], objectPermissions: [], + rowLevelPermissionPredicateGroups: [], + rowLevelPermissionPredicates: [], permissionFlagUniversalIdentifiers: [], }, { @@ -1431,6 +1433,8 @@ export const EXPECTED_MANIFEST: Manifest = { objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05', }, ], + rowLevelPermissionPredicateGroups: [], + rowLevelPermissionPredicates: [], permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS], universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061', }, diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap index e97f7f008e..4d13aff039 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap +++ b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap @@ -14,6 +14,8 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1 "OnDeleteAction", "PageLayoutTabLayoutMode", "RelationType", + "RowLevelPermissionPredicateGroupLogicalOperator", + "RowLevelPermissionPredicateOperand", "STANDARD_OBJECT", "STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS", "STANDARD_PAGE_LAYOUT", diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/from-role-config-to-role-manifest.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/from-role-config-to-role-manifest.spec.ts new file mode 100644 index 0000000000..6ef4319cc6 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/from-role-config-to-role-manifest.spec.ts @@ -0,0 +1,70 @@ +import { fromRoleConfigToRoleManifest } from '@/cli/utilities/build/manifest/utils/from-role-config-to-role-manifest'; +import { type RoleConfig } from '@/sdk/define/roles/role-config'; +import { + RowLevelPermissionPredicateGroupLogicalOperator, + RowLevelPermissionPredicateOperand, +} from '@/sdk/define'; + +const ROLE_UNIVERSAL_IDENTIFIER = 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343'; +const OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d'; +const FIELD_UNIVERSAL_IDENTIFIER = '0e49f2e4-1e45-433d-bf49-79acc0b06d0e'; +const PREDICATE_UNIVERSAL_IDENTIFIER = '22222222-0000-4000-8000-000000000000'; +const GROUP_UNIVERSAL_IDENTIFIER = '11111111-0000-4000-8000-000000000000'; + +const baseConfig: RoleConfig = { + universalIdentifier: ROLE_UNIVERSAL_IDENTIFIER, + label: 'Partner', +}; + +describe('fromRoleConfigToRoleManifest', () => { + it('passes predicates through with their explicit universalIdentifier', () => { + const config: RoleConfig = { + ...baseConfig, + rowLevelPermissionPredicates: [ + { + universalIdentifier: PREDICATE_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + fieldUniversalIdentifier: FIELD_UNIVERSAL_IDENTIFIER, + operand: RowLevelPermissionPredicateOperand.IS, + }, + ], + }; + + const manifest = fromRoleConfigToRoleManifest(config); + const predicate = manifest.rowLevelPermissionPredicates?.[0]; + + expect(predicate?.universalIdentifier).toBe(PREDICATE_UNIVERSAL_IDENTIFIER); + expect(predicate?.objectUniversalIdentifier).toBe( + OBJECT_UNIVERSAL_IDENTIFIER, + ); + expect(predicate?.fieldUniversalIdentifier).toBe(FIELD_UNIVERSAL_IDENTIFIER); + expect(predicate?.operand).toBe(RowLevelPermissionPredicateOperand.IS); + }); + + it('passes predicate groups through with their explicit universalIdentifier', () => { + const config: RoleConfig = { + ...baseConfig, + rowLevelPermissionPredicateGroups: [ + { + universalIdentifier: GROUP_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator.OR, + }, + ], + }; + + const manifest = fromRoleConfigToRoleManifest(config); + + expect(manifest.rowLevelPermissionPredicateGroups).toHaveLength(1); + expect( + manifest.rowLevelPermissionPredicateGroups?.[0]?.universalIdentifier, + ).toBe(GROUP_UNIVERSAL_IDENTIFIER); + }); + + it('defaults predicate collections to empty arrays', () => { + const manifest = fromRoleConfigToRoleManifest(baseConfig); + + expect(manifest.rowLevelPermissionPredicates).toEqual([]); + expect(manifest.rowLevelPermissionPredicateGroups).toEqual([]); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/from-role-config-to-role-manifest.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/from-role-config-to-role-manifest.ts index b9bcc800b4..ba16997533 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/from-role-config-to-role-manifest.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/from-role-config-to-role-manifest.ts @@ -28,6 +28,10 @@ export const fromRoleConfigToRoleManifest = ( ), }), ), + rowLevelPermissionPredicateGroups: + roleConfig.rowLevelPermissionPredicateGroups ?? [], + rowLevelPermissionPredicates: + roleConfig.rowLevelPermissionPredicates ?? [], permissionFlagUniversalIdentifiers: roleConfig.permissionFlagUniversalIdentifiers ?? [], }; diff --git a/packages/twenty-sdk/src/sdk/define/index.ts b/packages/twenty-sdk/src/sdk/define/index.ts index 7f6b6d8cc9..df7457a2ce 100644 --- a/packages/twenty-sdk/src/sdk/define/index.ts +++ b/packages/twenty-sdk/src/sdk/define/index.ts @@ -139,6 +139,14 @@ export type { export { defineApplicationRole } from '@/sdk/define/roles/define-application-role'; export { defineRole } from '@/sdk/define/roles/define-role'; +export type { + RowLevelPermissionPredicateManifest, + RowLevelPermissionPredicateGroupManifest, +} from 'twenty-shared/application'; +export { + RowLevelPermissionPredicateGroupLogicalOperator, + RowLevelPermissionPredicateOperand, +} from 'twenty-shared/types'; export { SystemPermissionFlag } from 'twenty-shared/constants'; export { defineSkill } from '@/sdk/define/skills/define-skill'; diff --git a/packages/twenty-sdk/src/sdk/define/roles/__tests__/define-role.spec.ts b/packages/twenty-sdk/src/sdk/define/roles/__tests__/define-role.spec.ts index 0315a956a8..e080709d13 100644 --- a/packages/twenty-sdk/src/sdk/define/roles/__tests__/define-role.spec.ts +++ b/packages/twenty-sdk/src/sdk/define/roles/__tests__/define-role.spec.ts @@ -124,4 +124,227 @@ describe('defineRole', () => { 'Field permission must have a fieldUniversalIdentifier', ); }); + + it('should accept row level permission predicates', () => { + const config = { + ...validConfig, + rowLevelPermissionPredicates: [ + { + universalIdentifier: '22222222-0000-4000-8000-000000000000', + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6', + operand: 'IS', + workspaceMemberFieldUniversalIdentifier: + 'b1c2d3e4-0829-4475-a794-d0d4959161e6', + }, + ], + }; + + const result = defineRole(config as any); + + expect(result.success).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should return error when predicate has no universalIdentifier', () => { + const config = { + ...validConfig, + rowLevelPermissionPredicates: [ + { + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6', + operand: 'IS', + }, + ], + }; + + const result = defineRole(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Row level permission predicate must have a universalIdentifier', + ); + }); + + it('should return error when two predicates share a universalIdentifier', () => { + const config = { + ...validConfig, + rowLevelPermissionPredicates: [ + { + universalIdentifier: '22222222-0000-4000-8000-000000000000', + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6', + operand: 'IS', + }, + { + universalIdentifier: '22222222-0000-4000-8000-000000000000', + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6', + operand: 'IS_NOT', + }, + ], + }; + + const result = defineRole(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Duplicate row level permission predicate universalIdentifier "22222222-0000-4000-8000-000000000000"', + ); + }); + + it('should return error when predicate has no objectUniversalIdentifier', () => { + const config = { + ...validConfig, + rowLevelPermissionPredicates: [ + { + universalIdentifier: '22222222-0000-4000-8000-000000000000', + fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6', + operand: 'IS', + }, + ], + }; + + const result = defineRole(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Row level permission predicate must have an objectUniversalIdentifier', + ); + }); + + it('should return error when predicate has no fieldUniversalIdentifier', () => { + const config = { + ...validConfig, + rowLevelPermissionPredicates: [ + { + universalIdentifier: '22222222-0000-4000-8000-000000000000', + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + operand: 'IS', + }, + ], + }; + + const result = defineRole(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Row level permission predicate must have a fieldUniversalIdentifier', + ); + }); + + it('should return error when predicate has no operand', () => { + const config = { + ...validConfig, + rowLevelPermissionPredicates: [ + { + universalIdentifier: '22222222-0000-4000-8000-000000000000', + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6', + }, + ], + }; + + const result = defineRole(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Row level permission predicate must have an operand', + ); + }); + + it('should return error when predicate references an unknown predicate group', () => { + const config = { + ...validConfig, + rowLevelPermissionPredicates: [ + { + universalIdentifier: '22222222-0000-4000-8000-000000000000', + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6', + operand: 'IS', + predicateGroupUniversalIdentifier: + '00000000-0000-4000-8000-000000000000', + }, + ], + }; + + const result = defineRole(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Row level permission predicate references unknown predicate group "00000000-0000-4000-8000-000000000000"', + ); + }); + + it('should accept a predicate referencing a declared predicate group', () => { + const config = { + ...validConfig, + rowLevelPermissionPredicateGroups: [ + { + universalIdentifier: '11111111-0000-4000-8000-000000000000', + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + logicalOperator: 'OR', + }, + ], + rowLevelPermissionPredicates: [ + { + universalIdentifier: '22222222-0000-4000-8000-000000000000', + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6', + operand: 'IS', + predicateGroupUniversalIdentifier: + '11111111-0000-4000-8000-000000000000', + }, + ], + }; + + const result = defineRole(config as any); + + expect(result.success).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should return error when predicate group has no logicalOperator', () => { + const config = { + ...validConfig, + rowLevelPermissionPredicateGroups: [ + { + universalIdentifier: '11111111-0000-4000-8000-000000000000', + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + }, + ], + }; + + const result = defineRole(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Row level permission predicate group must have a logicalOperator', + ); + }); + + it('should return error when two predicate groups share a universalIdentifier', () => { + const config = { + ...validConfig, + rowLevelPermissionPredicateGroups: [ + { + universalIdentifier: '11111111-0000-4000-8000-000000000000', + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + logicalOperator: 'AND', + }, + { + universalIdentifier: '11111111-0000-4000-8000-000000000000', + objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18', + logicalOperator: 'OR', + }, + ], + }; + + const result = defineRole(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + 'Duplicate row level permission predicate group universalIdentifier "11111111-0000-4000-8000-000000000000"', + ); + }); }); diff --git a/packages/twenty-sdk/src/sdk/define/roles/define-role.ts b/packages/twenty-sdk/src/sdk/define/roles/define-role.ts index 3874149bf9..670915c0f8 100644 --- a/packages/twenty-sdk/src/sdk/define/roles/define-role.ts +++ b/packages/twenty-sdk/src/sdk/define/roles/define-role.ts @@ -33,5 +33,84 @@ export const defineRole: DefineEntity = (config) => { } } + const predicateGroupUniversalIdentifiers = new Set(); + + if (config.rowLevelPermissionPredicateGroups) { + for (const group of config.rowLevelPermissionPredicateGroups) { + if (!group.universalIdentifier) { + errors.push( + 'Row level permission predicate group must have a universalIdentifier', + ); + } else if ( + predicateGroupUniversalIdentifiers.has(group.universalIdentifier) + ) { + errors.push( + `Duplicate row level permission predicate group universalIdentifier "${group.universalIdentifier}"`, + ); + } else { + predicateGroupUniversalIdentifiers.add(group.universalIdentifier); + } + + if (!group.objectUniversalIdentifier) { + errors.push( + 'Row level permission predicate group must have an objectUniversalIdentifier', + ); + } + + if (!group.logicalOperator) { + errors.push( + 'Row level permission predicate group must have a logicalOperator', + ); + } + } + } + + const predicateUniversalIdentifiers = new Set(); + + if (config.rowLevelPermissionPredicates) { + for (const predicate of config.rowLevelPermissionPredicates) { + if (!predicate.universalIdentifier) { + errors.push( + 'Row level permission predicate must have a universalIdentifier', + ); + } else if ( + predicateUniversalIdentifiers.has(predicate.universalIdentifier) + ) { + errors.push( + `Duplicate row level permission predicate universalIdentifier "${predicate.universalIdentifier}"`, + ); + } else { + predicateUniversalIdentifiers.add(predicate.universalIdentifier); + } + + if (!predicate.objectUniversalIdentifier) { + errors.push( + 'Row level permission predicate must have an objectUniversalIdentifier', + ); + } + + if (!predicate.fieldUniversalIdentifier) { + errors.push( + 'Row level permission predicate must have a fieldUniversalIdentifier', + ); + } + + if (!predicate.operand) { + errors.push('Row level permission predicate must have an operand'); + } + + if ( + predicate.predicateGroupUniversalIdentifier && + !predicateGroupUniversalIdentifiers.has( + predicate.predicateGroupUniversalIdentifier, + ) + ) { + errors.push( + `Row level permission predicate references unknown predicate group "${predicate.predicateGroupUniversalIdentifier}"`, + ); + } + } + } + return createValidationResult({ config, errors }); }; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-group-manifest-to-universal-flat-row-level-permission-predicate-group.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-group-manifest-to-universal-flat-row-level-permission-predicate-group.util.ts new file mode 100644 index 0000000000..33d61db00e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-group-manifest-to-universal-flat-row-level-permission-predicate-group.util.ts @@ -0,0 +1,36 @@ +import { type RowLevelPermissionPredicateGroupManifest } from 'twenty-shared/application'; + +import { type UniversalFlatRowLevelPermissionPredicateGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-row-level-permission-predicate-group.type'; + +export const fromRowLevelPermissionPredicateGroupManifestToUniversalFlatRowLevelPermissionPredicateGroup = + ({ + rowLevelPermissionPredicateGroupManifest, + roleUniversalIdentifier, + applicationUniversalIdentifier, + now, + }: { + rowLevelPermissionPredicateGroupManifest: RowLevelPermissionPredicateGroupManifest; + roleUniversalIdentifier: string; + applicationUniversalIdentifier: string; + now: string; + }): UniversalFlatRowLevelPermissionPredicateGroup => { + return { + universalIdentifier: + rowLevelPermissionPredicateGroupManifest.universalIdentifier, + applicationUniversalIdentifier, + roleUniversalIdentifier, + objectMetadataUniversalIdentifier: + rowLevelPermissionPredicateGroupManifest.objectUniversalIdentifier, + logicalOperator: rowLevelPermissionPredicateGroupManifest.logicalOperator, + parentRowLevelPermissionPredicateGroupUniversalIdentifier: + rowLevelPermissionPredicateGroupManifest.parentPredicateGroupUniversalIdentifier ?? + null, + positionInRowLevelPermissionPredicateGroup: + rowLevelPermissionPredicateGroupManifest.position ?? null, + childRowLevelPermissionPredicateGroupUniversalIdentifiers: [], + rowLevelPermissionPredicateUniversalIdentifiers: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + }; + }; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-manifest-to-universal-flat-row-level-permission-predicate.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-manifest-to-universal-flat-row-level-permission-predicate.util.ts new file mode 100644 index 0000000000..16ed38a61b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-manifest-to-universal-flat-row-level-permission-predicate.util.ts @@ -0,0 +1,43 @@ +import { type RowLevelPermissionPredicateManifest } from 'twenty-shared/application'; + +import { type UniversalFlatRowLevelPermissionPredicate } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-row-level-permission-predicate.type'; + +export const fromRowLevelPermissionPredicateManifestToUniversalFlatRowLevelPermissionPredicate = + ({ + rowLevelPermissionPredicateManifest, + roleUniversalIdentifier, + applicationUniversalIdentifier, + now, + }: { + rowLevelPermissionPredicateManifest: RowLevelPermissionPredicateManifest; + roleUniversalIdentifier: string; + applicationUniversalIdentifier: string; + now: string; + }): UniversalFlatRowLevelPermissionPredicate => { + return { + universalIdentifier: + rowLevelPermissionPredicateManifest.universalIdentifier, + applicationUniversalIdentifier, + roleUniversalIdentifier, + objectMetadataUniversalIdentifier: + rowLevelPermissionPredicateManifest.objectUniversalIdentifier, + fieldMetadataUniversalIdentifier: + rowLevelPermissionPredicateManifest.fieldUniversalIdentifier, + operand: rowLevelPermissionPredicateManifest.operand, + value: rowLevelPermissionPredicateManifest.value ?? null, + subFieldName: rowLevelPermissionPredicateManifest.subFieldName ?? null, + workspaceMemberFieldMetadataUniversalIdentifier: + rowLevelPermissionPredicateManifest.workspaceMemberFieldUniversalIdentifier ?? + null, + workspaceMemberSubFieldName: + rowLevelPermissionPredicateManifest.workspaceMemberSubFieldName ?? null, + rowLevelPermissionPredicateGroupUniversalIdentifier: + rowLevelPermissionPredicateManifest.predicateGroupUniversalIdentifier ?? + null, + positionInRowLevelPermissionPredicateGroup: + rowLevelPermissionPredicateManifest.position ?? null, + createdAt: now, + updatedAt: now, + deletedAt: null, + }; + }; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts index 4be337448e..123ba1cbde 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts @@ -22,6 +22,8 @@ import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src import { fromPermissionFlagManifestToUniversalFlatPermissionFlag } from 'src/engine/core-modules/application/application-manifest/converters/from-permission-flag-manifest-to-universal-flat-permission-flag.util'; import { fromPermissionFlagToUniversalFlatRolePermissionFlag } from 'src/engine/core-modules/application/application-manifest/converters/from-permission-flag-to-universal-flat-role-permission-flag.util'; import { fromRoleManifestToUniversalFlatRole } from 'src/engine/core-modules/application/application-manifest/converters/from-role-manifest-to-universal-flat-role.util'; +import { fromRowLevelPermissionPredicateGroupManifestToUniversalFlatRowLevelPermissionPredicateGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-group-manifest-to-universal-flat-row-level-permission-predicate-group.util'; +import { fromRowLevelPermissionPredicateManifestToUniversalFlatRowLevelPermissionPredicate } from 'src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-manifest-to-universal-flat-row-level-permission-predicate.util'; import { fromSkillManifestToUniversalFlatSkill } from 'src/engine/core-modules/application/application-manifest/converters/from-skill-manifest-to-universal-flat-skill.util'; import { computeSearchVectorUniversalSettingsFromObjectManifest } from 'src/engine/core-modules/application/application-manifest/utils/compute-search-vector-universal-settings-from-object-manifest.util'; import { fromViewFieldGroupManifestToUniversalFlatViewFieldGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-group-manifest-to-universal-flat-view-field-group.util'; @@ -350,6 +352,40 @@ export class ComputeApplicationManifestAllUniversalFlatEntityMapsService { allUniversalFlatEntityMaps.flatRolePermissionFlagMaps, }); } + + for (const rowLevelPermissionPredicateGroupManifest of roleManifest.rowLevelPermissionPredicateGroups ?? + []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromRowLevelPermissionPredicateGroupManifestToUniversalFlatRowLevelPermissionPredicateGroup( + { + rowLevelPermissionPredicateGroupManifest, + roleUniversalIdentifier: roleManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }, + ), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatRowLevelPermissionPredicateGroupMaps, + }); + } + + for (const rowLevelPermissionPredicateManifest of roleManifest.rowLevelPermissionPredicates ?? + []) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: + fromRowLevelPermissionPredicateManifestToUniversalFlatRowLevelPermissionPredicate( + { + rowLevelPermissionPredicateManifest, + roleUniversalIdentifier: roleManifest.universalIdentifier, + applicationUniversalIdentifier, + now, + }, + ), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatRowLevelPermissionPredicateMaps, + }); + } } for (const skillManifest of manifest.skills ?? []) { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts index 6a66039ba4..94808e85fc 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts @@ -204,14 +204,6 @@ export class WorkspaceMigrationBuildOrchestratorService { ALL_METADATA_NAME.viewSort, workspaceMigrationViewSortActionsBuilderService, ), - createEntityActionsBuilderTask( - ALL_METADATA_NAME.rowLevelPermissionPredicateGroup, - workspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService, - ), - createEntityActionsBuilderTask( - ALL_METADATA_NAME.rowLevelPermissionPredicate, - workspaceMigrationRowLevelPermissionPredicateActionsBuilderService, - ), createEntityActionsBuilderTask( ALL_METADATA_NAME.logicFunction, workspaceMigrationLogicFunctionActionsBuilderService, @@ -220,6 +212,14 @@ export class WorkspaceMigrationBuildOrchestratorService { ALL_METADATA_NAME.role, workspaceMigrationRoleActionsBuilderService, ), + createEntityActionsBuilderTask( + ALL_METADATA_NAME.rowLevelPermissionPredicateGroup, + workspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService, + ), + createEntityActionsBuilderTask( + ALL_METADATA_NAME.rowLevelPermissionPredicate, + workspaceMigrationRowLevelPermissionPredicateActionsBuilderService, + ), createEntityActionsBuilderTask( ALL_METADATA_NAME.objectPermission, workspaceMigrationObjectPermissionActionsBuilderService, diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-sync-row-level-permission-predicate.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-sync-row-level-permission-predicate.integration-spec.ts new file mode 100644 index 0000000000..9c96f00a78 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-sync-row-level-permission-predicate.integration-spec.ts @@ -0,0 +1,226 @@ +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util'; +import { type FieldManifest, type Manifest } from 'twenty-shared/application'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { + FieldMetadataType, + RowLevelPermissionPredicateOperand, +} from 'twenty-shared/types'; +import { v4 as uuidv4 } from 'uuid'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +const TEST_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const TEST_OBJECT_PERMISSION_ID = uuidv4(); +const TEST_FIELD_ID = uuidv4(); +const TEST_PREDICATE_ID = uuidv4(); + +const PERSON_OBJECT_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.person.universalIdentifier; +const WORKSPACE_MEMBER_ID_FIELD_UNIVERSAL_IDENTIFIER = + STANDARD_OBJECTS.workspaceMember.fields.id.universalIdentifier; + +const personScopingFieldManifest: FieldManifest = { + universalIdentifier: TEST_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'integrationRlsScopingColumn', + label: 'Integration RLS Scoping Column', + description: 'Custom field a row-level predicate scopes on', + icon: 'IconLock', + objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER, +}; + +const buildManifestWithPredicates = ( + predicates: NonNullable< + Manifest['roles'][number]['rowLevelPermissionPredicates'] + >, +): Manifest => + buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + fields: [personScopingFieldManifest], + roles: [ + { + universalIdentifier: TEST_ROLE_ID, + label: 'RLS Test Role', + description: 'Role exercising declarative row-level predicates', + objectPermissions: [ + { + universalIdentifier: TEST_OBJECT_PERMISSION_ID, + objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER, + canReadObjectRecords: true, + }, + ], + rowLevelPermissionPredicates: predicates, + }, + ], + }, + }); + +const partnerUserStylePredicate = ( + operand: RowLevelPermissionPredicateOperand, +) => ({ + universalIdentifier: TEST_PREDICATE_ID, + objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER, + fieldUniversalIdentifier: TEST_FIELD_ID, + operand, + workspaceMemberFieldUniversalIdentifier: + WORKSPACE_MEMBER_ID_FIELD_UNIVERSAL_IDENTIFIER, +}); + +type PredicateRow = { + id: string; + operand: string; + roleId: string; + objectMetadataId: string; + fieldMetadataId: string; + workspaceMemberFieldMetadataId: string | null; + applicationId: string; +}; + +const findActivePredicateRows = async (): Promise => + globalThis.testDataSource.query( + `SELECT id, operand, "roleId", "objectMetadataId", "fieldMetadataId", + "workspaceMemberFieldMetadataId", "applicationId" + FROM core."rowLevelPermissionPredicate" + WHERE "universalIdentifier" = $1 AND "workspaceId" = $2 AND "deletedAt" IS NULL`, + [TEST_PREDICATE_ID, TEST_WORKSPACE_ID], + ); + +const findApplicationId = async (): Promise => { + const rows = await globalThis.testDataSource.query( + `SELECT id FROM core."application" + WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`, + [TEST_APP_ID, TEST_WORKSPACE_ID], + ); + + return rows[0]?.id; +}; + +const findRoleId = async (): Promise => { + const rows = await globalThis.testDataSource.query( + `SELECT id FROM core."role" + WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`, + [TEST_ROLE_ID, TEST_WORKSPACE_ID], + ); + + return rows[0]?.id; +}; + +const findPersonObjectId = async (): Promise => { + const rows = await globalThis.testDataSource.query( + `SELECT id FROM core."objectMetadata" + WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`, + [PERSON_OBJECT_UNIVERSAL_IDENTIFIER, TEST_WORKSPACE_ID], + ); + + return rows[0]?.id; +}; + +describe('Manifest sync - row level permission predicates declared on a role', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test RLS Predicate Application', + description: 'App for testing declarative row-level predicate sync', + sourcePath: 'test-manifest-rls-predicate', + }); + }, 60000); + + afterEach(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + it('creates a row-level predicate owned by the app and pointing at the declared role/object/field', async () => { + const { errors } = await syncApplication({ + manifest: buildManifestWithPredicates([ + partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS), + ]), + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + + const [applicationId, roleId, personObjectId, predicateRows] = + await Promise.all([ + findApplicationId(), + findRoleId(), + findPersonObjectId(), + findActivePredicateRows(), + ]); + + expect(predicateRows).toHaveLength(1); + + const predicate = predicateRows[0]; + + expect(predicate.operand).toBe(RowLevelPermissionPredicateOperand.IS); + expect(predicate.roleId).toBe(roleId); + expect(predicate.objectMetadataId).toBe(personObjectId); + expect(predicate.fieldMetadataId).toBeTruthy(); + expect(predicate.workspaceMemberFieldMetadataId).toBeTruthy(); + expect(predicate.applicationId).toBe(applicationId); + }, 60000); + + it('updates the predicate in place on re-sync and removes it when dropped from the manifest', async () => { + await syncApplication({ + manifest: buildManifestWithPredicates([ + partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS), + ]), + expectToFail: false, + }); + + const createdRows = await findActivePredicateRows(); + + expect(createdRows).toHaveLength(1); + const predicateId = createdRows[0].id; + + await syncApplication({ + manifest: buildManifestWithPredicates([ + partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS_NOT), + ]), + expectToFail: false, + }); + + const updatedRows = await findActivePredicateRows(); + + expect(updatedRows).toHaveLength(1); + expect(updatedRows[0].id).toBe(predicateId); + expect(updatedRows[0].operand).toBe( + RowLevelPermissionPredicateOperand.IS_NOT, + ); + + await syncApplication({ + manifest: buildManifestWithPredicates([]), + expectToFail: false, + }); + + expect(await findActivePredicateRows()).toHaveLength(0); + }, 60000); + + it('removes the predicate on uninstall', async () => { + await syncApplication({ + manifest: buildManifestWithPredicates([ + partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS), + ]), + expectToFail: false, + }); + + expect(await findActivePredicateRows()).toHaveLength(1); + + await uninstallApplication({ + universalIdentifier: TEST_APP_ID, + expectToFail: false, + }); + + expect(await findActivePredicateRows()).toHaveLength(0); + }, 60000); +}); diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 5096398907..bfd7d5b964 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -64,6 +64,8 @@ export type { PreInstallLogicFunctionApplicationManifest } from './preInstallLog export type { ObjectPermissionManifest, FieldPermissionManifest, + RowLevelPermissionPredicateGroupManifest, + RowLevelPermissionPredicateManifest, RoleManifest, } from './roleManifestType'; export type { RunAgentInput, RunAgentResult } from './runAgentType'; diff --git a/packages/twenty-shared/src/application/roleManifestType.ts b/packages/twenty-shared/src/application/roleManifestType.ts index 0fa318e36e..b1b4af926b 100644 --- a/packages/twenty-shared/src/application/roleManifestType.ts +++ b/packages/twenty-shared/src/application/roleManifestType.ts @@ -1,4 +1,9 @@ import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; +import { + type RowLevelPermissionPredicateGroupLogicalOperator, + type RowLevelPermissionPredicateOperand, + type RowLevelPermissionPredicateValue, +} from '@/types'; export type ObjectPermissionManifest = SyncableEntityOptions & { objectUniversalIdentifier: string; @@ -15,6 +20,25 @@ export type FieldPermissionManifest = SyncableEntityOptions & { canUpdateFieldValue?: boolean; }; +export type RowLevelPermissionPredicateGroupManifest = SyncableEntityOptions & { + objectUniversalIdentifier: string; + logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator; + parentPredicateGroupUniversalIdentifier?: string | null; + position?: number | null; +}; + +export type RowLevelPermissionPredicateManifest = SyncableEntityOptions & { + objectUniversalIdentifier: string; + fieldUniversalIdentifier: string; + operand: RowLevelPermissionPredicateOperand; + value?: RowLevelPermissionPredicateValue | null; + subFieldName?: string | null; + workspaceMemberFieldUniversalIdentifier?: string | null; + workspaceMemberSubFieldName?: string | null; + predicateGroupUniversalIdentifier?: string | null; + position?: number | null; +}; + export type RoleManifest = SyncableEntityOptions & { label: string; description?: string; @@ -30,5 +54,7 @@ export type RoleManifest = SyncableEntityOptions & { canBeAssignedToApiKeys?: boolean; objectPermissions?: ObjectPermissionManifest[]; fieldPermissions?: FieldPermissionManifest[]; + rowLevelPermissionPredicates?: RowLevelPermissionPredicateManifest[]; + rowLevelPermissionPredicateGroups?: RowLevelPermissionPredicateGroupManifest[]; permissionFlagUniversalIdentifiers?: string[]; };