Skip to content

Commit 9613396

Browse files
authored
feat(security): 让用户级 export 权限轴真正在服务端生效 (#3544) (#3709)
`allowExport` 此前只有 spec 位 + `/me/permissions` 注解,前端据此隐藏 Export 按钮,服务端一行未拦:`export ⊆ list`,导出路由经 `findData` 流式读,中间件看到 的是普通 `find`,只按 `allowRead` 放行。本次补上调用点。 - plugin-security: `checkObjectPermission('export', …)` 成为真正的判定(读权限 ∧ 未被显式拒绝);新增 `resolveUserExportAllowed()` 做三态折叠,与 `/me/permissions` 的对象合并逐例一致。`allowExport` 刻意不进 OPERATION_TO_PERMISSION —— 那张表要求「位为真」,会让本轴之前的权限集全部 失去导出。 - spec: `ISecurityService.canExport(object, context)`,fail closed;`isSystem` 与零权限集放行,与中间件一致。 - rest: 导出路由在取第一个 chunk 之前返回 403 EXPORT_NOT_PERMITTED,与对象级 405 分开且 405 仍在前。 - plugin-hono-server: 注解回落到 `'*'` 条目的 export 位,消除「按钮给你、请求 403」的客户端/服务端分歧。 - docs: permission-sets.mdx 补上 allowExport 的三态与合并规则。 向后兼容:未设 = 继承 read,现有权限集行为不变。 范围外的报表侧门已开 #3710
1 parent c2f1002 commit 9613396

11 files changed

Lines changed: 681 additions & 4 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/plugin-hono-server": patch
5+
"@objectstack/rest": minor
6+
---
7+
8+
feat(security): ENFORCE the user-level export axis on the server (#3544)
9+
10+
`allowExport` landed as a spec bit plus a `/me/permissions` annotation, which
11+
hid the client's Export button — and nothing else. Because `export ⊆ list`, the
12+
REST export route streams through `findData` and the engine middleware sees an
13+
ordinary `find` gated by `allowRead`, so no code path ever read the bit: a caller
14+
holding `allowExport: false` could still `curl
15+
/api/v1/data/:object/export` and drain the whole table. Declared, not enforced.
16+
17+
- **plugin-security** `PermissionEvaluator.checkObjectPermission('export', …)` is
18+
now a real decision: `export` = read granted ∧ not explicitly denied.
19+
`allowExport` stays out of `OPERATION_TO_PERMISSION` on purpose — that map
20+
means "the bit must be truthy", which would have denied export to every
21+
permission set authored before the axis existed. The new exported
22+
`resolveUserExportAllowed()` folds the tri-state across sets (`true` beats
23+
`false` beats unset) exactly as the `/me/permissions` merge does.
24+
- **spec** `ISecurityService` gains `canExport(object, context)` — the question a
25+
bulk-egress door outside the engine middleware has to ask before it reads.
26+
Fails CLOSED; `isSystem` and an empty set resolution bypass, mirroring the
27+
middleware.
28+
- **rest** `GET /data/:object/export` calls it and answers **403
29+
`EXPORT_NOT_PERMITTED`** before the first chunk is fetched. Distinct from the
30+
object-level 405 `OBJECT_API_METHOD_NOT_ALLOWED`, which still runs first: 405
31+
says the object exposes no export, 403 says this caller may not use it. No
32+
security service (no `plugin-security` ⇒ no permission sets) → allowed, the
33+
same fail-open posture as every other permission gate in that layer; service
34+
present but unable to answer → denied.
35+
- **plugin-hono-server** the `/me/permissions` annotation now falls back to the
36+
`'*'` entry's export bit when a per-object entry declares none, matching the
37+
evaluator's own wildcard fallback — so a set that denies export wholesale via
38+
`'*'` no longer offers a button the server refuses.
39+
40+
Backward-compatible: `allowExport` is still an opt-out with no default, so an
41+
unset bit inherits read and existing permission sets behave exactly as before.
42+
Only a permission set that explicitly sets `allowExport: false` changes — and it
43+
now changes on the server, which is the point.
44+
45+
Implementers of `ISecurityService` outside this repo must add `canExport`; the
46+
interface member is required, matching how `getReadableFields` was added.
47+
Consumers still feature-detect (`typeof svc.canExport === 'function'`), so a
48+
partial implementation degrades rather than throwing.

content/docs/permissions/permission-sets.mdx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,41 @@ export const SalesUser = definePermissionSet({
5050
| Permission | Description |
5151
|------------|-------------|
5252
| `allowCreate` / `allowRead` / `allowEdit` / `allowDelete` | CRUD on records the user can see |
53+
| `allowExport` | Bulk data egress — narrows read, see below |
5354
| `allowTransfer` / `allowRestore` / `allowPurge` | Lifecycle class (RBAC-gated ahead of the M2 operations) |
5455
| `viewAllRecords` | Read ALL records regardless of ownership (super-user read) |
5556
| `modifyAllRecords` | Edit ALL records regardless of ownership (super-user write) |
5657

58+
### `allowExport` — the export axis
59+
60+
Read and export are not the same privilege. Reading a record on screen and
61+
pulling the whole table down as a CSV differ in blast radius, which is why
62+
Salesforce ("Export Reports"), Dynamics ("Export to Excel"), NetSuite
63+
("Export Lists") and SAP (`S_GUI` 61) all carry a separate export permission.
64+
`allowExport` is that axis here: `export = list ∧ allowExport`.
65+
66+
It is a **tri-state**, and unset is not the same as `false`:
67+
68+
| Value | Meaning |
69+
|------------|-------------|
70+
| unset | Inherit read — anyone who can list can export. The default, so adding the key changes nothing for existing permission sets. |
71+
| `false` | Deny export while **keeping** read. The reason the axis exists. |
72+
| `true` | Explicitly granted. |
73+
74+
Across several permission sets the merge is most-permissive with an explicit
75+
deny in the middle: any set saying `true` wins, otherwise any set saying `false`
76+
wins, otherwise the bit stays unset and inherits read.
77+
78+
It narrows read — it never widens it. A set granting `allowExport: true` without
79+
a read grant exports nothing, and a `modifyAllRecords` super-user wildcard does
80+
**not** override an explicit per-object `allowExport: false`.
81+
82+
Enforcement is server-side: `GET /api/v1/data/:object/export` answers
83+
`403 EXPORT_NOT_PERMITTED` before it reads the first row. The same decision is
84+
published on `/me/permissions` as the object's effective `apiOperations`, which
85+
is what makes the client hide its Export button — the button and the refusal are
86+
one decision, not two.
87+
5788
## Access depth — `readScope` / `writeScope` (ADR-0057 D1)
5889

5990
An owner-scoped grant can widen the owner-match declaratively — the "see my

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,47 @@ describe('annotateEffectiveApiOperations (#3391)', () => {
108108
}));
109109
expect(objects.deal.apiOperations).toContain('export');
110110
});
111+
112+
// The merge keeps `'*'` and named objects as independent keys, but the
113+
// SERVER evaluator does not — `resolveObjectPermission` falls back to the
114+
// wildcard for any object a set has no explicit entry for. Reading it here
115+
// too is what keeps the hidden button and the refused request the same
116+
// decision; without it a `'*': {allowExport:false}` set would still be
117+
// offered an Export button that then 403s.
118+
it("inherits the '*' export bit when the object entry declares none", () => {
119+
const objects: Record<string, any> = {
120+
'*': { allowRead: true, allowExport: false },
121+
deal: { allowRead: true }, // no allowExport of its own
122+
};
123+
annotateEffectiveApiOperations(objects, schemaOf({
124+
deal: { name: 'deal', enable: {} }, // unrestricted
125+
}));
126+
expect(objects.deal.apiOperations).toBeDefined();
127+
expect(objects.deal.apiOperations).not.toContain('export');
128+
expect(objects.deal.apiOperations).toContain('list');
129+
});
130+
131+
it("an explicit per-object allowExport:true overrides a '*' deny", () => {
132+
const objects: Record<string, any> = {
133+
'*': { allowRead: true, allowExport: false },
134+
deal: { allowRead: true, allowExport: true },
135+
};
136+
annotateEffectiveApiOperations(objects, schemaOf({
137+
deal: { name: 'deal', enable: { apiMethods: ['get', 'list'] } },
138+
}));
139+
expect(objects.deal.apiOperations).toContain('export');
140+
});
141+
142+
it("a '*' carrying no export bit changes nothing", () => {
143+
const objects: Record<string, any> = {
144+
'*': { modifyAllRecords: true },
145+
deal: { allowRead: true },
146+
};
147+
annotateEffectiveApiOperations(objects, schemaOf({
148+
deal: { name: 'deal', enable: {} },
149+
}));
150+
expect('apiOperations' in objects.deal).toBe(false);
151+
});
111152
});
112153
});
113154

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,14 +340,26 @@ export function annotateEffectiveApiOperations(
340340
objects: Record<string, any>,
341341
schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined,
342342
): void {
343+
// [#3544] The `'*'` entry's export bit is the FALLBACK for objects that do
344+
// not declare one of their own. The merge keeps `'*'` and named objects as
345+
// independent keys, but the server evaluator does not: its
346+
// `resolveObjectPermission` falls back to the wildcard whenever a set has no
347+
// explicit entry for the object, so a set that denies export wholesale via
348+
// `'*': { allowExport: false }` really does deny it per-object. Reading the
349+
// wildcard here keeps the button the client hides and the request the server
350+
// refuses in agreement — the same class of client/server divergence
351+
// `foldWildcardSuperUser` exists to close, on the export axis.
352+
const wildExport = objects?.['*']?.allowExport;
343353
for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) {
344354
if (obj === '*' || !acc) continue;
345355
const schema = schemaOf(obj);
346356
if (!schema) continue; // schema missing → no annotation (client falls back)
347357
// [#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;
358+
// Unset → inherit the wildcard, then read (backward-compatible:
359+
// can-list ⇒ can-export); explicit `false` → export removed from the
360+
// effective set.
361+
const exportBit = acc.allowExport ?? wildExport;
362+
const userExportAllowed = exportBit !== false;
351363
const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed });
352364
// Annotate when the object tightens via `apiMethods`, OR when the export
353365
// axis removes `export` from an otherwise-open object (so the client
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #3544 — the user-level EXPORT axis, at the evaluator.
5+
*
6+
* `allowExport` shipped as a spec bit and a `/me/permissions` annotation, which
7+
* hid the client's Export button but enforced nothing: `export ⊆ list`, so a
8+
* bulk export reaches the engine middleware as an ordinary `find` gated by
9+
* `allowRead`, and no code path ever read the bit. These pin the enforcement
10+
* half — the evaluator's `export` branch and the tri-state fold it rests on.
11+
*
12+
* The fold must agree, case for case, with the `/me/permissions` per-object
13+
* merge in `hono-plugin.ts` (`if (v === true) acc[k] = true; else if
14+
* (acc[k] === undefined) acc[k] = v`, read back as `acc.allowExport !== false`).
15+
* A divergence there is the same declared ≠ enforced bug, just inverted: the
16+
* client would offer a button the server refuses.
17+
*/
18+
19+
import { describe, it, expect } from 'vitest';
20+
import type { PermissionSet } from '@objectstack/spec/security';
21+
import { PermissionEvaluator, resolveUserExportAllowed } from './permission-evaluator';
22+
23+
const evaluator = new PermissionEvaluator();
24+
25+
/** A permission set granting `objects` as authored (defaults omitted). */
26+
const set = (name: string, objects: Record<string, any>): PermissionSet =>
27+
({ name, objects } as unknown as PermissionSet);
28+
29+
/** The read-granting baseline every export case builds on. */
30+
const READER = { allowRead: true };
31+
32+
describe('resolveUserExportAllowed — tri-state fold (#3544)', () => {
33+
it('all sets unset → undefined (inherit read, backward-compatible)', () => {
34+
expect(resolveUserExportAllowed('deal', [set('a', { deal: READER })])).toBeUndefined();
35+
});
36+
37+
it('an explicit false → false', () => {
38+
expect(
39+
resolveUserExportAllowed('deal', [set('a', { deal: { ...READER, allowExport: false } })]),
40+
).toBe(false);
41+
});
42+
43+
it('an explicit true → true', () => {
44+
expect(
45+
resolveUserExportAllowed('deal', [set('a', { deal: { ...READER, allowExport: true } })]),
46+
).toBe(true);
47+
});
48+
49+
it('true outranks false regardless of set order (most-permissive merge)', () => {
50+
const deny = set('deny', { deal: { ...READER, allowExport: false } });
51+
const grant = set('grant', { deal: { ...READER, allowExport: true } });
52+
expect(resolveUserExportAllowed('deal', [deny, grant])).toBe(true);
53+
expect(resolveUserExportAllowed('deal', [grant, deny])).toBe(true);
54+
});
55+
56+
it('false outranks silence — one opt-out set denies even alongside plain readers', () => {
57+
expect(
58+
resolveUserExportAllowed('deal', [
59+
set('reader', { deal: READER }),
60+
set('no_export', { deal: { ...READER, allowExport: false } }),
61+
]),
62+
).toBe(false);
63+
});
64+
65+
it('is per-object — a deny on one object leaves another untouched', () => {
66+
const sets = [set('a', { deal: { ...READER, allowExport: false }, lead: READER })];
67+
expect(resolveUserExportAllowed('deal', sets)).toBe(false);
68+
expect(resolveUserExportAllowed('lead', sets)).toBeUndefined();
69+
});
70+
71+
it("reads the '*' wildcard when the set has no explicit entry for the object", () => {
72+
const sets = [set('a', { '*': { ...READER, allowExport: false } })];
73+
expect(resolveUserExportAllowed('deal', sets)).toBe(false);
74+
});
75+
76+
it("an explicit per-object entry overrides the '*' wildcard", () => {
77+
const sets = [set('a', { '*': { ...READER, allowExport: false }, deal: { ...READER, allowExport: true } })];
78+
expect(resolveUserExportAllowed('deal', sets)).toBe(true);
79+
});
80+
81+
it("a non-super-user '*' does not reach a PRIVATE object (ADR-0066 D2)", () => {
82+
const sets = [set('a', { '*': { ...READER, allowExport: false } })];
83+
expect(resolveUserExportAllowed('deal', sets, { isPrivate: true })).toBeUndefined();
84+
// …but a super-user wildcard does.
85+
const superSets = [set('a', { '*': { viewAllRecords: true, allowExport: false } as any })];
86+
expect(resolveUserExportAllowed('deal', superSets, { isPrivate: true })).toBe(false);
87+
});
88+
});
89+
90+
describe("checkObjectPermission('export') — export ⊆ list ∧ allowExport (#3544)", () => {
91+
const canExport = (sets: PermissionSet[], opts?: { isPrivate?: boolean }) =>
92+
evaluator.checkObjectPermission('export', 'deal', sets, opts ?? {});
93+
94+
it('unset allowExport inherits read — a plain reader may still export', () => {
95+
// The whole point of the opt-out default: every permission set authored
96+
// before this axis existed keeps working unchanged.
97+
expect(canExport([set('a', { deal: READER })])).toBe(true);
98+
});
99+
100+
it('allowExport:false denies export while READ stays granted', () => {
101+
const sets = [set('a', { deal: { ...READER, allowExport: false } })];
102+
expect(canExport(sets)).toBe(false);
103+
// The Salesforce "Export Reports" shape: read is untouched.
104+
expect(evaluator.checkObjectPermission('find', 'deal', sets)).toBe(true);
105+
});
106+
107+
it('allowExport:true grants export', () => {
108+
expect(canExport([set('a', { deal: { ...READER, allowExport: true } })])).toBe(true);
109+
});
110+
111+
it('no read grant → no export, even with allowExport:true (export ⊆ list)', () => {
112+
// The axis NARROWS read; it is not an independent grant. A set that says
113+
// "may export" but not "may read" exports nothing.
114+
expect(canExport([set('a', { deal: { allowRead: false, allowExport: true } })])).toBe(false);
115+
});
116+
117+
it('no matching grant at all → denied', () => {
118+
expect(canExport([set('a', { other: READER })])).toBe(false);
119+
});
120+
121+
it('viewAllRecords satisfies the read half', () => {
122+
expect(canExport([set('a', { deal: { viewAllRecords: true } })])).toBe(true);
123+
});
124+
125+
it('a super-user wildcard does NOT bypass an explicit per-object export deny', () => {
126+
// modifyAllRecords is a bypass for the CRUD bits, not a licence to exfiltrate
127+
// an object whose set explicitly opted out of export.
128+
const sets = [
129+
set('admin', { '*': { modifyAllRecords: true } }),
130+
set('no_export', { deal: { ...READER, allowExport: false } }),
131+
];
132+
expect(canExport(sets)).toBe(false);
133+
expect(evaluator.checkObjectPermission('find', 'deal', sets)).toBe(true);
134+
});
135+
136+
it('an empty set list denies (the middleware skips its CRUD gate in that case)', () => {
137+
// Guard rail: the "no sets ⇒ unrestricted" decision belongs to the CALLER
138+
// (SecurityPlugin.canExport), matching how the middleware guards its whole
139+
// CRUD block. The evaluator itself never invents a grant.
140+
expect(canExport([])).toBe(false);
141+
});
142+
143+
it('other operations are untouched by the axis', () => {
144+
const sets = [set('a', { deal: { allowRead: true, allowCreate: true, allowExport: false } })];
145+
expect(evaluator.checkObjectPermission('find', 'deal', sets)).toBe(true);
146+
expect(evaluator.checkObjectPermission('insert', 'deal', sets)).toBe(true);
147+
expect(evaluator.checkObjectPermission('export', 'deal', sets)).toBe(false);
148+
});
149+
});

packages/plugins/plugin-security/src/permission-evaluator.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,41 @@ function resolveObjectPermission(
101101
return wild.viewAllRecords || wild.modifyAllRecords ? wild : undefined;
102102
}
103103

104+
/**
105+
* [#3544] Fold the user-level EXPORT axis across the resolved permission sets.
106+
*
107+
* `allowExport` is deliberately absent from {@link OPERATION_TO_PERMISSION}:
108+
* that map's semantics are "the bit must be truthy", which would deny export to
109+
* every permission set authored before the axis existed. The bit is a TRI-state
110+
* instead, and this is its merge rule:
111+
*
112+
* - any set `true` → `true` (an explicit grant outranks another set's deny)
113+
* - else any `false` → `false` (an explicit opt-out outranks silence)
114+
* - else all unset → `undefined` (inherit read — the pre-#3544
115+
* "can-list ⇒ can-export" behaviour, so existing sets are unaffected)
116+
*
117+
* This is the same answer the `/me/permissions` per-object merge produces
118+
* (`if (v === true) acc[k] = true; else if (acc[k] === undefined) acc[k] = v`,
119+
* read back as `acc.allowExport !== false`). That equality is load-bearing, not
120+
* incidental: the client hides its Export button on the merged map while this
121+
* decides the server's 403, and the two disagreeing is exactly the
122+
* `declared ≠ enforced` gap the axis exists to close.
123+
*/
124+
export function resolveUserExportAllowed(
125+
objectName: string,
126+
permissionSets: PermissionSet[],
127+
opts: { isPrivate?: boolean } = {},
128+
): boolean | undefined {
129+
let denied = false;
130+
for (const ps of permissionSets) {
131+
const objPerm = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false);
132+
const bit = objPerm?.allowExport;
133+
if (bit === true) return true;
134+
if (bit === false) denied = true;
135+
}
136+
return denied ? false : undefined;
137+
}
138+
104139
/**
105140
* PermissionEvaluator
106141
*
@@ -119,6 +154,17 @@ export class PermissionEvaluator {
119154
/** [ADR-0066 D2] When the object is `private`, the `'*'` wildcard only covers it if it is a super-user grant. */
120155
opts: { isPrivate?: boolean } = {},
121156
): boolean {
157+
// [#3544] User-level export axis. `export` is NOT a bit lookup: per the
158+
// spec's derivation table (`API_METHOD_DERIVATION`) it is `list ∧
159+
// userExportAllowed`, so it requires READ and is then vetoed by an explicit
160+
// `allowExport: false`. Handled here rather than in OPERATION_TO_PERMISSION
161+
// so an UNSET bit keeps inheriting read — otherwise every permission set
162+
// written before the axis existed would silently lose export.
163+
if (operation === 'export') {
164+
if (resolveUserExportAllowed(objectName, permissionSets, opts) === false) return false;
165+
return this.checkObjectPermission('find', objectName, permissionSets, opts);
166+
}
167+
122168
const permKey = OPERATION_TO_PERMISSION[operation];
123169
if (!permKey) {
124170
// Fail CLOSED for the destructive operation class (ADR-0049): an

0 commit comments

Comments
 (0)