From ec97287526b02e87e1c15a4339d1c273460e40ec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 04:16:08 +0000 Subject: [PATCH 1/2] feat(spec,hono): user-level export permission axis (#3544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make `export` a user-gated operation, not just "anyone who can list", wiring the `userExportAllowed` slot reserved in #3391 P1. - spec: ObjectPermissionSchema gains an optional `allowExport` bit — no default, so it is a backward-compatible opt-out: unset inherits read (today's can-list ⇒ can-export), false denies export while keeping read, true grants. - plugin-hono-server: annotateEffectiveApiOperations derives userExportAllowed = allowExport !== false and threads it into resolveEffectiveApiMethods (export ⊆ list ∧ userExportAllowed). When the axis removes export from an otherwise-open object, that object is now annotated (effective set minus export) so the client hides Export; an open object with export still allowed stays unannotated (default-allow). - Regenerated content/docs/references/security/permission.mdx. Zero change to the derivation table or the frontend (already consumes the effective apiOperations). Existing permission sets keep today's behavior. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012L8EfEa157Pe6C73qRnaJH --- .changeset/user-level-export-axis.md | 28 ++++++++++++ .../docs/references/security/permission.mdx | 2 + .../src/effective-api-operations.test.ts | 43 +++++++++++++++++++ .../plugin-hono-server/src/hono-plugin.ts | 12 +++++- packages/spec/src/security/permission.test.ts | 11 ++++- packages/spec/src/security/permission.zod.ts | 22 +++++++++- 6 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 .changeset/user-level-export-axis.md diff --git a/.changeset/user-level-export-axis.md b/.changeset/user-level-export-axis.md new file mode 100644 index 0000000000..b703a7c3cc --- /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 9acb07b910..c3b3dcec0a 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 f00e02a6a1..19ea80974a 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 07f593ea82..85d642ffc1 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/src/security/permission.test.ts b/packages/spec/src/security/permission.test.ts index 46aea3ccf7..c31c017034 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 eba1727799..76fa8abc24 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. * From d274d29b7dc7acb84abd40a9dc63bcaaf5e1028a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 04:21:23 +0000 Subject: [PATCH 2/2] chore(spec): classify permission.objects.allowExport in liveness registry (#3544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new allowExport property on ObjectPermissionSchema is a governed-type property and must carry a liveness classification. It is `live` — enforced end-to-end via the hono /me/permissions annotate (userExportAllowed) → effective apiOperations → REST export gate + UI. Fixes the "Spec property liveness" gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012L8EfEa157Pe6C73qRnaJH --- packages/spec/liveness/permission.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/spec/liveness/permission.json b/packages/spec/liveness/permission.json index 1eff20d8b7..bf5b719bce 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)",