Skip to content

Commit d8c4957

Browse files
os-zhuangclaude
andauthored
feat(spec,hono): user-level export permission axis (#3544) (#3553)
* feat(spec,hono): user-level export permission axis (#3544) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012L8EfEa157Pe6C73qRnaJH * chore(spec): classify permission.objects.allowExport in liveness registry (#3544) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012L8EfEa157Pe6C73qRnaJH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 503be86 commit d8c4957

7 files changed

Lines changed: 119 additions & 4 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-hono-server": minor
4+
---
5+
6+
feat: user-level export permission axis (#3544, #3391 follow-up)
7+
8+
`export` is a user-gated operation, not just "anyone who can list". A permission
9+
set can now deny export on an object while keeping read — matching Salesforce
10+
"Export Reports" / Dynamics "Export to Excel" / NetSuite "Export Lists" / SAP
11+
S_GUI 61.
12+
13+
- **spec** `ObjectPermissionSchema` gains an optional `allowExport` bit. It is
14+
deliberately OPTIONAL with **no default** so it is a backward-compatible
15+
opt-out: unset → inherits read (today's "can-list ⇒ can-export"), `false`
16+
export denied while read is kept, `true` → granted.
17+
- **plugin-hono-server** `annotateEffectiveApiOperations` derives
18+
`userExportAllowed = allowExport !== false` from the resolved per-object
19+
permission and threads it into `resolveEffectiveApiMethods` — so `export`
20+
derives from `list ∧ userExportAllowed`. When the axis removes `export` from
21+
an otherwise-open object, the object is now annotated (the effective set minus
22+
`export`) so the client hides the Export button; an unrestricted object with
23+
export still allowed stays unannotated (client default-allow).
24+
25+
Wires the `userExportAllowed` slot reserved in #3391 P1 — zero contract change
26+
to the derivation table or the frontend (it already consumes the effective
27+
`apiOperations`). Backward-compatible: existing permission sets (no
28+
`allowExport`) keep today's behavior everywhere.

content/docs/references/security/permission.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const result = AdminScope.parse(data);
5959
| **allowRead** | `boolean` || Read permission |
6060
| **allowEdit** | `boolean` || Edit permission |
6161
| **allowDelete** | `boolean` || Delete permission |
62+
| **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. |
6263
| **allowTransfer** | `boolean` || [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) |
6364
| **allowRestore** | `boolean` || [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
6465
| **allowPurge** | `boolean` || [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |
@@ -106,6 +107,7 @@ const result = AdminScope.parse(data);
106107
| **allowRead** | `boolean` || Read permission |
107108
| **allowEdit** | `boolean` || Edit permission |
108109
| **allowDelete** | `boolean` || Delete permission |
110+
| **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. |
109111
| **allowTransfer** | `boolean` || [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) |
110112
| **allowRestore** | `boolean` || [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
111113
| **allowPurge** | `boolean` || [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |

packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,49 @@ describe('annotateEffectiveApiOperations (#3391)', () => {
6666
expect(objects.deal.apiOperations).toContain('upsert');
6767
expect(objects.deal.apiOperations).not.toContain('restore');
6868
});
69+
70+
// [#3544] user-level export axis: allowExport on the per-object perm entry
71+
// drives userExportAllowed → export derives from list ∧ that bit.
72+
describe('user-level export axis (#3544)', () => {
73+
it('allowExport:false removes export from a restricting object', () => {
74+
const objects: Record<string, any> = { deal: { allowRead: true, allowExport: false } };
75+
annotateEffectiveApiOperations(objects, schemaOf({
76+
deal: { name: 'deal', enable: { apiMethods: ['get', 'list'] } },
77+
}));
78+
expect(objects.deal.apiOperations).toContain('list');
79+
expect(objects.deal.apiOperations).not.toContain('export');
80+
});
81+
82+
it('allowExport:false removes export even from an otherwise-open object (annotation forced)', () => {
83+
const objects: Record<string, any> = { deal: { allowRead: true, allowExport: false } };
84+
annotateEffectiveApiOperations(objects, schemaOf({
85+
deal: { name: 'deal', enable: {} }, // no apiMethods → unrestricted
86+
}));
87+
// Even though unrestricted, the export axis forces an annotation that
88+
// excludes export while keeping the rest.
89+
expect(objects.deal.apiOperations).toBeDefined();
90+
expect(objects.deal.apiOperations).not.toContain('export');
91+
expect(objects.deal.apiOperations).toContain('create');
92+
expect(objects.deal.apiOperations).toContain('import');
93+
});
94+
95+
it('unset allowExport inherits read — export kept; unrestricted object still unannotated', () => {
96+
const objects: Record<string, any> = { deal: { allowRead: true } }; // allowExport unset
97+
annotateEffectiveApiOperations(objects, schemaOf({
98+
deal: { name: 'deal', enable: {} },
99+
}));
100+
// Unrestricted + export allowed → no annotation (client default-allow).
101+
expect('apiOperations' in objects.deal).toBe(false);
102+
});
103+
104+
it('allowExport:true keeps export on a list-derived restricting object', () => {
105+
const objects: Record<string, any> = { deal: { allowRead: true, allowExport: true } };
106+
annotateEffectiveApiOperations(objects, schemaOf({
107+
deal: { name: 'deal', enable: { apiMethods: ['get', 'list'] } },
108+
}));
109+
expect(objects.deal.apiOperations).toContain('export');
110+
});
111+
});
69112
});
70113

71114
describe('seedSuperUserRestrictedObjects (#3391)', () => {

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,16 @@ export function annotateEffectiveApiOperations(
344344
if (obj === '*' || !acc) continue;
345345
const schema = schemaOf(obj);
346346
if (!schema) continue; // schema missing → no annotation (client falls back)
347-
const eff = resolveEffectiveApiMethods(schema.enable ?? undefined);
348-
if (eff.mode === 'unrestricted') continue; // open object → no annotation
347+
// [#3544] User-level export axis: `export` derives from `list ∧ this bit`.
348+
// Unset → inherit read (backward-compatible: can-list ⇒ can-export);
349+
// explicit `allowExport:false` → export removed from the effective set.
350+
const userExportAllowed = acc.allowExport !== false;
351+
const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed });
352+
// Annotate when the object tightens via `apiMethods`, OR when the export
353+
// axis removes `export` from an otherwise-open object (so the client
354+
// hides the Export button). An unrestricted object with export still
355+
// allowed needs no annotation — the client keeps its default-allow path.
356+
if (eff.mode === 'unrestricted' && userExportAllowed) continue;
349357
acc.apiOperations = effectiveOperationsArray(eff);
350358
}
351359
}

packages/spec/liveness/permission.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949
"status": "live",
5050
"evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts:8"
5151
},
52+
"allowExport": {
53+
"status": "live",
54+
"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)",
55+
"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."
56+
},
5257
"allowTransfer": {
5358
"status": "live",
5459
"evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts (OPERATION_TO_PERMISSION transfer→allowTransfer + modifyAllRecords bypass)",

packages/spec/src/security/permission.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ describe('AdminScopeSchema (ADR-0090 D12)', () => {
4646
describe('ObjectPermissionSchema', () => {
4747
it('should apply default values to false', () => {
4848
const result = ObjectPermissionSchema.parse({});
49-
49+
5050
expect(result.allowCreate).toBe(false);
5151
expect(result.allowRead).toBe(false);
5252
expect(result.allowEdit).toBe(false);
@@ -55,6 +55,15 @@ describe('ObjectPermissionSchema', () => {
5555
expect(result.modifyAllRecords).toBe(false);
5656
});
5757

58+
it('allowExport is optional with no default — unset stays undefined (#3544)', () => {
59+
// Deliberately NOT defaulted: unset = inherit read (backward-compatible
60+
// opt-out), so adding the key changes nothing for existing permission sets.
61+
const result = ObjectPermissionSchema.parse({});
62+
expect(result.allowExport).toBeUndefined();
63+
expect(ObjectPermissionSchema.parse({ allowExport: false }).allowExport).toBe(false);
64+
expect(ObjectPermissionSchema.parse({ allowExport: true }).allowExport).toBe(true);
65+
});
66+
5867
it('should accept CRUD permissions', () => {
5968
const permission: ObjectPermission = {
6069
allowCreate: true,

packages/spec/src/security/permission.zod.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,27 @@ export const ObjectPermissionSchema = lazySchema(() => z.object({
3131
allowEdit: z.boolean().default(false).describe('Edit permission'),
3232
/** D: Delete (Owned records or Shared records) */
3333
allowDelete: z.boolean().default(false).describe('Delete permission'),
34-
34+
35+
/**
36+
* Export (data portability) — the user-level export axis (#3391 / #3544).
37+
*
38+
* `export` derives from `list` AND this bit
39+
* (`@objectstack/spec/data` `resolveEffectiveApiMethods` →
40+
* `ResolveApiOptions.userExportAllowed`). Deliberately OPTIONAL with no
41+
* default so it is a backward-compatible **opt-out**, not an opt-in:
42+
* - UNSET (undefined) → inherits read — the current "anyone who can list can
43+
* one-click export" behavior — so adding this key changes nothing for
44+
* existing permission sets.
45+
* - `false` → deny export while KEEPING read (Salesforce "Export Reports",
46+
* Dynamics "Export to Excel", NetSuite "Export Lists", SAP S_GUI 61 parity).
47+
* - `true` → explicitly granted.
48+
*
49+
* The server resolves this into the per-object effective operation set
50+
* (`apiOperations` on `/me/permissions`); the frontend renders that set and
51+
* never reads this bit directly.
52+
*/
53+
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.'),
54+
3555
/**
3656
* Lifecycle Operations.
3757
*

0 commit comments

Comments
 (0)