Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/user-level-export-axis.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = { 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<string, any> = { 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<string, any> = { 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<string, any> = { deal: { allowRead: true, allowExport: true } };
annotateEffectiveApiOperations(objects, schemaOf({
deal: { name: 'deal', enable: { apiMethods: ['get', 'list'] } },
}));
expect(objects.deal.apiOperations).toContain('export');
});
});
});

describe('seedSuperUserRestrictedObjects (#3391)', () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/liveness/permission.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
11 changes: 10 additions & 1 deletion packages/spec/src/security/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down
22 changes: 21 additions & 1 deletion packages/spec/src/security/permission.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down