feat(partners): partner role row-level security (RLS) with scoped edits (#21386)

## Summary
Adds an external **Partner** self-service role that sees and edits only
its own
records via row-level security (RLS), so a validated partner can sign in
and manage
just the deals they're matched on.

## What's included
- **`partnerUser` relation** on Partner, Person, Company, Opportunity (+
inverse
  relations on Workspace Member) — the login member a record belongs to.
- **RLS predicates** scoping each of those objects to "partnerUser IS
the current
workspace member", plus a self-scope on Workspace Member so member-typed
relations
resolve without exposing the internal team roster. Applied out-of-band
via
  `yarn rls:configure` (the app manifest cannot ship RLS predicates).
- **Assign / unassign cascade** (`on-opportunity-partner-assigned` logic
function):
assigning a Partner to an Opportunity stamps `partnerUser` onto the
Opportunity +
its Company + People; removing the Partner clears it (and cascades to
the
  Company/People when no other deal of that member still uses them).
- **Partner role permissions**
  - Partner profile: full read/update.
- Opportunity: read all; **update `stage` and `amount` only** (every
other
    user-facing field locked).
  - Company / Person: read-only.
  - Workspace Member: read-only, RLS-scoped to self.
- **`partnerUser` column** added to the Validated Partners view so the
login member
  can be assigned inline.

## Install / upgrade note
After install or reinstall, run `yarn rls:configure` (`:prod` variant
for prod) to
(re)apply the RLS predicates and verify the field-permission locks.
Manifest sync
handles object/field permissions; predicates are applied by this script.

## Platform gaps found (for the eng team)
1. **Manifest sync doesn't invalidate the roles-permissions Redis
cache.** Permission
changes deployed via `yarn twenty dev --once` persist to the DB but
aren't reflected
   in the cached snapshot used for enforcement until

`engine:workspace:metadata:permissions:roles-permissions:<workspaceId>:{data,hash}`
   is flushed. Relevant on any real workspace when permissions change.
2. **Locking a server-injected field silently breaks all updates.** The
`*.updateOne`
pre-query hook writes `updatedBy` into every update, so
`canUpdateFieldValue:false`
on `updatedBy` makes the permission check reject *every* record update
with
`PERMISSION_DENIED`. Field-permission lock lists must exclude
server-managed/injected
fields (`updatedBy`; and `position`, co-written with `stage` on kanban
drag).

## Version
Minor bump → `0.5.0` (new role, new fields, new behaviour;
backwards-compatible).

## Testing
- Verified locally as a partner user: edits own profile; edits
Opportunity stage +
amount; Company/Person read-only; sees only matched deals; unassigning a
partner
  removes the deal (and its company/people) from the partner's view.
- `yarn rls:configure` passes (5 predicates upserted; 24 Opportunity
fields locked,
  stage + amount editable).
- Lint clean.
This commit is contained in:
Rashad Karanouh
2026-06-11 15:37:35 +04:00
committed by GitHub
parent 166f7ee0d2
commit f7463886a6
24 changed files with 1369 additions and 18 deletions
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "0.4.3",
"version": "0.5.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -25,7 +25,9 @@
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts",
"import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts",
"migrate:partner-scope": "tsx src/scripts/migrate-partner-scope.ts",
"migrate:partner-scope:prod": "ENV_FILE=.env.prod tsx src/scripts/migrate-partner-scope.ts"
"migrate:partner-scope:prod": "ENV_FILE=.env.prod tsx src/scripts/migrate-partner-scope.ts",
"rls:configure": "tsx src/scripts/configure-partner-rls.ts",
"rls:configure:prod": "ENV_FILE=.env.prod tsx src/scripts/configure-partner-rls.ts"
},
"dependencies": {
"twenty-client-sdk": "2.10.1",
@@ -0,0 +1,17 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { COMPANIES_AS_PARTNER_USER_FIELD_ID, PARTNER_USER_ON_COMPANY_FIELD_ID } from './partner-user-on-company.field';
export default defineField({
universalIdentifier: COMPANIES_AS_PARTNER_USER_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'companiesAsPartnerUser',
label: 'Companies (as partner user)',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,17 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID, PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from './partner-user-on-opportunity.field';
export default defineField({
universalIdentifier: OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'opportunitiesAsPartnerUser',
label: 'Opportunities (as partner user)',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,7 +1,9 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID = 'cc6b8a59-f860-493f-8b9a-f138c078fbf1';
export default defineField({
universalIdentifier: 'cc6b8a59-f860-493f-8b9a-f138c078fbf1',
universalIdentifier: OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'designDocStatus',
@@ -1,7 +1,9 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID = '37e5428c-6c8c-4616-b626-f0ea1caa443d';
export default defineField({
universalIdentifier: '37e5428c-6c8c-4616-b626-f0ea1caa443d',
universalIdentifier: OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.LINKS,
name: 'designDocUrl',
@@ -1,7 +1,9 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_HOSTING_TYPE_FIELD_ID = '7ac7517f-bbca-4b4c-8996-6f864f71219b';
export default defineField({
universalIdentifier: '7ac7517f-bbca-4b4c-8996-6f864f71219b',
universalIdentifier: OPPORTUNITY_HOSTING_TYPE_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'hostingType',
@@ -1,7 +1,9 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_LOST_REASON_FIELD_ID = '834e233d-b171-409e-825f-77ac49b0f19d';
export default defineField({
universalIdentifier: '834e233d-b171-409e-825f-77ac49b0f19d',
universalIdentifier: OPPORTUNITY_LOST_REASON_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'lostReason',
@@ -1,7 +1,9 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID = '90c683ec-2365-4533-a187-7b9ae162b753';
export default defineField({
universalIdentifier: '90c683ec-2365-4533-a187-7b9ae162b753',
universalIdentifier: OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.NUMBER,
name: 'numberOfSeats',
@@ -1,7 +1,9 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID = '59d5de53-202f-4913-a417-8a08970d87cc';
export default defineField({
universalIdentifier: '59d5de53-202f-4913-a417-8a08970d87cc',
universalIdentifier: OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'subscriptionFrequency',
@@ -1,7 +1,9 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID = 'a58214e9-38f9-4faf-8927-09b3980fd8c3';
export default defineField({
universalIdentifier: 'a58214e9-38f9-4faf-8927-09b3980fd8c3',
universalIdentifier: OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.SELECT,
name: 'subscriptionType',
@@ -1,7 +1,9 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_TFT_ID_FIELD_ID = '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5';
export default defineField({
universalIdentifier: '2e3e1d04-2719-4e0d-9a6b-ec73acf896c5',
universalIdentifier: OPPORTUNITY_TFT_ID_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'tftOpportunityId',
@@ -1,7 +1,9 @@
import { FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const OPPORTUNITY_USE_CASE_FIELD_ID = '1bc57f52-a621-4243-ae3e-05c3f504b90c';
export default defineField({
universalIdentifier: '1bc57f52-a621-4243-ae3e-05c3f504b90c',
universalIdentifier: OPPORTUNITY_USE_CASE_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.TEXT,
name: 'useCase',
@@ -0,0 +1,18 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_PROFILES_AS_USER_FIELD_ID, PARTNER_USER_ON_PARTNER_FIELD_ID } from './partner-user-on-partner.field';
export default defineField({
universalIdentifier: PARTNER_PROFILES_AS_USER_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerProfilesAsUser',
label: 'Partner Profiles (as user)',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,20 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const PARTNER_USER_ON_COMPANY_FIELD_ID = 'f8f1dbb6-9f9f-4c13-9dab-a19ea6223801';
export const COMPANIES_AS_PARTNER_USER_FIELD_ID = '6235bf5b-9ecf-482f-b8b0-e6ae37659638';
export default defineField({
universalIdentifier: PARTNER_USER_ON_COMPANY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerUser',
label: 'Partner User',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: COMPANIES_AS_PARTNER_USER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerUserId',
},
});
@@ -0,0 +1,20 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const PARTNER_USER_ON_OPPORTUNITY_FIELD_ID = '7022a40a-a954-4e6b-96b8-1faff0919ec0';
export const OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID = 'b03a26e8-6d9d-4d70-930b-2006929c9869';
export default defineField({
universalIdentifier: PARTNER_USER_ON_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerUser',
label: 'Partner User',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: OPPORTUNITIES_AS_PARTNER_USER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerUserId',
},
});
@@ -0,0 +1,22 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_USER_ON_PARTNER_FIELD_ID = '0e49f2e4-1e45-433d-bf49-79acc0b06d0e';
export const PARTNER_PROFILES_AS_USER_FIELD_ID = 'c0791b65-802f-4ae0-85e0-5434459214f1';
export default defineField({
universalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerUser',
label: 'Partner User',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_PROFILES_AS_USER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerUserId',
},
});
@@ -0,0 +1,20 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
export const PARTNER_USER_ON_PERSON_FIELD_ID = '2fc8b812-b7f9-49f2-829a-050be7ee1e5e';
export const PERSONS_AS_PARTNER_USER_FIELD_ID = '94d17fac-3d32-4b13-9fb3-a671d4bf9c46';
export default defineField({
universalIdentifier: PARTNER_USER_ON_PERSON_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerUser',
label: 'Partner User',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PERSONS_AS_PARTNER_USER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerUserId',
},
});
@@ -0,0 +1,17 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_USER_ON_PERSON_FIELD_ID, PERSONS_AS_PARTNER_USER_FIELD_ID } from './partner-user-on-person.field';
export default defineField({
universalIdentifier: PERSONS_AS_PARTNER_USER_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
type: FieldType.RELATION,
name: 'personsAsPartnerUser',
label: 'People (as partner user)',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_USER_ON_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,273 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import onOpportunityPartnerAssigned from '../on-opportunity-partner-assigned';
// defineLogicFunction wraps the handler in a ValidationResult; the fn is on config.handler.
const handler = onOpportunityPartnerAssigned.config.handler;
function requireId(id: string | null | undefined, what: string): string {
if (!id) throw new Error(`${what} returned no id`);
return id;
}
async function getWorkspaceMemberId(client: CoreApiClient): Promise<string> {
const r = await client.query({
workspaceMembers: {
__args: { first: 1 },
edges: { node: { id: true } },
},
});
const memberId = r.workspaceMembers?.edges?.[0]?.node?.id;
if (!memberId) throw new Error('No workspace members found — cannot run test');
return memberId;
}
async function createPartner(client: CoreApiClient, memberId: string): Promise<string> {
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: `[test-rls] partner ${Date.now()}`,
slug: `test-rls-${Date.now()}`,
partnerUserId: memberId,
},
},
id: true,
},
});
return requireId(r.createPartner?.id, 'createPartner');
}
async function destroyPartner(client: CoreApiClient, id: string) {
await client.mutation({ destroyPartner: { __args: { id }, id: true } }).catch(() => {});
}
async function createCompany(client: CoreApiClient, name: string): Promise<string> {
const r = await client.mutation({
createCompany: { __args: { data: { name } }, id: true },
});
return requireId(r.createCompany?.id, 'createCompany');
}
async function destroyCompany(client: CoreApiClient, id: string) {
await client.mutation({ destroyCompany: { __args: { id }, id: true } }).catch(() => {});
}
async function createPerson(client: CoreApiClient, companyId: string): Promise<string> {
const r = await client.mutation({
createPerson: {
__args: { data: { name: { firstName: 'Test', lastName: `RLS-${Date.now()}` }, companyId } },
id: true,
},
});
return requireId(r.createPerson?.id, 'createPerson');
}
async function destroyPerson(client: CoreApiClient, id: string) {
await client.mutation({ destroyPerson: { __args: { id }, id: true } }).catch(() => {});
}
async function createOpportunity(
client: CoreApiClient,
name: string,
companyId: string,
): Promise<string> {
const r = await client.mutation({
createOpportunity: { __args: { data: { name, companyId } }, id: true },
});
return requireId(r.createOpportunity?.id, 'createOpportunity');
}
async function destroyOpportunity(client: CoreApiClient, id: string) {
await client.mutation({ destroyOpportunity: { __args: { id }, id: true } }).catch(() => {});
}
async function getOpportunityPartnerUserId(
client: CoreApiClient,
id: string,
): Promise<string | null> {
const r = await client.query({
opportunity: {
__args: { filter: { id: { eq: id } } },
id: true,
partnerUserId: true,
},
});
return r.opportunity?.partnerUserId ?? null;
}
async function getCompanyPartnerUserId(
client: CoreApiClient,
id: string,
): Promise<string | null> {
const r = await client.query({
company: {
__args: { filter: { id: { eq: id } } },
id: true,
partnerUserId: true,
},
});
return r.company?.partnerUserId ?? null;
}
async function getPersonPartnerUserId(
client: CoreApiClient,
id: string,
): Promise<string | null> {
const r = await client.query({
person: {
__args: { filter: { id: { eq: id } } },
id: true,
partnerUserId: true,
},
});
return r.person?.partnerUserId ?? null;
}
// Invoke the handler directly against a live workspace to assert its cascade logic and
// resulting DB state deterministically, without depending on the event bus. The trigger
// wiring (opportunity.updated → fires) is covered separately by app sync + manual E2E.
describe('on-opportunity-partner-assigned', () => {
let client: CoreApiClient;
// Track created record IDs for cleanup — order matters for FK constraints.
const createdOpportunityIds: string[] = [];
const createdPersonIds: string[] = [];
const createdCompanyIds: string[] = [];
const createdPartnerIds: string[] = [];
beforeEach(() => {
client = new CoreApiClient();
});
afterEach(async () => {
// Destroy in reverse FK order.
for (const id of createdOpportunityIds) await destroyOpportunity(client, id);
createdOpportunityIds.length = 0;
for (const id of createdPersonIds) await destroyPerson(client, id);
createdPersonIds.length = 0;
for (const id of createdCompanyIds) await destroyCompany(client, id);
createdCompanyIds.length = 0;
for (const id of createdPartnerIds) await destroyPartner(client, id);
createdPartnerIds.length = 0;
});
it('returns {} without any mutations when partnerId is not in updatedFields', async () => {
const result = await handler({
properties: { updatedFields: ['name'], after: { id: 'fake-id' } },
});
expect(result).toEqual({});
});
it('clears partnerUser on the opportunity, company, and people when the partner is removed (unassignment)', async () => {
const memberId = await getWorkspaceMemberId(client);
const partnerId = await createPartner(client, memberId);
createdPartnerIds.push(partnerId);
const companyId = await createCompany(client, `[test-rls] company ${Date.now()}`);
createdCompanyIds.push(companyId);
const personId = await createPerson(client, companyId);
createdPersonIds.push(personId);
const oppId = await createOpportunity(client, `[test-rls] opp ${Date.now()}`, companyId);
createdOpportunityIds.push(oppId);
// Assign first so the opportunity + company + person have partnerUser stamped.
await handler({
properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId, companyId } },
});
expect(await getOpportunityPartnerUserId(client, oppId)).toBe(memberId);
expect(await getCompanyPartnerUserId(client, companyId)).toBe(memberId);
expect(await getPersonPartnerUserId(client, personId)).toBe(memberId);
// Now unassign: partnerId set to null. No other opportunity uses this company for the
// member, so the company + person should be cleared too. `before` carries the removed
// partnerId so the handler can decide on the source of truth.
const result = await handler({
properties: {
updatedFields: ['partnerId'],
before: { id: oppId, partnerId, partnerUserId: memberId, companyId },
after: { id: oppId, partnerId: null, companyId },
},
});
expect(result.cascaded).toBe(true);
expect(result.cleared).toBe(true);
expect(await getOpportunityPartnerUserId(client, oppId)).toBeNull();
expect(await getCompanyPartnerUserId(client, companyId)).toBeNull();
expect(await getPersonPartnerUserId(client, personId)).toBeNull();
});
it('stamps partnerUserId on opportunity, company, and people when partner is assigned', async () => {
const memberId = await getWorkspaceMemberId(client);
const partnerId = await createPartner(client, memberId);
createdPartnerIds.push(partnerId);
const companyId = await createCompany(client, `[test-rls] company ${Date.now()}`);
createdCompanyIds.push(companyId);
const personId = await createPerson(client, companyId);
createdPersonIds.push(personId);
const oppId = await createOpportunity(client, `[test-rls] opp ${Date.now()}`, companyId);
createdOpportunityIds.push(oppId);
const result = await handler({
properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId, companyId } },
});
expect(result.cascaded).toBe(true);
expect(result.partnerUserId).toBe(memberId);
expect(await getOpportunityPartnerUserId(client, oppId)).toBe(memberId);
expect(await getCompanyPartnerUserId(client, companyId)).toBe(memberId);
expect(await getPersonPartnerUserId(client, personId)).toBe(memberId);
});
it('stamps partnerUserId on opportunity and returns cascaded: true when no companyId is present', async () => {
const memberId = await getWorkspaceMemberId(client);
const partnerId = await createPartner(client, memberId);
createdPartnerIds.push(partnerId);
// Opportunity with no linked company, so there is no company/people cascade.
const r = await client.mutation({
createOpportunity: {
__args: { data: { name: `[test-rls] no-company opp ${Date.now()}` } },
id: true,
},
});
const oppId = requireId(r.createOpportunity?.id, 'createOpportunity');
createdOpportunityIds.push(oppId);
const result = await handler({
properties: { updatedFields: ['partnerId'], after: { id: oppId, partnerId } },
});
expect(result.cascaded).toBe(true);
expect(result.partnerUserId).toBe(memberId);
expect(await getOpportunityPartnerUserId(client, oppId)).toBe(memberId);
});
it('returns { cascaded: false, reason: "partner_has_no_user" } when partner.partnerUserId is null', async () => {
// Create a partner WITHOUT a partnerUserId.
const r = await client.mutation({
createPartner: {
__args: {
data: {
name: `[test-rls] no-user partner ${Date.now()}`,
slug: `test-rls-nouser-${Date.now()}`,
},
},
id: true,
},
});
const noUserPartnerId = requireId(r.createPartner?.id, 'createPartner');
createdPartnerIds.push(noUserPartnerId);
const result = await handler({
properties: { updatedFields: ['partnerId'], after: { id: 'fake-opp-id', partnerId: noUserPartnerId } },
});
expect(result.cascaded).toBe(false);
expect(result.reason).toBe('partner_has_no_user');
});
});
@@ -0,0 +1,212 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import {
type DatabaseEventPayload,
defineLogicFunction,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk/define';
// Defined here (not in universal-identifiers.ts) to avoid touching that file's local-only id.
const ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER = 'd7e4a4e6-9142-4597-adcf-6fb83c0f042d';
const PEOPLE_PAGE_SIZE = 200;
// Every Person id matching the filter, paginated fully — a company can have more than one
// page of contacts and a single capped page would leave RLS stamps stale on the rest.
async function collectPeopleIds(
client: CoreApiClient,
filter: CoreSchema.PersonFilterInput,
): Promise<string[]> {
const ids: string[] = [];
let after: string | undefined;
for (;;) {
const page = await client.query({
people: {
__args: { filter, first: PEOPLE_PAGE_SIZE, ...(after ? { after } : {}) },
edges: { node: { id: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
for (const edge of page.people?.edges ?? []) {
if (edge?.node?.id) ids.push(edge.node.id);
}
if (!page.people?.pageInfo?.hasNextPage) break;
after = page.people.pageInfo.endCursor ?? undefined;
}
return ids;
}
// Set (or clear, with null) partnerUser on each Person, attempting all of them even if some
// fail. Returns the failure count so the caller can force a retry rather than silently
// leaving a partial stamp.
async function setPeoplePartnerUser(
client: CoreApiClient,
personIds: string[],
partnerUserId: string | null,
): Promise<number> {
const results = await Promise.allSettled(
personIds.map((id) =>
client.mutation({
updatePerson: { __args: { id, data: { partnerUserId } }, id: true },
}),
),
);
return results.filter((result) => result.status === 'rejected').length;
}
// On opportunity.updated when partnerId changes, propagate partnerUser (the assigned
// Partner's workspace member) across the deal so RLS visibility tracks the assignment:
// assign stamps it onto the Opportunity + linked Company + People; unassign clears it,
// keeping the Company/People if the same Partner still has another opportunity on that
// Company. Runs under the app identity, so its writes bypass partner RLS / field locks.
const handler = async (
payload: DatabaseEventPayload<ObjectRecordUpdateEvent<CoreSchema.Opportunity>>,
): Promise<Record<string, unknown>> => {
const { after, before, updatedFields } = payload.properties;
if (!updatedFields?.includes('partnerId')) return {};
const opportunityId = after?.id;
if (!opportunityId) return {};
const client = new CoreApiClient();
const partnerId = after?.partnerId;
// ── Unassign: the partner was removed from the opportunity ───────────────────
if (!partnerId) {
// Resolve who/what to revoke from the event's before-image so a retry (after a partial
// failure below) still has these values even though the opportunity's own partnerUser is
// cleared first. The removed partnerId is the source of truth for "is the company still
// in use". Fall back to reading the not-yet-cleared opportunity if before is incomplete.
let removedMemberId = before?.partnerUserId ?? null;
let companyId = before?.companyId ?? null;
const removedPartnerId = before?.partnerId;
if (!removedMemberId || !companyId) {
const oppResult = await client.query({
opportunity: {
__args: { filter: { id: { eq: opportunityId } } },
id: true,
partnerUserId: true,
companyId: true,
},
});
removedMemberId = removedMemberId ?? oppResult.opportunity?.partnerUserId ?? null;
companyId = companyId ?? oppResult.opportunity?.companyId ?? null;
}
await client.mutation({
updateOpportunity: {
__args: { id: opportunityId, data: { partnerUserId: null } },
id: true,
},
});
if (!removedMemberId || !companyId) {
return { cascaded: true, cleared: true };
}
// Keep the company (and its people) if the same Partner still has another opportunity on
// it. Decided on partnerId, not the derived partnerUser stamp (which a prior partial
// cascade may have left unset); fall back to the stamp only if the old partnerId is
// unavailable. The just-cleared opportunity no longer matches either filter.
const stillInUse = await client.query({
opportunities: {
__args: {
filter: {
companyId: { eq: companyId },
...(removedPartnerId
? { partnerId: { eq: removedPartnerId } }
: { partnerUserId: { eq: removedMemberId } }),
},
first: 1,
},
edges: { node: { id: true } },
},
});
if ((stillInUse.opportunities?.edges?.length ?? 0) > 0) {
return { cascaded: true, cleared: true, companyKept: true };
}
// Clear the company (only if it belongs to this member) and every person stamped for them.
const companyResult = await client.query({
company: {
__args: { filter: { id: { eq: companyId } } },
id: true,
partnerUserId: true,
},
});
if (companyResult.company?.partnerUserId === removedMemberId) {
await client.mutation({
updateCompany: {
__args: { id: companyId, data: { partnerUserId: null } },
id: true,
},
});
}
const peopleIds = await collectPeopleIds(client, {
companyId: { eq: companyId },
partnerUserId: { eq: removedMemberId },
});
const failed = await setPeoplePartnerUser(client, peopleIds, null);
if (failed > 0) {
throw new Error(
`on-opportunity-partner-assigned: ${failed} person clear(s) failed — retrying`,
);
}
return { cascaded: true, cleared: true, companyCleared: true };
}
// ── Assign / reassign ────────────────────────────────────────────────────────
const partnerResult = await client.query({
partner: { __args: { filter: { id: { eq: partnerId } } }, id: true, partnerUserId: true },
});
const partnerUserId = partnerResult.partner?.partnerUserId;
if (!partnerUserId) return { cascaded: false, reason: 'partner_has_no_user' };
await client.mutation({
updateOpportunity: { __args: { id: opportunityId, data: { partnerUserId } }, id: true },
});
const companyId = after?.companyId;
if (!companyId) return { cascaded: true, partnerUserId };
// Don't clobber a company already owned by a DIFFERENT partner member. The single
// partnerUser column on Company/Person models one owner per company, so reassigning it
// here would steal the company (and its contacts) from the other partner and expose their
// data. Stamp only the opportunity in that case and leave the company/people alone.
const companyResult = await client.query({
company: {
__args: { filter: { id: { eq: companyId } } },
id: true,
partnerUserId: true,
},
});
const companyOwner = companyResult.company?.partnerUserId;
if (companyOwner && companyOwner !== partnerUserId) {
return { cascaded: true, partnerUserId, companyShared: true };
}
await client.mutation({
updateCompany: { __args: { id: companyId, data: { partnerUserId } }, id: true },
});
const peopleIds = await collectPeopleIds(client, { companyId: { eq: companyId } });
const failed = await setPeoplePartnerUser(client, peopleIds, partnerUserId);
if (failed > 0) {
throw new Error(
`on-opportunity-partner-assigned: ${failed} person stamp(s) failed — retrying`,
);
}
return { cascaded: true, partnerUserId };
};
export default defineLogicFunction({
universalIdentifier: ON_OPP_PARTNER_ASSIGNED_FN_UNIVERSAL_IDENTIFIER,
name: 'on-opportunity-partner-assigned',
timeoutSeconds: 15,
handler,
databaseEventTriggerSettings: { eventName: 'opportunity.updated' },
});
@@ -1,18 +1,41 @@
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineRole } from 'twenty-sdk/define';
import {
INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID } from 'src/fields/opportunity-design-doc-status.field';
import { OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID } from 'src/fields/opportunity-design-doc-url.field';
import { OPPORTUNITY_HOSTING_TYPE_FIELD_ID } from 'src/fields/opportunity-hosting-type.field';
import { OPPORTUNITY_LOST_REASON_FIELD_ID } from 'src/fields/opportunity-lost-reason.field';
import { OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID } from 'src/fields/opportunity-number-of-seats.field';
import { OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID } from 'src/fields/opportunity-subscription-frequency.field';
import { OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID } from 'src/fields/opportunity-subscription-type.field';
import { OPPORTUNITY_TFT_ID_FIELD_ID } from 'src/fields/opportunity-tft-id.field';
import { OPPORTUNITY_USE_CASE_FIELD_ID } from 'src/fields/opportunity-use-case.field';
import { PARTNER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-on-opportunity.field';
import { PARTNER_USER_ON_OPPORTUNITY_FIELD_ID } from 'src/fields/partner-user-on-opportunity.field';
// PLACEHOLDER role for external partners. Twenty has no row-level filtering yet,
// so anyone assigned this role currently sees every record — do not hand out
// until RLP ships and we can scope to records owned by the assigned user.
// Shared with configure-partner-rls.ts, which locates the role by this label.
export const PARTNER_ROLE_LABEL = 'Partner';
// External partner self-service role: a partner sees only its own records, can edit its
// own Partner profile and an Opportunity's stage + amount; Company/Person are read-only.
// Row-level predicates can't ship in the manifest, so run `yarn rls:configure` after install.
//
// `updatedBy` and `position` must stay editable even though they're not partner-facing: the
// server injects `updatedBy` into every update (ActorFromAuthContextService) and co-writes
// `position` with `stage` on a kanban drag, so locking either makes ALL opportunity updates
// fail with PERMISSION_DENIED. The server overwrites both regardless, so there's nothing to
// protect. `createdBy` stays locked (not injected on update); `searchVector` is a generated
// column, so its lock is an inert no-op.
export default defineRole({
universalIdentifier: PARTNER_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Partner',
label: PARTNER_ROLE_LABEL,
description:
'PLACEHOLDER. External partner self-service role. Sees ALL Partner/Opportunity records today because Twenty does not yet support row-level record filtering. When RLP ships, scope these permissions to records owned by the assigned user. DO NOT assign to real external partners until then.',
'External partner self-service role. Sees only its own Partner/Person/Company/Opportunity records (row-level). Can edit its own Partner profile and an Opportunitys stage + amount; Company and Person are read-only. Configure predicates with `yarn rls:configure` after install.',
icon: 'IconBuildingStore',
canBeAssignedToUsers: true,
canUpdateAllSettings: false,
@@ -20,6 +43,177 @@ export default defineRole({
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
// Lock every Opportunity field except stage/amount (editable) and the system/
// server-managed fields left out here (id, timestamps, updatedBy, position — see header).
fieldPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.name
.universalIdentifier,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.closeDate
.universalIdentifier,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.createdBy
.universalIdentifier,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.pointOfContact
.universalIdentifier,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.company
.universalIdentifier,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.owner
.universalIdentifier,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.taskTargets
.universalIdentifier,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.noteTargets
.universalIdentifier,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.attachments
.universalIdentifier,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields
.timelineActivities.universalIdentifier,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.fields.searchVector
.universalIdentifier,
canUpdateFieldValue: false,
},
// App-specific Opportunity fields (added by this app, also read-only for Partner).
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: PARTNER_ON_OPPORTUNITY_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: PARTNER_USER_ON_OPPORTUNITY_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: MATCH_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY_USE_CASE_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY_TFT_ID_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY_DESIGN_DOC_URL_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY_SUBSCRIPTION_FREQUENCY_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY_HOSTING_TYPE_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY_LOST_REASON_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY_NUMBER_OF_SEATS_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY_SUBSCRIPTION_TYPE_FIELD_ID,
canUpdateFieldValue: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY_DESIGN_DOC_STATUS_FIELD_ID,
canUpdateFieldValue: false,
},
],
objectPermissions: [
{
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
@@ -32,7 +226,7 @@ export default defineRole({
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
@@ -52,5 +246,16 @@ export default defineRole({
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
// Read-only so the UI can resolve member-typed relations (own partnerUser link,
// owner/createdBy). An RLS predicate scopes this to the partner's own member record
// (see scripts/configure-partner-rls.ts) so the internal roster stays hidden.
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
});
@@ -0,0 +1,484 @@
// Idempotent. Re-run after every install/reinstall — the app manifest can't ship RLS
// predicates. Does two things:
//
// 1. Upserts row-level-permission predicates on the Partner role: "partnerUser IS the
// current member" on each of partner/person/company/opportunity, plus "id IS the
// current member" on workspaceMember so the partner sees only its own member record
// (member-typed relations still resolve; the internal roster stays hidden).
//
// 2. Verifies (does NOT set) the Opportunity field permissions from `partner.role.ts`.
// upsertFieldPermissions rejects out-of-band changes to app-owned roles
// (ROLE_BELONGS_TO_ANOTHER_APPLICATION), so those must come from the manifest; if any
// expected lock is missing, the script exits non-zero and tells you to re-sync.
//
// Usage:
// yarn rls:configure # against .env.local
// yarn rls:configure:prod # against .env.prod
import { config } from 'dotenv';
config({ path: process.env.ENV_FILE ?? '.env.local' });
import { PARTNER_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_ROLE_LABEL } from 'src/roles/partner.role';
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing env var: ${name}`);
return value;
};
const TARGET_OBJECTS = ['partner', 'person', 'company', 'opportunity'] as const;
type TargetObject = (typeof TARGET_OBJECTS)[number];
// Opportunity fields that must NOT be locked: system columns, the editable business
// fields (stage, amount), and updatedBy/position (server-managed — locking them breaks
// every update; see src/roles/partner.role.ts). Everything else is expected to be locked.
const OPPORTUNITY_FIELD_LOCK_SKIP = new Set([
'id',
'createdAt',
'updatedAt',
'deletedAt',
'updatedBy',
'position',
'stage',
'amount',
]);
type ObjectInfo = {
objectMetadataId: string;
partnerUserFieldMetadataId: string;
};
type FieldEdge = {
node: {
id: string;
name: string;
type: string;
};
};
type PageInfo = {
hasNextPage: boolean;
endCursor: string | null;
};
type FieldsPage = {
edges: FieldEdge[];
pageInfo: PageInfo;
};
type MetadataResponse<T> = {
data: T;
errors?: { message: string }[];
};
async function metadataFetch<T>(
metadataUrl: string,
apiKey: string,
query: string,
variables?: Record<string, unknown>,
): Promise<T> {
const res = await fetch(metadataUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ query, variables }),
});
const json = (await res.json()) as MetadataResponse<T>;
if (json.errors && json.errors.length > 0) {
throw new Error(
`GraphQL errors: ${json.errors.map((e) => e.message).join('; ')}`,
);
}
return json.data;
}
// Pages through an object's fields until it finds a field with the given name.
// Uses cursor-based pagination to avoid truncation on large objects (company/opportunity
// have >200 fields, so a single 200-cap request may miss partnerUser).
async function findFieldByName(
metadataUrl: string,
apiKey: string,
objectId: string,
objectName: string,
fieldName: string,
): Promise<string> {
let after: string | null = null;
let page = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
page++;
const pagingArg = after
? `paging:{first:200, after:"${after}"}`
: `paging:{first:200}`;
const query = `{
object(id: "${objectId}") {
fields(${pagingArg}) {
edges { node { id name type } }
pageInfo { hasNextPage endCursor }
}
}
}`;
const data = await metadataFetch<{
object: { fields: FieldsPage };
}>(metadataUrl, apiKey, query);
const match = data.object.fields.edges.find(
(e) => e.node.name === fieldName,
);
if (match) {
console.log(
` [rls:configure] ${objectName}.${fieldName} found on page ${page} ` +
`(type=${match.node.type}, id=${match.node.id})`,
);
return match.node.id;
}
if (!data.object.fields.pageInfo.hasNextPage) {
// Print available fields to help diagnose a schema mismatch.
const names = data.object.fields.edges
.map((e) => e.node.name)
.join(', ');
throw new Error(
`Field "${fieldName}" not found on object "${objectName}" after ${page} page(s). ` +
`Last-page fields: ${names}`,
);
}
after = data.object.fields.pageInfo.endCursor;
}
}
// Collects every field on an object across all cursor pages.
// Required for Opportunity which can grow; always paginate fully.
async function collectAllFields(
metadataUrl: string,
apiKey: string,
objectId: string,
): Promise<{ id: string; name: string; type: string }[]> {
const all: { id: string; name: string; type: string }[] = [];
let after: string | null = null;
// eslint-disable-next-line no-constant-condition
while (true) {
const pagingArg = after
? `paging:{first:200, after:"${after}"}`
: `paging:{first:200}`;
const query = `{
object(id: "${objectId}") {
fields(${pagingArg}) {
edges { node { id name type } }
pageInfo { hasNextPage endCursor }
}
}
}`;
const data = await metadataFetch<{
object: { fields: FieldsPage };
}>(metadataUrl, apiKey, query);
for (const edge of data.object.fields.edges) {
all.push(edge.node);
}
if (!data.object.fields.pageInfo.hasNextPage) break;
after = data.object.fields.pageInfo.endCursor;
}
return all;
}
type FieldPermissionResult = {
id: string;
objectMetadataId: string;
fieldMetadataId: string;
canReadFieldValue: boolean | null;
canUpdateFieldValue: boolean | null;
};
type PredicateResult = {
id: string;
fieldMetadataId: string;
objectMetadataId: string;
operand: string;
workspaceMemberFieldMetadataId: string | null;
roleId: string;
};
async function main() {
const baseUrl = requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, '');
const apiKey = requireEnv('TWENTY_PARTNERS_API_KEY');
const metadataUrl = `${baseUrl}/metadata`;
console.log(`[rls:configure] target: ${metadataUrl}`);
// ── 1. Resolve all object metadata IDs and their partnerUser field IDs ──────
const objectsData = await metadataFetch<{
objects: { edges: { node: { id: string; nameSingular: string } }[] };
}>(
metadataUrl,
apiKey,
`{ objects(paging:{first:100}) { edges { node { id nameSingular } } } }`,
);
const objectIdByName = new Map<string, string>(
objectsData.objects.edges.map((e) => [e.node.nameSingular, e.node.id]),
);
for (const name of TARGET_OBJECTS) {
if (!objectIdByName.has(name)) {
throw new Error(
`Object "${name}" not found in workspace metadata. ` +
`Has the app been installed and synced?`,
);
}
}
if (!objectIdByName.has('workspaceMember')) {
throw new Error('workspaceMember object not found in workspace metadata.');
}
const workspaceMemberId = objectIdByName.get('workspaceMember') as string;
const objectInfoByName = new Map<TargetObject, ObjectInfo>();
for (const name of TARGET_OBJECTS) {
const objectId = objectIdByName.get(name) as string;
const partnerUserFieldMetadataId = await findFieldByName(
metadataUrl,
apiKey,
objectId,
name,
'partnerUser',
);
objectInfoByName.set(name, {
objectMetadataId: objectId,
partnerUserFieldMetadataId,
});
}
// ── 2. Resolve workspaceMember.id field metadata id ──────────────────────────
const workspaceMemberIdFieldId = await findFieldByName(
metadataUrl,
apiKey,
workspaceMemberId,
'workspaceMember',
'id',
);
// ── 3. Resolve Partner role id and fetch field permissions in one request ──────
//
// getRoles returns a flat array (not a connection) and does NOT expose
// universalIdentifier, so we match on the role label via the shared PARTNER_ROLE_LABEL
// constant (exported from partner.role.ts) — a rename there can't desync this script.
// Fetching fieldPermissions here avoids a second getRoles call later in step 5.
const rolesData = await metadataFetch<{
getRoles: {
id: string;
label: string;
fieldPermissions: FieldPermissionResult[];
}[];
}>(
metadataUrl,
apiKey,
`{ getRoles { id label fieldPermissions { id fieldMetadataId objectMetadataId canUpdateFieldValue canReadFieldValue } } }`,
);
const roles = rolesData.getRoles;
const partnerRole = roles.find((r) => r.label === PARTNER_ROLE_LABEL);
if (!partnerRole) {
const labels = roles.map((r) => r.label).join(', ');
throw new Error(
`Partner role not found. Available roles: ${labels}. ` +
`Ensure the app is installed (universalIdentifier=${PARTNER_ROLE_UNIVERSAL_IDENTIFIER}).`,
);
}
console.log(
`[rls:configure] Partner role id: ${partnerRole.id} ` +
`(universalIdentifier in manifest: ${PARTNER_ROLE_UNIVERSAL_IDENTIFIER})`,
);
// ── 4. Upsert one predicate per object ───────────────────────────────────────
//
// "the record's partnerUser relation IS the current workspace member". Operand must be IS,
// not CONTAINS: the upsert accepts CONTAINS but the RELATION query filter only allows
// IS / IS_NOT and throws "Unknown operand CONTAINS for RELATION filter" at query time.
// value stays null; workspaceMemberFieldMetadataId injects the current member at query time.
const MUTATION = `
mutation UpsertRLSPredicates($input: UpsertRowLevelPermissionPredicatesInput!) {
upsertRowLevelPermissionPredicates(input: $input) {
predicates {
id
fieldMetadataId
objectMetadataId
operand
workspaceMemberFieldMetadataId
roleId
}
}
}
`;
const results: PredicateResult[] = [];
for (const name of TARGET_OBJECTS) {
const info = objectInfoByName.get(name) as ObjectInfo;
const data = await metadataFetch<{
upsertRowLevelPermissionPredicates: {
predicates: PredicateResult[];
};
}>(metadataUrl, apiKey, MUTATION, {
input: {
roleId: partnerRole.id,
objectMetadataId: info.objectMetadataId,
predicates: [
{
fieldMetadataId: info.partnerUserFieldMetadataId,
operand: 'IS',
workspaceMemberFieldMetadataId: workspaceMemberIdFieldId,
},
],
predicateGroups: [],
},
});
const predicate =
data.upsertRowLevelPermissionPredicates.predicates[0];
if (!predicate) {
throw new Error(
`upsertRowLevelPermissionPredicates returned no predicates for object "${name}"`,
);
}
results.push(predicate);
console.log(
`[rls:configure] ✓ ${name}: predicate id=${predicate.id} ` +
`(fieldMetadataId=${predicate.fieldMetadataId}, operand=${predicate.operand})`,
);
}
// workspaceMember predicate: "id IS the current member", scoping the role's read to the
// partner's own record. Other members (e.g. an opportunity's internal owner) resolve to null.
{
const wmData = await metadataFetch<{
upsertRowLevelPermissionPredicates: { predicates: PredicateResult[] };
}>(metadataUrl, apiKey, MUTATION, {
input: {
roleId: partnerRole.id,
objectMetadataId: workspaceMemberId,
predicates: [
{
fieldMetadataId: workspaceMemberIdFieldId,
operand: 'IS',
workspaceMemberFieldMetadataId: workspaceMemberIdFieldId,
},
],
predicateGroups: [],
},
});
const wmPredicate =
wmData.upsertRowLevelPermissionPredicates.predicates[0];
if (!wmPredicate) {
throw new Error(
'upsertRowLevelPermissionPredicates returned no predicate for workspaceMember',
);
}
results.push(wmPredicate);
console.log(
`[rls:configure] ✓ workspaceMember: predicate id=${wmPredicate.id} ` +
`(fieldMetadataId=${wmPredicate.fieldMetadataId}, operand=${wmPredicate.operand})`,
);
}
console.log(
`\n[rls:configure] Done — ${results.length} predicates upserted on Partner role ` +
`(${TARGET_OBJECTS.length} objects + workspaceMember self-scope)`,
);
// ── 5. Verify Opportunity field permissions (set via manifest, not here — see header) ─
// Read back the role's existing fieldPermissions so one run confirms the full state.
const oppInfo = objectInfoByName.get('opportunity') as ObjectInfo;
const oppObjectId = oppInfo.objectMetadataId;
const allOppFields = await collectAllFields(metadataUrl, apiKey, oppObjectId);
const oppFieldIdToName = new Map<string, string>(
allOppFields.map((f) => [f.id, f.name]),
);
// Build the expected lock set: every non-system, non-stage Opportunity field.
const expectedLockedNames = new Set<string>(
allOppFields
.filter((f) => !OPPORTUNITY_FIELD_LOCK_SKIP.has(f.name))
.map((f) => f.name),
);
// Filter to Opportunity field permissions that lock update access.
// partnerRole was fetched with fieldPermissions in step 3 — no second getRoles needed.
const oppLockedFps = partnerRole.fieldPermissions.filter(
(fp) =>
fp.objectMetadataId === oppObjectId && fp.canUpdateFieldValue === false,
);
const missingLocks = [...expectedLockedNames].filter(
(name) =>
!oppLockedFps.some(
(fp) => oppFieldIdToName.get(fp.fieldMetadataId) === name,
),
);
const stageIsLocked = oppLockedFps.some(
(fp) => oppFieldIdToName.get(fp.fieldMetadataId) === 'stage',
);
if (stageIsLocked) {
console.warn(
`[rls:configure] WARNING: stage field is locked — it should be editable. ` +
`Remove it from fieldPermissions in partner.role.ts and re-sync.`,
);
}
if (missingLocks.length > 0) {
console.error(
`\n[rls:configure] DRIFT DETECTED: ${missingLocks.length} Opportunity field(s) ` +
`are NOT locked (canUpdateFieldValue: false) on the Partner role:\n` +
` ${missingLocks.join(', ')}\n\n` +
`These permissions are declared in partner.role.ts and must be deployed via the\n` +
`app manifest. Run the following to deploy them:\n\n` +
` yarn twenty dev --once -r <remote>\n\n` +
`(e.g. \`yarn twenty dev --once\` for local, ` +
`\`yarn twenty dev --once -r partner-twenty-com\` for prod)\n`,
);
process.exitCode = 1;
return;
}
console.log(
`[rls:configure] ✓ ${oppLockedFps.length} Opportunity fields locked (stage + amount editable) — field permissions verified`,
);
}
main().catch((err: unknown) => {
console.error('[rls:configure] FAILED:', err);
process.exit(1);
});
@@ -4,6 +4,7 @@ import {
ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field';
// Every partner, all stages — kept last in the Partners folder. No Last Match column.
export default defineView({
@@ -25,5 +26,7 @@ export default defineView({
{ universalIdentifier: '52408b5f-5e13-4e3c-af2d-ce50033ec126', fieldMetadataUniversalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad', position: 4, isVisible: true },
// Categories (partnerScope)
{ universalIdentifier: '34d58668-a689-42e2-9aff-3fee315092d6', fieldMetadataUniversalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a', position: 5, isVisible: true, size: 260 },
// partnerUser: the member this partner logs in as — prerequisite for the cascade + RLS visibility.
{ universalIdentifier: '19d1fc62-2486-4f59-8e77-b4ee177481b1', fieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, position: 6, isVisible: true },
],
});
@@ -4,6 +4,7 @@ import {
PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { PARTNER_USER_ON_PARTNER_FIELD_ID } from 'src/fields/partner-user-on-partner.field';
// Validated partners — serves both partner intros and the public website list.
// Columns mirror the Applications view (for consistent reading) plus Partner Tier;
@@ -38,8 +39,10 @@ export default defineView({
{ universalIdentifier: '75d5dad8-259f-4911-9607-06f247410329', fieldMetadataUniversalIdentifier: 'a77d7fa6-c398-47db-af0f-036a5c719f20', position: 5, isVisible: true },
// LinkedIn
{ universalIdentifier: '72390fd5-b148-4074-b41d-c64bc28097a6', fieldMetadataUniversalIdentifier: '640bbf33-45d7-4174-a862-dbe611ab8d1a', position: 6, isVisible: true },
// Type of Team (kept last)
// Type of Team
{ universalIdentifier: '300ea008-d311-4375-b8eb-74e8731c52cc', fieldMetadataUniversalIdentifier: 'a451e557-a488-470a-8b35-6f9b8cfb1a10', position: 7, isVisible: true },
// partnerUser: the login member this partner signs in as (set inline here, like setting an owner).
{ universalIdentifier: '9e06eeaa-649d-412a-aacd-0f8bf7ee69c3', fieldMetadataUniversalIdentifier: PARTNER_USER_ON_PARTNER_FIELD_ID, position: 8, isVisible: true },
],
filters: [
{