Fix orphaned mirrored field permission blocking relation field updates - #24220
Fix orphaned mirrored field permission blocking relation field updates#24220FelixMalfait wants to merge 4 commits into
Conversation
Restricting a relation field mirrors the restriction onto the inverse field of the target object, but granting it back only deleted the row for the field named in the request. The mirrored row survived as an invisible orphan and kept denying updates on the relation's join column (e.g. memberId) with a generic "User does not have permission" error, even though role settings showed the field as editable. - Propagate grant-back to the mirrored inverse field permission: a cleared source row now clears the mirror too, and rows left with no restriction are deleted instead of kept or created empty - Make the one-to-many editability check honor a field-level update restriction on the inverse many-to-one field, so the UI no longer offers an edit the server will reject
Greptile SummaryThis PR fixes orphaned mirrored relation-field permissions by propagating grant-back operations to inverse fields and deleting permission rows once both restrictions are cleared. It also makes ordinary one-to-many relation fields read-only in the frontend when the inverse field cannot be updated.
Confidence Score: 3/5This PR is not safe to merge until partial permission updates preserve omitted inverse restrictions instead of deleting them. The new mirror-clear branch treats an omitted permission flag as an explicit clear, which can remove an inverse field’s update restriction and allow a relation join-column update that should remain denied. Files Needing Attention: packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.service.ts
|
| Filename | Overview |
|---|---|
| packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.service.ts | Adds mirrored grant-back deletion and empty-row suppression, but conflates omitted and explicitly cleared flags during partial updates. |
| packages/twenty-front/src/modules/object-record/read-only/utils/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission.ts | Correctly checks the target object's inverse field metadata ID against its field-level update restriction. |
| packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/tests/field-permissions.service.spec.ts | Covers complete grant-back and absent-row behavior but does not exercise partial null/undefined permission updates. |
| packages/twenty-front/src/modules/object-record/read-only/utils/tests/isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission.test.ts | Adds focused coverage for inverse-field and unrelated-field update restrictions. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
A["Relation field permission input"] --> B["Build desired source permission"]
B --> C["Mirror onto inverse relation field"]
C --> D{"Both effective flags cleared?"}
D -- Yes --> E["Delete permission row"]
D -- No --> F["Create or update permission row"]
E --> G["Recompute workspace permission cache"]
F --> G
G --> H["Frontend read-only check and server update enforcement"]
Reviews (1): Last reviewed commit: "Fix orphaned mirrored field permission b..." | Re-trigger Greptile
| const sourceIsCleared = | ||
| !isDefined(fieldPermission.canReadFieldValue) && | ||
| !isDefined(fieldPermission.canUpdateFieldValue); |
There was a problem hiding this comment.
Partial updates clear inverse restrictions
When a partial relation-field permission update clears one flag with null and omits the other with undefined, sourceIsCleared treats both flags as cleared and writes null for the omitted inverse flag, deleting its existing update restriction and allowing relation join-column updates that should remain denied.
How this was verified: The optional input flags were traced through the mirror-clear branch and empty-row deletion to the join-column field-permission check.
| const sourceIsCleared = | |
| !isDefined(fieldPermission.canReadFieldValue) && | |
| !isDefined(fieldPermission.canUpdateFieldValue); | |
| const sourceIsCleared = | |
| fieldPermission.canReadFieldValue === null && | |
| fieldPermission.canUpdateFieldValue === null; |
Knowledge Base Used: Metadata System and Workspace ORM (twenty-orm)
There was a problem hiding this comment.
Not applying this: the premise conflicts with the service's pre-existing contract for the source row.
An input row with {canReadFieldValue: null, canUpdateFieldValue: undefined} is already treated as a full clear by the unchanged bothNull handling earlier in upsertFieldPermissions: both flags count as cleared whether they are null or undefined, the row is excluded from desiredMap, and the deletion loop removes the source permission row entirely, including its update restriction. That input shape means "remove this field's permission row", not "clear read, keep update".
sourceIsCleared deliberately uses the same predicate so the mirrored row follows the source row. With the suggested === null && === null, that same input would delete the source row but leave the inverse row restricted, which is exactly the orphaned-mirror state this PR fixes, reachable through a different input encoding.
A caller who wants to clear read while keeping the update restriction sends {canReadFieldValue: null, canUpdateFieldValue: false} (this is what the role settings UI sends). That keeps sourceIsCleared false and propagates {null, false} to the inverse field, preserving its update restriction.
Generated by Claude Code
There was a problem hiding this comment.
That's a well-reasoned explanation — thank you for tracing it through bothNull. You're right: since {canReadFieldValue: null, canUpdateFieldValue: undefined} is already treated as a full row deletion by the earlier bothNull branch, sourceIsCleared must use the same predicate (!isDefined) so the mirror row follows the source. My === null && === null suggestion would have reintroduced the orphaned-mirror case for exactly that input shape. The current implementation is correct.
There was a problem hiding this comment.
1 issue found across 4 files
You’re at about 95% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.service.ts">
<violation number="1" location="packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.service.ts:514">
P1: When a relation permission clears one flag while leaving the other `undefined`, `sourceIsCleared` treats `undefined` as cleared and clears both inverse flags. This can remove the inverse update restriction and re-enable join-column updates; only treat explicit `null` values as cleared.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| !isDefined(fieldPermission.canReadFieldValue) && | ||
| !isDefined(fieldPermission.canUpdateFieldValue); |
There was a problem hiding this comment.
P1: When a relation permission clears one flag while leaving the other undefined, sourceIsCleared treats undefined as cleared and clears both inverse flags. This can remove the inverse update restriction and re-enable join-column updates; only treat explicit null values as cleared.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.service.ts, line 514:
<comment>When a relation permission clears one flag while leaving the other `undefined`, `sourceIsCleared` treats `undefined` as cleared and clears both inverse flags. This can remove the inverse update restriction and re-enable join-column updates; only treat explicit `null` values as cleared.</comment>
<file context>
@@ -476,11 +505,24 @@ export class FieldPermissionService {
+ // Otherwise the mirrored row survives as an orphan that keeps blocking
+ // updates on the relation's join column after the admin granted the field back.
+ const sourceIsCleared =
+ !isDefined(fieldPermission.canReadFieldValue) &&
+ !isDefined(fieldPermission.canUpdateFieldValue);
+
</file context>
| !isDefined(fieldPermission.canReadFieldValue) && | |
| !isDefined(fieldPermission.canUpdateFieldValue); | |
| fieldPermission.canReadFieldValue === null && | |
| fieldPermission.canUpdateFieldValue === null; |
There was a problem hiding this comment.
Same finding as the thread above, same answer: {null, undefined} already deletes the entire source permission row under the pre-existing bothNull handling in this method (unchanged here), so treating undefined differently in sourceIsCleared would delete the source row while keeping the inverse restricted, recreating the orphaned-mirror bug this PR fixes. Partial clears that keep the update restriction are expressed as {canReadFieldValue: null, canUpdateFieldValue: false} and are unaffected by this branch.
Generated by Claude Code
✅ Standard review · no findings
High-level — Focused single-purpose bug fix (mirror relation-field permission clears to the inverse field, delete emptied rows, align FE one-to-many read-only with inverse field-level update restriction), well under size budget, reuses existing seams, and ships FE unit + BE integration regression tests. Reviewed against the |
…ission-bug-vktlvl # Conflicts: # packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/__tests__/field-permissions.service.spec.ts
…elete mapping Main removed all mock-based service unit specs (#24094), taking the grant-back unit tests with it. Replace them with an integration spec that exercises the real upsert flow: restricting a relation field mirrors the restriction onto the inverse field, granting it back deletes both rows, and repeated grants do not create empty permission rows. Also extract toUniversalFlatFieldPermissionToDelete so the delete-entity mapping is built in one place.
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
You’re at about 95% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
🚀 Preview Environment Ready! Your preview environment is available at: https://dts-example-routes-enabled.trycloudflare.com This environment will automatically shut down after 5 hours. |
The repeat-grant assertion depended on the previous test's cleared state and duplicated its input. Fold it into the same test so the no-empty-row guard is verified independently of test ordering.
🔍 Automated Pre-Review✅ No issues detected - This PR is ready for human review. Automated pre-review — human approval still required. |
🔍 Visual Regression Review —
|
| Story | Verdict | Confidence | Explained by | |
|---|---|---|---|---|
| 🟡 | ui-data-field-input-richtextfieldinput--default |
uncertain | 72% | No changed file in this PR touches RichTextFieldInput or any rich-text component… |
| 🟡 | modules-pagelayout-widgets-graphwidgetpiechart--default |
uncertain | 65% | No changed file in this PR touches GraphWidgetPieChart or any page-layout widget… |
Changed stories
| Story | Diff % |
|---|---|
| ui-data-field-input-richtextfieldinput--default | 17% |
| modules-pagelayout-widgets-graphwidgetpiechart--default | 7% |
View run details · advisory mode
|
Auto-closed: this PR is draft and has had no update in over 24 hours. Please reopen it once you have the bandwidth to take it forward. |
Context
Reported by a user: after creating a new role and explicitly enabling Edit on a relation field (the field backing a
member_idjoin column), updating that field still fails with the generic "User does not have permission" error, even though every permission visible in the role settings is enabled.Root cause
Restricting a relation field intentionally mirrors the restriction onto the inverse relation field of the target object (
addRelatedFieldPermissionsToDesired), writing a secondfieldPermissionrow on another object. Granting the permission back, however, only deleted the row for the field named in the request: the mirror pass mappednull(clear) toundefined(keep as is), and the deletion loop only considered fields present in the input. The mirrored row survived as an invisible orphan.Since updates always write the join column owned by the many-to-one side, the enforcement layer resolved
memberIdback to that relation field, found the orphanedcanUpdateFieldValue: falserow, and denied the update, while role settings showed Edit enabled on the side the admin had toggled.Reproduced end to end on a seeded workspace: restricting
company.peoplecreates rows for bothcompany.peopleandperson.company; grantingcompany.peopleback deleted only its own row; a member updatingperson.companyIdwas then denied despite the role showing the field as editable.Changes
field-permission.service.tsnullpropagates,undefinedstill means keep)toUniversalFlatFieldPermissionToDelete, used by both deletion sitesisOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission.tsTests
relation-field-permission-grant-back.integration-spec.tsexercising the real upsert flow: restricting a relation field mirrors the restriction onto the inverse field, granting it back deletes both rows, and repeated grants do not create empty permission rows (the original unit-spec coverage was moved here after test(server): delete service unit specs #24094 removed all mock-based service unit specs on main)Notes
This fix stops new orphans from being created. Orphaned mirror rows already present in existing databases remain until an admin re-toggles Edit on either side of the relation (the grant now cascades and clears them). An automated cleanup could be done as a follow-up data migration, kept out of this PR deliberately since it rewrites existing permission data.