Skip to content

Commit 4ed7ed4

Browse files
authored
feat(security)!: export 权限轴改为 opt-in,接入 explain,并堵上报表侧门 (#3544, #3710) (#3721)
BREAKING: `allowExport` 未设不再继承 read。导出成为与其他 `allow*` 位一样的 opt-in 授予 —— 「看一条记录」和「把整张表拉成机读副本」是两种权限。 迁移:给需要保留导出的权限集在对象条目(或 `'*'`)上加 `allowExport: true`。 package 级集升级时重新 seed(内置已处理),环境自建的集需手动改; `member_default` 故意不带,普通用户默认失去导出。 - plugin-security:`resolveUserExportAllowed` 改为授予折叠(任一集 `true` 即 授予;未设与 `false` 同为「无授予」—— 权限集是可加容器,里面没有 deny)。 超级用户位不再蕴含导出:把「能看到全部数据」和「能整份带走」分开,正是这条 轴存在的理由。`admin_full_access` / `organization_admin` 显式带上授予。 - spec:带 `allowExport` 的集算 high-privilege,不能绑到 `everyone` / `guest` 锚点 —— 否则把这样的集绑到 everyone 就能一键还原 opt-in。共享谓词,运行时 闸门 / lint 规则 / 安装期 suggestion 同时生效。 - spec/plugin-security:`ExplainOperationSchema` 加入 `export`,让管理员能问 出 403 EXPORT_NOT_PERMITTED 的原因 —— 问 `read` 只会答 `allowed`,毫无用处。 `object_crud` 报告 `read ∧ 授予` 并归因到具体权限集;数据形状的层一律按 export 实际执行的那次 `find` 计算,避免拿 `export` 去问 RLS 编译器时匹配不到 任何 select 策略、却对行已被过滤的主体报告「无 RLS 生效」。 - plugin-reports:堵上侧门(#3710)。csv/json 报表是同一对象的同一份机读副本, 用同一个 canExport 把关,落在 `executeReport` —— 交互式 run、ad-hoc、定时派发 三条路都汇聚于此。`scheduleReport` 另在创建时就拒。授予被撤销后,已建好的 csv 定时任务停止投递。`html_table` 保持是 read。
1 parent 53d37f1 commit 4ed7ed4

20 files changed

Lines changed: 841 additions & 182 deletions

.changeset/export-axis-opt-in.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/plugin-security": major
4+
"@objectstack/plugin-reports": major
5+
"@objectstack/plugin-hono-server": minor
6+
---
7+
8+
feat(security)!: the export axis is now OPT-IN, explainable, and covers reports (#3544, #3710)
9+
10+
**BREAKING — `allowExport` unset no longer means "inherit read".** Reading a
11+
record and taking a bulk machine-readable copy of the whole table are different
12+
privileges (Salesforce "Export Reports", Dynamics "Export to Excel", NetSuite
13+
"Export Lists", SAP `S_GUI` 61 all separate them). The axis now says so.
14+
15+
### Migration — FROM → TO
16+
17+
| | before | after |
18+
|---|---|---|
19+
| `allowExport` unset | export **allowed** (inherited read) | export **denied** |
20+
| `allowExport: false` | export denied | export denied (unchanged) |
21+
| `allowExport: true` | export allowed | export allowed (unchanged) |
22+
23+
**The one-line fix:** add `allowExport: true` to the object entry (or the `'*'`
24+
wildcard) of every permission set whose holders should keep exporting.
25+
26+
```ts
27+
objects: {
28+
deal: { allowRead: true, allowExport: true }, // ← add the grant
29+
}
30+
```
31+
32+
Nothing else changes: read, CRUD, RLS, FLS and sharing are untouched, and a set
33+
that never exported is unaffected.
34+
35+
**Who is affected.** Package-shipped sets are re-seeded on upgrade, so the
36+
built-ins are handled for you — `admin_full_access` and `organization_admin` now
37+
carry `allowExport: true` explicitly. **Environment-authored sets are not**: any
38+
custom set whose users export must be edited. `member_default` deliberately does
39+
NOT carry the grant, so ordinary authenticated users lose export until an admin
40+
grants it — that is the point of the flip, not an oversight.
41+
42+
**Merge semantics.** Most-permissive, exactly like the CRUD bits: any set
43+
granting `true` grants export. `false` and unset are the same outcome; `false`
44+
is authoring intent, not a veto, because permission sets are additive capability
45+
containers (ADR-0090).
46+
47+
**Not implied by super-user bits.** `viewAllRecords` / `modifyAllRecords` no
48+
longer confer export. Separating "may see all data" from "may take a bulk copy"
49+
is the segregation-of-duties case the axis exists for.
50+
51+
### Also in this change
52+
53+
- **spec** — a set carrying `allowExport` is now **high-privilege**
54+
(`describeHighPrivilegeBits`), so it cannot be bound to the `everyone` /
55+
`guest` audience anchors. Without this the opt-in was defeatable by binding an
56+
export-granting set to `everyone`. One predicate, so the runtime anchor gate,
57+
the `@objectstack/lint` security-posture rule and the install-time suggestion
58+
surface all pick it up together.
59+
- **spec / plugin-security**`ExplainOperationSchema` gains `export`, so
60+
`explain` can answer *why* a caller got `403 EXPORT_NOT_PERMITTED`. It
61+
explains as `read ∧ the export grant`: `object_crud` reports the conjunction
62+
and attributes the granting set, while every data-shaped layer
63+
(requiredPermissions, OWD/depth/sharing, RLS, record attribution) is computed
64+
as the `find` the export actually performs — asking the RLS compiler about an
65+
`export` operation would match no policy and wrongly report "no RLS applies".
66+
`readFilter` is surfaced for `export` as it is for `read`.
67+
- **plugin-reports** — closes the reports side door (#3710). A report rendered
68+
as `csv`/`json` is the same bulk copy of the same object, so it is gated by
69+
the same `ISecurityService.canExport`. Enforced in `executeReport`, which the
70+
interactive run, the ad-hoc run and the scheduled dispatch all funnel through;
71+
`scheduleReport` additionally refuses at create time so an author is not told
72+
at 3am. A schedule created while granted stops delivering once the grant is
73+
revoked. `html_table` stays a read — it is a rendered view, not a bulk copy.
74+
Deployments without `plugin-security` are unaffected (no permission sets
75+
exist, so the axis does not apply).

content/docs/permissions/explain.mdx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,18 @@ curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/jso
7171
```
7272

7373
`operation` is one of `read | create | update | delete | transfer | restore |
74-
purge` (defaults to `read`); omit `userId` to explain **yourself**.
74+
purge | export` (defaults to `read`); omit `userId` to explain **yourself**.
75+
76+
`export` is the one operation whose answer you cannot infer from `read`. Since
77+
the [export axis](/docs/permissions/permission-sets#allowexport--the-export-axis)
78+
is opt-in, a user can be fully able to read an object and still be refused a
79+
bulk copy of it — so when someone hits `403 EXPORT_NOT_PERMITTED`, ask with
80+
`operation: 'export'`. Asking with `read` will answer `allowed` and tell you
81+
nothing, because reading is precisely what they may still do. The `object_crud`
82+
layer reports the conjunction (`read ∧ the export grant`) and names the set that
83+
supplies it; every other layer is computed as the `find` the export performs, so
84+
the `readFilter` you get back is the real row filter the export would stream
85+
through.
7586

7687
## Who may ask
7788

content/docs/permissions/permission-sets.mdx

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ 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 |
53+
| `allowExport` | Bulk data egress — an opt-in grant on top of read, see below |
5454
| `allowTransfer` / `allowRestore` / `allowPurge` | Lifecycle class (RBAC-gated ahead of the M2 operations) |
5555
| `viewAllRecords` | Read ALL records regardless of ownership (super-user read) |
5656
| `modifyAllRecords` | Edit ALL records regardless of ownership (super-user write) |
@@ -63,27 +63,49 @@ Salesforce ("Export Reports"), Dynamics ("Export to Excel"), NetSuite
6363
("Export Lists") and SAP (`S_GUI` 61) all carry a separate export permission.
6464
`allowExport` is that axis here: `export = list ∧ allowExport`.
6565

66-
It is a **tri-state**, and unset is not the same as `false`:
66+
It is an **opt-in grant**, like every other `allow*` bit:
6767

6868
| Value | Meaning |
6969
|------------|-------------|
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.
70+
| `true` | Export granted — still bounded by read. |
71+
| unset | **No export.** Read alone never confers it. |
72+
| `false` | No export. Same outcome as unset; write it when you want the intent on the record. |
73+
74+
Across several permission sets the merge is most-permissive, exactly like the
75+
CRUD bits: any set granting `true` grants export. `false` is therefore
76+
documentation rather than a veto — permission sets are additive capability
77+
containers, and nothing in them is a deny.
78+
79+
Two consequences worth stating plainly:
80+
81+
- **It narrows read; it never widens it.** A set granting `allowExport: true`
82+
without a read grant exports nothing — `export = list ∧ allowExport`.
83+
- **`viewAllRecords` / `modifyAllRecords` do not imply it.** Separating "may see
84+
all data" from "may take a bulk copy of it" is the segregation-of-duties case
85+
the axis exists for. The built-in `admin_full_access` and `organization_admin`
86+
sets carry `allowExport: true` explicitly; remove that line to get the
87+
separation. `member_default` deliberately does not carry it, so ordinary
88+
authenticated users have no export by default.
89+
90+
A set carrying `allowExport` is treated as **high-privilege** and cannot be
91+
bound to the `everyone` or `guest` audience anchors — binding it there would
92+
hand bulk egress to every authenticated user (or every visitor) and undo the
93+
opt-in. Grant it through an ordinary position-distributed set instead.
94+
95+
Enforcement is server-side and covers both egress doors:
96+
97+
- `GET /api/v1/data/:object/export` answers `403 EXPORT_NOT_PERMITTED` before it
98+
reads the first row.
99+
- A **report** rendered as `csv` or `json` is the same bulk copy and is gated the
100+
same way, whether run interactively or delivered by a schedule. `html_table`
101+
is a rendered view and stays a read.
102+
103+
The same decision is published on `/me/permissions` as the object's effective
104+
`apiOperations`, which is what makes the client hide its Export button — the
105+
button and the refusal are one decision, not two. To ask why a particular user
106+
was refused, use [`explain`](/docs/permissions/explain) with
107+
`operation: 'export'`; `operation: 'read'` will answer `allowed` and tell you
108+
nothing, because reading is exactly what they are still permitted to do.
87109

88110
## Access depth — `readScope` / `writeScope` (ADR-0057 D1)
89111

content/docs/permissions/permissions-matrix.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ ObjectStack's `ObjectPermission` schema defines these boolean flags for object a
2323
| **Create** | `allowCreate` | Create new records | Insert records |
2424
| **Edit** | `allowEdit` | Modify records owned by the user or shared with them | Edit own records |
2525
| **Delete** | `allowDelete` | Remove records owned by the user or shared with them | Delete own records |
26+
| **Export** | `allowExport` | Take a bulk machine-readable copy of the records the user can read | Export / bulk egress ([details](/docs/permissions/permission-sets#allowexport--the-export-axis)) |
2627
| **Transfer** | `allowTransfer` | Change record ownership | Reassign owner |
2728
| **Restore** | `allowRestore` | Undelete from trash | Recover soft-deleted records |
2829
| **Purge** | `allowPurge` | Permanently (hard) delete | GDPR / compliance erase |
@@ -32,6 +33,14 @@ ObjectStack's `ObjectPermission` schema defines these boolean flags for object a
3233
<Callout type="tip">
3334
**Super-user bypass:** When `modifyAllRecords` is set it satisfies write checks (`allowEdit`/`allowDelete`, and the lifecycle class `allowTransfer`/`allowRestore`/`allowPurge`) on any record; `viewAllRecords` (or `modifyAllRecords`) satisfies `allowRead` on any record — both bypass ownership and sharing. See `packages/plugins/plugin-security/src/permission-evaluator.ts`.
3435

36+
**The one exception is `allowExport`.** Neither super-user bit confers it: a
37+
principal with View/Modify All Data may read every record and still be refused a
38+
bulk copy of them. Export is an opt-in grant that must be stated explicitly —
39+
separating "may see all data" from "may take a bulk copy of it" is the
40+
segregation-of-duties case the axis exists for. The built-in
41+
`admin_full_access` / `organization_admin` sets therefore carry
42+
`allowExport: true` alongside their super-user bits.
43+
3544
The bypass is **posture-gated for row-level security** (ADR-0066 D2 ①): RLS
3645
policies are short-circuited only on objects whose posture permits it —
3746
`access: { default: 'private' }`, `tenancy.enabled: false`

content/docs/references/security/explain.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
120120
| :--- | :--- | :--- | :--- |
121121
| **allowed** | `boolean` || |
122122
| **object** | `string` || |
123-
| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge'>` || |
123+
| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge' \| 'export'>` || |
124124
| **principal** | `{ userId: string \| null; positions: string[]; permissionSets: string[]; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>; … }` || |
125125
| **layers** | `{ layer: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| 'fls' \| 'owd_baseline' \| 'depth' \| 'sharing' \| 'vama_bypass' \| 'rls'>; kernelTier?: Enum<'layer_0_tenant' \| 'layer_1_business'>; verdict: Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>; detail: string; … }[]` || |
126126
| **readFilter** | `any` | optional | |
@@ -172,6 +172,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
172172
* `transfer`
173173
* `restore`
174174
* `purge`
175+
* `export`
175176

176177

177178
---
@@ -198,7 +199,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
198199
| Property | Type | Required | Description |
199200
| :--- | :--- | :--- | :--- |
200201
| **object** | `string` || |
201-
| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge'>` || |
202+
| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge' \| 'export'>` || |
202203
| **recordId** | `string` | optional | Optional id of one concrete record to explain at row granularity; omitted = object-level (pre-C2) request. |
203204
| **userId** | `string` | optional | |
204205

content/docs/references/security/permission.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +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. |
62+
| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
6363
| **allowTransfer** | `boolean` || [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) |
6464
| **allowRestore** | `boolean` || [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
6565
| **allowPurge** | `boolean` || [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |
@@ -107,7 +107,7 @@ const result = AdminScope.parse(data);
107107
| **allowRead** | `boolean` || Read permission |
108108
| **allowEdit** | `boolean` || Edit permission |
109109
| **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. |
110+
| **allowExport** | `boolean` | optional | [#3544] User-level export axis over read (opt-in grant). true = export granted (still bounded by read); unset/false = no export. Merged most-permissively like the CRUD bits; NOT implied by viewAllRecords/modifyAllRecords. |
111111
| **allowTransfer** | `boolean` || [RBAC-gated; ENFORCED now via insert/update owner_id guard, #3004] Change record ownership (assign/reassign/disown owner_id) |
112112
| **allowRestore** | `boolean` || [RBAC-gated; operation pending M2] Restore from trash (Undelete) |
113113
| **allowPurge** | `boolean` || [RBAC-gated; operation pending M2] Permanently delete (Hard Delete/GDPR) |

0 commit comments

Comments
 (0)