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
75 changes: 75 additions & 0 deletions .changeset/export-axis-opt-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
"@objectstack/spec": major
"@objectstack/plugin-security": major
"@objectstack/plugin-reports": major
"@objectstack/plugin-hono-server": minor
---

feat(security)!: the export axis is now OPT-IN, explainable, and covers reports (#3544, #3710)

**BREAKING — `allowExport` unset no longer means "inherit read".** Reading a
record and taking a bulk machine-readable copy of the whole table are different
privileges (Salesforce "Export Reports", Dynamics "Export to Excel", NetSuite
"Export Lists", SAP `S_GUI` 61 all separate them). The axis now says so.

### Migration — FROM → TO

| | before | after |
|---|---|---|
| `allowExport` unset | export **allowed** (inherited read) | export **denied** |
| `allowExport: false` | export denied | export denied (unchanged) |
| `allowExport: true` | export allowed | export allowed (unchanged) |

**The one-line fix:** add `allowExport: true` to the object entry (or the `'*'`
wildcard) of every permission set whose holders should keep exporting.

```ts
objects: {
deal: { allowRead: true, allowExport: true }, // ← add the grant
}
```

Nothing else changes: read, CRUD, RLS, FLS and sharing are untouched, and a set
that never exported is unaffected.

**Who is affected.** Package-shipped sets are re-seeded on upgrade, so the
built-ins are handled for you — `admin_full_access` and `organization_admin` now
carry `allowExport: true` explicitly. **Environment-authored sets are not**: any
custom set whose users export must be edited. `member_default` deliberately does
NOT carry the grant, so ordinary authenticated users lose export until an admin
grants it — that is the point of the flip, not an oversight.

**Merge semantics.** Most-permissive, exactly like the CRUD bits: any set
granting `true` grants export. `false` and unset are the same outcome; `false`
is authoring intent, not a veto, because permission sets are additive capability
containers (ADR-0090).

**Not implied by super-user bits.** `viewAllRecords` / `modifyAllRecords` no
longer confer export. Separating "may see all data" from "may take a bulk copy"
is the segregation-of-duties case the axis exists for.

### Also in this change

- **spec** — a set carrying `allowExport` is now **high-privilege**
(`describeHighPrivilegeBits`), so it cannot be bound to the `everyone` /
`guest` audience anchors. Without this the opt-in was defeatable by binding an
export-granting set to `everyone`. One predicate, so the runtime anchor gate,
the `@objectstack/lint` security-posture rule and the install-time suggestion
surface all pick it up together.
- **spec / plugin-security** — `ExplainOperationSchema` gains `export`, so
`explain` can answer *why* a caller got `403 EXPORT_NOT_PERMITTED`. It
explains as `read ∧ the export grant`: `object_crud` reports the conjunction
and attributes the granting set, while every data-shaped layer
(requiredPermissions, OWD/depth/sharing, RLS, record attribution) is computed
as the `find` the export actually performs — asking the RLS compiler about an
`export` operation would match no policy and wrongly report "no RLS applies".
`readFilter` is surfaced for `export` as it is for `read`.
- **plugin-reports** — closes the reports side door (#3710). A report rendered
as `csv`/`json` is the same bulk copy of the same object, so it is gated by
the same `ISecurityService.canExport`. Enforced in `executeReport`, which the
interactive run, the ad-hoc run and the scheduled dispatch all funnel through;
`scheduleReport` additionally refuses at create time so an author is not told
at 3am. A schedule created while granted stops delivering once the grant is
revoked. `html_table` stays a read — it is a rendered view, not a bulk copy.
Deployments without `plugin-security` are unaffected (no permission sets
exist, so the axis does not apply).
13 changes: 12 additions & 1 deletion content/docs/permissions/explain.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,18 @@ curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/jso
```

`operation` is one of `read | create | update | delete | transfer | restore |
purge` (defaults to `read`); omit `userId` to explain **yourself**.
purge | export` (defaults to `read`); omit `userId` to explain **yourself**.

`export` is the one operation whose answer you cannot infer from `read`. Since
the [export axis](/docs/permissions/permission-sets#allowexport--the-export-axis)
is opt-in, a user can be fully able to read an object and still be refused a
bulk copy of it — so when someone hits `403 EXPORT_NOT_PERMITTED`, ask with
`operation: 'export'`. Asking with `read` will answer `allowed` and tell you
nothing, because reading is precisely what they may still do. The `object_crud`
layer reports the conjunction (`read ∧ the export grant`) and names the set that
supplies it; every other layer is computed as the `find` the export performs, so
the `readFilter` you get back is the real row filter the export would stream
through.

## Who may ask

Expand Down
60 changes: 41 additions & 19 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const SalesUser = definePermissionSet({
| Permission | Description |
|------------|-------------|
| `allowCreate` / `allowRead` / `allowEdit` / `allowDelete` | CRUD on records the user can see |
| `allowExport` | Bulk data egress — narrows read, see below |
| `allowExport` | Bulk data egress — an opt-in grant on top of read, see below |
| `allowTransfer` / `allowRestore` / `allowPurge` | Lifecycle class (RBAC-gated ahead of the M2 operations) |
| `viewAllRecords` | Read ALL records regardless of ownership (super-user read) |
| `modifyAllRecords` | Edit ALL records regardless of ownership (super-user write) |
Expand All @@ -63,27 +63,49 @@ Salesforce ("Export Reports"), Dynamics ("Export to Excel"), NetSuite
("Export Lists") and SAP (`S_GUI` 61) all carry a separate export permission.
`allowExport` is that axis here: `export = list ∧ allowExport`.

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

| Value | Meaning |
|------------|-------------|
| unset | Inherit read — anyone who can list can export. The default, so adding the key changes nothing for existing permission sets. |
| `false` | Deny export while **keeping** read. The reason the axis exists. |
| `true` | Explicitly granted. |

Across several permission sets the merge is most-permissive with an explicit
deny in the middle: any set saying `true` wins, otherwise any set saying `false`
wins, otherwise the bit stays unset and inherits read.

It narrows read — it never widens it. A set granting `allowExport: true` without
a read grant exports nothing, and a `modifyAllRecords` super-user wildcard does
**not** override an explicit per-object `allowExport: false`.

Enforcement is server-side: `GET /api/v1/data/:object/export` answers
`403 EXPORT_NOT_PERMITTED` before it reads the first row. The same decision is
published on `/me/permissions` as the object's effective `apiOperations`, which
is what makes the client hide its Export button — the button and the refusal are
one decision, not two.
| `true` | Export granted — still bounded by read. |
| unset | **No export.** Read alone never confers it. |
| `false` | No export. Same outcome as unset; write it when you want the intent on the record. |

Across several permission sets the merge is most-permissive, exactly like the
CRUD bits: any set granting `true` grants export. `false` is therefore
documentation rather than a veto — permission sets are additive capability
containers, and nothing in them is a deny.

Two consequences worth stating plainly:

- **It narrows read; it never widens it.** A set granting `allowExport: true`
without a read grant exports nothing — `export = list ∧ allowExport`.
- **`viewAllRecords` / `modifyAllRecords` do not imply it.** Separating "may see
all data" from "may take a bulk copy of it" is the segregation-of-duties case
the axis exists for. The built-in `admin_full_access` and `organization_admin`
sets carry `allowExport: true` explicitly; remove that line to get the
separation. `member_default` deliberately does not carry it, so ordinary
authenticated users have no export by default.

A set carrying `allowExport` is treated as **high-privilege** and cannot be
bound to the `everyone` or `guest` audience anchors — binding it there would
hand bulk egress to every authenticated user (or every visitor) and undo the
opt-in. Grant it through an ordinary position-distributed set instead.

Enforcement is server-side and covers both egress doors:

- `GET /api/v1/data/:object/export` answers `403 EXPORT_NOT_PERMITTED` before it
reads the first row.
- A **report** rendered as `csv` or `json` is the same bulk copy and is gated the
same way, whether run interactively or delivered by a schedule. `html_table`
is a rendered view and stays a read.

The same decision is published on `/me/permissions` as the object's effective
`apiOperations`, which is what makes the client hide its Export button — the
button and the refusal are one decision, not two. To ask why a particular user
was refused, use [`explain`](/docs/permissions/explain) with
`operation: 'export'`; `operation: 'read'` will answer `allowed` and tell you
nothing, because reading is exactly what they are still permitted to do.

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

Expand Down
9 changes: 9 additions & 0 deletions content/docs/permissions/permissions-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ ObjectStack's `ObjectPermission` schema defines these boolean flags for object a
| **Create** | `allowCreate` | Create new records | Insert records |
| **Edit** | `allowEdit` | Modify records owned by the user or shared with them | Edit own records |
| **Delete** | `allowDelete` | Remove records owned by the user or shared with them | Delete own records |
| **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)) |
| **Transfer** | `allowTransfer` | Change record ownership | Reassign owner |
| **Restore** | `allowRestore` | Undelete from trash | Recover soft-deleted records |
| **Purge** | `allowPurge` | Permanently (hard) delete | GDPR / compliance erase |
Expand All @@ -32,6 +33,14 @@ ObjectStack's `ObjectPermission` schema defines these boolean flags for object a
<Callout type="tip">
**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`.

**The one exception is `allowExport`.** Neither super-user bit confers it: a
principal with View/Modify All Data may read every record and still be refused a
bulk copy of them. Export is an opt-in grant that must be stated explicitly —
separating "may see all data" from "may take a bulk copy of it" is the
segregation-of-duties case the axis exists for. The built-in
`admin_full_access` / `organization_admin` sets therefore carry
`allowExport: true` alongside their super-user bits.

The bypass is **posture-gated for row-level security** (ADR-0066 D2 ①): RLS
policies are short-circuited only on objects whose posture permits it —
`access: { default: 'private' }`, `tenancy.enabled: false`
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/security/explain.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
| :--- | :--- | :--- | :--- |
| **allowed** | `boolean` | ✅ | |
| **object** | `string` | ✅ | |
| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge'>` | ✅ | |
| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge' \| 'export'>` | ✅ | |
| **principal** | `{ userId: string \| null; positions: string[]; permissionSets: string[]; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>; … }` | ✅ | |
| **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; … }[]` | ✅ | |
| **readFilter** | `any` | optional | |
Expand Down Expand Up @@ -172,6 +172,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
* `transfer`
* `restore`
* `purge`
* `export`


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

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/security/permission.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +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. |
| **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. |
| **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 @@ -107,7 +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. |
| **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. |
| **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
Loading
Loading