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
@@ -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 ?? []) {