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:
Rashad Karanouh
2026-07-31 17:35:21 +02:00
committed by GitHub
parent d2085e60c8
commit 0f8c227105
21 changed files with 230 additions and 236 deletions
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "1.6.0",
"version": "1.6.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -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: {} }),
@@ -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',
@@ -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
@@ -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 },
});
}
@@ -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' };
@@ -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 =
@@ -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);
};
@@ -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' } })
@@ -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);
}
@@ -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,
});
@@ -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).
{
@@ -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)