From 0f8c227105c47e11625d9f12efda93903dd853e1 Mon Sep 17 00:00:00 2001 From: Rashad Karanouh <11599358+rashad@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:35:21 +0200 Subject: [PATCH] =?UTF-8?q?v1.6.1=20=E2=80=94=20fix(partners):=20scope=20A?= =?UTF-8?q?pplication=20RLS=20to=20the=20partner's=20own=20partnerUser=20(?= =?UTF-8?q?#23597)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Version:** twenty-partners `1.6.1` (`on-application-created` gains behaviour; no schema change). ## The bug On https://partners.twenty.com every partner could list **all** applications, not just their own. The Partner role's row-level predicate on `Application` was: ``` (partnerUser IS the current member) OR (lastActivityAt IS EMPTY) ``` The `IS EMPTY` branch was an insert escape hatch: the Apply workflow creates the row before `on-application-created` can stamp it, and RLS validates an insert against the row as submitted. Only that self-apply path ever writes `lastActivityAt` (`resolve-candidacy.service.ts`). Every other creation — admin invite, TFT import, seed — returned early, so `lastActivityAt` stayed `null` forever and the hatch never closed. All 7 applications in production had `lastActivityAt = null`, which made the whole table readable by every partner. ## The fix 1. **Drop the OR group.** `application` becomes a plain `partnerUser IS the current member` predicate, like the other partner-scoped objects. The upsert reconciles per (role, object), so the stale group and predicate are soft-deleted on re-run — a leaking workspace self-heals. 2. **Keep admin invites visible.** The OR branch was also the only reason an admin-created invite reached its recipient. `resolve-candidacy` now stamps `partnerUser` from the partner on the admin path, instead of returning early. 3. **Backfill the rows created before the narrowing.** Neither writer covers an application an admin created for an already-linked partner; those existed only behind the leak. `stampPartnerUserFromPartner` now covers `application` (its three copy-pasted branches collapsed into an accessor map routed through `shared/graphql/`), and the walk runs from the app's post-install logic function, gated on `previousVersion < 1.6.1`. No manual step. 4. **Preserve the insert path.** The Apply workflow must map exactly Opportunity + Partner User. `partnerUser` is writable at insert only because the server exempts RLS predicate fields there (`permissions.utils.ts`, insert case only); every other Application field is locked, so mapping `State` fails the insert — and `state` already defaults to `APPLIED`. ## Order of operations, per workspace 1. Publish the Apply workflow with the Partner User mapping (edit it if it already exists). 2. `yarn rls:configure` (`:prod`). `app:install` stamps the pre-existing rows before step 2 runs, so no window exists where a partner reads nothing. The script prints these steps before and after its writes, because the deploy path never opens the runbook. ## Verification (local bundle, real Partner-role account) | Case | Result | |---|---| | Another partner's application | not visible | | Own application (`lastActivityAt` null) | visible | | Admin invite created with `partnerUser` null | stamped from the partner within seconds | | All applications stripped of `partnerUser`, then upgraded from 1.6.0 | post-install returns `{ stamped: 3 }`; the 4th belongs to a partner with no member | | Upgrade from 1.6.1 | post-install returns `{ skipped: true }` | | Insert without `partnerUser` | rejected — *Record does not satisfy row-level security constraints of your current role* | | Insert with `partnerUser` = self | accepted | | Insert with `state` mapped | rejected — *no permission to write field "state"* | Lint 0, typecheck 0, 221 unit tests. ## Production notes - The fix lands on prod by running `yarn rls:configure:prod`. Installing this version alone does not narrow the predicate. - The 7 production applications were already backfilled by hand; the post-install hook makes that reproducible for any other workspace. ## Out of scope - Creating an OR predicate group fails on server 2.23.2 in a fresh workspace (`Migration action 'create' for 'rowLevelPermissionPredicateGroup' failed`). Pre-existing and unrelated; production is unaffected because its `opportunity` group already exists. It does block `rls:configure` on newly provisioned local bundles. - Partners still see the `Matching Admin Workspace` navigation folder. Navigation menu items cannot be scoped by role in the SDK; the views are row-filtered. - `configure-partner-rls.ts` should not exist. #21919 made `rowLevelPermissionPredicates` declarable on the role manifest, and the SDK we depend on already ships it, so the predicates belong in `partner.role.ts`. The predicates on the workspace today were written through the metadata API and are not app-owned, so adopting them needs its own migration and test pass. Follow-up. - Deferred cleanups are listed in the thermo review comments below. --- .../internal/twenty-partners/package.json | 2 +- .../__tests__/on-application-created.test.ts | 56 +++++++- .../application/objects/application.object.ts | 2 - .../services/resolve-candidacy.service.ts | 22 +++- .../queries/get-partner-partner-user.ts | 7 - .../propagate-partner-user.service.ts | 4 +- .../services/link-partner-user.service.ts | 10 +- .../stamp-partner-user-on-child.service.ts | 98 +++++--------- .../stamp-partner-user-on-child.test.ts | 15 +++ .../update-application-partner-user.ts | 0 .../update-partner-content-partner-user.ts | 0 .../update-partner-link-partner-user.ts | 0 .../update-partner-service-partner-user.ts | 0 .../graphql/queries/get-child-partner-user.ts | 25 ++++ .../graphql/queries/get-partner-owner.ts | 0 .../shared/post-install.logic-function.ts | 58 ++++---- ...kfill-partner-user-on-children.service.ts} | 3 +- .../twenty-partners/src/roles/partner.role.ts | 4 +- .../src/scripts/configure-partner-rls.ts | 124 ++++-------------- .../twenty-partners/src/scripts/seed.ts | 2 +- .../twenty-partners/src/workflows/README.md | 34 +++-- 21 files changed, 230 insertions(+), 236 deletions(-) delete mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-partner-partner-user.ts rename packages/twenty-apps/internal/twenty-partners/src/modules/{partner/onboarding => shared}/graphql/mutations/update-application-partner-user.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/modules/{partner/onboarding => shared}/graphql/mutations/update-partner-content-partner-user.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/modules/{partner/onboarding => shared}/graphql/mutations/update-partner-link-partner-user.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/modules/{partner/onboarding => shared}/graphql/mutations/update-partner-service-partner-user.ts (100%) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/get-child-partner-user.ts rename packages/twenty-apps/internal/twenty-partners/src/modules/{partner/onboarding => shared}/graphql/queries/get-partner-owner.ts (100%) rename packages/twenty-apps/internal/twenty-partners/src/{scripts/backfill-partner-user-on-children.ts => modules/shared/services/backfill-partner-user-on-children.service.ts} (97%) diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index b24c7c83f4..5561b8945e 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": "1.6.0", + "version": "1.6.1", "license": "MIT", "engines": { "node": "^24.5.0", diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/application/__tests__/on-application-created.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/__tests__/on-application-created.test.ts index 1d1d8d732f..82e9b53cb5 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/application/__tests__/on-application-created.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/__tests__/on-application-created.test.ts @@ -29,15 +29,67 @@ describe('on-application-created', () => { mutationMock.mockResolvedValue({ updateApplication: { id: APPLICATION_ID } }); }); - it('does nothing when partnerId is already set (admin path)', async () => { + it('does nothing when the admin-created row already carries partnerUser', async () => { const result = await handler( - event({ id: APPLICATION_ID, partnerId: PARTNER_ID }), + event({ + id: APPLICATION_ID, + partnerId: PARTNER_ID, + partnerUserId: MEMBER_ID, + }), ); expect(result).toEqual({}); expect(queryMock).not.toHaveBeenCalled(); expect(mutationMock).not.toHaveBeenCalled(); }); + it('stamps partnerUser from the partner on an admin-created row', async () => { + queryMock.mockResolvedValue({ + partner: { id: PARTNER_ID, partnerUserId: MEMBER_ID }, + }); + + const result = await handler( + event({ id: APPLICATION_ID, partnerId: PARTNER_ID }), + ); + + expect(result).toEqual({ stamped: MEMBER_ID }); + expect(mutationMock).toHaveBeenCalledTimes(1); + const args = mutationMock.mock.calls[0][0].updateApplication.__args; + expect(args.id).toBe(APPLICATION_ID); + expect(args.data).toEqual({ partnerUserId: MEMBER_ID }); + }); + + it('leaves an admin-created row alone when the partner has no member', async () => { + queryMock.mockResolvedValue({ + partner: { id: PARTNER_ID, partnerUserId: null }, + }); + + const result = await handler( + event({ id: APPLICATION_ID, partnerId: PARTNER_ID }), + ); + + expect(result).toEqual({ skipped: true, reason: 'partner_has_no_user' }); + expect(mutationMock).not.toHaveBeenCalled(); + }); + + it('resolves the candidacy when the self-apply row already carries partnerUser', async () => { + queryMock.mockResolvedValue({ + partners: { edges: [{ node: { id: PARTNER_ID } }] }, + }); + + const result = await handler( + event({ + id: APPLICATION_ID, + partnerUserId: MEMBER_ID, + createdBy: { workspaceMemberId: MEMBER_ID }, + }), + ); + + expect(result).toEqual({ applied: true, partnerId: PARTNER_ID }); + const args = mutationMock.mock.calls[0][0].updateApplication.__args; + expect(args.data.partnerId).toBe(PARTNER_ID); + expect(args.data.state).toBe('APPLIED'); + }); + it('does nothing when createdBy.workspaceMemberId is missing', async () => { const result = await handler( event({ id: APPLICATION_ID, createdBy: {} }), diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/application/objects/application.object.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/objects/application.object.ts index 87bae2acb0..c90e09df23 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/application/objects/application.object.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/objects/application.object.ts @@ -85,8 +85,6 @@ export default defineObject({ }, }, { - // RLS pivot (B7): the applying member. Populated by the apply logic-function (B4); - // left null for admin-created invites until then. Locked from partner edits in B7. universalIdentifier: APPLICATION_PARTNER_USER_FIELD_ID, type: FieldType.RELATION, name: 'partnerUser', diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/application/services/resolve-candidacy.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/application/services/resolve-candidacy.service.ts index 65ac1f7ac8..c31e4b14d8 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/application/services/resolve-candidacy.service.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/application/services/resolve-candidacy.service.ts @@ -5,6 +5,7 @@ import type { } from 'twenty-sdk/define'; import { findPartnerByMember } from 'src/modules/application/graphql/queries/find-partner-by-member'; +import { getPartnerOwner } from 'src/modules/shared/graphql/queries/get-partner-owner'; import { findDuplicateApplication } from 'src/modules/application/graphql/queries/find-duplicate-application'; import { deleteApplication } from 'src/modules/application/graphql/mutations/delete-application'; import { updateApplication } from 'src/modules/application/graphql/mutations/update-application'; @@ -14,17 +15,28 @@ type ApplicationCreatedProperties = DatabaseEventPayload< >['properties']; // A partner self-applies via the "Apply to brief as partner" workflow: a Create Record action -// makes an Application with the opportunity set and createdBy = the clicking member, but no -// partner. Resolve the partner from createdBy and complete the candidacy. Admin-created -// applications (partner already set, or the creator is not a partner) are left untouched. The -// name is set by on-application-set-name, which fires on the partnerId update below. +// makes an Application with the opportunity set, createdBy = the clicking member and +// partnerUser = that member (mandatory — the Partner role's RLS rejects the insert otherwise), +// but no partner. Resolve the partner from createdBy and complete the candidacy. The name is +// set by on-application-set-name, which fires on the partnerId update below. export async function resolveCandidacy( client: CoreApiClient, after: ApplicationCreatedProperties['after'], ): Promise> { const applicationId = after?.id; if (!applicationId) return {}; - if (after.partnerId) return {}; // already linked (admin path) — leave it + + // Admin path (invite/import): without partnerUser, RLS hides the row from its own partner. + if (after.partnerId) { + if (after.partnerUserId) return {}; + + const ownerRes = await getPartnerOwner(client, after.partnerId); + const partnerUserId = ownerRes.partner?.partnerUserId; + if (!partnerUserId) return { skipped: true, reason: 'partner_has_no_user' }; + + await updateApplication(client, applicationId, { partnerUserId }); + return { stamped: partnerUserId }; + } const memberId = after.createdBy?.workspaceMemberId; if (!memberId) return {}; // no member actor (system/import) — not a self-apply diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-partner-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-partner-partner-user.ts deleted file mode 100644 index c3be3bd142..0000000000 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/graphql/queries/get-partner-partner-user.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { CoreApiClient } from 'twenty-client-sdk/core'; - -export function getPartnerPartnerUser(client: CoreApiClient, partnerId: string) { - return client.query({ - partner: { __args: { filter: { id: { eq: partnerId } } }, id: true, partnerUserId: true }, - }); -} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/propagate-partner-user.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/propagate-partner-user.service.ts index d4f42a010e..1362969594 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/propagate-partner-user.service.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/propagate-partner-user.service.ts @@ -7,7 +7,7 @@ import type { import { collectAll } from 'src/modules/shared/utils/paginate.util'; import { getCompanyPartnerUser } from 'src/modules/opportunity/matching/graphql/queries/get-company-partner-user'; import { getOpportunityCascadeFields } from 'src/modules/opportunity/matching/graphql/queries/get-opportunity-cascade-fields'; -import { getPartnerPartnerUser } from 'src/modules/opportunity/matching/graphql/queries/get-partner-partner-user'; +import { getPartnerOwner } from 'src/modules/shared/graphql/queries/get-partner-owner'; import { findOpportunityStillUsingCompany } from 'src/modules/opportunity/matching/graphql/queries/find-opportunity-still-using-company'; import { listPeopleByFilter } from 'src/modules/opportunity/matching/graphql/queries/list-people-by-filter'; import { updateCompanyPartnerUser } from 'src/modules/opportunity/matching/graphql/mutations/update-company-partner-user'; @@ -114,7 +114,7 @@ export async function propagatePartnerUser( } // ── Assign / reassign ──────────────────────────────────────────────────────── - const partnerResult = await getPartnerPartnerUser(client, partnerId); + const partnerResult = await getPartnerOwner(client, partnerId); const partnerUserId = partnerResult.partner?.partnerUserId; if (!partnerUserId) return { cascaded: false, reason: 'partner_has_no_user' }; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.ts index 56ef242e9a..eac6bc8101 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/services/link-partner-user.service.ts @@ -2,13 +2,13 @@ import { type CoreApiClient } from 'twenty-client-sdk/core'; import { getCompanyPartnerUser } from 'src/modules/partner/onboarding/graphql/queries/get-company-partner-user'; import { getPartnerCascadeFields } from 'src/modules/partner/onboarding/graphql/queries/get-partner-cascade-fields'; -import { getPartnerOwner } from 'src/modules/partner/onboarding/graphql/queries/get-partner-owner'; -import { updateApplicationPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-application-partner-user'; +import { getPartnerOwner } from 'src/modules/shared/graphql/queries/get-partner-owner'; +import { updateApplicationPartnerUser } from 'src/modules/shared/graphql/mutations/update-application-partner-user'; import { updateCompanyPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-company-partner-user'; -import { updatePartnerContentPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-partner-content-partner-user'; -import { updatePartnerLinkPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-partner-link-partner-user'; +import { updatePartnerContentPartnerUser } from 'src/modules/shared/graphql/mutations/update-partner-content-partner-user'; +import { updatePartnerLinkPartnerUser } from 'src/modules/shared/graphql/mutations/update-partner-link-partner-user'; import { updatePartnerPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-partner-partner-user'; -import { updatePartnerServicePartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-partner-service-partner-user'; +import { updatePartnerServicePartnerUser } from 'src/modules/shared/graphql/mutations/update-partner-service-partner-user'; import { updatePersonPartnerUser } from 'src/modules/partner/onboarding/graphql/mutations/update-person-partner-user'; export type LinkPartnerUserResult = diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.service.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.service.ts index 6def1756c3..17c1af0601 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.service.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.service.ts @@ -1,6 +1,29 @@ import { type CoreApiClient } from 'twenty-client-sdk/core'; -export type PartnerChildObject = 'partnerLink' | 'partnerService' | 'partnerContent'; +import { getPartnerOwner } from 'src/modules/shared/graphql/queries/get-partner-owner'; +import { + getApplicationPartnerUser, + getPartnerContentPartnerUser, + getPartnerLinkPartnerUser, + getPartnerServicePartnerUser, +} from 'src/modules/shared/graphql/queries/get-child-partner-user'; +import { updateApplicationPartnerUser } from 'src/modules/shared/graphql/mutations/update-application-partner-user'; +import { updatePartnerContentPartnerUser } from 'src/modules/shared/graphql/mutations/update-partner-content-partner-user'; +import { updatePartnerLinkPartnerUser } from 'src/modules/shared/graphql/mutations/update-partner-link-partner-user'; +import { updatePartnerServicePartnerUser } from 'src/modules/shared/graphql/mutations/update-partner-service-partner-user'; + +export type PartnerChildObject = + | 'partnerLink' + | 'partnerService' + | 'partnerContent' + | 'application'; + +const CHILD_ACCESSORS = { + partnerLink: { read: getPartnerLinkPartnerUser, write: updatePartnerLinkPartnerUser }, + partnerService: { read: getPartnerServicePartnerUser, write: updatePartnerServicePartnerUser }, + partnerContent: { read: getPartnerContentPartnerUser, write: updatePartnerContentPartnerUser }, + application: { read: getApplicationPartnerUser, write: updateApplicationPartnerUser }, +} as const; export const stampPartnerUserFromPartner = async ( client: CoreApiClient, @@ -8,74 +31,15 @@ export const stampPartnerUserFromPartner = async ( childObject: PartnerChildObject, childId: string, ): Promise => { - const partnerRes = await client.query({ - partner: { - __args: { filter: { id: { eq: partnerId } } }, - id: true, - partnerUserId: true, - }, - }); - - const partnerUserId = partnerRes.partner?.partnerUserId; + const partnerUserId = (await getPartnerOwner(client, partnerId)).partner + ?.partnerUserId; if (!partnerUserId) return; - if (childObject === 'partnerLink') { - const childRes = await client.query({ - partnerLink: { - __args: { filter: { id: { eq: childId } } }, - id: true, - partnerUserId: true, - }, - }); + const { read, write } = CHILD_ACCESSORS[childObject]; + const child = await read(client, childId); - if (!childRes.partnerLink) return; - if (childRes.partnerLink.partnerUserId === partnerUserId) return; + if (!child) return; + if (child.partnerUserId === partnerUserId) return; - await client.mutation({ - updatePartnerLink: { - __args: { id: childId, data: { partnerUserId } }, - id: true, - }, - }); - return; - } - - if (childObject === 'partnerService') { - const childRes = await client.query({ - partnerService: { - __args: { filter: { id: { eq: childId } } }, - id: true, - partnerUserId: true, - }, - }); - - if (!childRes.partnerService) return; - if (childRes.partnerService.partnerUserId === partnerUserId) return; - - await client.mutation({ - updatePartnerService: { - __args: { id: childId, data: { partnerUserId } }, - id: true, - }, - }); - return; - } - - const childRes = await client.query({ - partnerContent: { - __args: { filter: { id: { eq: childId } } }, - id: true, - partnerUserId: true, - }, - }); - - if (!childRes.partnerContent) return; - if (childRes.partnerContent.partnerUserId === partnerUserId) return; - - await client.mutation({ - updatePartnerContent: { - __args: { id: childId, data: { partnerUserId } }, - id: true, - }, - }); + await write(client, childId, partnerUserId); }; diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.test.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.test.ts index 19ce546e7c..32a98bbed5 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/partner/self-service/services/stamp-partner-user-on-child.test.ts @@ -30,6 +30,21 @@ describe('stampPartnerUserFromPartner', () => { }); }); + it('stamps application partnerUserId when missing', async () => { + query + .mockResolvedValueOnce({ partner: { id: 'partner-1', partnerUserId: 'member-1' } }) + .mockResolvedValueOnce({ application: { id: 'application-1', partnerUserId: null } }); + + await stampPartnerUserFromPartner(client, 'partner-1', 'application', 'application-1'); + + expect(mutation).toHaveBeenCalledWith({ + updateApplication: { + __args: { id: 'application-1', data: { partnerUserId: 'member-1' } }, + id: true, + }, + }); + }); + it('stamps partnerService partnerUserId when missing', async () => { query .mockResolvedValueOnce({ partner: { id: 'partner-1', partnerUserId: 'member-1' } }) diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-application-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/update-application-partner-user.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-application-partner-user.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/update-application-partner-user.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-content-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/update-partner-content-partner-user.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-content-partner-user.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/update-partner-content-partner-user.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-link-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/update-partner-link-partner-user.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-link-partner-user.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/update-partner-link-partner-user.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-service-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/update-partner-service-partner-user.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/mutations/update-partner-service-partner-user.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/mutations/update-partner-service-partner-user.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/get-child-partner-user.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/get-child-partner-user.ts new file mode 100644 index 0000000000..5bcd6bfd8e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/get-child-partner-user.ts @@ -0,0 +1,25 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +export function getPartnerLinkPartnerUser(client: CoreApiClient, id: string) { + return client + .query({ partnerLink: { __args: { filter: { id: { eq: id } } }, id: true, partnerUserId: true } }) + .then((res) => res.partnerLink); +} + +export function getPartnerServicePartnerUser(client: CoreApiClient, id: string) { + return client + .query({ partnerService: { __args: { filter: { id: { eq: id } } }, id: true, partnerUserId: true } }) + .then((res) => res.partnerService); +} + +export function getPartnerContentPartnerUser(client: CoreApiClient, id: string) { + return client + .query({ partnerContent: { __args: { filter: { id: { eq: id } } }, id: true, partnerUserId: true } }) + .then((res) => res.partnerContent); +} + +export function getApplicationPartnerUser(client: CoreApiClient, id: string) { + return client + .query({ application: { __args: { filter: { id: { eq: id } } }, id: true, partnerUserId: true } }) + .then((res) => res.application); +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-partner-owner.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/get-partner-owner.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-partners/src/modules/partner/onboarding/graphql/queries/get-partner-owner.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/graphql/queries/get-partner-owner.ts diff --git a/packages/twenty-apps/internal/twenty-partners/src/modules/shared/post-install.logic-function.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/post-install.logic-function.ts index dceb5b3c46..9976ec30a4 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/modules/shared/post-install.logic-function.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/post-install.logic-function.ts @@ -1,48 +1,38 @@ -import { type InstallPayload, definePostInstallLogicFunction } from 'twenty-sdk/define'; import { CoreApiClient } from 'twenty-client-sdk/core'; +import { type InstallPayload, definePostInstallLogicFunction } from 'twenty-sdk/define'; -const handler = async (_payload: InstallPayload) => { - const client = new CoreApiClient(); +import { backfillPartnerUserOnChildren } from 'src/modules/shared/services/backfill-partner-user-on-children.service'; - const partnerResult = await client.mutation({ - createPartner: { - __args: { - data: { - name: 'Test Partner Alpha', - validationStage: 'VALIDATED', - availability: 'AVAILABLE', - languagesSpoken: ['ENGLISH', 'FRENCH'], - deploymentExpertise: ['CLOUD', 'SELF_HOST'], - region: 'EUROPE', - }, - }, - id: true, - }, - } as any); +// The release that narrowed the Application RLS predicate to `partnerUser IS me`. +const STRICT_APPLICATION_RLS_VERSION = [1, 6, 1]; - const partnerId = (partnerResult.createPartner as any).id; +const isBefore = (version: string, target: number[]): boolean => { + const parts = version.split('.').map((part) => Number.parseInt(part, 10) || 0); - await client.mutation({ - createPerson: { - __args: { - data: { - name: { - firstName: 'Test Partner', - lastName: 'Contact', - }, - partnerId, - }, - }, - id: true, - }, - } as any); + for (let index = 0; index < target.length; index++) { + const part = parts[index] ?? 0; + if (part !== target[index]) return part < target[index]; + } - return { seeded: true, partnerId }; + return false; +}; + +const handler = async ({ previousVersion }: InstallPayload) => { + if (previousVersion && !isBefore(previousVersion, STRICT_APPLICATION_RLS_VERSION)) { + return { skipped: true }; + } + + const stamped = await backfillPartnerUserOnChildren(new CoreApiClient()); + + return { stamped }; }; export default definePostInstallLogicFunction({ universalIdentifier: 'f92bad2e-5905-4757-96ee-af9869d4ca0c', name: 'post-install', + description: + 'Stamps partnerUser on partner-owned records created before the Application RLS narrowing.', handler, + shouldRunOnVersionUpgrade: true, shouldRunSynchronously: true, }); diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/backfill-partner-user-on-children.ts b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/services/backfill-partner-user-on-children.service.ts similarity index 97% rename from packages/twenty-apps/internal/twenty-partners/src/scripts/backfill-partner-user-on-children.ts rename to packages/twenty-apps/internal/twenty-partners/src/modules/shared/services/backfill-partner-user-on-children.service.ts index 8a7a554262..b23287ec4b 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/backfill-partner-user-on-children.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/modules/shared/services/backfill-partner-user-on-children.service.ts @@ -9,11 +9,12 @@ const PAGE_SIZE = 200; const CHILD_QUERIES: { childObject: PartnerChildObject; - listKey: 'partnerLinks' | 'partnerServices' | 'partnerContents'; + listKey: 'partnerLinks' | 'partnerServices' | 'partnerContents' | 'applications'; }[] = [ { childObject: 'partnerLink', listKey: 'partnerLinks' }, { childObject: 'partnerService', listKey: 'partnerServices' }, { childObject: 'partnerContent', listKey: 'partnerContents' }, + { childObject: 'application', listKey: 'applications' }, ]; type Connection = { 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 ac3aa41531..f87f4fcc06 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 @@ -415,7 +415,9 @@ export default defineRole({ canUpdateFieldValue: false, }, // Application — lock every field except pitch and opportunity (partner sets opportunity - // on apply/create; state/partnerUser are populated by on-application-created as the app). + // on apply/create; state is populated by on-application-created as the app). partnerUser + // is listed as locked but stays writable at insert — the server exempts RLS predicate + // fields there (permissions.utils.ts, insert case only). // System/server-managed fields (id, timestamps, updatedBy, position, searchVector) stay // out — locking updatedBy/position breaks every update (same trap as Opportunity above). { 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 aaba953f68..8511ccdb63 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 @@ -2,17 +2,10 @@ // predicates. Does three things: // // 1. Upserts row-level-permission predicates on the Partner role: -// - "partnerUser IS the current member" on partner/person/company/partnerLink/partnerService/partnerContent -// - "(partnerUser IS me) OR (lastActivityAt IS EMPTY)" on application. RLS is validated on -// INSERT against the row exactly as submitted, but a partner's Apply workflow creates the -// row with partnerUser=null (on-application-created stamps it AFTER insert, as the app). -// A strict "partnerUser IS me" would reject every apply, so the OR adds an escape hatch: -// a freshly-created row (lastActivityAt null, set by the same handler) passes insert and -// is readable by its creator until the handler stamps it. -// ponytail: trade-off — an unstamped row is briefly readable by ANY partner (sub-second -// window; permanent only if the handler fails to stamp). Acceptable for an internal -// partner marketplace; tighten by setting partnerUser at insert if a current-member -// workflow variable ever lands. +// - "partnerUser IS the current member" on partner/person/company/partnerLink/partnerService/ +// partnerContent/application. The Apply workflow must map Partner User to the clicking +// member at insert (see src/workflows/README.md) — RLS validates the insert against the +// row as submitted, so a row created without partnerUser is rejected. // - "(partnerUser IS me) OR (isListed = true)" on opportunity (marketplace briefs) // - "id IS the current member" on workspaceMember (self-scope; internal roster hidden) // @@ -46,20 +39,16 @@ const SIMPLE_TARGET_OBJECTS = [ 'partnerLink', 'partnerService', 'partnerContent', + 'application', ] as const; type SimpleTargetObject = (typeof SIMPLE_TARGET_OBJECTS)[number]; -// application + opportunity use OR groups (handled separately), but still need existence checks. -const ALL_TARGET_OBJECTS = [ - ...SIMPLE_TARGET_OBJECTS, - 'application', - 'opportunity', -] as const; +// opportunity uses an OR group (handled separately), but still needs an existence check. +const ALL_TARGET_OBJECTS = [...SIMPLE_TARGET_OBJECTS, 'opportunity'] as const; -// Stable ids for the OR predicate groups — re-runs upsert in place instead of creating -// duplicate groups. +// Stable id for the OR predicate group — re-runs upsert in place instead of creating +// a duplicate group. const OPPORTUNITY_RLS_OR_GROUP_ID = 'b7e7f3a0-4c5d-4e8f-9a1b-2c3d4e5f6789'; -const APPLICATION_RLS_OR_GROUP_ID = 'a9c1f3d2-5b6e-4a7c-8d9f-1e2b3c4d5e6f'; // Opportunity fields that must NOT be locked: system columns and updatedBy/position // (server-managed — locking them breaks every update; see src/roles/partner.role.ts). @@ -96,6 +85,13 @@ const APPLICATION_FIELD_LOCK_SKIP = new Set([ 'opportunity', ]); +const APPLY_WORKFLOW_WARNING = + `[rls:configure] \u26a0 The "Apply to Brief" workflow in this workspace MUST map\n` + + ` Partner User -> {{trigger.workspaceMember}} and map no other field, and every\n` + + ` Application created before this run needs \`yarn backfill:partner-user\`.\n` + + ` Otherwise partners cannot apply, and admin invites stay invisible to them.\n` + + ` See src/workflows/README.md.\n`; + type ObjectInfo = { objectMetadataId: string; partnerUserFieldMetadataId: string; @@ -357,24 +353,6 @@ async function main() { 'isListed', ); - const applicationObjectIdForPredicate = objectIdByName.get( - 'application', - ) as string; - const applicationPartnerUserFieldId = await findFieldByName( - metadataUrl, - apiKey, - applicationObjectIdForPredicate, - 'application', - 'partnerUser', - ); - const applicationLastActivityAtFieldId = await findFieldByName( - metadataUrl, - apiKey, - applicationObjectIdForPredicate, - 'application', - 'lastActivityAt', - ); - // ── 2. Resolve workspaceMember.id field metadata id ────────────────────────── const workspaceMemberIdFieldId = await findFieldByName( @@ -478,6 +456,8 @@ async function main() { } }; + console.log(`\n${APPLY_WORKFLOW_WARNING}`); + const results: PredicateResult[] = []; for (const name of SIMPLE_TARGET_OBJECTS) { @@ -568,59 +548,6 @@ async function main() { ); } - // Application: (partnerUser IS me) OR (lastActivityAt IS EMPTY). The IS-EMPTY branch lets a - // partner CREATE their own application — RLS is validated on insert against the row as - // submitted (partnerUser is null until on-application-created stamps it). A scalar IS_EMPTY - // on lastActivityAt resolves to `{ is: 'NULL' }`, which is unambiguous on both the insert - // check and the SQL read path (a relation IS_EMPTY is riskier there). See header for the leak. - { - const appPredicates = await upsertPredicates( - { - roleId: partnerRole.id, - objectMetadataId: applicationObjectIdForPredicate, - predicateGroups: [ - { - id: APPLICATION_RLS_OR_GROUP_ID, - objectMetadataId: applicationObjectIdForPredicate, - logicalOperator: 'OR', - parentRowLevelPermissionPredicateGroupId: null, - }, - ], - predicates: [ - { - fieldMetadataId: applicationPartnerUserFieldId, - operand: 'IS', - workspaceMemberFieldMetadataId: workspaceMemberIdFieldId, - rowLevelPermissionPredicateGroupId: APPLICATION_RLS_OR_GROUP_ID, - positionInRowLevelPermissionPredicateGroup: 0, - }, - { - fieldMetadataId: applicationLastActivityAtFieldId, - operand: 'IS_EMPTY', - rowLevelPermissionPredicateGroupId: APPLICATION_RLS_OR_GROUP_ID, - positionInRowLevelPermissionPredicateGroup: 1, - }, - ], - } satisfies UpsertPredicatesInput, - 'application', - ); - - if (appPredicates.length < 2) { - throw new Error( - 'upsertRowLevelPermissionPredicates returned fewer than 2 predicates for application OR group', - ); - } - - for (const predicate of appPredicates) { - results.push(predicate); - } - - console.log( - `[rls:configure] ✓ application: OR group id=${APPLICATION_RLS_OR_GROUP_ID} ` + - `(${appPredicates.length} predicates: partnerUser IS me OR lastActivityAt IS EMPTY)`, - ); - } - // 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. { @@ -659,8 +586,9 @@ async function main() { console.log( `\n[rls:configure] Done — ${results.length} predicates upserted on Partner role ` + - `(${SIMPLE_TARGET_OBJECTS.length} simple objects + application OR group + opportunity OR group + workspaceMember self-scope)`, + `(${SIMPLE_TARGET_OBJECTS.length} simple objects + opportunity OR group + workspaceMember self-scope)`, ); + console.log(`\n${APPLY_WORKFLOW_WARNING}`); // ── 5. Verify Opportunity field permissions (set via manifest, not here — see header) ─ @@ -699,9 +627,9 @@ async function main() { ` ${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`, + ` yarn twenty apply -r \n\n` + + `(e.g. \`yarn twenty apply\` for local, ` + + `\`yarn twenty apply -r partner-twenty-com\` for prod)\n`, ); process.exitCode = 1; return; @@ -768,9 +696,9 @@ async function main() { ` ${missingAppLocks.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`, + ` yarn twenty apply -r \n\n` + + `(e.g. \`yarn twenty apply\` for local, ` + + `\`yarn twenty apply -r partner-twenty-com\` for prod)\n`, ); process.exitCode = 1; return; diff --git a/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts b/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts index 2b0bd26de0..5804f41444 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/scripts/seed.ts @@ -15,7 +15,7 @@ config({ path: process.env.ENV_FILE ?? '.env.local' }); import { CoreApiClient } from 'twenty-client-sdk/core'; -import { backfillPartnerUserOnChildren } from './backfill-partner-user-on-children'; +import { backfillPartnerUserOnChildren } from 'src/modules/shared/services/backfill-partner-user-on-children.service'; const requireEnv = (name: string): string => { const value = process.env[name]; diff --git a/packages/twenty-apps/internal/twenty-partners/src/workflows/README.md b/packages/twenty-apps/internal/twenty-partners/src/workflows/README.md index 55d1e8464e..11505e3171 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/workflows/README.md +++ b/packages/twenty-apps/internal/twenty-partners/src/workflows/README.md @@ -10,7 +10,7 @@ runbook. ## Prerequisites -- Twenty Partners app installed and synced (`yarn twenty dev --once` or `install`). +- Twenty Partners app installed and synced (`yarn twenty apply`). - **Partner role** already grants the **WORKFLOWS** permission flag (shipped in the app). Partners need this to see **Run workflow → Apply** on a brief. Admins run **Mark as Winner** with their own role (no special flag beyond workflow access). @@ -31,18 +31,32 @@ Partner self-apply on an **Opportunity** (brief). Creates an **Application** and 5. Choose object **Opportunity**. 6. Add an action: **Create Record**. 7. Set **Object** to **Application**. -8. Map fields: +8. Map exactly these two fields — **nothing else**: - **Opportunity** → `{{trigger.record.id}}` - - **State** → `APPLIED` - - **Partner User** → `{{trigger.workspaceMember}}` *(see note below)* + - **Partner User** → `{{trigger.workspaceMember}}` *(mandatory — see note below)* 9. **Publish** (activate) the workflow version. -**Partner User note:** `on-application-created` resolves the Partner from -`createdBy.workspaceMemberId` on the new Application. Manual workflows run as the -clicking user, so **Create Record** sets `createdBy` to that member automatically. At -build time, confirm whether **Partner User** is still required — if `createdBy` is -populated on the new record, you can omit **Partner User** and rely on the logic -function alone. +**Partner User is mandatory.** The Partner role's row-level security on Application is +`partnerUser IS the current member`, and the server validates it against the row as +submitted. A **Create Record** without **Partner User** fails with *"Record does not +satisfy row-level security constraints of your current role"*. `on-application-created` +runs after the insert, so it cannot rescue it. Partner User is writable only because it is +the RLS predicate field — the server exempts those from the role's field locks. + +**Map no other field.** Every remaining Application field is locked for the Partner role, +so adding one (e.g. **State** → `APPLIED`) fails the insert with *"no permission to write +field …"*. `state` defaults to `APPLIED` on its own. + +**Order of operations.** On a workspace that already runs the app: + +1. Publish this workflow version first — the strict predicate rejects every apply until the + Partner User mapping is live. If the workflow already exists, edit it instead: add + **Partner User**, remove every other mapping, republish. +2. `yarn rls:configure` (`:prod`) — narrows the Application predicate to `partnerUser IS me`. + +Rows created before the narrowing carry no `partnerUser`, so the predicate hides them from +their own partner. The app's post-install logic function stamps them during `app:install`, +before step 2 runs. No manual step. ### Expected UI (partner)