From a0e0c49714316267b5b8ad524748439afd3d8050 Mon Sep 17 00:00:00 2001 From: rashad Date: Mon, 8 Jun 2026 21:52:44 +0400 Subject: [PATCH 01/14] feat(partners): add partnerUser relation to Partner/Person/Company/Opportunity --- ...-partner-user-on-workspace-member.field.ts | 17 ++++++++++++++ ...-partner-user-on-workspace-member.field.ts | 17 ++++++++++++++ ...files-as-user-on-workspace-member.field.ts | 18 +++++++++++++++ .../fields/partner-user-on-company.field.ts | 20 +++++++++++++++++ .../partner-user-on-opportunity.field.ts | 20 +++++++++++++++++ .../fields/partner-user-on-partner.field.ts | 22 +++++++++++++++++++ .../fields/partner-user-on-person.field.ts | 20 +++++++++++++++++ ...-partner-user-on-workspace-member.field.ts | 17 ++++++++++++++ 8 files changed, 151 insertions(+) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/fields/companies-as-partner-user-on-workspace-member.field.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/fields/opportunities-as-partner-user-on-workspace-member.field.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/fields/partner-profiles-as-user-on-workspace-member.field.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-company.field.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-opportunity.field.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner.field.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-person.field.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/fields/persons-as-partner-user-on-workspace-member.field.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/companies-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/companies-as-partner-user-on-workspace-member.field.ts new file mode 100644 index 0000000000000..541080c7e2d98 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/companies-as-partner-user-on-workspace-member.field.ts @@ -0,0 +1,17 @@ +import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { COMPANIES_AS_PARTNER_USER_FIELD_ID, PARTNER_USER_ON_COMPANY_FIELD_ID } from './partner-user-on-company.field'; + +export default defineField({ + universalIdentifier: COMPANIES_AS_PARTNER_USER_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'companiesAsPartnerUser', + label: 'Companies (as partner user)', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_COMPANY_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunities-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunities-as-partner-user-on-workspace-member.field.ts new file mode 100644 index 0000000000000..1694df0a49103 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunities-as-partner-user-on-workspace-member.field.ts @@ -0,0 +1,17 @@ +import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID, PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from './partner-user-on-opportunity.field'; + +export default defineField({ + universalIdentifier: OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'opportunitiesAsPartnerUser', + label: 'Opportunities (as partner user)', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_OPPORTUNITY_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-profiles-as-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-profiles-as-user-on-workspace-member.field.ts new file mode 100644 index 0000000000000..8dfa54d6d2805 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-profiles-as-user-on-workspace-member.field.ts @@ -0,0 +1,18 @@ +import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { PARTNER_PROFILES_AS_USER_FIELD_ID, PARTNER_USER_ON_PARTNER_FIELD_ID } from './partner-user-on-partner.field'; + +export default defineField({ + universalIdentifier: PARTNER_PROFILES_AS_USER_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerProfilesAsUser', + label: 'Partner Profiles (as user)', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-company.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-company.field.ts new file mode 100644 index 0000000000000..01a6206dc8aa6 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-company.field.ts @@ -0,0 +1,20 @@ +import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +export const PARTNER_USER_ON_COMPANY_FIELD_ID = 'f8f1dbb6-9f9f-4c13-9dab-a19ea6223801'; +export const COMPANIES_AS_PARTNER_USER_FIELD_ID = '6235bf5b-9ecf-482f-b8b0-e6ae37659638'; + +export default defineField({ + universalIdentifier: PARTNER_USER_ON_COMPANY_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerUser', + label: 'Partner User', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: COMPANIES_AS_PARTNER_USER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerUserId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-opportunity.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-opportunity.field.ts new file mode 100644 index 0000000000000..a045a626dac39 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-opportunity.field.ts @@ -0,0 +1,20 @@ +import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +export const PARTNER_USER_ON_OPPORTUNITY_FIELD_ID = '7022a40a-a954-4e6b-96b8-1faff0919ec0'; +export const OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID = 'b03a26e8-6d9d-4d70-930b-2006929c9869'; + +export default defineField({ + universalIdentifier: PARTNER_USER_ON_OPPORTUNITY_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerUser', + label: 'Partner User', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerUserId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner.field.ts new file mode 100644 index 0000000000000..8dab5f9dadd2d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner.field.ts @@ -0,0 +1,22 @@ +import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export const PARTNER_USER_ON_PARTNER_FIELD_ID = '0e49f2e4-1e45-433d-bf49-79acc0b06d0e'; +export const PARTNER_PROFILES_AS_USER_FIELD_ID = 'c0791b65-802f-4ae0-85e0-5434459214f1'; + +export default defineField({ + universalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, + objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'partnerUser', + label: 'Partner User', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_PROFILES_AS_USER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerUserId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-person.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-person.field.ts new file mode 100644 index 0000000000000..c6768fa0c0fdd --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-person.field.ts @@ -0,0 +1,20 @@ +import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +export const PARTNER_USER_ON_PERSON_FIELD_ID = '2fc8b812-b7f9-49f2-829a-050be7ee1e5e'; +export const PERSONS_AS_PARTNER_USER_FIELD_ID = '94d17fac-3d32-4b13-9fb3-a671d4bf9c46'; + +export default defineField({ + universalIdentifier: PARTNER_USER_ON_PERSON_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerUser', + label: 'Partner User', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PERSONS_AS_PARTNER_USER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerUserId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/persons-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/persons-as-partner-user-on-workspace-member.field.ts new file mode 100644 index 0000000000000..97a45a93ff11d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/persons-as-partner-user-on-workspace-member.field.ts @@ -0,0 +1,17 @@ +import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; + +import { PARTNER_USER_ON_PERSON_FIELD_ID, PERSONS_AS_PARTNER_USER_FIELD_ID } from './partner-user-on-person.field'; + +export default defineField({ + universalIdentifier: PERSONS_AS_PARTNER_USER_FIELD_ID, + objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'personsAsPartnerUser', + label: 'People (as partner user)', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_PERSON_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); From 69a0521dfe9ac57bd66a132880b24f8694a947e3 Mon Sep 17 00:00:00 2001 From: rashad Date: Mon, 8 Jun 2026 22:04:23 +0400 Subject: [PATCH 02/14] feat(partners): partner role update perms (own records via RLS) --- .../twenty-partners/src/roles/partner.role.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts index c4c193d20fe4e..b685ccb01a757 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts @@ -5,14 +5,14 @@ import { PARTNER_ROLE_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; -// PLACEHOLDER role for external partners. Twenty has no row-level filtering yet, -// so anyone assigned this role currently sees every record — do not hand out -// until RLP ships and we can scope to records owned by the assigned user. +// External partner self-service role. Scoped to the records owned by the assigned +// workspace member via row-level predicates configured out-of-band (the app manifest +// cannot ship RLS predicates). Run `yarn rls:configure` after install/reinstall. export default defineRole({ universalIdentifier: PARTNER_ROLE_UNIVERSAL_IDENTIFIER, label: 'Partner', description: - 'PLACEHOLDER. External partner self-service role. Sees ALL Partner/Opportunity records today because Twenty does not yet support row-level record filtering. When RLP ships, scope these permissions to records owned by the assigned user. DO NOT assign to real external partners until then.', + 'External partner self-service role. Sees and edits only its own Partner/Person/Company/Opportunity records via row-level permissions. Configure predicates with `yarn rls:configure` after install.', icon: 'IconBuildingStore', canBeAssignedToUsers: true, canUpdateAllSettings: false, @@ -32,7 +32,7 @@ export default defineRole({ objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, canReadObjectRecords: true, - canUpdateObjectRecords: false, + canUpdateObjectRecords: true, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, @@ -40,7 +40,7 @@ export default defineRole({ objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, canReadObjectRecords: true, - canUpdateObjectRecords: false, + canUpdateObjectRecords: true, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, @@ -48,7 +48,7 @@ export default defineRole({ objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, canReadObjectRecords: true, - canUpdateObjectRecords: false, + canUpdateObjectRecords: true, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, From 6581514af5930133fe79751ee001fa54aabf908e Mon Sep 17 00:00:00 2001 From: rashad Date: Mon, 8 Jun 2026 22:04:30 +0400 Subject: [PATCH 03/14] feat(partners): RLS predicate config script for partner role --- .../internal/twenty-partners/package.json | 4 +- .../src/scripts/configure-partner-rls.ts | 322 ++++++++++++++++++ 2 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index 10485f4a248d7..a730674180562 100644 --- a/packages/twenty-apps/internal/twenty-partners/package.json +++ b/packages/twenty-apps/internal/twenty-partners/package.json @@ -25,7 +25,9 @@ "import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts", "import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts", "migrate:partner-scope": "tsx src/scripts/migrate-partner-scope.ts", - "migrate:partner-scope:prod": "ENV_FILE=.env.prod tsx src/scripts/migrate-partner-scope.ts" + "migrate:partner-scope:prod": "ENV_FILE=.env.prod tsx src/scripts/migrate-partner-scope.ts", + "rls:configure": "tsx src/scripts/configure-partner-rls.ts", + "rls:configure:prod": "ENV_FILE=.env.prod tsx src/scripts/configure-partner-rls.ts" }, "dependencies": { "twenty-client-sdk": "2.10.1", diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts new file mode 100644 index 0000000000000..0be831a500623 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts @@ -0,0 +1,322 @@ +// Upserts one row-level-permission predicate per object (partner, person, company, +// opportunity) on the Partner role: "partnerUser is the current workspace member". +// +// Usage: +// yarn rls:configure # against .env.local +// yarn rls:configure:prod # against .env.prod +// +// Run after every install or reinstall of the app so that the RLS predicates are +// re-attached to the role (the app manifest cannot ship them). +// +// Field choice: the metadata API exposes the relation field `partnerUser` (type RELATION) +// rather than a separate `partnerUserId` join-column field. The +// upsertRowLevelPermissionPredicates mutation accepts the RELATION field id directly. +// Operand: CONTAINS — confirmed working against this server version. + +import { config } from 'dotenv'; +config({ path: process.env.ENV_FILE ?? '.env.local' }); + +import { PARTNER_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +const requireEnv = (name: string): string => { + const value = process.env[name]; + if (!value) throw new Error(`Missing env var: ${name}`); + return value; +}; + +// The four object names we need to configure RLS on. +const TARGET_OBJECTS = ['partner', 'person', 'company', 'opportunity'] as const; +type TargetObject = (typeof TARGET_OBJECTS)[number]; + +type ObjectInfo = { + objectMetadataId: string; + partnerUserFieldMetadataId: string; +}; + +type FieldEdge = { + node: { + id: string; + name: string; + type: string; + }; +}; + +type PageInfo = { + hasNextPage: boolean; + endCursor: string | null; +}; + +type FieldsPage = { + edges: FieldEdge[]; + pageInfo: PageInfo; +}; + +type MetadataResponse = { + data: T; + errors?: { message: string }[]; +}; + +async function metadataFetch( + metadataUrl: string, + apiKey: string, + query: string, + variables?: Record, +): Promise { + const res = await fetch(metadataUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ query, variables }), + }); + + const json = (await res.json()) as MetadataResponse; + + if (json.errors && json.errors.length > 0) { + throw new Error( + `GraphQL errors: ${json.errors.map((e) => e.message).join('; ')}`, + ); + } + + return json.data; +} + +// Pages through an object's fields until it finds a field with the given name. +// Uses cursor-based pagination to avoid truncation on large objects (company/opportunity +// have >200 fields, so a single 200-cap request may miss partnerUser). +async function findFieldByName( + metadataUrl: string, + apiKey: string, + objectId: string, + objectName: string, + fieldName: string, +): Promise { + let after: string | null = null; + let page = 0; + + // eslint-disable-next-line no-constant-condition + while (true) { + page++; + const pagingArg = after + ? `paging:{first:200, after:"${after}"}` + : `paging:{first:200}`; + + const query = `{ + object(id: "${objectId}") { + fields(${pagingArg}) { + edges { node { id name type } } + pageInfo { hasNextPage endCursor } + } + } + }`; + + const data = await metadataFetch<{ + object: { fields: FieldsPage }; + }>(metadataUrl, apiKey, query); + + const match = data.object.fields.edges.find( + (e) => e.node.name === fieldName, + ); + + if (match) { + console.log( + ` [rls:configure] ${objectName}.${fieldName} found on page ${page} ` + + `(type=${match.node.type}, id=${match.node.id})`, + ); + return match.node.id; + } + + if (!data.object.fields.pageInfo.hasNextPage) { + // Print available fields to help diagnose a schema mismatch. + const names = data.object.fields.edges + .map((e) => e.node.name) + .join(', '); + throw new Error( + `Field "${fieldName}" not found on object "${objectName}" after ${page} page(s). ` + + `Last-page fields: ${names}`, + ); + } + + after = data.object.fields.pageInfo.endCursor; + } +} + +type PredicateResult = { + id: string; + fieldMetadataId: string; + objectMetadataId: string; + operand: string; + workspaceMemberFieldMetadataId: string | null; + roleId: string; +}; + +async function main() { + const baseUrl = requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, ''); + const apiKey = requireEnv('TWENTY_PARTNERS_API_KEY'); + const metadataUrl = `${baseUrl}/metadata`; + + console.log(`[rls:configure] target: ${metadataUrl}`); + + // ── 1. Resolve all object metadata IDs and their partnerUser field IDs ────── + + // First get all objects in one request to map nameSingular → id. + const objectsData = await metadataFetch<{ + objects: { edges: { node: { id: string; nameSingular: string } }[] }; + }>( + metadataUrl, + apiKey, + `{ objects(paging:{first:100}) { edges { node { id nameSingular } } } }`, + ); + + const objectIdByName = new Map( + objectsData.objects.edges.map((e) => [e.node.nameSingular, e.node.id]), + ); + + for (const name of TARGET_OBJECTS) { + if (!objectIdByName.has(name)) { + throw new Error( + `Object "${name}" not found in workspace metadata. ` + + `Has the app been installed and synced?`, + ); + } + } + + // Also need workspaceMember. + if (!objectIdByName.has('workspaceMember')) { + throw new Error('workspaceMember object not found in workspace metadata.'); + } + + const workspaceMemberId = objectIdByName.get('workspaceMember') as string; + + // Resolve partnerUser field id for each target object (with pagination). + const objectInfoByName = new Map(); + + for (const name of TARGET_OBJECTS) { + const objectId = objectIdByName.get(name) as string; + const partnerUserFieldMetadataId = await findFieldByName( + metadataUrl, + apiKey, + objectId, + name, + 'partnerUser', + ); + objectInfoByName.set(name, { + objectMetadataId: objectId, + partnerUserFieldMetadataId, + }); + } + + // ── 2. Resolve workspaceMember.id field metadata id ────────────────────────── + + const workspaceMemberIdFieldId = await findFieldByName( + metadataUrl, + apiKey, + workspaceMemberId, + 'workspaceMember', + 'id', + ); + + // ── 3. Resolve Partner role id ─────────────────────────────────────────────── + + // getRoles returns a flat array (not a connection) and does NOT expose + // universalIdentifier, so we match on the role label. This is the only available + // discriminator: if the Partner role's label is renamed in partner.role.ts, + // update the literal below to match. + const rolesData = await metadataFetch<{ + getRoles: { id: string; label: string }[]; + }>(metadataUrl, apiKey, `{ getRoles { id label } }`); + + const roles = rolesData.getRoles; + + // Match by label "Partner" (the label we set in the manifest). + // Note: universalIdentifier is PARTNER_ROLE_UNIVERSAL_IDENTIFIER but is not + // returned by getRoles. Label is the safest available discriminator. + const partnerRole = roles.find((r) => r.label === 'Partner'); + + if (!partnerRole) { + const labels = roles.map((r) => r.label).join(', '); + throw new Error( + `Partner role not found. Available roles: ${labels}. ` + + `Ensure the app is installed (universalIdentifier=${PARTNER_ROLE_UNIVERSAL_IDENTIFIER}).`, + ); + } + + console.log( + `[rls:configure] Partner role id: ${partnerRole.id} ` + + `(universalIdentifier in manifest: ${PARTNER_ROLE_UNIVERSAL_IDENTIFIER})`, + ); + + // ── 4. Upsert one predicate per object ─────────────────────────────────────── + // + // Predicate semantics: "the record's partnerUser relation CONTAINS the current + // workspace member" — i.e. the partnerUser FK equals the session user. + // Operand CONTAINS is the correct choice for relation/current-member matching + // (confirmed against this server version by manual introspection). + + const MUTATION = ` + mutation UpsertRLSPredicates($input: UpsertRowLevelPermissionPredicatesInput!) { + upsertRowLevelPermissionPredicates(input: $input) { + predicates { + id + fieldMetadataId + objectMetadataId + operand + workspaceMemberFieldMetadataId + roleId + } + } + } + `; + + const results: PredicateResult[] = []; + + for (const name of TARGET_OBJECTS) { + const info = objectInfoByName.get(name) as ObjectInfo; + + const data = await metadataFetch<{ + upsertRowLevelPermissionPredicates: { + predicates: PredicateResult[]; + }; + }>(metadataUrl, apiKey, MUTATION, { + input: { + roleId: partnerRole.id, + objectMetadataId: info.objectMetadataId, + predicates: [ + { + fieldMetadataId: info.partnerUserFieldMetadataId, + operand: 'CONTAINS', + // workspaceMemberFieldMetadataId scopes the predicate to the + // current session's workspace member (the "id" field). + workspaceMemberFieldMetadataId: workspaceMemberIdFieldId, + }, + ], + predicateGroups: [], + }, + }); + + const predicate = + data.upsertRowLevelPermissionPredicates.predicates[0]; + + if (!predicate) { + throw new Error( + `upsertRowLevelPermissionPredicates returned no predicates for object "${name}"`, + ); + } + + results.push(predicate); + console.log( + `[rls:configure] ✓ ${name}: predicate id=${predicate.id} ` + + `(fieldMetadataId=${predicate.fieldMetadataId}, operand=${predicate.operand})`, + ); + } + + console.log( + `\n[rls:configure] Done — ${results.length}/${TARGET_OBJECTS.length} predicates upserted on Partner role`, + ); +} + +main().catch((err: unknown) => { + console.error('[rls:configure] FAILED:', err); + process.exit(1); +}); From 6534769f0660b479d16e060bae866afd5756a2ff Mon Sep 17 00:00:00 2001 From: rashad Date: Mon, 8 Jun 2026 22:16:49 +0400 Subject: [PATCH 04/14] feat(partners): cascade partnerUser to company + people on partner assignment --- ...unity-partner-assigned.integration-test.ts | 347 ++++++++++++++++++ .../on-opportunity-partner-assigned.ts | 67 ++++ 2 files changed, 414 insertions(+) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts create mode 100644 packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts new file mode 100644 index 0000000000000..0a3a0d94b1285 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts @@ -0,0 +1,347 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +// Lazily import the handler under test so the "module not found" failure is +// clear when the implementation file doesn't exist yet (TDD red phase). +type HandlerFn = (payload: { properties: unknown }) => Promise; +let handler: HandlerFn | undefined; +try { + // Dynamic import so the test file can be parsed even when the impl is absent. + // defineLogicFunction returns ValidationResult which has + // a `config` property containing the original handler. + const mod = await import('../on-opportunity-partner-assigned'); + // mod.default is ValidationResult; config.handler is the fn. + handler = (mod.default as any)?.config?.handler as HandlerFn | undefined; +} catch { + // Implementation not yet written — tests will fail with a clear message. + handler = undefined; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function getWorkspaceMemberId(client: CoreApiClient): Promise { + const r = await client.query({ + workspaceMembers: { + __args: { first: 1 }, + edges: { node: { id: true } }, + }, + } as any); + const edges = ((r as any).workspaceMembers?.edges ?? []) as { node: { id: string } }[]; + if (edges.length === 0) throw new Error('No workspace members found — cannot run test'); + return edges[0].node.id; +} + +async function createPartner(client: CoreApiClient, memberId: string): Promise { + const r = await client.mutation({ + createPartner: { + __args: { + data: { + name: `[test-rls] partner ${Date.now()}`, + slug: `test-rls-${Date.now()}`, + partnerUserId: memberId, + }, + }, + id: true, + }, + } as any); + return (r as any).createPartner.id as string; +} + +async function destroyPartner(client: CoreApiClient, id: string) { + await client + .mutation({ destroyPartner: { __args: { id }, id: true } } as any) + .catch(() => {}); +} + +async function createCompany(client: CoreApiClient, name: string): Promise { + const r = await client.mutation({ + createCompany: { + __args: { data: { name } }, + id: true, + }, + } as any); + return (r as any).createCompany.id as string; +} + +async function destroyCompany(client: CoreApiClient, id: string) { + await client + .mutation({ destroyCompany: { __args: { id }, id: true } } as any) + .catch(() => {}); +} + +async function createPerson(client: CoreApiClient, companyId: string): Promise { + const r = await client.mutation({ + createPerson: { + __args: { data: { name: { firstName: 'Test', lastName: `RLS-${Date.now()}` }, companyId } }, + id: true, + }, + } as any); + return (r as any).createPerson.id as string; +} + +async function destroyPerson(client: CoreApiClient, id: string) { + await client + .mutation({ destroyPerson: { __args: { id }, id: true } } as any) + .catch(() => {}); +} + +async function createOpportunity( + client: CoreApiClient, + name: string, + companyId: string, +): Promise { + const r = await client.mutation({ + createOpportunity: { + __args: { data: { name, companyId } }, + id: true, + }, + } as any); + return (r as any).createOpportunity.id as string; +} + +async function destroyOpportunity(client: CoreApiClient, id: string) { + await client + .mutation({ destroyOpportunity: { __args: { id }, id: true } } as any) + .catch(() => {}); +} + +async function getOpportunity( + client: CoreApiClient, + id: string, +): Promise<{ id: string; partnerUserId: string | null }> { + const r = await client.query({ + opportunity: { + __args: { filter: { id: { eq: id } } }, + id: true, + partnerUserId: true, + }, + } as any); + return (r as any).opportunity as { id: string; partnerUserId: string | null }; +} + +async function getCompany( + client: CoreApiClient, + id: string, +): Promise<{ id: string; partnerUserId: string | null }> { + const r = await client.query({ + company: { + __args: { filter: { id: { eq: id } } }, + id: true, + partnerUserId: true, + }, + } as any); + return (r as any).company as { id: string; partnerUserId: string | null }; +} + +async function getPerson( + client: CoreApiClient, + id: string, +): Promise<{ id: string; partnerUserId: string | null }> { + const r = await client.query({ + person: { + __args: { filter: { id: { eq: id } } }, + id: true, + partnerUserId: true, + }, + } as any); + return (r as any).person as { id: string; partnerUserId: string | null }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Test strategy +// --------------------------------------------------------------------------- +// These tests invoke the handler directly (in-process) to verify its cascade +// logic deterministically against a live workspace. Direct invocation lets us +// assert exact return values and DB state without depending on the event bus. +// +// The end-to-end trigger wiring (opportunity.updated → function fires) is +// verified separately: the app sync step registers the function with the +// correct databaseEventTriggerSettings, and Task 5's manual E2E confirms the +// cascade fires when a partner is assigned via the UI. That deliberate split +// keeps these unit-style integration tests fast and reproducible. +// --------------------------------------------------------------------------- + +describe('on-opportunity-partner-assigned', () => { + let client: CoreApiClient; + + // Track created record IDs for cleanup — order matters for FK constraints. + const createdOpportunityIds: string[] = []; + const createdPersonIds: string[] = []; + const createdCompanyIds: string[] = []; + const createdPartnerIds: string[] = []; + + beforeEach(() => { + client = new CoreApiClient(); + }); + + afterEach(async () => { + // Destroy in reverse FK order. + for (const id of createdOpportunityIds) await destroyOpportunity(client, id); + createdOpportunityIds.length = 0; + + for (const id of createdPersonIds) await destroyPerson(client, id); + createdPersonIds.length = 0; + + for (const id of createdCompanyIds) await destroyCompany(client, id); + createdCompanyIds.length = 0; + + for (const id of createdPartnerIds) await destroyPartner(client, id); + createdPartnerIds.length = 0; + }); + + // ------------------------------------------------------------------------- + // No-op guard: when partnerId is not in updatedFields, handler returns {} + // ------------------------------------------------------------------------- + it('returns {} without any mutations when partnerId is not in updatedFields', async () => { + if (!handler) throw new Error('Handler not found — implementation file missing'); + + const result = await handler({ + properties: { updatedFields: ['name'], after: { id: 'fake-id' } }, + } as never); + + expect(result).toEqual({}); + }); + + // ------------------------------------------------------------------------- + // No-op guard: updatedFields includes partnerId but after.partnerId is null + // ------------------------------------------------------------------------- + it('returns {} when partnerId is set to null (unassignment)', async () => { + if (!handler) throw new Error('Handler not found — implementation file missing'); + + const result = await handler({ + properties: { updatedFields: ['partnerId'], after: { id: 'fake-id', partnerId: null } }, + } as never); + + expect(result).toEqual({}); + }); + + // ------------------------------------------------------------------------- + // Happy path: cascades partnerUser to opportunity, company, and people + // ------------------------------------------------------------------------- + it('stamps partnerUserId on opportunity, company, and people when partner is assigned', async () => { + if (!handler) throw new Error('Handler not found — implementation file missing'); + + // 1. Resolve an existing workspace member to use as the partner's user. + const memberId = await getWorkspaceMemberId(client); + + // 2. Create a Partner with partnerUserId pointing to that member. + const partnerId = await createPartner(client, memberId); + createdPartnerIds.push(partnerId); + + // 3. Create a Company (partnerUser unset). + const companyId = await createCompany(client, `[test-rls] company ${Date.now()}`); + createdCompanyIds.push(companyId); + + // 4. Create a Person on that Company (partnerUser unset). + const personId = await createPerson(client, companyId); + createdPersonIds.push(personId); + + // 5. Create an Opportunity linked to the Company, with no partner yet. + const oppId = await createOpportunity( + client, + `[test-rls] opp ${Date.now()}`, + companyId, + ); + createdOpportunityIds.push(oppId); + + // 6. Invoke handler directly, simulating opportunity.updated with partnerId change. + const result = await handler({ + properties: { + updatedFields: ['partnerId'], + after: { id: oppId, partnerId, companyId }, + }, + } as never); + + // 7. Assert handler reports cascaded: true and the correct memberId. + expect((result as any).cascaded).toBe(true); + expect((result as any).partnerUserId).toBe(memberId); + + // 8. Re-query all three records and verify partnerUserId was stamped. + const opp = await getOpportunity(client, oppId); + expect(opp.partnerUserId).toBe(memberId); + + const company = await getCompany(client, companyId); + expect(company.partnerUserId).toBe(memberId); + + const person = await getPerson(client, personId); + expect(person.partnerUserId).toBe(memberId); + }); + + // ------------------------------------------------------------------------- + // No linked company — only opportunity is stamped; no company/people cascade + // ------------------------------------------------------------------------- + it('stamps partnerUserId on opportunity and returns cascaded: true when no companyId is present', async () => { + if (!handler) throw new Error('Handler not found — implementation file missing'); + + // 1. Resolve an existing workspace member to use as the partner's user. + const memberId = await getWorkspaceMemberId(client); + + // 2. Create a Partner with partnerUserId. + const partnerId = await createPartner(client, memberId); + createdPartnerIds.push(partnerId); + + // 3. Create an Opportunity with NO linked company (companyId omitted from payload). + const r = await client.mutation({ + createOpportunity: { + __args: { data: { name: `[test-rls] no-company opp ${Date.now()}` } }, + id: true, + }, + } as any); + const oppId = (r as any).createOpportunity.id as string; + createdOpportunityIds.push(oppId); + + // 4. Invoke handler with no companyId in the after payload. + const result = await handler({ + properties: { + updatedFields: ['partnerId'], + after: { id: oppId, partnerId }, + }, + } as never); + + // 5. Handler should still report cascaded: true with the correct memberId. + expect((result as any).cascaded).toBe(true); + expect((result as any).partnerUserId).toBe(memberId); + + // 6. Opportunity should have partnerUserId stamped. + const opp = await getOpportunity(client, oppId); + expect(opp.partnerUserId).toBe(memberId); + }); + + // ------------------------------------------------------------------------- + // Partner has no partnerUserId — cascade is skipped gracefully + // ------------------------------------------------------------------------- + it('returns { cascaded: false, reason: "partner_has_no_user" } when partner.partnerUserId is null', async () => { + if (!handler) throw new Error('Handler not found — implementation file missing'); + + // Create a partner WITHOUT a partnerUserId. + const r = await client.mutation({ + createPartner: { + __args: { + data: { + name: `[test-rls] no-user partner ${Date.now()}`, + slug: `test-rls-nouser-${Date.now()}`, + }, + }, + id: true, + }, + } as any); + const noUserPartnerId = (r as any).createPartner.id as string; + createdPartnerIds.push(noUserPartnerId); + + const result = await handler({ + properties: { + updatedFields: ['partnerId'], + after: { id: 'fake-opp-id', partnerId: noUserPartnerId }, + }, + } as never); + + expect((result as any).cascaded).toBe(false); + expect((result as any).reason).toBe('partner_has_no_user'); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts new file mode 100644 index 0000000000000..5584292e10a00 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts @@ -0,0 +1,67 @@ +import { DatabaseEventPayload, defineLogicFunction } from 'twenty-sdk/define'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +// Inline universalIdentifier — defined here (not in universal-identifiers.ts) to avoid +// touching that file's local-only APPLICATION_UNIVERSAL_IDENTIFIER rewrite in the bundle. +const ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER = 'd7e4a4e6-9142-4597-adcf-6fb83c0f042d'; + +// Fires on opportunity.updated when partnerId changes to a non-null value. +// Resolves the assigned Partner's partnerUser (workspace member) and stamps it onto the +// Opportunity and its linked Company + People, so the whole deal becomes visible to the +// partner under row-level permissions. +const handler = async (payload: DatabaseEventPayload) => { + const props = payload.properties as { + after?: { id: string; partnerId?: string | null; companyId?: string | null }; + updatedFields?: string[]; + }; + + if (!props.updatedFields?.includes('partnerId')) return {}; + const partnerId = props.after?.partnerId; + const opportunityId = props.after?.id; + if (!partnerId || !opportunityId) return {}; + + const client = new CoreApiClient(); + + const partnerResult = await client.query({ + partner: { __args: { filter: { id: { eq: partnerId } } }, id: true, partnerUserId: true }, + } as any); + const partnerUserId = (partnerResult as any).partner?.partnerUserId as string | null | undefined; + if (!partnerUserId) return { cascaded: false, reason: 'partner_has_no_user' }; + + await client.mutation({ + updateOpportunity: { __args: { id: opportunityId, data: { partnerUserId } }, id: true }, + } as any); + + const companyId = props.after?.companyId; + if (companyId) { + await client.mutation({ + updateCompany: { __args: { id: companyId, data: { partnerUserId } }, id: true }, + } as any); + const peopleResult = await client.query({ + people: { + __args: { filter: { companyId: { eq: companyId } }, first: 200 }, + edges: { node: { id: true } }, + }, + } as any); + // A company's contacts are bounded in practice; cap at 200 and update in parallel + // (distinct records, safe to run concurrently) to stay within the function timeout. + const people = ((peopleResult as any).people?.edges ?? []) as { node: { id: string } }[]; + await Promise.all( + people.map(({ node }) => + client.mutation({ + updatePerson: { __args: { id: node.id, data: { partnerUserId } }, id: true }, + } as any), + ), + ); + } + + return { cascaded: true, partnerUserId }; +}; + +export default defineLogicFunction({ + universalIdentifier: ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER, + name: 'on-opportunity-partner-assigned', + timeoutSeconds: 15, + handler, + databaseEventTriggerSettings: { eventName: 'opportunity.updated' }, +}); From f3567435d28b0d694b0e4552c32448342fe5a242 Mon Sep 17 00:00:00 2001 From: rashad Date: Mon, 8 Jun 2026 22:49:56 +0400 Subject: [PATCH 05/14] feat(partners): lock partner Opportunity edits to the stage field --- .../twenty-partners/src/roles/partner.role.ts | 208 ++++++++++++++++++ .../src/scripts/configure-partner-rls.ts | 180 +++++++++++++-- 2 files changed, 374 insertions(+), 14 deletions(-) diff --git a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts index b685ccb01a757..87b315c8d86cf 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts @@ -1,13 +1,24 @@ import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineRole } from 'twenty-sdk/define'; import { + INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER, + MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_ROLE_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; +import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field'; +import { PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-user-on-opportunity.field'; // External partner self-service role. Scoped to the records owned by the assigned // workspace member via row-level predicates configured out-of-band (the app manifest // cannot ship RLS predicates). Run `yarn rls:configure` after install/reinstall. +// +// Opportunity field-level restrictions: the Partner role may READ all Opportunity +// fields but may only UPDATE the `stage` field. Every other non-system field is +// declared with canUpdateFieldValue: false below. These are deployed via manifest +// sync (yarn twenty dev --once) — the upsertFieldPermissions metadata mutation +// enforces an application-ownership check that prevents setting them post-deploy +// via workspace API key. `yarn rls:configure` queries and verifies these instead. export default defineRole({ universalIdentifier: PARTNER_ROLE_UNIVERSAL_IDENTIFIER, label: 'Partner', @@ -20,6 +31,203 @@ export default defineRole({ canUpdateAllObjectRecords: false, canSoftDeleteAllObjectRecords: false, canDestroyAllObjectRecords: false, + // Opportunity field permissions: read-all / update-stage-only. + // System fields (id, createdAt, updatedAt, deletedAt) are intentionally + // omitted — they cannot be restricted here. All other non-stage fields are + // locked to canUpdateFieldValue: false. + fieldPermissions: [ + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.name + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.amount + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.closeDate + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.position + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.createdBy + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.updatedBy + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.pointOfContact + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.company + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.owner + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.taskTargets + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.noteTargets + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.attachments + .universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields + .timelineActivities.universalIdentifier, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.searchVector + .universalIdentifier, + canUpdateFieldValue: false, + }, + // App-specific Opportunity fields (added by this app, also read-only for Partner). + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: PARTNER_USER_ON_OPPORTUNITY_FIELD_ID, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER, + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: '1bc57f52-a621-4243-ae3e-05c3f504b90c', // useCase + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5', // tftOpportunityId + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: '37e5428c-6c8c-4616-b626-f0ea1caa443d', // designDocUrl + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: '59d5de53-202f-4913-a417-8a08970d87cc', // subscriptionFrequency + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: '7ac7517f-bbca-4b4c-8996-6f864f71219b', // hostingType + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: '834e233d-b171-409e-825f-77ac49b0f19d', // lostReason + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: '90c683ec-2365-4533-a187-7b9ae162b753', // numberOfSeats + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: 'a58214e9-38f9-4faf-8927-09b3980fd8c3', // subscriptionType + canUpdateFieldValue: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, + fieldUniversalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1', // designDocStatus + canUpdateFieldValue: false, + }, + ], objectPermissions: [ { objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts index 0be831a500623..17c8eb0cbd216 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts @@ -1,17 +1,27 @@ -// Upserts one row-level-permission predicate per object (partner, person, company, -// opportunity) on the Partner role: "partnerUser is the current workspace member". +// Does two things on each run: +// +// 1. UPSERTS the 4 row-level-permission predicates on the Partner role — one for each +// target object (partner, person, company, opportunity): "partnerUser CONTAINS the +// current workspace member". These are configured out-of-band because the app +// manifest cannot ship RLS predicates. Re-run after every install or reinstall. +// The mutation is fully idempotent (upsert semantics). +// +// Field choice: the metadata API exposes the relation field `partnerUser` +// (type RELATION) rather than a separate `partnerUserId` join-column field. The +// upsertRowLevelPermissionPredicates mutation accepts the RELATION field id directly. +// Operand: CONTAINS — confirmed working against this server version. +// +// 2. VERIFIES the Opportunity field permissions declared in `partner.role.ts` and +// deployed via `yarn twenty dev --once`. The script does NOT set these permissions: +// upsertFieldPermissions enforces an application-ownership check that rejects +// out-of-band changes to app-owned roles (ROLE_BELONGS_TO_ANOTHER_APPLICATION). +// The manifest is the correct and only supported mechanism for app-owned roles. +// If any expected field locks are missing, the script exits non-zero and instructs +// the operator to run `yarn twenty dev --once` to deploy the manifest. // // Usage: // yarn rls:configure # against .env.local // yarn rls:configure:prod # against .env.prod -// -// Run after every install or reinstall of the app so that the RLS predicates are -// re-attached to the role (the app manifest cannot ship them). -// -// Field choice: the metadata API exposes the relation field `partnerUser` (type RELATION) -// rather than a separate `partnerUserId` join-column field. The -// upsertRowLevelPermissionPredicates mutation accepts the RELATION field id directly. -// Operand: CONTAINS — confirmed working against this server version. import { config } from 'dotenv'; config({ path: process.env.ENV_FILE ?? '.env.local' }); @@ -28,6 +38,17 @@ const requireEnv = (name: string): string => { const TARGET_OBJECTS = ['partner', 'person', 'company', 'opportunity'] as const; type TargetObject = (typeof TARGET_OBJECTS)[number]; +// System / immutable Opportunity fields that must NOT be included in the +// field-permission lock list. `stage` is also excluded because it is the one +// field the Partner role should be allowed to update. +const OPPORTUNITY_FIELD_LOCK_SKIP = new Set([ + 'id', + 'createdAt', + 'updatedAt', + 'deletedAt', + 'stage', +]); + type ObjectInfo = { objectMetadataId: string; partnerUserFieldMetadataId: string; @@ -142,6 +163,54 @@ async function findFieldByName( } } +// Collects every field on an object across all cursor pages. +// Required for Opportunity which can grow; always paginate fully. +async function collectAllFields( + metadataUrl: string, + apiKey: string, + objectId: string, +): Promise<{ id: string; name: string; type: string }[]> { + const all: { id: string; name: string; type: string }[] = []; + let after: string | null = null; + + // eslint-disable-next-line no-constant-condition + while (true) { + const pagingArg = after + ? `paging:{first:200, after:"${after}"}` + : `paging:{first:200}`; + + const query = `{ + object(id: "${objectId}") { + fields(${pagingArg}) { + edges { node { id name type } } + pageInfo { hasNextPage endCursor } + } + } + }`; + + const data = await metadataFetch<{ + object: { fields: FieldsPage }; + }>(metadataUrl, apiKey, query); + + for (const edge of data.object.fields.edges) { + all.push(edge.node); + } + + if (!data.object.fields.pageInfo.hasNextPage) break; + after = data.object.fields.pageInfo.endCursor; + } + + return all; +} + +type FieldPermissionResult = { + id: string; + objectMetadataId: string; + fieldMetadataId: string; + canReadFieldValue: boolean | null; + canUpdateFieldValue: boolean | null; +}; + type PredicateResult = { id: string; fieldMetadataId: string; @@ -217,15 +286,24 @@ async function main() { 'id', ); - // ── 3. Resolve Partner role id ─────────────────────────────────────────────── - + // ── 3. Resolve Partner role id and fetch field permissions in one request ────── + // // getRoles returns a flat array (not a connection) and does NOT expose // universalIdentifier, so we match on the role label. This is the only available // discriminator: if the Partner role's label is renamed in partner.role.ts, // update the literal below to match. + // Fetching fieldPermissions here avoids a second getRoles call later in step 5. const rolesData = await metadataFetch<{ - getRoles: { id: string; label: string }[]; - }>(metadataUrl, apiKey, `{ getRoles { id label } }`); + getRoles: { + id: string; + label: string; + fieldPermissions: FieldPermissionResult[]; + }[]; + }>( + metadataUrl, + apiKey, + `{ getRoles { id label fieldPermissions { id fieldMetadataId objectMetadataId canUpdateFieldValue canReadFieldValue } } }`, + ); const roles = rolesData.getRoles; @@ -314,6 +392,80 @@ async function main() { console.log( `\n[rls:configure] Done — ${results.length}/${TARGET_OBJECTS.length} predicates upserted on Partner role`, ); + + // ── 5. Verify Opportunity field permissions: read-all / update-stage-only ───── + // + // Opportunity field permissions for the Partner role are declared in + // src/roles/partner.role.ts and deployed by `yarn twenty dev --once` (manifest + // sync). They cannot be set here via upsertFieldPermissions because the server + // enforces an application-ownership check: the mutation always runs in the + // workspace Custom-app context, which does not match the Partner role's owning + // application (the partners app). Attempting to set them would return + // ROLE_BELONGS_TO_ANOTHER_APPLICATION. The manifest route is the correct + // and only supported mechanism for app-owned roles. + // + // This step queries the Partner role's existing fieldPermissions and prints the + // verification summary so a single run of `yarn rls:configure` confirms the + // full permission state (predicates + field locks) after a sync. + + const oppInfo = objectInfoByName.get('opportunity') as ObjectInfo; + const oppObjectId = oppInfo.objectMetadataId; + + const allOppFields = await collectAllFields(metadataUrl, apiKey, oppObjectId); + const oppFieldIdToName = new Map( + allOppFields.map((f) => [f.id, f.name]), + ); + + // Build the expected lock set: every non-system, non-stage Opportunity field. + const expectedLockedNames = new Set( + allOppFields + .filter((f) => !OPPORTUNITY_FIELD_LOCK_SKIP.has(f.name)) + .map((f) => f.name), + ); + + // Filter to Opportunity field permissions that lock update access. + // partnerRole was fetched with fieldPermissions in step 3 — no second getRoles needed. + const oppLockedFps = partnerRole.fieldPermissions.filter( + (fp) => + fp.objectMetadataId === oppObjectId && fp.canUpdateFieldValue === false, + ); + + const missingLocks = [...expectedLockedNames].filter( + (name) => + !oppLockedFps.some( + (fp) => oppFieldIdToName.get(fp.fieldMetadataId) === name, + ), + ); + + const stageIsLocked = oppLockedFps.some( + (fp) => oppFieldIdToName.get(fp.fieldMetadataId) === 'stage', + ); + + if (stageIsLocked) { + console.warn( + `[rls:configure] WARNING: stage field is locked — it should be editable. ` + + `Remove it from fieldPermissions in partner.role.ts and re-sync.`, + ); + } + + if (missingLocks.length > 0) { + console.error( + `\n[rls:configure] DRIFT DETECTED: ${missingLocks.length} Opportunity field(s) ` + + `are NOT locked (canUpdateFieldValue: false) on the Partner role:\n` + + ` ${missingLocks.join(', ')}\n\n` + + `These permissions are declared in partner.role.ts and must be deployed via the\n` + + `app manifest. Run the following to deploy them:\n\n` + + ` yarn twenty dev --once -r \n\n` + + `(e.g. \`yarn twenty dev --once\` for local, ` + + `\`yarn twenty dev --once -r partner-twenty-com\` for prod)\n`, + ); + process.exitCode = 1; + return; + } + + console.log( + `[rls:configure] ✓ ${oppLockedFps.length} Opportunity fields locked (stage editable only) — field permissions verified`, + ); } main().catch((err: unknown) => { From c9d1c84fe417203988cde29da6182e0048dccd26 Mon Sep 17 00:00:00 2001 From: rashad Date: Mon, 8 Jun 2026 23:04:27 +0400 Subject: [PATCH 06/14] chore(partners): bump version to 0.5.0 for partner RLS --- packages/twenty-apps/internal/twenty-partners/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index a730674180562..a083adfdc8784 100644 --- a/packages/twenty-apps/internal/twenty-partners/package.json +++ b/packages/twenty-apps/internal/twenty-partners/package.json @@ -1,6 +1,6 @@ { "name": "twenty-partners", - "version": "0.4.2", + "version": "0.5.0", "license": "MIT", "engines": { "node": "^24.5.0", From a21d0895b1f145da781d9433d5e0f8a631721481 Mon Sep 17 00:00:00 2001 From: rashad Date: Tue, 9 Jun 2026 09:24:09 +0400 Subject: [PATCH 07/14] feat(partners): grant scoped workspaceMember read to partner role + surface partnerUser --- .../twenty-partners/src/roles/partner.role.ts | 12 ++++ .../src/scripts/configure-partner-rls.ts | 55 +++++++++++++++++-- .../src/views/all-partners.view.ts | 4 ++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts index 87b315c8d86cf..8921b2bde3cea 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts @@ -260,5 +260,17 @@ export default defineRole({ canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, + { + // Read-only on workspace members so the partner UI can resolve member-typed + // relations (their own partnerUser link; owner/createdBy on their records). + // Scoped to the partner's OWN member record by an RLS predicate + // (see scripts/configure-partner-rls.ts) so the internal team roster stays hidden. + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, ], }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts index 17c8eb0cbd216..5addb3b908118 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts @@ -1,10 +1,12 @@ // Does two things on each run: // -// 1. UPSERTS the 4 row-level-permission predicates on the Partner role — one for each -// target object (partner, person, company, opportunity): "partnerUser CONTAINS the -// current workspace member". These are configured out-of-band because the app -// manifest cannot ship RLS predicates. Re-run after every install or reinstall. -// The mutation is fully idempotent (upsert semantics). +// 1. UPSERTS the row-level-permission predicates on the Partner role: one per target +// object (partner, person, company, opportunity) = "partnerUser CONTAINS the current +// workspace member", PLUS one on workspaceMember = "id IS the current member" so the +// role's read on workspaceMember is scoped to the partner's own record (member-typed +// relations resolve for themselves; the internal team roster stays hidden). These are +// configured out-of-band because the app manifest cannot ship RLS predicates. Re-run +// after every install or reinstall. The mutation is fully idempotent (upsert semantics). // // Field choice: the metadata API exposes the relation field `partnerUser` // (type RELATION) rather than a separate `partnerUserId` join-column field. The @@ -389,8 +391,49 @@ async function main() { ); } + // workspaceMember predicate: scope the Partner role to its OWN member record only. + // The role has read on workspaceMember so the partner UI can resolve member-typed + // relations (their partnerUser link; owner/createdBy on their opportunities). Without + // this scope, that read would expose the whole internal team roster. Semantics: + // "this workspaceMember's id IS the current session member" → a partner sees only + // themselves; other members (e.g. an opportunity's internal owner) resolve to null. + { + const wmData = await metadataFetch<{ + upsertRowLevelPermissionPredicates: { predicates: PredicateResult[] }; + }>(metadataUrl, apiKey, MUTATION, { + input: { + roleId: partnerRole.id, + objectMetadataId: workspaceMemberId, + predicates: [ + { + fieldMetadataId: workspaceMemberIdFieldId, + operand: 'IS', + workspaceMemberFieldMetadataId: workspaceMemberIdFieldId, + }, + ], + predicateGroups: [], + }, + }); + + const wmPredicate = + wmData.upsertRowLevelPermissionPredicates.predicates[0]; + + if (!wmPredicate) { + throw new Error( + 'upsertRowLevelPermissionPredicates returned no predicate for workspaceMember', + ); + } + + results.push(wmPredicate); + console.log( + `[rls:configure] ✓ workspaceMember: predicate id=${wmPredicate.id} ` + + `(fieldMetadataId=${wmPredicate.fieldMetadataId}, operand=${wmPredicate.operand})`, + ); + } + console.log( - `\n[rls:configure] Done — ${results.length}/${TARGET_OBJECTS.length} predicates upserted on Partner role`, + `\n[rls:configure] Done — ${results.length} predicates upserted on Partner role ` + + `(${TARGET_OBJECTS.length} objects + workspaceMember self-scope)`, ); // ── 5. Verify Opportunity field permissions: read-all / update-stage-only ───── diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts b/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts index 343ac92898b13..0797bf310061f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts @@ -4,6 +4,7 @@ import { ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER, PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; +import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field'; export default defineView({ universalIdentifier: ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER, @@ -19,5 +20,8 @@ export default defineView({ { universalIdentifier: '4ebe0b9d-0c2d-4416-b187-150b02473a01', fieldMetadataUniversalIdentifier: 'a0000010-0000-4000-8000-000000000010', position: 4, isVisible: true }, { universalIdentifier: '52408b5f-5e13-4e3c-af2d-ce50033ec126', fieldMetadataUniversalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad', position: 5, isVisible: true }, { universalIdentifier: '34d58668-a689-42e2-9aff-3fee315092d6', fieldMetadataUniversalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a', position: 6, isVisible: true }, + // partnerUser: links a Partner to the workspace member who logs in as them. + // Setting this is the prerequisite for the assignment cascade + the partner's RLS visibility. + { universalIdentifier: '19d1fc62-2486-4f59-8e77-b4ee177481b1', fieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, position: 7, isVisible: true }, ], }); From 4421a18c2601ded1378c5e2e636b4b393b862a93 Mon Sep 17 00:00:00 2001 From: rashad Date: Tue, 9 Jun 2026 17:15:14 +0400 Subject: [PATCH 08/14] feat(partners): add Partner User column to Validated Partners view --- .../twenty-partners/src/views/validated-partners.view.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/validated-partners.view.ts b/packages/twenty-apps/internal/twenty-partners/src/views/validated-partners.view.ts index 554cb93fc039b..7c093a6e72b7b 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/validated-partners.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/views/validated-partners.view.ts @@ -4,6 +4,7 @@ import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; +import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field'; // Validated partners — serves both partner intros and the public website list. export default defineView({ @@ -20,6 +21,8 @@ export default defineView({ { universalIdentifier: 'd6df98ac-9c6c-46c2-8784-d4e1d6521f75', fieldMetadataUniversalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad', position: 3, isVisible: true }, { universalIdentifier: '375cc871-fdda-42d0-8308-e60174b6d467', fieldMetadataUniversalIdentifier: 'a0000004-0000-4000-8000-000000000004', position: 4, isVisible: true }, { universalIdentifier: '6a7d6b14-9216-42af-bbd9-89bd185fd492', fieldMetadataUniversalIdentifier: 'a0000007-0000-4000-8000-000000000007', position: 5, isVisible: true }, + // partnerUser: the login member this partner signs in as (set inline here, like setting an owner). + { universalIdentifier: '9e06eeaa-649d-412a-aacd-0f8bf7ee69c3', fieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, position: 6, isVisible: true }, ], filters: [ { From b1f72b2b99c1f51647d2323a4f4857dddc0c22ac Mon Sep 17 00:00:00 2001 From: rashad Date: Tue, 9 Jun 2026 17:26:43 +0400 Subject: [PATCH 09/14] fix(partners): RLS relation predicates use operand IS (CONTAINS rejected by query engine) --- .../src/scripts/configure-partner-rls.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts index 5addb3b908118..8b8327f3e6e2d 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts @@ -1,7 +1,7 @@ // Does two things on each run: // // 1. UPSERTS the row-level-permission predicates on the Partner role: one per target -// object (partner, person, company, opportunity) = "partnerUser CONTAINS the current +// object (partner, person, company, opportunity) = "partnerUser IS the current // workspace member", PLUS one on workspaceMember = "id IS the current member" so the // role's read on workspaceMember is scoped to the partner's own record (member-typed // relations resolve for themselves; the internal team roster stays hidden). These are @@ -11,7 +11,10 @@ // Field choice: the metadata API exposes the relation field `partnerUser` // (type RELATION) rather than a separate `partnerUserId` join-column field. The // upsertRowLevelPermissionPredicates mutation accepts the RELATION field id directly. -// Operand: CONTAINS — confirmed working against this server version. +// Operand: IS (value null + workspaceMemberFieldMetadataId injects the current member). +// NOT CONTAINS — the query engine's RELATION filter only accepts IS / IS_NOT and throws +// "Unknown operand CONTAINS for RELATION filter" at enforcement time, even though the +// upsert mutation silently accepts CONTAINS. // // 2. VERIFIES the Opportunity field permissions declared in `partner.role.ts` and // deployed via `yarn twenty dev --once`. The script does NOT set these permissions: @@ -329,10 +332,13 @@ async function main() { // ── 4. Upsert one predicate per object ─────────────────────────────────────── // - // Predicate semantics: "the record's partnerUser relation CONTAINS the current - // workspace member" — i.e. the partnerUser FK equals the session user. - // Operand CONTAINS is the correct choice for relation/current-member matching - // (confirmed against this server version by manual introspection). + // Predicate semantics: "the record's partnerUser relation IS the current workspace + // member" — i.e. the partnerUser FK equals the session user. + // Operand IS (not CONTAINS): the query engine's RELATION filter only accepts IS / IS_NOT. + // The upsert mutation accepts CONTAINS, but enforcement throws "Unknown operand CONTAINS + // for RELATION filter" at query time. value stays null; workspaceMemberFieldMetadataId + // injects the current member at query time. Mirrors the Roles-UI conversion for a + // relation current-member RLS predicate (operand IS, value null, workspaceMemberFieldMetadataId). const MUTATION = ` mutation UpsertRLSPredicates($input: UpsertRowLevelPermissionPredicatesInput!) { @@ -365,7 +371,7 @@ async function main() { predicates: [ { fieldMetadataId: info.partnerUserFieldMetadataId, - operand: 'CONTAINS', + operand: 'IS', // workspaceMemberFieldMetadataId scopes the predicate to the // current session's workspace member (the "id" field). workspaceMemberFieldMetadataId: workspaceMemberIdFieldId, From 481874e3d690b803a15d8621599c21e824b7b4e9 Mon Sep 17 00:00:00 2001 From: rashad Date: Tue, 9 Jun 2026 17:40:16 +0400 Subject: [PATCH 10/14] fix(partners): clear opportunity partnerUser on partner unassignment --- ...unity-partner-assigned.integration-test.ts | 26 +++++++++++++++--- .../on-opportunity-partner-assigned.ts | 27 ++++++++++++++----- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts index 0a3a0d94b1285..60336ed5ff0c2 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts @@ -209,16 +209,34 @@ describe('on-opportunity-partner-assigned', () => { }); // ------------------------------------------------------------------------- - // No-op guard: updatedFields includes partnerId but after.partnerId is null + // Unassign: partnerId cleared → the opportunity's partnerUser is cleared so it + // leaves the partner's row-level view. // ------------------------------------------------------------------------- - it('returns {} when partnerId is set to null (unassignment)', async () => { + it('clears the opportunity partnerUser when the partner is removed (unassignment)', async () => { if (!handler) throw new Error('Handler not found — implementation file missing'); + const memberId = await getWorkspaceMemberId(client); + const partnerId = await createPartner(client, memberId); + createdPartnerIds.push(partnerId); + const companyId = await createCompany(client, `[test-rls] company ${Date.now()}`); + createdCompanyIds.push(companyId); + const oppId = await createOpportunity(client, `[test-rls] opp ${Date.now()}`, companyId); + createdOpportunityIds.push(oppId); + + // Assign first so the opportunity has partnerUser stamped. + await handler({ + properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId, companyId } }, + } as never); + expect((await getOpportunity(client, oppId)).partnerUserId).toBe(memberId); + + // Now unassign: partnerId set to null. const result = await handler({ - properties: { updatedFields: ['partnerId'], after: { id: 'fake-id', partnerId: null } }, + properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId: null, companyId } }, } as never); - expect(result).toEqual({}); + expect((result as any).cascaded).toBe(true); + expect((result as any).cleared).toBe(true); + expect((await getOpportunity(client, oppId)).partnerUserId).toBeNull(); }); // ------------------------------------------------------------------------- diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts index 5584292e10a00..bb1e4145fa100 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts @@ -5,10 +5,13 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; // touching that file's local-only APPLICATION_UNIVERSAL_IDENTIFIER rewrite in the bundle. const ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER = 'd7e4a4e6-9142-4597-adcf-6fb83c0f042d'; -// Fires on opportunity.updated when partnerId changes to a non-null value. -// Resolves the assigned Partner's partnerUser (workspace member) and stamps it onto the -// Opportunity and its linked Company + People, so the whole deal becomes visible to the -// partner under row-level permissions. +// Fires on opportunity.updated when partnerId changes. +// - Assign / reassign (partnerId set): resolve the assigned Partner's partnerUser +// (workspace member) and stamp it onto the Opportunity + linked Company + People, so the +// whole deal becomes visible to the partner under row-level permissions. +// - Unassign (partnerId cleared): clear the Opportunity's partnerUser so it leaves the +// partner's row-level view. (Linked Company/People keep their partnerUser as deal +// context — clearing those safely requires checking the partner's other deals; deferred.) const handler = async (payload: DatabaseEventPayload) => { const props = payload.properties as { after?: { id: string; partnerId?: string | null; companyId?: string | null }; @@ -16,11 +19,23 @@ const handler = async (payload: DatabaseEventPayload) => { }; if (!props.updatedFields?.includes('partnerId')) return {}; - const partnerId = props.after?.partnerId; const opportunityId = props.after?.id; - if (!partnerId || !opportunityId) return {}; + if (!opportunityId) return {}; const client = new CoreApiClient(); + const partnerId = props.after?.partnerId; + + // Unassign: the partner was removed from the opportunity. Clear the opportunity's + // partnerUser so it disappears from that partner's row-level view. + if (!partnerId) { + await client.mutation({ + updateOpportunity: { + __args: { id: opportunityId, data: { partnerUserId: null } }, + id: true, + }, + } as any); + return { cascaded: true, cleared: true }; + } const partnerResult = await client.query({ partner: { __args: { filter: { id: { eq: partnerId } } }, id: true, partnerUserId: true }, From 3962680f5b3da9bf50424b7d4eec284ee1f58f97 Mon Sep 17 00:00:00 2001 From: rashad Date: Tue, 9 Jun 2026 17:53:14 +0400 Subject: [PATCH 11/14] fix(partners): unassign also clears partnerUser on linked company + people (when not used by another deal) --- ...unity-partner-assigned.integration-test.ts | 13 ++- .../on-opportunity-partner-assigned.ts | 93 ++++++++++++++++++- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts index 60336ed5ff0c2..5f816bdc52397 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts @@ -212,7 +212,7 @@ describe('on-opportunity-partner-assigned', () => { // Unassign: partnerId cleared → the opportunity's partnerUser is cleared so it // leaves the partner's row-level view. // ------------------------------------------------------------------------- - it('clears the opportunity partnerUser when the partner is removed (unassignment)', async () => { + it('clears partnerUser on the opportunity, company, and people when the partner is removed (unassignment)', async () => { if (!handler) throw new Error('Handler not found — implementation file missing'); const memberId = await getWorkspaceMemberId(client); @@ -220,16 +220,21 @@ describe('on-opportunity-partner-assigned', () => { createdPartnerIds.push(partnerId); const companyId = await createCompany(client, `[test-rls] company ${Date.now()}`); createdCompanyIds.push(companyId); + const personId = await createPerson(client, companyId); + createdPersonIds.push(personId); const oppId = await createOpportunity(client, `[test-rls] opp ${Date.now()}`, companyId); createdOpportunityIds.push(oppId); - // Assign first so the opportunity has partnerUser stamped. + // Assign first so the opportunity + company + person have partnerUser stamped. await handler({ properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId, companyId } }, } as never); expect((await getOpportunity(client, oppId)).partnerUserId).toBe(memberId); + expect((await getCompany(client, companyId)).partnerUserId).toBe(memberId); + expect((await getPerson(client, personId)).partnerUserId).toBe(memberId); - // Now unassign: partnerId set to null. + // Now unassign: partnerId set to null. No other opportunity uses this company for the + // member, so the company + person should be cleared too. const result = await handler({ properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId: null, companyId } }, } as never); @@ -237,6 +242,8 @@ describe('on-opportunity-partner-assigned', () => { expect((result as any).cascaded).toBe(true); expect((result as any).cleared).toBe(true); expect((await getOpportunity(client, oppId)).partnerUserId).toBeNull(); + expect((await getCompany(client, companyId)).partnerUserId).toBeNull(); + expect((await getPerson(client, personId)).partnerUserId).toBeNull(); }); // ------------------------------------------------------------------------- diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts index bb1e4145fa100..b4bfda25049c4 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts @@ -10,8 +10,9 @@ const ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER = 'd7e4a4e6-9142-4597-adcf // (workspace member) and stamp it onto the Opportunity + linked Company + People, so the // whole deal becomes visible to the partner under row-level permissions. // - Unassign (partnerId cleared): clear the Opportunity's partnerUser so it leaves the -// partner's row-level view. (Linked Company/People keep their partnerUser as deal -// context — clearing those safely requires checking the partner's other deals; deferred.) +// partner's row-level view, and cascade the clear to the linked Company + People — but +// only when that member has no OTHER opportunity still using the same Company (otherwise +// the Company is still part of an active deal and must stay visible). const handler = async (payload: DatabaseEventPayload) => { const props = payload.properties as { after?: { id: string; partnerId?: string | null; companyId?: string | null }; @@ -25,16 +26,98 @@ const handler = async (payload: DatabaseEventPayload) => { const client = new CoreApiClient(); const partnerId = props.after?.partnerId; - // Unassign: the partner was removed from the opportunity. Clear the opportunity's - // partnerUser so it disappears from that partner's row-level view. + // Unassign: the partner was removed from the opportunity. if (!partnerId) { + // The opportunity still carries the previously-stamped member (only partnerId changed), + // so read it to know whose visibility to revoke and which company is linked. + const oppResult = await client.query({ + opportunity: { + __args: { filter: { id: { eq: opportunityId } } }, + id: true, + partnerUserId: true, + companyId: true, + }, + } as any); + const removedMemberId = (oppResult as any).opportunity?.partnerUserId as + | string + | null + | undefined; + const companyId = (oppResult as any).opportunity?.companyId as string | null | undefined; + + // Clear the opportunity itself. await client.mutation({ updateOpportunity: { __args: { id: opportunityId, data: { partnerUserId: null } }, id: true, }, } as any); - return { cascaded: true, cleared: true }; + + if (!removedMemberId || !companyId) { + return { cascaded: true, cleared: true }; + } + + // Keep the Company (and its People) if the same member still has another opportunity on + // it — the opportunity we just cleared no longer matches this filter. + const otherOpps = await client.query({ + opportunities: { + __args: { + filter: { + companyId: { eq: companyId }, + partnerUserId: { eq: removedMemberId }, + }, + first: 1, + }, + edges: { node: { id: true } }, + }, + } as any); + const companyStillInUse = + (((otherOpps as any).opportunities?.edges ?? []) as unknown[]).length > 0; + if (companyStillInUse) { + return { cascaded: true, cleared: true, companyKept: true }; + } + + // Clear the Company (only if it belongs to this member) and the People stamped for them. + const companyResult = await client.query({ + company: { + __args: { filter: { id: { eq: companyId } } }, + id: true, + partnerUserId: true, + }, + } as any); + if ((companyResult as any).company?.partnerUserId === removedMemberId) { + await client.mutation({ + updateCompany: { + __args: { id: companyId, data: { partnerUserId: null } }, + id: true, + }, + } as any); + } + const peopleResult = await client.query({ + people: { + __args: { + filter: { + companyId: { eq: companyId }, + partnerUserId: { eq: removedMemberId }, + }, + first: 200, + }, + edges: { node: { id: true } }, + }, + } as any); + const peopleToClear = ((peopleResult as any).people?.edges ?? []) as { + node: { id: string }; + }[]; + await Promise.all( + peopleToClear.map(({ node }) => + client.mutation({ + updatePerson: { + __args: { id: node.id, data: { partnerUserId: null } }, + id: true, + }, + } as any), + ), + ); + return { cascaded: true, cleared: true, companyCleared: true }; } const partnerResult = await client.query({ From 4265cec34973aab272d77bcb616133beb3445413 Mon Sep 17 00:00:00 2001 From: rashad Date: Wed, 10 Jun 2026 05:52:29 +0400 Subject: [PATCH 12/14] feat(partners): partner edits limited to own profile + opportunity stage/amount; company & person read-only --- .../twenty-partners/src/roles/partner.role.ts | 37 ++++++++----------- .../src/scripts/configure-partner-rls.ts | 11 +++--- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts index 8921b2bde3cea..d515f2cf17393 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts @@ -13,17 +13,19 @@ import { PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-user-on // workspace member via row-level predicates configured out-of-band (the app manifest // cannot ship RLS predicates). Run `yarn rls:configure` after install/reinstall. // -// Opportunity field-level restrictions: the Partner role may READ all Opportunity -// fields but may only UPDATE the `stage` field. Every other non-system field is -// declared with canUpdateFieldValue: false below. These are deployed via manifest -// sync (yarn twenty dev --once) — the upsertFieldPermissions metadata mutation -// enforces an application-ownership check that prevents setting them post-deploy -// via workspace API key. `yarn rls:configure` queries and verifies these instead. +// Partner edit scope: +// - Partner (own profile): full update. +// - Opportunity: read-all, but UPDATE only `stage` and `amount` (every other non-system +// field is locked via canUpdateFieldValue: false below). +// - Company / Person: READ-ONLY (no object-level update — see objectPermissions). +// Field permissions are deployed via manifest sync (yarn twenty dev --once) — the +// upsertFieldPermissions metadata mutation enforces an application-ownership check that +// prevents setting them post-deploy via workspace API key. `yarn rls:configure` verifies them. export default defineRole({ universalIdentifier: PARTNER_ROLE_UNIVERSAL_IDENTIFIER, label: 'Partner', description: - 'External partner self-service role. Sees and edits only its own Partner/Person/Company/Opportunity records via row-level permissions. Configure predicates with `yarn rls:configure` after install.', + 'External partner self-service role. Sees only its own Partner/Person/Company/Opportunity records (row-level). Can edit its own Partner profile and an Opportunity’s stage + amount; Company and Person are read-only. Configure predicates with `yarn rls:configure` after install.', icon: 'IconBuildingStore', canBeAssignedToUsers: true, canUpdateAllSettings: false, @@ -31,10 +33,9 @@ export default defineRole({ canUpdateAllObjectRecords: false, canSoftDeleteAllObjectRecords: false, canDestroyAllObjectRecords: false, - // Opportunity field permissions: read-all / update-stage-only. - // System fields (id, createdAt, updatedAt, deletedAt) are intentionally - // omitted — they cannot be restricted here. All other non-stage fields are - // locked to canUpdateFieldValue: false. + // Opportunity field permissions: read-all / update `stage` + `amount` only. + // System fields (id, createdAt, updatedAt, deletedAt) plus `stage` and `amount` are + // intentionally omitted from this lock list — everything else is canUpdateFieldValue: false. fieldPermissions: [ { objectUniversalIdentifier: @@ -44,14 +45,6 @@ export default defineRole({ .universalIdentifier, canUpdateFieldValue: false, }, - { - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.amount - .universalIdentifier, - canUpdateFieldValue: false, - }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, @@ -245,18 +238,20 @@ export default defineRole({ canDestroyObjectRecords: false, }, { + // Person: read-only for partners (they see their deal's contacts but can't edit them). objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, canReadObjectRecords: true, - canUpdateObjectRecords: true, + canUpdateObjectRecords: false, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, { + // Company: read-only for partners (they see their deal's company but can't edit it). objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, canReadObjectRecords: true, - canUpdateObjectRecords: true, + canUpdateObjectRecords: false, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }, diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts index 8b8327f3e6e2d..e57e45161e3de 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts @@ -43,15 +43,16 @@ const requireEnv = (name: string): string => { const TARGET_OBJECTS = ['partner', 'person', 'company', 'opportunity'] as const; type TargetObject = (typeof TARGET_OBJECTS)[number]; -// System / immutable Opportunity fields that must NOT be included in the -// field-permission lock list. `stage` is also excluded because it is the one -// field the Partner role should be allowed to update. +// System / immutable Opportunity fields that must NOT be in the field-permission lock +// list, PLUS the fields the Partner role is allowed to update (`stage`, `amount`). +// Everything else is locked (canUpdateFieldValue: false). const OPPORTUNITY_FIELD_LOCK_SKIP = new Set([ 'id', 'createdAt', 'updatedAt', 'deletedAt', 'stage', + 'amount', ]); type ObjectInfo = { @@ -442,7 +443,7 @@ async function main() { `(${TARGET_OBJECTS.length} objects + workspaceMember self-scope)`, ); - // ── 5. Verify Opportunity field permissions: read-all / update-stage-only ───── + // ── 5. Verify Opportunity field permissions: read-all / update stage + amount only ─ // // Opportunity field permissions for the Partner role are declared in // src/roles/partner.role.ts and deployed by `yarn twenty dev --once` (manifest @@ -513,7 +514,7 @@ async function main() { } console.log( - `[rls:configure] ✓ ${oppLockedFps.length} Opportunity fields locked (stage editable only) — field permissions verified`, + `[rls:configure] ✓ ${oppLockedFps.length} Opportunity fields locked (stage + amount editable) — field permissions verified`, ); } From 81ebf0fe0348304ee89921e8917812ebdbed07ea Mon Sep 17 00:00:00 2001 From: rashad Date: Wed, 10 Jun 2026 08:28:42 +0400 Subject: [PATCH 13/14] fix(partners): allow partner opportunity stage + amount edits The Partner role locked every Opportunity field except stage/amount with canUpdateFieldValue:false. But the *.updateOne pre-query hook injects updatedBy into every update, so the permission check rejected ALL partner opportunity updates (stage and amount alike) with PERMISSION_DENIED. updatedBy is overwritten with the real actor on every write, so locking it protected nothing. - Remove updatedBy from the Opportunity field-lock list (auto-written on every update). - Remove position (co-written with stage when changing stage via kanban drag). - Keep createdBy locked (not auto-written on update, still blocks tampering) and searchVector (STORED generated column, inert). - Update OPPORTUNITY_FIELD_LOCK_SKIP in configure-partner-rls.ts to match. --- .../twenty-partners/src/roles/partner.role.ts | 39 +++++++++---------- .../src/scripts/configure-partner-rls.ts | 16 ++++++-- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts index d515f2cf17393..90a90d1098f02 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts @@ -15,12 +15,25 @@ import { PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-user-on // // Partner edit scope: // - Partner (own profile): full update. -// - Opportunity: read-all, but UPDATE only `stage` and `amount` (every other non-system -// field is locked via canUpdateFieldValue: false below). +// - Opportunity: read-all, but UPDATE only `stage` and `amount` (every other +// user-facing field is locked via canUpdateFieldValue: false below). // - Company / Person: READ-ONLY (no object-level update — see objectPermissions). // Field permissions are deployed via manifest sync (yarn twenty dev --once) — the // upsertFieldPermissions metadata mutation enforces an application-ownership check that // prevents setting them post-deploy via workspace API key. `yarn rls:configure` verifies them. +// +// Do NOT lock server-managed fields that get written on a normal update: +// - `updatedBy` (ACTOR) is injected into EVERY update by the `*.updateOne` pre-query hook +// (ActorFromAuthContextService.injectUpdatedBy). Locking it makes the permission check +// reject every opportunity update — stage and amount included — with PERMISSION_DENIED. +// The server overwrites it with the real actor regardless, so locking it is both +// pointless (no spoofing possible) and breaking. Left editable. +// - `position` (POSITION) is co-written with `stage` when the stage is changed by dragging +// a card on the kanban board. Locking it would make kanban stage-changes fail. Left +// editable (a partner reordering their own view is cosmetic only). +// `createdBy` stays locked: it is NOT injected on update, so locking it blocks a client from +// rewriting authorship without breaking normal edits. `searchVector` is a STORED generated +// column (never written by the app), so its lock is an inert no-op kept for completeness. export default defineRole({ universalIdentifier: PARTNER_ROLE_UNIVERSAL_IDENTIFIER, label: 'Partner', @@ -34,8 +47,10 @@ export default defineRole({ canSoftDeleteAllObjectRecords: false, canDestroyAllObjectRecords: false, // Opportunity field permissions: read-all / update `stage` + `amount` only. - // System fields (id, createdAt, updatedAt, deletedAt) plus `stage` and `amount` are - // intentionally omitted from this lock list — everything else is canUpdateFieldValue: false. + // Omitted from this lock list (i.e. editable): system fields (id, createdAt, updatedAt, + // deletedAt), the two server-managed/co-written fields (`updatedBy`, `position` — see the + // header comment), plus the two intentionally-editable fields (`stage`, `amount`). + // Everything else is canUpdateFieldValue: false. fieldPermissions: [ { objectUniversalIdentifier: @@ -53,14 +68,6 @@ export default defineRole({ .universalIdentifier, canUpdateFieldValue: false, }, - { - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.position - .universalIdentifier, - canUpdateFieldValue: false, - }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, @@ -69,14 +76,6 @@ export default defineRole({ .universalIdentifier, canUpdateFieldValue: false, }, - { - objectUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: - STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.updatedBy - .universalIdentifier, - canUpdateFieldValue: false, - }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts index e57e45161e3de..d32547638a29e 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts @@ -43,14 +43,24 @@ const requireEnv = (name: string): string => { const TARGET_OBJECTS = ['partner', 'person', 'company', 'opportunity'] as const; type TargetObject = (typeof TARGET_OBJECTS)[number]; -// System / immutable Opportunity fields that must NOT be in the field-permission lock -// list, PLUS the fields the Partner role is allowed to update (`stage`, `amount`). -// Everything else is locked (canUpdateFieldValue: false). +// Opportunity fields that must NOT be in the field-permission lock list. Three groups: +// - system/immutable columns: id, createdAt, updatedAt, deletedAt +// - server-managed columns written on a normal update (locking them breaks the update): +// `updatedBy` — injected into EVERY update by the *.updateOne pre-query hook, so +// locking it rejects all opportunity updates (stage + amount) with +// PERMISSION_DENIED. The server overwrites it with the real actor, so +// there is nothing to protect by locking it. +// `position` — co-written with `stage` when the stage is changed via kanban drag, +// so locking it breaks kanban stage-changes. +// - the intentionally-editable business fields: `stage`, `amount`. +// Everything else is locked (canUpdateFieldValue: false). See src/roles/partner.role.ts. const OPPORTUNITY_FIELD_LOCK_SKIP = new Set([ 'id', 'createdAt', 'updatedAt', 'deletedAt', + 'updatedBy', + 'position', 'stage', 'amount', ]); From 1fde0140946bb6214d5e9ad418f0588297f294f9 Mon Sep 17 00:00:00 2001 From: rashad Date: Wed, 10 Jun 2026 09:44:28 +0400 Subject: [PATCH 14/14] refactor(partners): address code review on partner RLS cascade + role Cascade (on-opportunity-partner-assigned): - Paginate the People stamp/clear fully instead of a 200-row cap, so a large company's contacts can't be left with a stale partnerUser RLS stamp (notably an ex-partner keeping visibility after unassignment). - Guard assign against clobbering a Company already owned by a different partner member (the single partnerUser column models one owner per company); stamp only the opportunity and return companyShared in that case. - Attempt every per-person update via Promise.allSettled (not fail-fast) and throw on any failure so the idempotent cascade retries; source the unassign revoke target from the event before-image so a retry still works after the opportunity's own partnerUser is cleared first. - Decide "company still in use" on partnerId (source of truth) via the before-image rather than the derived partnerUser stamp; fall back to the stamp if absent. Type-safety: - Drop all `as any` in the cascade function and its integration test; type the handler via DatabaseEventPayload> and let the generated client / CoreSchema types flow through. Maintainability: - Export *_FIELD_ID constants from the custom Opportunity field files and use them in partner.role.ts instead of hardcoded UUIDs. - Share a single PARTNER_ROLE_LABEL constant between partner.role.ts and configure-partner-rls.ts so a role-label rename can't desync the script. Also trims verbose comments across the touched files. --- .../opportunity-design-doc-status.field.ts | 4 +- .../opportunity-design-doc-url.field.ts | 4 +- .../fields/opportunity-hosting-type.field.ts | 4 +- .../fields/opportunity-lost-reason.field.ts | 4 +- .../opportunity-number-of-seats.field.ts | 4 +- ...pportunity-subscription-frequency.field.ts | 4 +- .../opportunity-subscription-type.field.ts | 4 +- .../src/fields/opportunity-tft-id.field.ts | 4 +- .../src/fields/opportunity-use-case.field.ts | 4 +- ...unity-partner-assigned.integration-test.ts | 263 ++++++------------ .../on-opportunity-partner-assigned.ts | 247 +++++++++------- .../twenty-partners/src/roles/partner.role.ts | 81 +++--- .../src/scripts/configure-partner-rls.ts | 100 ++----- .../src/views/all-partners.view.ts | 3 +- 14 files changed, 318 insertions(+), 412 deletions(-) diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-status.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-status.field.ts index b5ca6a39e5a07..11866bcabaa55 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-status.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-status.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID = 'cc6b8a59-f860-493f-8b9a-f138c078fbf1'; + export default defineField({ - universalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1', + universalIdentifier: OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.SELECT, name: 'designDocStatus', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-url.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-url.field.ts index 9d1869a427a16..e8897ad09d6d6 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-url.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-design-doc-url.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID = '37e5428c-6c8c-4616-b626-f0ea1caa443d'; + export default defineField({ - universalIdentifier: '37e5428c-6c8c-4616-b626-f0ea1caa443d', + universalIdentifier: OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.LINKS, name: 'designDocUrl', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-hosting-type.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-hosting-type.field.ts index 21e21547c9ab0..1b54a18b842b3 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-hosting-type.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-hosting-type.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_HOSTING_TYPE_FIELD_ID = '7ac7517f-bbca-4b4c-8996-6f864f71219b'; + export default defineField({ - universalIdentifier: '7ac7517f-bbca-4b4c-8996-6f864f71219b', + universalIdentifier: OPPORTUNITY_HOSTING_TYPE_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.SELECT, name: 'hostingType', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-lost-reason.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-lost-reason.field.ts index 570a3d107bc2b..15a9ed7832948 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-lost-reason.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-lost-reason.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_LOST_REASON_FIELD_ID = '834e233d-b171-409e-825f-77ac49b0f19d'; + export default defineField({ - universalIdentifier: '834e233d-b171-409e-825f-77ac49b0f19d', + universalIdentifier: OPPORTUNITY_LOST_REASON_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.TEXT, name: 'lostReason', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-number-of-seats.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-number-of-seats.field.ts index a087f1ee90386..8160cdb8a8625 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-number-of-seats.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-number-of-seats.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID = '90c683ec-2365-4533-a187-7b9ae162b753'; + export default defineField({ - universalIdentifier: '90c683ec-2365-4533-a187-7b9ae162b753', + universalIdentifier: OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.NUMBER, name: 'numberOfSeats', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-frequency.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-frequency.field.ts index accee28b0cbb2..a9741c2144d3d 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-frequency.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-frequency.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID = '59d5de53-202f-4913-a417-8a08970d87cc'; + export default defineField({ - universalIdentifier: '59d5de53-202f-4913-a417-8a08970d87cc', + universalIdentifier: OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.SELECT, name: 'subscriptionFrequency', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-type.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-type.field.ts index c11bd2f92092e..0a8f4afa5cc1f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-type.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-subscription-type.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID = 'a58214e9-38f9-4faf-8927-09b3980fd8c3'; + export default defineField({ - universalIdentifier: 'a58214e9-38f9-4faf-8927-09b3980fd8c3', + universalIdentifier: OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.SELECT, name: 'subscriptionType', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-tft-id.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-tft-id.field.ts index 1d1ff67ce1c6b..9622291076b4b 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-tft-id.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-tft-id.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_TFT_ID_FIELD_ID = '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5'; + export default defineField({ - universalIdentifier: '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5', + universalIdentifier: OPPORTUNITY_TFT_ID_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.TEXT, name: 'tftOpportunityId', diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-use-case.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-use-case.field.ts index a72a471e5b5f2..2d2336d9ceac6 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-use-case.field.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/opportunity-use-case.field.ts @@ -1,7 +1,9 @@ import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define'; +export const OPPORTUNITY_USE_CASE_FIELD_ID = '1bc57f52-a621-4243-ae3e-05c3f504b90c'; + export default defineField({ - universalIdentifier: '1bc57f52-a621-4243-ae3e-05c3f504b90c', + universalIdentifier: OPPORTUNITY_USE_CASE_FIELD_ID, objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, type: FieldType.TEXT, name: 'useCase', diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts index 5f816bdc52397..0598e7e26ce9f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-opportunity-partner-assigned.integration-test.ts @@ -1,25 +1,15 @@ import { CoreApiClient } from 'twenty-client-sdk/core'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -// Lazily import the handler under test so the "module not found" failure is -// clear when the implementation file doesn't exist yet (TDD red phase). -type HandlerFn = (payload: { properties: unknown }) => Promise; -let handler: HandlerFn | undefined; -try { - // Dynamic import so the test file can be parsed even when the impl is absent. - // defineLogicFunction returns ValidationResult which has - // a `config` property containing the original handler. - const mod = await import('../on-opportunity-partner-assigned'); - // mod.default is ValidationResult; config.handler is the fn. - handler = (mod.default as any)?.config?.handler as HandlerFn | undefined; -} catch { - // Implementation not yet written — tests will fail with a clear message. - handler = undefined; -} +import onOpportunityPartnerAssigned from '../on-opportunity-partner-assigned'; + +// defineLogicFunction wraps the handler in a ValidationResult; the fn is on config.handler. +const handler = onOpportunityPartnerAssigned.config.handler; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +function requireId(id: string | null | undefined, what: string): string { + if (!id) throw new Error(`${what} returned no id`); + return id; +} async function getWorkspaceMemberId(client: CoreApiClient): Promise { const r = await client.query({ @@ -27,10 +17,10 @@ async function getWorkspaceMemberId(client: CoreApiClient): Promise { __args: { first: 1 }, edges: { node: { id: true } }, }, - } as any); - const edges = ((r as any).workspaceMembers?.edges ?? []) as { node: { id: string } }[]; - if (edges.length === 0) throw new Error('No workspace members found — cannot run test'); - return edges[0].node.id; + }); + const memberId = r.workspaceMembers?.edges?.[0]?.node?.id; + if (!memberId) throw new Error('No workspace members found — cannot run test'); + return memberId; } async function createPartner(client: CoreApiClient, memberId: string): Promise { @@ -45,30 +35,23 @@ async function createPartner(client: CoreApiClient, memberId: string): Promise {}); + await client.mutation({ destroyPartner: { __args: { id }, id: true } }).catch(() => {}); } async function createCompany(client: CoreApiClient, name: string): Promise { const r = await client.mutation({ - createCompany: { - __args: { data: { name } }, - id: true, - }, - } as any); - return (r as any).createCompany.id as string; + createCompany: { __args: { data: { name } }, id: true }, + }); + return requireId(r.createCompany?.id, 'createCompany'); } async function destroyCompany(client: CoreApiClient, id: string) { - await client - .mutation({ destroyCompany: { __args: { id }, id: true } } as any) - .catch(() => {}); + await client.mutation({ destroyCompany: { __args: { id }, id: true } }).catch(() => {}); } async function createPerson(client: CoreApiClient, companyId: string): Promise { @@ -77,14 +60,12 @@ async function createPerson(client: CoreApiClient, companyId: string): Promise {}); + await client.mutation({ destroyPerson: { __args: { id }, id: true } }).catch(() => {}); } async function createOpportunity( @@ -93,80 +74,60 @@ async function createOpportunity( companyId: string, ): Promise { const r = await client.mutation({ - createOpportunity: { - __args: { data: { name, companyId } }, - id: true, - }, - } as any); - return (r as any).createOpportunity.id as string; + createOpportunity: { __args: { data: { name, companyId } }, id: true }, + }); + return requireId(r.createOpportunity?.id, 'createOpportunity'); } async function destroyOpportunity(client: CoreApiClient, id: string) { - await client - .mutation({ destroyOpportunity: { __args: { id }, id: true } } as any) - .catch(() => {}); + await client.mutation({ destroyOpportunity: { __args: { id }, id: true } }).catch(() => {}); } -async function getOpportunity( +async function getOpportunityPartnerUserId( client: CoreApiClient, id: string, -): Promise<{ id: string; partnerUserId: string | null }> { +): Promise { const r = await client.query({ opportunity: { __args: { filter: { id: { eq: id } } }, id: true, partnerUserId: true, }, - } as any); - return (r as any).opportunity as { id: string; partnerUserId: string | null }; + }); + return r.opportunity?.partnerUserId ?? null; } -async function getCompany( +async function getCompanyPartnerUserId( client: CoreApiClient, id: string, -): Promise<{ id: string; partnerUserId: string | null }> { +): Promise { const r = await client.query({ company: { __args: { filter: { id: { eq: id } } }, id: true, partnerUserId: true, }, - } as any); - return (r as any).company as { id: string; partnerUserId: string | null }; + }); + return r.company?.partnerUserId ?? null; } -async function getPerson( +async function getPersonPartnerUserId( client: CoreApiClient, id: string, -): Promise<{ id: string; partnerUserId: string | null }> { +): Promise { const r = await client.query({ person: { __args: { filter: { id: { eq: id } } }, id: true, partnerUserId: true, }, - } as any); - return (r as any).person as { id: string; partnerUserId: string | null }; + }); + return r.person?.partnerUserId ?? null; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Test strategy -// --------------------------------------------------------------------------- -// These tests invoke the handler directly (in-process) to verify its cascade -// logic deterministically against a live workspace. Direct invocation lets us -// assert exact return values and DB state without depending on the event bus. -// -// The end-to-end trigger wiring (opportunity.updated → function fires) is -// verified separately: the app sync step registers the function with the -// correct databaseEventTriggerSettings, and Task 5's manual E2E confirms the -// cascade fires when a partner is assigned via the UI. That deliberate split -// keeps these unit-style integration tests fast and reproducible. -// --------------------------------------------------------------------------- - +// Invoke the handler directly against a live workspace to assert its cascade logic and +// resulting DB state deterministically, without depending on the event bus. The trigger +// wiring (opportunity.updated → fires) is covered separately by app sync + manual E2E. describe('on-opportunity-partner-assigned', () => { let client: CoreApiClient; @@ -195,26 +156,15 @@ describe('on-opportunity-partner-assigned', () => { createdPartnerIds.length = 0; }); - // ------------------------------------------------------------------------- - // No-op guard: when partnerId is not in updatedFields, handler returns {} - // ------------------------------------------------------------------------- it('returns {} without any mutations when partnerId is not in updatedFields', async () => { - if (!handler) throw new Error('Handler not found — implementation file missing'); - const result = await handler({ properties: { updatedFields: ['name'], after: { id: 'fake-id' } }, - } as never); + }); expect(result).toEqual({}); }); - // ------------------------------------------------------------------------- - // Unassign: partnerId cleared → the opportunity's partnerUser is cleared so it - // leaves the partner's row-level view. - // ------------------------------------------------------------------------- it('clears partnerUser on the opportunity, company, and people when the partner is removed (unassignment)', async () => { - if (!handler) throw new Error('Handler not found — implementation file missing'); - const memberId = await getWorkspaceMemberId(client); const partnerId = await createPartner(client, memberId); createdPartnerIds.push(partnerId); @@ -228,122 +178,76 @@ describe('on-opportunity-partner-assigned', () => { // Assign first so the opportunity + company + person have partnerUser stamped. await handler({ properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId, companyId } }, - } as never); - expect((await getOpportunity(client, oppId)).partnerUserId).toBe(memberId); - expect((await getCompany(client, companyId)).partnerUserId).toBe(memberId); - expect((await getPerson(client, personId)).partnerUserId).toBe(memberId); + }); + expect(await getOpportunityPartnerUserId(client, oppId)).toBe(memberId); + expect(await getCompanyPartnerUserId(client, companyId)).toBe(memberId); + expect(await getPersonPartnerUserId(client, personId)).toBe(memberId); // Now unassign: partnerId set to null. No other opportunity uses this company for the - // member, so the company + person should be cleared too. + // member, so the company + person should be cleared too. `before` carries the removed + // partnerId so the handler can decide on the source of truth. const result = await handler({ - properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId: null, companyId } }, - } as never); - - expect((result as any).cascaded).toBe(true); - expect((result as any).cleared).toBe(true); - expect((await getOpportunity(client, oppId)).partnerUserId).toBeNull(); - expect((await getCompany(client, companyId)).partnerUserId).toBeNull(); - expect((await getPerson(client, personId)).partnerUserId).toBeNull(); + properties: { + updatedFields: ['partnerId'], + before: { id: oppId, partnerId, partnerUserId: memberId, companyId }, + after: { id: oppId, partnerId: null, companyId }, + }, + }); + + expect(result.cascaded).toBe(true); + expect(result.cleared).toBe(true); + expect(await getOpportunityPartnerUserId(client, oppId)).toBeNull(); + expect(await getCompanyPartnerUserId(client, companyId)).toBeNull(); + expect(await getPersonPartnerUserId(client, personId)).toBeNull(); }); - // ------------------------------------------------------------------------- - // Happy path: cascades partnerUser to opportunity, company, and people - // ------------------------------------------------------------------------- it('stamps partnerUserId on opportunity, company, and people when partner is assigned', async () => { - if (!handler) throw new Error('Handler not found — implementation file missing'); - - // 1. Resolve an existing workspace member to use as the partner's user. const memberId = await getWorkspaceMemberId(client); - - // 2. Create a Partner with partnerUserId pointing to that member. const partnerId = await createPartner(client, memberId); createdPartnerIds.push(partnerId); - - // 3. Create a Company (partnerUser unset). const companyId = await createCompany(client, `[test-rls] company ${Date.now()}`); createdCompanyIds.push(companyId); - - // 4. Create a Person on that Company (partnerUser unset). const personId = await createPerson(client, companyId); createdPersonIds.push(personId); - - // 5. Create an Opportunity linked to the Company, with no partner yet. - const oppId = await createOpportunity( - client, - `[test-rls] opp ${Date.now()}`, - companyId, - ); + const oppId = await createOpportunity(client, `[test-rls] opp ${Date.now()}`, companyId); createdOpportunityIds.push(oppId); - // 6. Invoke handler directly, simulating opportunity.updated with partnerId change. const result = await handler({ - properties: { - updatedFields: ['partnerId'], - after: { id: oppId, partnerId, companyId }, - }, - } as never); - - // 7. Assert handler reports cascaded: true and the correct memberId. - expect((result as any).cascaded).toBe(true); - expect((result as any).partnerUserId).toBe(memberId); - - // 8. Re-query all three records and verify partnerUserId was stamped. - const opp = await getOpportunity(client, oppId); - expect(opp.partnerUserId).toBe(memberId); - - const company = await getCompany(client, companyId); - expect(company.partnerUserId).toBe(memberId); + properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId, companyId } }, + }); - const person = await getPerson(client, personId); - expect(person.partnerUserId).toBe(memberId); + expect(result.cascaded).toBe(true); + expect(result.partnerUserId).toBe(memberId); + expect(await getOpportunityPartnerUserId(client, oppId)).toBe(memberId); + expect(await getCompanyPartnerUserId(client, companyId)).toBe(memberId); + expect(await getPersonPartnerUserId(client, personId)).toBe(memberId); }); - // ------------------------------------------------------------------------- - // No linked company — only opportunity is stamped; no company/people cascade - // ------------------------------------------------------------------------- it('stamps partnerUserId on opportunity and returns cascaded: true when no companyId is present', async () => { - if (!handler) throw new Error('Handler not found — implementation file missing'); - - // 1. Resolve an existing workspace member to use as the partner's user. const memberId = await getWorkspaceMemberId(client); - - // 2. Create a Partner with partnerUserId. const partnerId = await createPartner(client, memberId); createdPartnerIds.push(partnerId); - // 3. Create an Opportunity with NO linked company (companyId omitted from payload). + // Opportunity with no linked company, so there is no company/people cascade. const r = await client.mutation({ createOpportunity: { __args: { data: { name: `[test-rls] no-company opp ${Date.now()}` } }, id: true, }, - } as any); - const oppId = (r as any).createOpportunity.id as string; + }); + const oppId = requireId(r.createOpportunity?.id, 'createOpportunity'); createdOpportunityIds.push(oppId); - // 4. Invoke handler with no companyId in the after payload. const result = await handler({ - properties: { - updatedFields: ['partnerId'], - after: { id: oppId, partnerId }, - }, - } as never); + properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId } }, + }); - // 5. Handler should still report cascaded: true with the correct memberId. - expect((result as any).cascaded).toBe(true); - expect((result as any).partnerUserId).toBe(memberId); - - // 6. Opportunity should have partnerUserId stamped. - const opp = await getOpportunity(client, oppId); - expect(opp.partnerUserId).toBe(memberId); + expect(result.cascaded).toBe(true); + expect(result.partnerUserId).toBe(memberId); + expect(await getOpportunityPartnerUserId(client, oppId)).toBe(memberId); }); - // ------------------------------------------------------------------------- - // Partner has no partnerUserId — cascade is skipped gracefully - // ------------------------------------------------------------------------- it('returns { cascaded: false, reason: "partner_has_no_user" } when partner.partnerUserId is null', async () => { - if (!handler) throw new Error('Handler not found — implementation file missing'); - // Create a partner WITHOUT a partnerUserId. const r = await client.mutation({ createPartner: { @@ -355,18 +259,15 @@ describe('on-opportunity-partner-assigned', () => { }, id: true, }, - } as any); - const noUserPartnerId = (r as any).createPartner.id as string; + }); + const noUserPartnerId = requireId(r.createPartner?.id, 'createPartner'); createdPartnerIds.push(noUserPartnerId); const result = await handler({ - properties: { - updatedFields: ['partnerId'], - after: { id: 'fake-opp-id', partnerId: noUserPartnerId }, - }, - } as never); + properties: { updatedFields: ['partnerId'], after: { id: 'fake-opp-id', partnerId: noUserPartnerId } }, + }); - expect((result as any).cascaded).toBe(false); - expect((result as any).reason).toBe('partner_has_no_user'); + expect(result.cascaded).toBe(false); + expect(result.reason).toBe('partner_has_no_user'); }); }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts index b4bfda25049c4..e6481ec78f12f 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-partner-assigned.ts @@ -1,155 +1,202 @@ -import { DatabaseEventPayload, defineLogicFunction } from 'twenty-sdk/define'; -import { CoreApiClient } from 'twenty-client-sdk/core'; +import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core'; +import { + type DatabaseEventPayload, + defineLogicFunction, + type ObjectRecordUpdateEvent, +} from 'twenty-sdk/define'; -// Inline universalIdentifier — defined here (not in universal-identifiers.ts) to avoid -// touching that file's local-only APPLICATION_UNIVERSAL_IDENTIFIER rewrite in the bundle. +// Defined here (not in universal-identifiers.ts) to avoid touching that file's local-only id. const ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER = 'd7e4a4e6-9142-4597-adcf-6fb83c0f042d'; -// Fires on opportunity.updated when partnerId changes. -// - Assign / reassign (partnerId set): resolve the assigned Partner's partnerUser -// (workspace member) and stamp it onto the Opportunity + linked Company + People, so the -// whole deal becomes visible to the partner under row-level permissions. -// - Unassign (partnerId cleared): clear the Opportunity's partnerUser so it leaves the -// partner's row-level view, and cascade the clear to the linked Company + People — but -// only when that member has no OTHER opportunity still using the same Company (otherwise -// the Company is still part of an active deal and must stay visible). -const handler = async (payload: DatabaseEventPayload) => { - const props = payload.properties as { - after?: { id: string; partnerId?: string | null; companyId?: string | null }; - updatedFields?: string[]; - }; - - if (!props.updatedFields?.includes('partnerId')) return {}; - const opportunityId = props.after?.id; +const PEOPLE_PAGE_SIZE = 200; + +// Every Person id matching the filter, paginated fully — a company can have more than one +// page of contacts and a single capped page would leave RLS stamps stale on the rest. +async function collectPeopleIds( + client: CoreApiClient, + filter: CoreSchema.PersonFilterInput, +): Promise { + const ids: string[] = []; + let after: string | undefined; + + for (;;) { + const page = await client.query({ + people: { + __args: { filter, first: PEOPLE_PAGE_SIZE, ...(after ? { after } : {}) }, + edges: { node: { id: true } }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + for (const edge of page.people?.edges ?? []) { + if (edge?.node?.id) ids.push(edge.node.id); + } + if (!page.people?.pageInfo?.hasNextPage) break; + after = page.people.pageInfo.endCursor ?? undefined; + } + + return ids; +} + +// Set (or clear, with null) partnerUser on each Person, attempting all of them even if some +// fail. Returns the failure count so the caller can force a retry rather than silently +// leaving a partial stamp. +async function setPeoplePartnerUser( + client: CoreApiClient, + personIds: string[], + partnerUserId: string | null, +): Promise { + const results = await Promise.allSettled( + personIds.map((id) => + client.mutation({ + updatePerson: { __args: { id, data: { partnerUserId } }, id: true }, + }), + ), + ); + return results.filter((result) => result.status === 'rejected').length; +} + +// On opportunity.updated when partnerId changes, propagate partnerUser (the assigned +// Partner's workspace member) across the deal so RLS visibility tracks the assignment: +// assign stamps it onto the Opportunity + linked Company + People; unassign clears it, +// keeping the Company/People if the same Partner still has another opportunity on that +// Company. Runs under the app identity, so its writes bypass partner RLS / field locks. +const handler = async ( + payload: DatabaseEventPayload>, +): Promise> => { + const { after, before, updatedFields } = payload.properties; + + if (!updatedFields?.includes('partnerId')) return {}; + const opportunityId = after?.id; if (!opportunityId) return {}; const client = new CoreApiClient(); - const partnerId = props.after?.partnerId; + const partnerId = after?.partnerId; - // Unassign: the partner was removed from the opportunity. + // ── Unassign: the partner was removed from the opportunity ─────────────────── if (!partnerId) { - // The opportunity still carries the previously-stamped member (only partnerId changed), - // so read it to know whose visibility to revoke and which company is linked. - const oppResult = await client.query({ - opportunity: { - __args: { filter: { id: { eq: opportunityId } } }, - id: true, - partnerUserId: true, - companyId: true, - }, - } as any); - const removedMemberId = (oppResult as any).opportunity?.partnerUserId as - | string - | null - | undefined; - const companyId = (oppResult as any).opportunity?.companyId as string | null | undefined; - - // Clear the opportunity itself. + // Resolve who/what to revoke from the event's before-image so a retry (after a partial + // failure below) still has these values even though the opportunity's own partnerUser is + // cleared first. The removed partnerId is the source of truth for "is the company still + // in use". Fall back to reading the not-yet-cleared opportunity if before is incomplete. + let removedMemberId = before?.partnerUserId ?? null; + let companyId = before?.companyId ?? null; + const removedPartnerId = before?.partnerId; + + if (!removedMemberId || !companyId) { + const oppResult = await client.query({ + opportunity: { + __args: { filter: { id: { eq: opportunityId } } }, + id: true, + partnerUserId: true, + companyId: true, + }, + }); + removedMemberId = removedMemberId ?? oppResult.opportunity?.partnerUserId ?? null; + companyId = companyId ?? oppResult.opportunity?.companyId ?? null; + } + await client.mutation({ updateOpportunity: { __args: { id: opportunityId, data: { partnerUserId: null } }, id: true, }, - } as any); + }); if (!removedMemberId || !companyId) { return { cascaded: true, cleared: true }; } - // Keep the Company (and its People) if the same member still has another opportunity on - // it — the opportunity we just cleared no longer matches this filter. - const otherOpps = await client.query({ + // Keep the company (and its people) if the same Partner still has another opportunity on + // it. Decided on partnerId, not the derived partnerUser stamp (which a prior partial + // cascade may have left unset); fall back to the stamp only if the old partnerId is + // unavailable. The just-cleared opportunity no longer matches either filter. + const stillInUse = await client.query({ opportunities: { __args: { filter: { companyId: { eq: companyId }, - partnerUserId: { eq: removedMemberId }, + ...(removedPartnerId + ? { partnerId: { eq: removedPartnerId } } + : { partnerUserId: { eq: removedMemberId } }), }, first: 1, }, edges: { node: { id: true } }, }, - } as any); - const companyStillInUse = - (((otherOpps as any).opportunities?.edges ?? []) as unknown[]).length > 0; - if (companyStillInUse) { + }); + if ((stillInUse.opportunities?.edges?.length ?? 0) > 0) { return { cascaded: true, cleared: true, companyKept: true }; } - // Clear the Company (only if it belongs to this member) and the People stamped for them. + // Clear the company (only if it belongs to this member) and every person stamped for them. const companyResult = await client.query({ company: { __args: { filter: { id: { eq: companyId } } }, id: true, partnerUserId: true, }, - } as any); - if ((companyResult as any).company?.partnerUserId === removedMemberId) { + }); + if (companyResult.company?.partnerUserId === removedMemberId) { await client.mutation({ updateCompany: { __args: { id: companyId, data: { partnerUserId: null } }, id: true, }, - } as any); + }); + } + + const peopleIds = await collectPeopleIds(client, { + companyId: { eq: companyId }, + partnerUserId: { eq: removedMemberId }, + }); + const failed = await setPeoplePartnerUser(client, peopleIds, null); + if (failed > 0) { + throw new Error( + `on-opportunity-partner-assigned: ${failed} person clear(s) failed — retrying`, + ); } - const peopleResult = await client.query({ - people: { - __args: { - filter: { - companyId: { eq: companyId }, - partnerUserId: { eq: removedMemberId }, - }, - first: 200, - }, - edges: { node: { id: true } }, - }, - } as any); - const peopleToClear = ((peopleResult as any).people?.edges ?? []) as { - node: { id: string }; - }[]; - await Promise.all( - peopleToClear.map(({ node }) => - client.mutation({ - updatePerson: { - __args: { id: node.id, data: { partnerUserId: null } }, - id: true, - }, - } as any), - ), - ); return { cascaded: true, cleared: true, companyCleared: true }; } + // ── Assign / reassign ──────────────────────────────────────────────────────── const partnerResult = await client.query({ partner: { __args: { filter: { id: { eq: partnerId } } }, id: true, partnerUserId: true }, - } as any); - const partnerUserId = (partnerResult as any).partner?.partnerUserId as string | null | undefined; + }); + const partnerUserId = partnerResult.partner?.partnerUserId; if (!partnerUserId) return { cascaded: false, reason: 'partner_has_no_user' }; await client.mutation({ updateOpportunity: { __args: { id: opportunityId, data: { partnerUserId } }, id: true }, - } as any); + }); - const companyId = props.after?.companyId; - if (companyId) { - await client.mutation({ - updateCompany: { __args: { id: companyId, data: { partnerUserId } }, id: true }, - } as any); - const peopleResult = await client.query({ - people: { - __args: { filter: { companyId: { eq: companyId } }, first: 200 }, - edges: { node: { id: true } }, - }, - } as any); - // A company's contacts are bounded in practice; cap at 200 and update in parallel - // (distinct records, safe to run concurrently) to stay within the function timeout. - const people = ((peopleResult as any).people?.edges ?? []) as { node: { id: string } }[]; - await Promise.all( - people.map(({ node }) => - client.mutation({ - updatePerson: { __args: { id: node.id, data: { partnerUserId } }, id: true }, - } as any), - ), + const companyId = after?.companyId; + if (!companyId) return { cascaded: true, partnerUserId }; + + // Don't clobber a company already owned by a DIFFERENT partner member. The single + // partnerUser column on Company/Person models one owner per company, so reassigning it + // here would steal the company (and its contacts) from the other partner and expose their + // data. Stamp only the opportunity in that case and leave the company/people alone. + const companyResult = await client.query({ + company: { + __args: { filter: { id: { eq: companyId } } }, + id: true, + partnerUserId: true, + }, + }); + const companyOwner = companyResult.company?.partnerUserId; + if (companyOwner && companyOwner !== partnerUserId) { + return { cascaded: true, partnerUserId, companyShared: true }; + } + + await client.mutation({ + updateCompany: { __args: { id: companyId, data: { partnerUserId } }, id: true }, + }); + + const peopleIds = await collectPeopleIds(client, { companyId: { eq: companyId } }); + const failed = await setPeoplePartnerUser(client, peopleIds, partnerUserId); + if (failed > 0) { + throw new Error( + `on-opportunity-partner-assigned: ${failed} person stamp(s) failed — retrying`, ); } diff --git a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts index 90a90d1098f02..96a14267aa034 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/roles/partner.role.ts @@ -6,37 +6,34 @@ import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_ROLE_UNIVERSAL_IDENTIFIER, } from 'src/constants/universal-identifiers'; +import { OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID } from 'src/fields/opportunity-design-doc-status.field'; +import { OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID } from 'src/fields/opportunity-design-doc-url.field'; +import { OPPORTUNITY_HOSTING_TYPE_FIELD_ID } from 'src/fields/opportunity-hosting-type.field'; +import { OPPORTUNITY_LOST_REASON_FIELD_ID } from 'src/fields/opportunity-lost-reason.field'; +import { OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID } from 'src/fields/opportunity-number-of-seats.field'; +import { OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID } from 'src/fields/opportunity-subscription-frequency.field'; +import { OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID } from 'src/fields/opportunity-subscription-type.field'; +import { OPPORTUNITY_TFT_ID_FIELD_ID } from 'src/fields/opportunity-tft-id.field'; +import { OPPORTUNITY_USE_CASE_FIELD_ID } from 'src/fields/opportunity-use-case.field'; import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field'; import { PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-user-on-opportunity.field'; -// External partner self-service role. Scoped to the records owned by the assigned -// workspace member via row-level predicates configured out-of-band (the app manifest -// cannot ship RLS predicates). Run `yarn rls:configure` after install/reinstall. -// -// Partner edit scope: -// - Partner (own profile): full update. -// - Opportunity: read-all, but UPDATE only `stage` and `amount` (every other -// user-facing field is locked via canUpdateFieldValue: false below). -// - Company / Person: READ-ONLY (no object-level update — see objectPermissions). -// Field permissions are deployed via manifest sync (yarn twenty dev --once) — the -// upsertFieldPermissions metadata mutation enforces an application-ownership check that -// prevents setting them post-deploy via workspace API key. `yarn rls:configure` verifies them. +// Shared with configure-partner-rls.ts, which locates the role by this label. +export const PARTNER_ROLE_LABEL = 'Partner'; + +// External partner self-service role: a partner sees only its own records, can edit its +// own Partner profile and an Opportunity's stage + amount; Company/Person are read-only. +// Row-level predicates can't ship in the manifest, so run `yarn rls:configure` after install. // -// Do NOT lock server-managed fields that get written on a normal update: -// - `updatedBy` (ACTOR) is injected into EVERY update by the `*.updateOne` pre-query hook -// (ActorFromAuthContextService.injectUpdatedBy). Locking it makes the permission check -// reject every opportunity update — stage and amount included — with PERMISSION_DENIED. -// The server overwrites it with the real actor regardless, so locking it is both -// pointless (no spoofing possible) and breaking. Left editable. -// - `position` (POSITION) is co-written with `stage` when the stage is changed by dragging -// a card on the kanban board. Locking it would make kanban stage-changes fail. Left -// editable (a partner reordering their own view is cosmetic only). -// `createdBy` stays locked: it is NOT injected on update, so locking it blocks a client from -// rewriting authorship without breaking normal edits. `searchVector` is a STORED generated -// column (never written by the app), so its lock is an inert no-op kept for completeness. +// `updatedBy` and `position` must stay editable even though they're not partner-facing: the +// server injects `updatedBy` into every update (ActorFromAuthContextService) and co-writes +// `position` with `stage` on a kanban drag, so locking either makes ALL opportunity updates +// fail with PERMISSION_DENIED. The server overwrites both regardless, so there's nothing to +// protect. `createdBy` stays locked (not injected on update); `searchVector` is a generated +// column, so its lock is an inert no-op. export default defineRole({ universalIdentifier: PARTNER_ROLE_UNIVERSAL_IDENTIFIER, - label: 'Partner', + label: PARTNER_ROLE_LABEL, description: 'External partner self-service role. Sees only its own Partner/Person/Company/Opportunity records (row-level). Can edit its own Partner profile and an Opportunity’s stage + amount; Company and Person are read-only. Configure predicates with `yarn rls:configure` after install.', icon: 'IconBuildingStore', @@ -46,11 +43,8 @@ export default defineRole({ canUpdateAllObjectRecords: false, canSoftDeleteAllObjectRecords: false, canDestroyAllObjectRecords: false, - // Opportunity field permissions: read-all / update `stage` + `amount` only. - // Omitted from this lock list (i.e. editable): system fields (id, createdAt, updatedAt, - // deletedAt), the two server-managed/co-written fields (`updatedBy`, `position` — see the - // header comment), plus the two intentionally-editable fields (`stage`, `amount`). - // Everything else is canUpdateFieldValue: false. + // Lock every Opportunity field except stage/amount (editable) and the system/ + // server-managed fields left out here (id, timestamps, updatedBy, position — see header). fieldPermissions: [ { objectUniversalIdentifier: @@ -168,55 +162,55 @@ export default defineRole({ { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: '1bc57f52-a621-4243-ae3e-05c3f504b90c', // useCase + fieldUniversalIdentifier: OPPORTUNITY_USE_CASE_FIELD_ID, canUpdateFieldValue: false, }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5', // tftOpportunityId + fieldUniversalIdentifier: OPPORTUNITY_TFT_ID_FIELD_ID, canUpdateFieldValue: false, }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: '37e5428c-6c8c-4616-b626-f0ea1caa443d', // designDocUrl + fieldUniversalIdentifier: OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID, canUpdateFieldValue: false, }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: '59d5de53-202f-4913-a417-8a08970d87cc', // subscriptionFrequency + fieldUniversalIdentifier: OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID, canUpdateFieldValue: false, }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: '7ac7517f-bbca-4b4c-8996-6f864f71219b', // hostingType + fieldUniversalIdentifier: OPPORTUNITY_HOSTING_TYPE_FIELD_ID, canUpdateFieldValue: false, }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: '834e233d-b171-409e-825f-77ac49b0f19d', // lostReason + fieldUniversalIdentifier: OPPORTUNITY_LOST_REASON_FIELD_ID, canUpdateFieldValue: false, }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: '90c683ec-2365-4533-a187-7b9ae162b753', // numberOfSeats + fieldUniversalIdentifier: OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID, canUpdateFieldValue: false, }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: 'a58214e9-38f9-4faf-8927-09b3980fd8c3', // subscriptionType + fieldUniversalIdentifier: OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID, canUpdateFieldValue: false, }, { objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier, - fieldUniversalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1', // designDocStatus + fieldUniversalIdentifier: OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID, canUpdateFieldValue: false, }, ], @@ -237,7 +231,6 @@ export default defineRole({ canDestroyObjectRecords: false, }, { - // Person: read-only for partners (they see their deal's contacts but can't edit them). objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, canReadObjectRecords: true, @@ -246,7 +239,6 @@ export default defineRole({ canDestroyObjectRecords: false, }, { - // Company: read-only for partners (they see their deal's company but can't edit it). objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier, canReadObjectRecords: true, @@ -255,10 +247,9 @@ export default defineRole({ canDestroyObjectRecords: false, }, { - // Read-only on workspace members so the partner UI can resolve member-typed - // relations (their own partnerUser link; owner/createdBy on their records). - // Scoped to the partner's OWN member record by an RLS predicate - // (see scripts/configure-partner-rls.ts) so the internal team roster stays hidden. + // Read-only so the UI can resolve member-typed relations (own partnerUser link, + // owner/createdBy). An RLS predicate scopes this to the partner's own member record + // (see scripts/configure-partner-rls.ts) so the internal roster stays hidden. objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, canReadObjectRecords: true, diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts index d32547638a29e..b2a472751b734 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/configure-partner-rls.ts @@ -1,28 +1,15 @@ -// Does two things on each run: +// Idempotent. Re-run after every install/reinstall — the app manifest can't ship RLS +// predicates. Does two things: // -// 1. UPSERTS the row-level-permission predicates on the Partner role: one per target -// object (partner, person, company, opportunity) = "partnerUser IS the current -// workspace member", PLUS one on workspaceMember = "id IS the current member" so the -// role's read on workspaceMember is scoped to the partner's own record (member-typed -// relations resolve for themselves; the internal team roster stays hidden). These are -// configured out-of-band because the app manifest cannot ship RLS predicates. Re-run -// after every install or reinstall. The mutation is fully idempotent (upsert semantics). +// 1. Upserts row-level-permission predicates on the Partner role: "partnerUser IS the +// current member" on each of partner/person/company/opportunity, plus "id IS the +// current member" on workspaceMember so the partner sees only its own member record +// (member-typed relations still resolve; the internal roster stays hidden). // -// Field choice: the metadata API exposes the relation field `partnerUser` -// (type RELATION) rather than a separate `partnerUserId` join-column field. The -// upsertRowLevelPermissionPredicates mutation accepts the RELATION field id directly. -// Operand: IS (value null + workspaceMemberFieldMetadataId injects the current member). -// NOT CONTAINS — the query engine's RELATION filter only accepts IS / IS_NOT and throws -// "Unknown operand CONTAINS for RELATION filter" at enforcement time, even though the -// upsert mutation silently accepts CONTAINS. -// -// 2. VERIFIES the Opportunity field permissions declared in `partner.role.ts` and -// deployed via `yarn twenty dev --once`. The script does NOT set these permissions: -// upsertFieldPermissions enforces an application-ownership check that rejects -// out-of-band changes to app-owned roles (ROLE_BELONGS_TO_ANOTHER_APPLICATION). -// The manifest is the correct and only supported mechanism for app-owned roles. -// If any expected field locks are missing, the script exits non-zero and instructs -// the operator to run `yarn twenty dev --once` to deploy the manifest. +// 2. Verifies (does NOT set) the Opportunity field permissions from `partner.role.ts`. +// upsertFieldPermissions rejects out-of-band changes to app-owned roles +// (ROLE_BELONGS_TO_ANOTHER_APPLICATION), so those must come from the manifest; if any +// expected lock is missing, the script exits non-zero and tells you to re-sync. // // Usage: // yarn rls:configure # against .env.local @@ -32,6 +19,7 @@ import { config } from 'dotenv'; config({ path: process.env.ENV_FILE ?? '.env.local' }); import { PARTNER_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { PARTNER_ROLE_LABEL } from 'src/roles/partner.role'; const requireEnv = (name: string): string => { const value = process.env[name]; @@ -39,21 +27,12 @@ const requireEnv = (name: string): string => { return value; }; -// The four object names we need to configure RLS on. const TARGET_OBJECTS = ['partner', 'person', 'company', 'opportunity'] as const; type TargetObject = (typeof TARGET_OBJECTS)[number]; -// Opportunity fields that must NOT be in the field-permission lock list. Three groups: -// - system/immutable columns: id, createdAt, updatedAt, deletedAt -// - server-managed columns written on a normal update (locking them breaks the update): -// `updatedBy` — injected into EVERY update by the *.updateOne pre-query hook, so -// locking it rejects all opportunity updates (stage + amount) with -// PERMISSION_DENIED. The server overwrites it with the real actor, so -// there is nothing to protect by locking it. -// `position` — co-written with `stage` when the stage is changed via kanban drag, -// so locking it breaks kanban stage-changes. -// - the intentionally-editable business fields: `stage`, `amount`. -// Everything else is locked (canUpdateFieldValue: false). See src/roles/partner.role.ts. +// Opportunity fields that must NOT be locked: system columns, the editable business +// fields (stage, amount), and updatedBy/position (server-managed — locking them breaks +// every update; see src/roles/partner.role.ts). Everything else is expected to be locked. const OPPORTUNITY_FIELD_LOCK_SKIP = new Set([ 'id', 'createdAt', @@ -245,7 +224,6 @@ async function main() { // ── 1. Resolve all object metadata IDs and their partnerUser field IDs ────── - // First get all objects in one request to map nameSingular → id. const objectsData = await metadataFetch<{ objects: { edges: { node: { id: string; nameSingular: string } }[] }; }>( @@ -267,14 +245,12 @@ async function main() { } } - // Also need workspaceMember. if (!objectIdByName.has('workspaceMember')) { throw new Error('workspaceMember object not found in workspace metadata.'); } const workspaceMemberId = objectIdByName.get('workspaceMember') as string; - // Resolve partnerUser field id for each target object (with pagination). const objectInfoByName = new Map(); for (const name of TARGET_OBJECTS) { @@ -305,9 +281,8 @@ async function main() { // ── 3. Resolve Partner role id and fetch field permissions in one request ────── // // getRoles returns a flat array (not a connection) and does NOT expose - // universalIdentifier, so we match on the role label. This is the only available - // discriminator: if the Partner role's label is renamed in partner.role.ts, - // update the literal below to match. + // universalIdentifier, so we match on the role label via the shared PARTNER_ROLE_LABEL + // constant (exported from partner.role.ts) — a rename there can't desync this script. // Fetching fieldPermissions here avoids a second getRoles call later in step 5. const rolesData = await metadataFetch<{ getRoles: { @@ -322,11 +297,7 @@ async function main() { ); const roles = rolesData.getRoles; - - // Match by label "Partner" (the label we set in the manifest). - // Note: universalIdentifier is PARTNER_ROLE_UNIVERSAL_IDENTIFIER but is not - // returned by getRoles. Label is the safest available discriminator. - const partnerRole = roles.find((r) => r.label === 'Partner'); + const partnerRole = roles.find((r) => r.label === PARTNER_ROLE_LABEL); if (!partnerRole) { const labels = roles.map((r) => r.label).join(', '); @@ -343,13 +314,10 @@ async function main() { // ── 4. Upsert one predicate per object ─────────────────────────────────────── // - // Predicate semantics: "the record's partnerUser relation IS the current workspace - // member" — i.e. the partnerUser FK equals the session user. - // Operand IS (not CONTAINS): the query engine's RELATION filter only accepts IS / IS_NOT. - // The upsert mutation accepts CONTAINS, but enforcement throws "Unknown operand CONTAINS - // for RELATION filter" at query time. value stays null; workspaceMemberFieldMetadataId - // injects the current member at query time. Mirrors the Roles-UI conversion for a - // relation current-member RLS predicate (operand IS, value null, workspaceMemberFieldMetadataId). + // "the record's partnerUser relation IS the current workspace member". Operand must be IS, + // not CONTAINS: the upsert accepts CONTAINS but the RELATION query filter only allows + // IS / IS_NOT and throws "Unknown operand CONTAINS for RELATION filter" at query time. + // value stays null; workspaceMemberFieldMetadataId injects the current member at query time. const MUTATION = ` mutation UpsertRLSPredicates($input: UpsertRowLevelPermissionPredicatesInput!) { @@ -383,8 +351,6 @@ async function main() { { fieldMetadataId: info.partnerUserFieldMetadataId, operand: 'IS', - // workspaceMemberFieldMetadataId scopes the predicate to the - // current session's workspace member (the "id" field). workspaceMemberFieldMetadataId: workspaceMemberIdFieldId, }, ], @@ -408,12 +374,8 @@ async function main() { ); } - // workspaceMember predicate: scope the Partner role to its OWN member record only. - // The role has read on workspaceMember so the partner UI can resolve member-typed - // relations (their partnerUser link; owner/createdBy on their opportunities). Without - // this scope, that read would expose the whole internal team roster. Semantics: - // "this workspaceMember's id IS the current session member" → a partner sees only - // themselves; other members (e.g. an opportunity's internal owner) resolve to null. + // workspaceMember predicate: "id IS the current member", scoping the role's read to the + // partner's own record. Other members (e.g. an opportunity's internal owner) resolve to null. { const wmData = await metadataFetch<{ upsertRowLevelPermissionPredicates: { predicates: PredicateResult[] }; @@ -453,20 +415,8 @@ async function main() { `(${TARGET_OBJECTS.length} objects + workspaceMember self-scope)`, ); - // ── 5. Verify Opportunity field permissions: read-all / update stage + amount only ─ - // - // Opportunity field permissions for the Partner role are declared in - // src/roles/partner.role.ts and deployed by `yarn twenty dev --once` (manifest - // sync). They cannot be set here via upsertFieldPermissions because the server - // enforces an application-ownership check: the mutation always runs in the - // workspace Custom-app context, which does not match the Partner role's owning - // application (the partners app). Attempting to set them would return - // ROLE_BELONGS_TO_ANOTHER_APPLICATION. The manifest route is the correct - // and only supported mechanism for app-owned roles. - // - // This step queries the Partner role's existing fieldPermissions and prints the - // verification summary so a single run of `yarn rls:configure` confirms the - // full permission state (predicates + field locks) after a sync. + // ── 5. Verify Opportunity field permissions (set via manifest, not here — see header) ─ + // Read back the role's existing fieldPermissions so one run confirms the full state. const oppInfo = objectInfoByName.get('opportunity') as ObjectInfo; const oppObjectId = oppInfo.objectMetadataId; diff --git a/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts b/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts index 0797bf310061f..6235e73558859 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/views/all-partners.view.ts @@ -20,8 +20,7 @@ export default defineView({ { universalIdentifier: '4ebe0b9d-0c2d-4416-b187-150b02473a01', fieldMetadataUniversalIdentifier: 'a0000010-0000-4000-8000-000000000010', position: 4, isVisible: true }, { universalIdentifier: '52408b5f-5e13-4e3c-af2d-ce50033ec126', fieldMetadataUniversalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad', position: 5, isVisible: true }, { universalIdentifier: '34d58668-a689-42e2-9aff-3fee315092d6', fieldMetadataUniversalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a', position: 6, isVisible: true }, - // partnerUser: links a Partner to the workspace member who logs in as them. - // Setting this is the prerequisite for the assignment cascade + the partner's RLS visibility. + // partnerUser: the member this partner logs in as — prerequisite for the cascade + RLS visibility. { universalIdentifier: '19d1fc62-2486-4f59-8e77-b4ee177481b1', fieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, position: 7, isVisible: true }, ], });