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:
@@ -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",
|
||||
|
||||
+17
@@ -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,
|
||||
},
|
||||
});
|
||||
+17
@@ -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,
|
||||
},
|
||||
});
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+3
-1
@@ -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',
|
||||
|
||||
+18
@@ -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,
|
||||
},
|
||||
});
|
||||
+20
@@ -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',
|
||||
},
|
||||
});
|
||||
+20
@@ -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',
|
||||
},
|
||||
});
|
||||
+22
@@ -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',
|
||||
},
|
||||
});
|
||||
+20
@@ -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',
|
||||
},
|
||||
});
|
||||
+17
@@ -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,
|
||||
},
|
||||
});
|
||||
+273
@@ -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');
|
||||
});
|
||||
});
|
||||
+212
@@ -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 Opportunity’s 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: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user