Skip to content

Commit 662bdf9

Browse files
os-zhuangclaude
andauthored
fix(fls): wire real per-caller FLS into import targets and grid columns, drop dead field.permissions shape (objectstack#3661) (#2866)
The `permissions?: { read?, write?, edit? }` key on field definitions was declared-but-never-enforced: no producer ever populated it, so all three guards reading it permanently short-circuited to "allow". Per ADR-0049 enforce-or-remove: - ImportWizard target fields (ObjectView): filter by the server-resolved /auth/me/permissions editable bit via usePermissions().checkField, so the mapping step and the downloadable CSV template stop offering columns the FLS write gate rejects with 403. - ObjectGrid auto-derived columns: drop columns the caller cannot read (same checkField gate ListView already applies). - ObjectForm: delete the redundant dead guard in field generation — the existing applyFieldPerms gate is the real enforcement point. - @object-ui/types: remove the never-populated `permissions` field shape (BREAKING) so future guards against it fail at compile time. Claude-Session: https://claude.ai/code/session_01AEb4XCVb7iLEBVe9HghjTt Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f947e4 commit 662bdf9

5 files changed

Lines changed: 56 additions & 22 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@object-ui/types": major
3+
"@object-ui/app-shell": patch
4+
"@object-ui/plugin-form": patch
5+
"@object-ui/plugin-grid": patch
6+
---
7+
8+
fix(fls): wire the real per-caller FLS channel into import targets and grid
9+
columns; remove the never-populated `field.permissions` shape (objectstack#3661)
10+
11+
The `permissions?: { read?, write?, edit? }` key on `@object-ui/types` field
12+
definitions (Phase 3.2.6) was declared-but-never-enforced: no producer in the
13+
stack ever populated it, so every guard reading it short-circuited to "allow".
14+
Per ADR-0049 enforce-or-remove, the shape is deleted and the three consumers
15+
now use the server-resolved `/auth/me/permissions` channel
16+
(`usePermissions().checkField`) — the same channel ObjectForm/ModalForm/ListView
17+
already enforce:
18+
19+
- **ImportWizard target fields (app-shell `ObjectView`)**: the importable
20+
field set (and thus the downloadable CSV template's columns) now drops
21+
fields the caller cannot edit, instead of offering columns the server's
22+
FLS write gate would 403.
23+
- **ObjectGrid auto-derived columns**: columns the caller cannot read are
24+
dropped (same gate ListView applies), instead of a dead schema-shape check.
25+
- **ObjectForm**: the redundant dead guard in field generation is removed;
26+
the existing `applyFieldPerms` gate remains the real enforcement point.
27+
28+
BREAKING CHANGE: `@object-ui/types` field definitions no longer accept a
29+
`permissions` key. It never carried data at runtime; consumers needing
30+
per-caller field-level permissions must use `@object-ui/permissions`
31+
(`MePermissionsProvider` + `useFieldPermissions`/`checkField`).

packages/app-shell/src/views/ObjectView.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,8 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
362362
// Admin users automatically get design tools (no toggle needed)
363363
const { user, activeOrganization } = useAuth();
364364
const isAdmin = useIsWorkspaceAdmin();
365-
const { can, getObjectApiOperations } = usePermissions();
365+
const perms = usePermissions();
366+
const { can, getObjectApiOperations } = perms;
366367

367368
// Get Object Definition. The outer ObjectView wrapper already guards the
368369
// missing-object case, so this always resolves while this component is
@@ -1790,14 +1791,19 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
17901791
? identityImportFields(objectDef.fields)
17911792
: Object.entries(objectDef.fields || {})
17921793
// Only writable fields are importable targets. Computed
1793-
// types (formula/summary/autonumber) and fields flagged
1794-
// readonly / write:false are server-rejected, so we omit
1795-
// them from the mapping step rather than let a user map to
1796-
// a column the import will silently drop.
1797-
.filter(([, def]: [string, any]) =>
1794+
// types (formula/summary/autonumber) and readonly fields
1795+
// are server-rejected, and FLS-restricted fields get a
1796+
// 403 from the write path — so we omit them from the
1797+
// mapping step (and the downloadable template) rather
1798+
// than let a user map to a column the import will
1799+
// reject. The FLS bit comes from the server-resolved
1800+
// /me/permissions channel (checkField), not from the
1801+
// schema, which carries no per-caller permission bits
1802+
// (objectstack#3661).
1803+
.filter(([name, def]: [string, any]) =>
17981804
!['formula', 'summary', 'autonumber'].includes(def?.type) &&
17991805
!def?.readonly &&
1800-
def?.permissions?.write !== false,
1806+
(!perms?.isLoaded || perms.checkField(objectDef.name, name, 'write')),
18011807
)
18021808
.map(([name, def]: [string, any]) => ({
18031809
name,

packages/plugin-form/src/ObjectForm.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -542,9 +542,9 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
542542
const field = objectSchema.fields?.[name];
543543
if (!field && !hasInlineFields) return; // Skip if not found in object definition unless inline
544544

545-
// Check field-level permissions for create/edit modes
546-
const hasWritePermission = !field?.permissions || field?.permissions.write !== false;
547-
if (schema.mode !== 'view' && !hasWritePermission) return; // Skip fields without write permission
545+
// Field-level permissions are enforced downstream by `applyFieldPerms`
546+
// (the real per-caller gate via `perms.checkField`); the schema itself
547+
// carries no per-caller permission bits (objectstack#3661).
548548

549549
// Check if there's a custom field configuration
550550
const customField = schema.customFields?.find(f => f.name === name);

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
417417
// gate with this — a missing set (unrestricted object / old backend / no
418418
// provider) keeps the current behavior. The frontend consumes the effective
419419
// set the server resolved; it never reads the raw `apiMethods`.
420-
const { getObjectApiOperations } = usePermissions();
420+
const perms = usePermissions();
421+
const { getObjectApiOperations } = perms;
421422
const effectiveApiOps = objectName ? getObjectApiOperations(objectName) : undefined;
422423
const schemaFields = schema.fields;
423424
const schemaColumns = schema.columns;
@@ -1229,7 +1230,12 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
12291230
const field = objectSchema.fields?.[fieldName];
12301231
if (!field) return;
12311232

1232-
if (field.permissions && field.permissions.read === false) return;
1233+
// FLS: drop columns the current user cannot read (server-resolved
1234+
// /me/permissions via checkField — the schema itself carries no
1235+
// per-caller permission bits, objectstack#3661). Same gate ListView
1236+
// applies to its auto-derived columns.
1237+
if (perms?.isLoaded && schema.objectName
1238+
&& !perms.checkField(schema.objectName, fieldName, 'read')) return;
12331239

12341240
const CellRenderer = getCellRenderer(field.type);
12351241
const numericTypes = ['number', 'currency', 'percent'];
@@ -1249,7 +1255,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
12491255
});
12501256

12511257
return generatedColumns;
1252-
}, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName]);
1258+
}, [objectSchema, schemaFields, schemaColumns, dataConfig, hasInlineData, navigation.handleClick, executeAction, data, resolveFieldLabel, translateOptions, schema.objectName, perms]);
12531259

12541260
const handleExport = useCallback((format: 'csv' | 'xlsx' | 'json' | 'pdf') => {
12551261
// Object-level export permission gate. Default-allow: an explicit

packages/types/src/field-types.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,6 @@ export interface BaseFieldMetadata {
7777
*/
7878
defaultValue?: any;
7979

80-
/**
81-
* Field permissions (Phase 3.2.6)
82-
*/
83-
permissions?: {
84-
read?: boolean;
85-
write?: boolean;
86-
edit?: boolean;
87-
};
88-
8980
/**
9081
* Whether field is sortable
9182
*/

0 commit comments

Comments
 (0)