diff --git a/.changeset/user-level-export-axis.md b/.changeset/user-level-export-axis.md new file mode 100644 index 000000000..b703a7c3c --- /dev/null +++ b/.changeset/user-level-export-axis.md @@ -0,0 +1,28 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-hono-server": minor +--- + +feat: user-level export permission axis (#3544, #3391 follow-up) + +`export` is a user-gated operation, not just "anyone who can list". A permission +set can now deny export on an object while keeping read — matching Salesforce +"Export Reports" / Dynamics "Export to Excel" / NetSuite "Export Lists" / SAP +S_GUI 61. + +- **spec** `ObjectPermissionSchema` gains an optional `allowExport` bit. It is + deliberately OPTIONAL with **no default** so it is a backward-compatible + opt-out: unset → inherits read (today's "can-list ⇒ can-export"), `false` → + export denied while read is kept, `true` → granted. +- **plugin-hono-server** `annotateEffectiveApiOperations` derives + `userExportAllowed = allowExport !== false` from the resolved per-object + permission and threads it into `resolveEffectiveApiMethods` — so `export` + derives from `list ∧ userExportAllowed`. When the axis removes `export` from + an otherwise-open object, the object is now annotated (the effective set minus + `export`) so the client hides the Export button; an unrestricted object with + export still allowed stays unannotated (client default-allow). + +Wires the `userExportAllowed` slot reserved in #3391 P1 — zero contract change +to the derivation table or the frontend (it already consumes the effective +`apiOperations`). Backward-compatible: existing permission sets (no +`allowExport`) keep today's behavior everywhere. diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 9acb07b91..c3b3dcec0 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -59,6 +59,7 @@ const result = AdminScope.parse(data); | **allowRead** | `boolean` | ✅ | Read permission | | **allowEdit** | `boolean` | ✅ | Edit permission | | **allowDelete** | `boolean` | ✅ | Delete permission | +| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read. Unset = inherit read (can-list ⇒ can-export, backward-compatible); false = deny export while keeping read; true = granted. | | **allowTransfer** | `boolean` | ✅ | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | | **allowRestore** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Restore from trash (Undelete) | | **allowPurge** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) | @@ -106,6 +107,7 @@ const result = AdminScope.parse(data); | **allowRead** | `boolean` | ✅ | Read permission | | **allowEdit** | `boolean` | ✅ | Edit permission | | **allowDelete** | `boolean` | ✅ | Delete permission | +| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read. Unset = inherit read (can-list ⇒ can-export, backward-compatible); false = deny export while keeping read; true = granted. | | **allowTransfer** | `boolean` | ✅ | [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) | | **allowRestore** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Restore from trash (Undelete) | | **allowPurge** | `boolean` | ✅ | [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) | diff --git a/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts b/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts index f00e02a6a..19ea80974 100644 --- a/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts +++ b/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts @@ -66,6 +66,49 @@ describe('annotateEffectiveApiOperations (#3391)', () => { expect(objects.deal.apiOperations).toContain('upsert'); expect(objects.deal.apiOperations).not.toContain('restore'); }); + + // [#3544] user-level export axis: allowExport on the per-object perm entry + // drives userExportAllowed → export derives from list ∧ that bit. + describe('user-level export axis (#3544)', () => { + it('allowExport:false removes export from a restricting object', () => { + const objects: Record = { deal: { allowRead: true, allowExport: false } }; + annotateEffectiveApiOperations(objects, schemaOf({ + deal: { name: 'deal', enable: { apiMethods: ['get', 'list'] } }, + })); + expect(objects.deal.apiOperations).toContain('list'); + expect(objects.deal.apiOperations).not.toContain('export'); + }); + + it('allowExport:false removes export even from an otherwise-open object (annotation forced)', () => { + const objects: Record = { deal: { allowRead: true, allowExport: false } }; + annotateEffectiveApiOperations(objects, schemaOf({ + deal: { name: 'deal', enable: {} }, // no apiMethods → unrestricted + })); + // Even though unrestricted, the export axis forces an annotation that + // excludes export while keeping the rest. + expect(objects.deal.apiOperations).toBeDefined(); + expect(objects.deal.apiOperations).not.toContain('export'); + expect(objects.deal.apiOperations).toContain('create'); + expect(objects.deal.apiOperations).toContain('import'); + }); + + it('unset allowExport inherits read — export kept; unrestricted object still unannotated', () => { + const objects: Record = { deal: { allowRead: true } }; // allowExport unset + annotateEffectiveApiOperations(objects, schemaOf({ + deal: { name: 'deal', enable: {} }, + })); + // Unrestricted + export allowed → no annotation (client default-allow). + expect('apiOperations' in objects.deal).toBe(false); + }); + + it('allowExport:true keeps export on a list-derived restricting object', () => { + const objects: Record = { deal: { allowRead: true, allowExport: true } }; + annotateEffectiveApiOperations(objects, schemaOf({ + deal: { name: 'deal', enable: { apiMethods: ['get', 'list'] } }, + })); + expect(objects.deal.apiOperations).toContain('export'); + }); + }); }); describe('seedSuperUserRestrictedObjects (#3391)', () => { diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 07f593ea8..85d642ffc 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -344,8 +344,16 @@ export function annotateEffectiveApiOperations( if (obj === '*' || !acc) continue; const schema = schemaOf(obj); if (!schema) continue; // schema missing → no annotation (client falls back) - const eff = resolveEffectiveApiMethods(schema.enable ?? undefined); - if (eff.mode === 'unrestricted') continue; // open object → no annotation + // [#3544] User-level export axis: `export` derives from `list ∧ this bit`. + // Unset → inherit read (backward-compatible: can-list ⇒ can-export); + // explicit `allowExport:false` → export removed from the effective set. + const userExportAllowed = acc.allowExport !== false; + const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed }); + // Annotate when the object tightens via `apiMethods`, OR when the export + // axis removes `export` from an otherwise-open object (so the client + // hides the Export button). An unrestricted object with export still + // allowed needs no annotation — the client keeps its default-allow path. + if (eff.mode === 'unrestricted' && userExportAllowed) continue; acc.apiOperations = effectiveOperationsArray(eff); } } diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 1eff20d8b..bf5b719bc 100644 --- a/packages/spec/liveness/permission.json +++ b/packages/spec/liveness/permission.json @@ -49,6 +49,11 @@ "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8" }, + "allowExport": { + "status": "live", + "evidence": "packages/plugins/plugin-hono-server/src/hono-plugin.ts (annotateEffectiveApiOperations: userExportAllowed = allowExport !== false) + packages/spec/src/data/api-derivation.ts (export derives from list ∧ userExportAllowed)", + "note": "#3544 — user-level export axis over read. Enforced end-to-end: the bit drives userExportAllowed, which removes `export` from the object's effective apiOperations on /me/permissions; the frontend hides Export and the REST export gate 405s. Optional/no-default = backward-compatible opt-out (unset inherits read); `false` denies export while keeping read." + }, "allowTransfer": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (OPERATION_TO_PERMISSION transfer→allowTransfer + modifyAllRecords bypass)", diff --git a/packages/spec/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts index 46aea3ccf..c31c01703 100644 --- a/packages/spec/src/security/permission.test.ts +++ b/packages/spec/src/security/permission.test.ts @@ -46,7 +46,7 @@ describe('AdminScopeSchema (ADR-0090 D12)', () => { describe('ObjectPermissionSchema', () => { it('should apply default values to false', () => { const result = ObjectPermissionSchema.parse({}); - + expect(result.allowCreate).toBe(false); expect(result.allowRead).toBe(false); expect(result.allowEdit).toBe(false); @@ -55,6 +55,15 @@ describe('ObjectPermissionSchema', () => { expect(result.modifyAllRecords).toBe(false); }); + it('allowExport is optional with no default — unset stays undefined (#3544)', () => { + // Deliberately NOT defaulted: unset = inherit read (backward-compatible + // opt-out), so adding the key changes nothing for existing permission sets. + const result = ObjectPermissionSchema.parse({}); + expect(result.allowExport).toBeUndefined(); + expect(ObjectPermissionSchema.parse({ allowExport: false }).allowExport).toBe(false); + expect(ObjectPermissionSchema.parse({ allowExport: true }).allowExport).toBe(true); + }); + it('should accept CRUD permissions', () => { const permission: ObjectPermission = { allowCreate: true, diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index eba172779..76fa8abc2 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -31,7 +31,27 @@ export const ObjectPermissionSchema = lazySchema(() => z.object({ allowEdit: z.boolean().default(false).describe('Edit permission'), /** D: Delete (Owned records or Shared records) */ allowDelete: z.boolean().default(false).describe('Delete permission'), - + + /** + * Export (data portability) — the user-level export axis (#3391 / #3544). + * + * `export` derives from `list` AND this bit + * (`@objectstack/spec/data` `resolveEffectiveApiMethods` → + * `ResolveApiOptions.userExportAllowed`). Deliberately OPTIONAL with no + * default so it is a backward-compatible **opt-out**, not an opt-in: + * - UNSET (undefined) → inherits read — the current "anyone who can list can + * one-click export" behavior — so adding this key changes nothing for + * existing permission sets. + * - `false` → deny export while KEEPING read (Salesforce "Export Reports", + * Dynamics "Export to Excel", NetSuite "Export Lists", SAP S_GUI 61 parity). + * - `true` → explicitly granted. + * + * The server resolves this into the per-object effective operation set + * (`apiOperations` on `/me/permissions`); the frontend renders that set and + * never reads this bit directly. + */ + allowExport: z.boolean().optional().describe('[#3544] User-level export axis over read. Unset = inherit read (can-list ⇒ can-export, backward-compatible); false = deny export while keeping read; true = granted.'), + /** * Lifecycle Operations. *