v1.6.1 — fix(partners): scope Application RLS to the partner's own partnerUser (#23597)
**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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-partners",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
||||
+54
-2
@@ -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: {} }),
|
||||
|
||||
-2
@@ -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',
|
||||
|
||||
+17
-5
@@ -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<Record<string, unknown>> {
|
||||
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
|
||||
|
||||
-7
@@ -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 },
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -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' };
|
||||
|
||||
|
||||
+5
-5
@@ -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 =
|
||||
|
||||
+31
-67
@@ -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<void> => {
|
||||
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);
|
||||
};
|
||||
|
||||
+15
@@ -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' } })
|
||||
|
||||
+25
@@ -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);
|
||||
}
|
||||
+24
-34
@@ -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,
|
||||
});
|
||||
|
||||
+2
-1
@@ -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<T> = {
|
||||
@@ -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).
|
||||
{
|
||||
|
||||
+26
-98
@@ -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 <remote>\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 <remote>\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 <remote>\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 <remote>\n\n` +
|
||||
`(e.g. \`yarn twenty apply\` for local, ` +
|
||||
`\`yarn twenty apply -r partner-twenty-com\` for prod)\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user