feat(sdk): declare row-level permission predicates in the role manifest (#21919)

## Why

Apps can declare object and field permissions on a role via
`defineRole`, but **not row-level security**. The RLS engine and the
metadata-sync machinery already support predicates fully — they're
first-class universal flat entities, the `FlatRole` already carries
`rowLevelPermissionPredicateUniversalIdentifiers`, and the
workspace-migration layer has builders/validators/handlers for them. The
only gap was the **manifest layer**: `RoleManifest` had no field for
predicates, so the sync converter always left them empty.

As a result, the only way to ship RLS with an app was a post-install
script that pushed predicates through the
`upsertRowLevelPermissionPredicates` mutation. That mutation assigns
predicates to the workspace's **generic custom application**, not the
app that owns the role — so a single role's definition ends up split
across two applications and drifts on every upgrade (you have to
remember to re-run the script). The Partner app does exactly this today
via `configure-partner-rls.ts`.

## What

Adds `rowLevelPermissionPredicates` and
`rowLevelPermissionPredicateGroups` to `RoleManifest` / `RoleConfig`,
mirroring how `objectPermissions` / `fieldPermissions` already flow
end-to-end:

- **twenty-shared** — predicate + predicate-group manifest types on
`RoleManifest` (referencing objects/fields by `universalIdentifier`,
operand/logical-operator from the existing GraphQL enums).
- **twenty-sdk** — `defineRole` accepts and validates them; the build
derives deterministic predicate `universalIdentifier`s (groups keep an
explicit one so predicates can reference them).
- **twenty-server** — two converters turn manifest predicates/groups
into universal flat entities during application-manifest sync, so they
are created/updated/deleted together with the role and **owned by the
app that ships it**.

### Bug fix found along the way

The migration build order ran the `rowLevelPermissionPredicate(Group)`
builders **before** the `role` builder, so a predicate declared
alongside a brand-new role failed validation with `ROLE_NOT_FOUND`. They
now run **after** the role builder, exactly like object/field
permissions.

## Partner app (second commit)

Converts `partner.role.ts` to declare its five predicates inline and
**deletes `configure-partner-rls.ts`** + the `rls:configure` scripts —
the workaround this PR is meant to retire. The predicates are
byte-for-byte the same semantics as the script produced.

> Live-deployment note: the existing script-created predicates are owned
by the *custom* application, so the Partner app sync won't touch them.
Clear them once (e.g. an empty upsert on the Partner role) around deploy
to avoid duplicates. Kept as a **separate commit** so it can be split
out if reviewers prefer.

## Testing

- **Integration (full app):** new
`successful-manifest-sync-row-level-permission-predicate.integration-spec.ts`
— installs an app whose role declares a predicate and asserts the
predicate row is created (and **owned by the app**, not the custom app),
updated in place on re-sync, removed when dropped from the manifest, and
removed on uninstall. Ran locally against a seeded test DB .
- Re-ran the existing cross-app permission + view-field manifest suites
to confirm the build-order change doesn't regress
object/field-permission sync (13/13 ).
- **Unit (utils only):** `defineRole` validation and
`fromRoleConfigToRoleManifest` deterministic-id derivation.
- Docs: new "Row-level security" section in `apps/config/roles.mdx`.

## Scope notes / possible follow-ups

- Surfacing RLS in the app-install permission summary UI was
intentionally left out (predicates *restrict* rather than grant, and
typically live on a non-default role) — easy follow-up if wanted.
- The `upsertRowLevelPermissionPredicates` mutation still homes
out-of-band predicates on the custom app for app-owned roles; making
that consistent (or rejecting it, like field permissions already do) is
a sensible follow-up.

https://claude.ai/code/session_01MipAis9z9okd4oCm9HCKEf

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MipAis9z9okd4oCm9HCKEf)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21919?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-06-21 22:09:19 +02:00
committed by GitHub
parent 573fd00ea7
commit a682c8fa62
16 changed files with 887 additions and 8 deletions
@@ -50,6 +50,124 @@ export default defineRole({
});
```
## Row-level security
Object and field permissions decide _which objects and fields_ a role can touch. **Row-level
permission predicates** go further and decide _which records_ a role can see and act on — for
example, a self-service role where each external user sees only their own records.
Declare predicates with `rowLevelPermissionPredicates` on the role. Like the rest of the manifest,
each predicate carries its own `universalIdentifier`, and references an object and a field by their
`universalIdentifier`, an `operand`, and (optionally) a workspaceMember field whose value is injected
at query time — so you can express "the record's owner relation **is** the current workspace member":
```ts src/roles/partner-role.ts
import {
defineRole,
RowLevelPermissionPredicateOperand,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER } from '../fields/account-owner.field';
export default defineRole({
universalIdentifier: 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343',
label: 'Partner',
description: 'External partner — sees only its own records',
canBeAssignedToUsers: true,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: 'd0f0c1a2-3b4c-4d5e-8f60-111111111111',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
fieldUniversalIdentifier: ACCOUNT_OWNER_FIELD_UNIVERSAL_IDENTIFIER,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id
.universalIdentifier,
},
],
});
```
Because predicates ship in the manifest, they are created, updated, and removed together with the
role on every install and upgrade — there is no separate post-install step to keep in sync.
### Combining predicates with groups
By default a role's predicates are combined with `AND`. To combine some of them with `OR` (or to
nest logic), declare a `rowLevelPermissionPredicateGroups` entry and point each predicate at it via
`predicateGroupUniversalIdentifier`. This role lets a partner see an Opportunity it either **owns**
or is the **point of contact** for:
```ts src/roles/partner-opportunities-role.ts
import {
defineRole,
RowLevelPermissionPredicateGroupLogicalOperator,
RowLevelPermissionPredicateOperand,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
const OPPORTUNITY = STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity;
const CURRENT_MEMBER =
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.fields.id
.universalIdentifier;
export default defineRole({
universalIdentifier: 'b2a1c0d9-8e7f-4a6b-9c5d-222222222222',
label: 'Partner (opportunities)',
canBeAssignedToUsers: true,
objectPermissions: [
{
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
canReadObjectRecords: true,
},
],
rowLevelPermissionPredicateGroups: [
{
universalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator.OR,
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: 'd4c3b2a1-0e9f-4c8b-9e7d-444444444444',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
fieldUniversalIdentifier: OPPORTUNITY.fields.owner.universalIdentifier,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER,
predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
},
{
universalIdentifier: 'e5d4c3b2-1f0e-4d9c-8f8e-555555555555',
objectUniversalIdentifier: OPPORTUNITY.universalIdentifier,
fieldUniversalIdentifier:
OPPORTUNITY.fields.pointOfContact.universalIdentifier,
operand: RowLevelPermissionPredicateOperand.IS,
workspaceMemberFieldUniversalIdentifier: CURRENT_MEMBER,
predicateGroupUniversalIdentifier: 'c3b2a1d0-9f8e-4b7a-8d6c-333333333333',
},
],
});
```
Notes:
- Give every predicate and group a stable `universalIdentifier` (any uuid) — it keys the entity
across upgrades, and predicates reference groups by it.
- Predicates can reference objects and fields owned by your app or by Twenty's standard objects.
- Row-level security is enforced for workspaces on plans that include it; the predicates still sync
on other plans, they are simply not enforced.
## The default function role
When you scaffold a new app, the CLI creates a default role file declared with `defineApplicationRole()`:
@@ -333,6 +333,8 @@ export const EXPECTED_MANIFEST: Manifest = {
canBeAssignedToApiKeys: false,
fieldPermissions: [],
objectPermissions: [],
rowLevelPermissionPredicateGroups: [],
rowLevelPermissionPredicates: [],
permissionFlagUniversalIdentifiers: [],
},
],
@@ -1399,6 +1399,8 @@ export const EXPECTED_MANIFEST: Manifest = {
universalIdentifier: 'c0c1c2c3-c4c5-4000-8000-000000000001',
fieldPermissions: [],
objectPermissions: [],
rowLevelPermissionPredicateGroups: [],
rowLevelPermissionPredicates: [],
permissionFlagUniversalIdentifiers: [],
},
{
@@ -1431,6 +1433,8 @@ export const EXPECTED_MANIFEST: Manifest = {
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
},
],
rowLevelPermissionPredicateGroups: [],
rowLevelPermissionPredicates: [],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
},
@@ -14,6 +14,8 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"OnDeleteAction",
"PageLayoutTabLayoutMode",
"RelationType",
"RowLevelPermissionPredicateGroupLogicalOperator",
"RowLevelPermissionPredicateOperand",
"STANDARD_OBJECT",
"STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS",
"STANDARD_PAGE_LAYOUT",
@@ -0,0 +1,70 @@
import { fromRoleConfigToRoleManifest } from '@/cli/utilities/build/manifest/utils/from-role-config-to-role-manifest';
import { type RoleConfig } from '@/sdk/define/roles/role-config';
import {
RowLevelPermissionPredicateGroupLogicalOperator,
RowLevelPermissionPredicateOperand,
} from '@/sdk/define';
const ROLE_UNIVERSAL_IDENTIFIER = 'c3c1dc2e-1a08-4de5-abb7-2139b3d99343';
const OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d';
const FIELD_UNIVERSAL_IDENTIFIER = '0e49f2e4-1e45-433d-bf49-79acc0b06d0e';
const PREDICATE_UNIVERSAL_IDENTIFIER = '22222222-0000-4000-8000-000000000000';
const GROUP_UNIVERSAL_IDENTIFIER = '11111111-0000-4000-8000-000000000000';
const baseConfig: RoleConfig = {
universalIdentifier: ROLE_UNIVERSAL_IDENTIFIER,
label: 'Partner',
};
describe('fromRoleConfigToRoleManifest', () => {
it('passes predicates through with their explicit universalIdentifier', () => {
const config: RoleConfig = {
...baseConfig,
rowLevelPermissionPredicates: [
{
universalIdentifier: PREDICATE_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: FIELD_UNIVERSAL_IDENTIFIER,
operand: RowLevelPermissionPredicateOperand.IS,
},
],
};
const manifest = fromRoleConfigToRoleManifest(config);
const predicate = manifest.rowLevelPermissionPredicates?.[0];
expect(predicate?.universalIdentifier).toBe(PREDICATE_UNIVERSAL_IDENTIFIER);
expect(predicate?.objectUniversalIdentifier).toBe(
OBJECT_UNIVERSAL_IDENTIFIER,
);
expect(predicate?.fieldUniversalIdentifier).toBe(FIELD_UNIVERSAL_IDENTIFIER);
expect(predicate?.operand).toBe(RowLevelPermissionPredicateOperand.IS);
});
it('passes predicate groups through with their explicit universalIdentifier', () => {
const config: RoleConfig = {
...baseConfig,
rowLevelPermissionPredicateGroups: [
{
universalIdentifier: GROUP_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator.OR,
},
],
};
const manifest = fromRoleConfigToRoleManifest(config);
expect(manifest.rowLevelPermissionPredicateGroups).toHaveLength(1);
expect(
manifest.rowLevelPermissionPredicateGroups?.[0]?.universalIdentifier,
).toBe(GROUP_UNIVERSAL_IDENTIFIER);
});
it('defaults predicate collections to empty arrays', () => {
const manifest = fromRoleConfigToRoleManifest(baseConfig);
expect(manifest.rowLevelPermissionPredicates).toEqual([]);
expect(manifest.rowLevelPermissionPredicateGroups).toEqual([]);
});
});
@@ -28,6 +28,10 @@ export const fromRoleConfigToRoleManifest = (
),
}),
),
rowLevelPermissionPredicateGroups:
roleConfig.rowLevelPermissionPredicateGroups ?? [],
rowLevelPermissionPredicates:
roleConfig.rowLevelPermissionPredicates ?? [],
permissionFlagUniversalIdentifiers:
roleConfig.permissionFlagUniversalIdentifiers ?? [],
};
@@ -139,6 +139,14 @@ export type {
export { defineApplicationRole } from '@/sdk/define/roles/define-application-role';
export { defineRole } from '@/sdk/define/roles/define-role';
export type {
RowLevelPermissionPredicateManifest,
RowLevelPermissionPredicateGroupManifest,
} from 'twenty-shared/application';
export {
RowLevelPermissionPredicateGroupLogicalOperator,
RowLevelPermissionPredicateOperand,
} from 'twenty-shared/types';
export { SystemPermissionFlag } from 'twenty-shared/constants';
export { defineSkill } from '@/sdk/define/skills/define-skill';
@@ -124,4 +124,227 @@ describe('defineRole', () => {
'Field permission must have a fieldUniversalIdentifier',
);
});
it('should accept row level permission predicates', () => {
const config = {
...validConfig,
rowLevelPermissionPredicates: [
{
universalIdentifier: '22222222-0000-4000-8000-000000000000',
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6',
operand: 'IS',
workspaceMemberFieldUniversalIdentifier:
'b1c2d3e4-0829-4475-a794-d0d4959161e6',
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
});
it('should return error when predicate has no universalIdentifier', () => {
const config = {
...validConfig,
rowLevelPermissionPredicates: [
{
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6',
operand: 'IS',
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Row level permission predicate must have a universalIdentifier',
);
});
it('should return error when two predicates share a universalIdentifier', () => {
const config = {
...validConfig,
rowLevelPermissionPredicates: [
{
universalIdentifier: '22222222-0000-4000-8000-000000000000',
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6',
operand: 'IS',
},
{
universalIdentifier: '22222222-0000-4000-8000-000000000000',
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6',
operand: 'IS_NOT',
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Duplicate row level permission predicate universalIdentifier "22222222-0000-4000-8000-000000000000"',
);
});
it('should return error when predicate has no objectUniversalIdentifier', () => {
const config = {
...validConfig,
rowLevelPermissionPredicates: [
{
universalIdentifier: '22222222-0000-4000-8000-000000000000',
fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6',
operand: 'IS',
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Row level permission predicate must have an objectUniversalIdentifier',
);
});
it('should return error when predicate has no fieldUniversalIdentifier', () => {
const config = {
...validConfig,
rowLevelPermissionPredicates: [
{
universalIdentifier: '22222222-0000-4000-8000-000000000000',
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
operand: 'IS',
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Row level permission predicate must have a fieldUniversalIdentifier',
);
});
it('should return error when predicate has no operand', () => {
const config = {
...validConfig,
rowLevelPermissionPredicates: [
{
universalIdentifier: '22222222-0000-4000-8000-000000000000',
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6',
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Row level permission predicate must have an operand',
);
});
it('should return error when predicate references an unknown predicate group', () => {
const config = {
...validConfig,
rowLevelPermissionPredicates: [
{
universalIdentifier: '22222222-0000-4000-8000-000000000000',
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6',
operand: 'IS',
predicateGroupUniversalIdentifier:
'00000000-0000-4000-8000-000000000000',
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Row level permission predicate references unknown predicate group "00000000-0000-4000-8000-000000000000"',
);
});
it('should accept a predicate referencing a declared predicate group', () => {
const config = {
...validConfig,
rowLevelPermissionPredicateGroups: [
{
universalIdentifier: '11111111-0000-4000-8000-000000000000',
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
logicalOperator: 'OR',
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: '22222222-0000-4000-8000-000000000000',
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6',
operand: 'IS',
predicateGroupUniversalIdentifier:
'11111111-0000-4000-8000-000000000000',
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
});
it('should return error when predicate group has no logicalOperator', () => {
const config = {
...validConfig,
rowLevelPermissionPredicateGroups: [
{
universalIdentifier: '11111111-0000-4000-8000-000000000000',
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Row level permission predicate group must have a logicalOperator',
);
});
it('should return error when two predicate groups share a universalIdentifier', () => {
const config = {
...validConfig,
rowLevelPermissionPredicateGroups: [
{
universalIdentifier: '11111111-0000-4000-8000-000000000000',
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
logicalOperator: 'AND',
},
{
universalIdentifier: '11111111-0000-4000-8000-000000000000',
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
logicalOperator: 'OR',
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Duplicate row level permission predicate group universalIdentifier "11111111-0000-4000-8000-000000000000"',
);
});
});
@@ -33,5 +33,84 @@ export const defineRole: DefineEntity<RoleConfig> = (config) => {
}
}
const predicateGroupUniversalIdentifiers = new Set<string>();
if (config.rowLevelPermissionPredicateGroups) {
for (const group of config.rowLevelPermissionPredicateGroups) {
if (!group.universalIdentifier) {
errors.push(
'Row level permission predicate group must have a universalIdentifier',
);
} else if (
predicateGroupUniversalIdentifiers.has(group.universalIdentifier)
) {
errors.push(
`Duplicate row level permission predicate group universalIdentifier "${group.universalIdentifier}"`,
);
} else {
predicateGroupUniversalIdentifiers.add(group.universalIdentifier);
}
if (!group.objectUniversalIdentifier) {
errors.push(
'Row level permission predicate group must have an objectUniversalIdentifier',
);
}
if (!group.logicalOperator) {
errors.push(
'Row level permission predicate group must have a logicalOperator',
);
}
}
}
const predicateUniversalIdentifiers = new Set<string>();
if (config.rowLevelPermissionPredicates) {
for (const predicate of config.rowLevelPermissionPredicates) {
if (!predicate.universalIdentifier) {
errors.push(
'Row level permission predicate must have a universalIdentifier',
);
} else if (
predicateUniversalIdentifiers.has(predicate.universalIdentifier)
) {
errors.push(
`Duplicate row level permission predicate universalIdentifier "${predicate.universalIdentifier}"`,
);
} else {
predicateUniversalIdentifiers.add(predicate.universalIdentifier);
}
if (!predicate.objectUniversalIdentifier) {
errors.push(
'Row level permission predicate must have an objectUniversalIdentifier',
);
}
if (!predicate.fieldUniversalIdentifier) {
errors.push(
'Row level permission predicate must have a fieldUniversalIdentifier',
);
}
if (!predicate.operand) {
errors.push('Row level permission predicate must have an operand');
}
if (
predicate.predicateGroupUniversalIdentifier &&
!predicateGroupUniversalIdentifiers.has(
predicate.predicateGroupUniversalIdentifier,
)
) {
errors.push(
`Row level permission predicate references unknown predicate group "${predicate.predicateGroupUniversalIdentifier}"`,
);
}
}
}
return createValidationResult({ config, errors });
};
@@ -0,0 +1,36 @@
import { type RowLevelPermissionPredicateGroupManifest } from 'twenty-shared/application';
import { type UniversalFlatRowLevelPermissionPredicateGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-row-level-permission-predicate-group.type';
export const fromRowLevelPermissionPredicateGroupManifestToUniversalFlatRowLevelPermissionPredicateGroup =
({
rowLevelPermissionPredicateGroupManifest,
roleUniversalIdentifier,
applicationUniversalIdentifier,
now,
}: {
rowLevelPermissionPredicateGroupManifest: RowLevelPermissionPredicateGroupManifest;
roleUniversalIdentifier: string;
applicationUniversalIdentifier: string;
now: string;
}): UniversalFlatRowLevelPermissionPredicateGroup => {
return {
universalIdentifier:
rowLevelPermissionPredicateGroupManifest.universalIdentifier,
applicationUniversalIdentifier,
roleUniversalIdentifier,
objectMetadataUniversalIdentifier:
rowLevelPermissionPredicateGroupManifest.objectUniversalIdentifier,
logicalOperator: rowLevelPermissionPredicateGroupManifest.logicalOperator,
parentRowLevelPermissionPredicateGroupUniversalIdentifier:
rowLevelPermissionPredicateGroupManifest.parentPredicateGroupUniversalIdentifier ??
null,
positionInRowLevelPermissionPredicateGroup:
rowLevelPermissionPredicateGroupManifest.position ?? null,
childRowLevelPermissionPredicateGroupUniversalIdentifiers: [],
rowLevelPermissionPredicateUniversalIdentifiers: [],
createdAt: now,
updatedAt: now,
deletedAt: null,
};
};
@@ -0,0 +1,43 @@
import { type RowLevelPermissionPredicateManifest } from 'twenty-shared/application';
import { type UniversalFlatRowLevelPermissionPredicate } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-row-level-permission-predicate.type';
export const fromRowLevelPermissionPredicateManifestToUniversalFlatRowLevelPermissionPredicate =
({
rowLevelPermissionPredicateManifest,
roleUniversalIdentifier,
applicationUniversalIdentifier,
now,
}: {
rowLevelPermissionPredicateManifest: RowLevelPermissionPredicateManifest;
roleUniversalIdentifier: string;
applicationUniversalIdentifier: string;
now: string;
}): UniversalFlatRowLevelPermissionPredicate => {
return {
universalIdentifier:
rowLevelPermissionPredicateManifest.universalIdentifier,
applicationUniversalIdentifier,
roleUniversalIdentifier,
objectMetadataUniversalIdentifier:
rowLevelPermissionPredicateManifest.objectUniversalIdentifier,
fieldMetadataUniversalIdentifier:
rowLevelPermissionPredicateManifest.fieldUniversalIdentifier,
operand: rowLevelPermissionPredicateManifest.operand,
value: rowLevelPermissionPredicateManifest.value ?? null,
subFieldName: rowLevelPermissionPredicateManifest.subFieldName ?? null,
workspaceMemberFieldMetadataUniversalIdentifier:
rowLevelPermissionPredicateManifest.workspaceMemberFieldUniversalIdentifier ??
null,
workspaceMemberSubFieldName:
rowLevelPermissionPredicateManifest.workspaceMemberSubFieldName ?? null,
rowLevelPermissionPredicateGroupUniversalIdentifier:
rowLevelPermissionPredicateManifest.predicateGroupUniversalIdentifier ??
null,
positionInRowLevelPermissionPredicateGroup:
rowLevelPermissionPredicateManifest.position ?? null,
createdAt: now,
updatedAt: now,
deletedAt: null,
};
};
@@ -22,6 +22,8 @@ import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src
import { fromPermissionFlagManifestToUniversalFlatPermissionFlag } from 'src/engine/core-modules/application/application-manifest/converters/from-permission-flag-manifest-to-universal-flat-permission-flag.util';
import { fromPermissionFlagToUniversalFlatRolePermissionFlag } from 'src/engine/core-modules/application/application-manifest/converters/from-permission-flag-to-universal-flat-role-permission-flag.util';
import { fromRoleManifestToUniversalFlatRole } from 'src/engine/core-modules/application/application-manifest/converters/from-role-manifest-to-universal-flat-role.util';
import { fromRowLevelPermissionPredicateGroupManifestToUniversalFlatRowLevelPermissionPredicateGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-group-manifest-to-universal-flat-row-level-permission-predicate-group.util';
import { fromRowLevelPermissionPredicateManifestToUniversalFlatRowLevelPermissionPredicate } from 'src/engine/core-modules/application/application-manifest/converters/from-row-level-permission-predicate-manifest-to-universal-flat-row-level-permission-predicate.util';
import { fromSkillManifestToUniversalFlatSkill } from 'src/engine/core-modules/application/application-manifest/converters/from-skill-manifest-to-universal-flat-skill.util';
import { computeSearchVectorUniversalSettingsFromObjectManifest } from 'src/engine/core-modules/application/application-manifest/utils/compute-search-vector-universal-settings-from-object-manifest.util';
import { fromViewFieldGroupManifestToUniversalFlatViewFieldGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-group-manifest-to-universal-flat-view-field-group.util';
@@ -350,6 +352,40 @@ export class ComputeApplicationManifestAllUniversalFlatEntityMapsService {
allUniversalFlatEntityMaps.flatRolePermissionFlagMaps,
});
}
for (const rowLevelPermissionPredicateGroupManifest of roleManifest.rowLevelPermissionPredicateGroups ??
[]) {
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity:
fromRowLevelPermissionPredicateGroupManifestToUniversalFlatRowLevelPermissionPredicateGroup(
{
rowLevelPermissionPredicateGroupManifest,
roleUniversalIdentifier: roleManifest.universalIdentifier,
applicationUniversalIdentifier,
now,
},
),
universalFlatEntityMapsToMutate:
allUniversalFlatEntityMaps.flatRowLevelPermissionPredicateGroupMaps,
});
}
for (const rowLevelPermissionPredicateManifest of roleManifest.rowLevelPermissionPredicates ??
[]) {
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity:
fromRowLevelPermissionPredicateManifestToUniversalFlatRowLevelPermissionPredicate(
{
rowLevelPermissionPredicateManifest,
roleUniversalIdentifier: roleManifest.universalIdentifier,
applicationUniversalIdentifier,
now,
},
),
universalFlatEntityMapsToMutate:
allUniversalFlatEntityMaps.flatRowLevelPermissionPredicateMaps,
});
}
}
for (const skillManifest of manifest.skills ?? []) {
@@ -204,14 +204,6 @@ export class WorkspaceMigrationBuildOrchestratorService {
ALL_METADATA_NAME.viewSort,
workspaceMigrationViewSortActionsBuilderService,
),
createEntityActionsBuilderTask(
ALL_METADATA_NAME.rowLevelPermissionPredicateGroup,
workspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService,
),
createEntityActionsBuilderTask(
ALL_METADATA_NAME.rowLevelPermissionPredicate,
workspaceMigrationRowLevelPermissionPredicateActionsBuilderService,
),
createEntityActionsBuilderTask(
ALL_METADATA_NAME.logicFunction,
workspaceMigrationLogicFunctionActionsBuilderService,
@@ -220,6 +212,14 @@ export class WorkspaceMigrationBuildOrchestratorService {
ALL_METADATA_NAME.role,
workspaceMigrationRoleActionsBuilderService,
),
createEntityActionsBuilderTask(
ALL_METADATA_NAME.rowLevelPermissionPredicateGroup,
workspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService,
),
createEntityActionsBuilderTask(
ALL_METADATA_NAME.rowLevelPermissionPredicate,
workspaceMigrationRowLevelPermissionPredicateActionsBuilderService,
),
createEntityActionsBuilderTask(
ALL_METADATA_NAME.objectPermission,
workspaceMigrationObjectPermissionActionsBuilderService,
@@ -0,0 +1,226 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util';
import { type FieldManifest, type Manifest } from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
FieldMetadataType,
RowLevelPermissionPredicateOperand,
} from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const TEST_WORKSPACE_ID = SEED_APPLE_WORKSPACE_ID;
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_OBJECT_PERMISSION_ID = uuidv4();
const TEST_FIELD_ID = uuidv4();
const TEST_PREDICATE_ID = uuidv4();
const PERSON_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.person.universalIdentifier;
const WORKSPACE_MEMBER_ID_FIELD_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.workspaceMember.fields.id.universalIdentifier;
const personScopingFieldManifest: FieldManifest = {
universalIdentifier: TEST_FIELD_ID,
type: FieldMetadataType.TEXT,
name: 'integrationRlsScopingColumn',
label: 'Integration RLS Scoping Column',
description: 'Custom field a row-level predicate scopes on',
icon: 'IconLock',
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
};
const buildManifestWithPredicates = (
predicates: NonNullable<
Manifest['roles'][number]['rowLevelPermissionPredicates']
>,
): Manifest =>
buildBaseManifest({
appId: TEST_APP_ID,
roleId: TEST_ROLE_ID,
overrides: {
fields: [personScopingFieldManifest],
roles: [
{
universalIdentifier: TEST_ROLE_ID,
label: 'RLS Test Role',
description: 'Role exercising declarative row-level predicates',
objectPermissions: [
{
universalIdentifier: TEST_OBJECT_PERMISSION_ID,
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
},
],
rowLevelPermissionPredicates: predicates,
},
],
},
});
const partnerUserStylePredicate = (
operand: RowLevelPermissionPredicateOperand,
) => ({
universalIdentifier: TEST_PREDICATE_ID,
objectUniversalIdentifier: PERSON_OBJECT_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: TEST_FIELD_ID,
operand,
workspaceMemberFieldUniversalIdentifier:
WORKSPACE_MEMBER_ID_FIELD_UNIVERSAL_IDENTIFIER,
});
type PredicateRow = {
id: string;
operand: string;
roleId: string;
objectMetadataId: string;
fieldMetadataId: string;
workspaceMemberFieldMetadataId: string | null;
applicationId: string;
};
const findActivePredicateRows = async (): Promise<PredicateRow[]> =>
globalThis.testDataSource.query(
`SELECT id, operand, "roleId", "objectMetadataId", "fieldMetadataId",
"workspaceMemberFieldMetadataId", "applicationId"
FROM core."rowLevelPermissionPredicate"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2 AND "deletedAt" IS NULL`,
[TEST_PREDICATE_ID, TEST_WORKSPACE_ID],
);
const findApplicationId = async (): Promise<string> => {
const rows = await globalThis.testDataSource.query(
`SELECT id FROM core."application"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[TEST_APP_ID, TEST_WORKSPACE_ID],
);
return rows[0]?.id;
};
const findRoleId = async (): Promise<string> => {
const rows = await globalThis.testDataSource.query(
`SELECT id FROM core."role"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[TEST_ROLE_ID, TEST_WORKSPACE_ID],
);
return rows[0]?.id;
};
const findPersonObjectId = async (): Promise<string> => {
const rows = await globalThis.testDataSource.query(
`SELECT id FROM core."objectMetadata"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[PERSON_OBJECT_UNIVERSAL_IDENTIFIER, TEST_WORKSPACE_ID],
);
return rows[0]?.id;
};
describe('Manifest sync - row level permission predicates declared on a role', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test RLS Predicate Application',
description: 'App for testing declarative row-level predicate sync',
sourcePath: 'test-manifest-rls-predicate',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('creates a row-level predicate owned by the app and pointing at the declared role/object/field', async () => {
const { errors } = await syncApplication({
manifest: buildManifestWithPredicates([
partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS),
]),
expectToFail: false,
});
expect(errors).toBeUndefined();
const [applicationId, roleId, personObjectId, predicateRows] =
await Promise.all([
findApplicationId(),
findRoleId(),
findPersonObjectId(),
findActivePredicateRows(),
]);
expect(predicateRows).toHaveLength(1);
const predicate = predicateRows[0];
expect(predicate.operand).toBe(RowLevelPermissionPredicateOperand.IS);
expect(predicate.roleId).toBe(roleId);
expect(predicate.objectMetadataId).toBe(personObjectId);
expect(predicate.fieldMetadataId).toBeTruthy();
expect(predicate.workspaceMemberFieldMetadataId).toBeTruthy();
expect(predicate.applicationId).toBe(applicationId);
}, 60000);
it('updates the predicate in place on re-sync and removes it when dropped from the manifest', async () => {
await syncApplication({
manifest: buildManifestWithPredicates([
partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS),
]),
expectToFail: false,
});
const createdRows = await findActivePredicateRows();
expect(createdRows).toHaveLength(1);
const predicateId = createdRows[0].id;
await syncApplication({
manifest: buildManifestWithPredicates([
partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS_NOT),
]),
expectToFail: false,
});
const updatedRows = await findActivePredicateRows();
expect(updatedRows).toHaveLength(1);
expect(updatedRows[0].id).toBe(predicateId);
expect(updatedRows[0].operand).toBe(
RowLevelPermissionPredicateOperand.IS_NOT,
);
await syncApplication({
manifest: buildManifestWithPredicates([]),
expectToFail: false,
});
expect(await findActivePredicateRows()).toHaveLength(0);
}, 60000);
it('removes the predicate on uninstall', async () => {
await syncApplication({
manifest: buildManifestWithPredicates([
partnerUserStylePredicate(RowLevelPermissionPredicateOperand.IS),
]),
expectToFail: false,
});
expect(await findActivePredicateRows()).toHaveLength(1);
await uninstallApplication({
universalIdentifier: TEST_APP_ID,
expectToFail: false,
});
expect(await findActivePredicateRows()).toHaveLength(0);
}, 60000);
});
@@ -64,6 +64,8 @@ export type { PreInstallLogicFunctionApplicationManifest } from './preInstallLog
export type {
ObjectPermissionManifest,
FieldPermissionManifest,
RowLevelPermissionPredicateGroupManifest,
RowLevelPermissionPredicateManifest,
RoleManifest,
} from './roleManifestType';
export type { RunAgentInput, RunAgentResult } from './runAgentType';
@@ -1,4 +1,9 @@
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
import {
type RowLevelPermissionPredicateGroupLogicalOperator,
type RowLevelPermissionPredicateOperand,
type RowLevelPermissionPredicateValue,
} from '@/types';
export type ObjectPermissionManifest = SyncableEntityOptions & {
objectUniversalIdentifier: string;
@@ -15,6 +20,25 @@ export type FieldPermissionManifest = SyncableEntityOptions & {
canUpdateFieldValue?: boolean;
};
export type RowLevelPermissionPredicateGroupManifest = SyncableEntityOptions & {
objectUniversalIdentifier: string;
logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator;
parentPredicateGroupUniversalIdentifier?: string | null;
position?: number | null;
};
export type RowLevelPermissionPredicateManifest = SyncableEntityOptions & {
objectUniversalIdentifier: string;
fieldUniversalIdentifier: string;
operand: RowLevelPermissionPredicateOperand;
value?: RowLevelPermissionPredicateValue | null;
subFieldName?: string | null;
workspaceMemberFieldUniversalIdentifier?: string | null;
workspaceMemberSubFieldName?: string | null;
predicateGroupUniversalIdentifier?: string | null;
position?: number | null;
};
export type RoleManifest = SyncableEntityOptions & {
label: string;
description?: string;
@@ -30,5 +54,7 @@ export type RoleManifest = SyncableEntityOptions & {
canBeAssignedToApiKeys?: boolean;
objectPermissions?: ObjectPermissionManifest[];
fieldPermissions?: FieldPermissionManifest[];
rowLevelPermissionPredicates?: RowLevelPermissionPredicateManifest[];
rowLevelPermissionPredicateGroups?: RowLevelPermissionPredicateGroupManifest[];
permissionFlagUniversalIdentifiers?: string[];
};