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
48 changes: 48 additions & 0 deletions .changeset/enforce-user-level-export-axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-security": minor
"@objectstack/plugin-hono-server": patch
"@objectstack/rest": minor
---

feat(security): ENFORCE the user-level export axis on the server (#3544)

`allowExport` landed as a spec bit plus a `/me/permissions` annotation, which
hid the client's Export button — and nothing else. Because `export ⊆ list`, the
REST export route streams through `findData` and the engine middleware sees an
ordinary `find` gated by `allowRead`, so no code path ever read the bit: a caller
holding `allowExport: false` could still `curl
/api/v1/data/:object/export` and drain the whole table. Declared, not enforced.

- **plugin-security** `PermissionEvaluator.checkObjectPermission('export', …)` is
now a real decision: `export` = read granted ∧ not explicitly denied.
`allowExport` stays out of `OPERATION_TO_PERMISSION` on purpose — that map
means "the bit must be truthy", which would have denied export to every
permission set authored before the axis existed. The new exported
`resolveUserExportAllowed()` folds the tri-state across sets (`true` beats
`false` beats unset) exactly as the `/me/permissions` merge does.
- **spec** `ISecurityService` gains `canExport(object, context)` — the question a
bulk-egress door outside the engine middleware has to ask before it reads.
Fails CLOSED; `isSystem` and an empty set resolution bypass, mirroring the
middleware.
- **rest** `GET /data/:object/export` calls it and answers **403
`EXPORT_NOT_PERMITTED`** before the first chunk is fetched. Distinct from the
object-level 405 `OBJECT_API_METHOD_NOT_ALLOWED`, which still runs first: 405
says the object exposes no export, 403 says this caller may not use it. No
security service (no `plugin-security` ⇒ no permission sets) → allowed, the
same fail-open posture as every other permission gate in that layer; service
present but unable to answer → denied.
- **plugin-hono-server** the `/me/permissions` annotation now falls back to the
`'*'` entry's export bit when a per-object entry declares none, matching the
evaluator's own wildcard fallback — so a set that denies export wholesale via
`'*'` no longer offers a button the server refuses.

Backward-compatible: `allowExport` is still an opt-out with no default, so an
unset bit inherits read and existing permission sets behave exactly as before.
Only a permission set that explicitly sets `allowExport: false` changes — and it
now changes on the server, which is the point.

Implementers of `ISecurityService` outside this repo must add `canExport`; the
interface member is required, matching how `getReadableFields` was added.
Consumers still feature-detect (`typeof svc.canExport === 'function'`), so a
partial implementation degrades rather than throwing.
31 changes: 31 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,41 @@ 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 |
| `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) |

### `allowExport` — the export axis

Read and export are not the same privilege. Reading a record on screen and
pulling the whole table down as a CSV differ in blast radius, which is why
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`:

| 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.

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

An owner-scoped grant can widen the owner-match declaratively — the "see my
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,47 @@ describe('annotateEffectiveApiOperations (#3391)', () => {
}));
expect(objects.deal.apiOperations).toContain('export');
});

// The merge keeps `'*'` and named objects as independent keys, but the
// SERVER evaluator does not — `resolveObjectPermission` falls back to the
// wildcard for any object a set has no explicit entry for. Reading it here
// too is what keeps the hidden button and the refused request the same
// decision; without it a `'*': {allowExport:false}` set would still be
// offered an Export button that then 403s.
it("inherits the '*' export bit when the object entry declares none", () => {
const objects: Record<string, any> = {
'*': { allowRead: true, allowExport: false },
deal: { allowRead: true }, // no allowExport of its own
};
annotateEffectiveApiOperations(objects, schemaOf({
deal: { name: 'deal', enable: {} }, // unrestricted
}));
expect(objects.deal.apiOperations).toBeDefined();
expect(objects.deal.apiOperations).not.toContain('export');
expect(objects.deal.apiOperations).toContain('list');
});

it("an explicit per-object allowExport:true overrides a '*' deny", () => {
const objects: Record<string, any> = {
'*': { allowRead: true, allowExport: false },
deal: { allowRead: true, allowExport: true },
};
annotateEffectiveApiOperations(objects, schemaOf({
deal: { name: 'deal', enable: { apiMethods: ['get', 'list'] } },
}));
expect(objects.deal.apiOperations).toContain('export');
});

it("a '*' carrying no export bit changes nothing", () => {
const objects: Record<string, any> = {
'*': { modifyAllRecords: true },
deal: { allowRead: true },
};
annotateEffectiveApiOperations(objects, schemaOf({
deal: { name: 'deal', enable: {} },
}));
expect('apiOperations' in objects.deal).toBe(false);
});
});
});

Expand Down
18 changes: 15 additions & 3 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,14 +340,26 @@ export function annotateEffectiveApiOperations(
objects: Record<string, any>,
schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined,
): void {
// [#3544] The `'*'` entry's export bit is the FALLBACK for objects that do
// not declare one of their own. The merge keeps `'*'` and named objects as
// independent keys, but the server evaluator does not: its
// `resolveObjectPermission` falls back to the wildcard whenever a set has no
// explicit entry for the object, so a set that denies export wholesale via
// `'*': { allowExport: false }` really does deny it per-object. Reading the
// wildcard here keeps the button the client hides and the request the server
// refuses in agreement — the same class of client/server divergence
// `foldWildcardSuperUser` exists to close, on the export axis.
const wildExport = objects?.['*']?.allowExport;
for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) {
if (obj === '*' || !acc) continue;
const schema = schemaOf(obj);
if (!schema) continue; // schema missing → no annotation (client falls back)
// [#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;
// Unset → inherit the wildcard, then read (backward-compatible:
// can-list ⇒ can-export); explicit `false` → export removed from the
// effective set.
const exportBit = acc.allowExport ?? wildExport;
const userExportAllowed = exportBit !== 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
Expand Down
149 changes: 149 additions & 0 deletions packages/plugins/plugin-security/src/export-permission-axis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #3544 — the user-level EXPORT axis, at the evaluator.
*
* `allowExport` shipped as a spec bit and a `/me/permissions` annotation, which
* hid the client's Export button but enforced nothing: `export ⊆ list`, so a
* bulk export reaches the engine middleware as an ordinary `find` gated by
* `allowRead`, and no code path ever read the bit. These pin the enforcement
* half — the evaluator's `export` branch and the tri-state fold it rests on.
*
* The fold must agree, case for case, with the `/me/permissions` per-object
* merge in `hono-plugin.ts` (`if (v === true) acc[k] = true; else if
* (acc[k] === undefined) acc[k] = v`, read back as `acc.allowExport !== false`).
* A divergence there is the same declared ≠ enforced bug, just inverted: the
* client would offer a button the server refuses.
*/

import { describe, it, expect } from 'vitest';
import type { PermissionSet } from '@objectstack/spec/security';
import { PermissionEvaluator, resolveUserExportAllowed } from './permission-evaluator';

const evaluator = new PermissionEvaluator();

/** A permission set granting `objects` as authored (defaults omitted). */
const set = (name: string, objects: Record<string, any>): PermissionSet =>
({ name, objects } as unknown as PermissionSet);

/** The read-granting baseline every export case builds on. */
const READER = { allowRead: true };

describe('resolveUserExportAllowed — tri-state fold (#3544)', () => {
it('all sets unset → undefined (inherit read, backward-compatible)', () => {
expect(resolveUserExportAllowed('deal', [set('a', { deal: READER })])).toBeUndefined();
});

it('an explicit false → false', () => {
expect(
resolveUserExportAllowed('deal', [set('a', { deal: { ...READER, allowExport: false } })]),
).toBe(false);
});

it('an explicit true → true', () => {
expect(
resolveUserExportAllowed('deal', [set('a', { deal: { ...READER, allowExport: true } })]),
).toBe(true);
});

it('true outranks false regardless of set order (most-permissive merge)', () => {
const deny = set('deny', { deal: { ...READER, allowExport: false } });
const grant = set('grant', { deal: { ...READER, allowExport: true } });
expect(resolveUserExportAllowed('deal', [deny, grant])).toBe(true);
expect(resolveUserExportAllowed('deal', [grant, deny])).toBe(true);
});

it('false outranks silence — one opt-out set denies even alongside plain readers', () => {
expect(
resolveUserExportAllowed('deal', [
set('reader', { deal: READER }),
set('no_export', { deal: { ...READER, allowExport: false } }),
]),
).toBe(false);
});

it('is per-object — a deny on one object leaves another untouched', () => {
const sets = [set('a', { deal: { ...READER, allowExport: false }, lead: READER })];
expect(resolveUserExportAllowed('deal', sets)).toBe(false);
expect(resolveUserExportAllowed('lead', sets)).toBeUndefined();
});

it("reads the '*' wildcard when the set has no explicit entry for the object", () => {
const sets = [set('a', { '*': { ...READER, allowExport: false } })];
expect(resolveUserExportAllowed('deal', sets)).toBe(false);
});

it("an explicit per-object entry overrides the '*' wildcard", () => {
const sets = [set('a', { '*': { ...READER, allowExport: false }, deal: { ...READER, allowExport: true } })];
expect(resolveUserExportAllowed('deal', sets)).toBe(true);
});

it("a non-super-user '*' does not reach a PRIVATE object (ADR-0066 D2)", () => {
const sets = [set('a', { '*': { ...READER, allowExport: false } })];
expect(resolveUserExportAllowed('deal', sets, { isPrivate: true })).toBeUndefined();
// …but a super-user wildcard does.
const superSets = [set('a', { '*': { viewAllRecords: true, allowExport: false } as any })];
expect(resolveUserExportAllowed('deal', superSets, { isPrivate: true })).toBe(false);
});
});

describe("checkObjectPermission('export') — export ⊆ list ∧ allowExport (#3544)", () => {
const canExport = (sets: PermissionSet[], opts?: { isPrivate?: boolean }) =>
evaluator.checkObjectPermission('export', 'deal', sets, opts ?? {});

it('unset allowExport inherits read — a plain reader may still export', () => {
// The whole point of the opt-out default: every permission set authored
// before this axis existed keeps working unchanged.
expect(canExport([set('a', { deal: READER })])).toBe(true);
});

it('allowExport:false denies export while READ stays granted', () => {
const sets = [set('a', { deal: { ...READER, allowExport: false } })];
expect(canExport(sets)).toBe(false);
// The Salesforce "Export Reports" shape: read is untouched.
expect(evaluator.checkObjectPermission('find', 'deal', sets)).toBe(true);
});

it('allowExport:true grants export', () => {
expect(canExport([set('a', { deal: { ...READER, allowExport: true } })])).toBe(true);
});

it('no read grant → no export, even with allowExport:true (export ⊆ list)', () => {
// The axis NARROWS read; it is not an independent grant. A set that says
// "may export" but not "may read" exports nothing.
expect(canExport([set('a', { deal: { allowRead: false, allowExport: true } })])).toBe(false);
});

it('no matching grant at all → denied', () => {
expect(canExport([set('a', { other: READER })])).toBe(false);
});

it('viewAllRecords satisfies the read half', () => {
expect(canExport([set('a', { deal: { viewAllRecords: true } })])).toBe(true);
});

it('a super-user wildcard does NOT bypass an explicit per-object export deny', () => {
// modifyAllRecords is a bypass for the CRUD bits, not a licence to exfiltrate
// an object whose set explicitly opted out of export.
const sets = [
set('admin', { '*': { modifyAllRecords: true } }),
set('no_export', { deal: { ...READER, allowExport: false } }),
];
expect(canExport(sets)).toBe(false);
expect(evaluator.checkObjectPermission('find', 'deal', sets)).toBe(true);
});

it('an empty set list denies (the middleware skips its CRUD gate in that case)', () => {
// Guard rail: the "no sets ⇒ unrestricted" decision belongs to the CALLER
// (SecurityPlugin.canExport), matching how the middleware guards its whole
// CRUD block. The evaluator itself never invents a grant.
expect(canExport([])).toBe(false);
});

it('other operations are untouched by the axis', () => {
const sets = [set('a', { deal: { allowRead: true, allowCreate: true, allowExport: false } })];
expect(evaluator.checkObjectPermission('find', 'deal', sets)).toBe(true);
expect(evaluator.checkObjectPermission('insert', 'deal', sets)).toBe(true);
expect(evaluator.checkObjectPermission('export', 'deal', sets)).toBe(false);
});
});
46 changes: 46 additions & 0 deletions packages/plugins/plugin-security/src/permission-evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,41 @@ function resolveObjectPermission(
return wild.viewAllRecords || wild.modifyAllRecords ? wild : undefined;
}

/**
* [#3544] Fold the user-level EXPORT axis across the resolved permission sets.
*
* `allowExport` is deliberately absent from {@link OPERATION_TO_PERMISSION}:
* that map's semantics are "the bit must be truthy", which would deny export to
* every permission set authored before the axis existed. The bit is a TRI-state
* instead, and this is its merge rule:
*
* - any set `true` → `true` (an explicit grant outranks another set's deny)
* - else any `false` → `false` (an explicit opt-out outranks silence)
* - else all unset → `undefined` (inherit read — the pre-#3544
* "can-list ⇒ can-export" behaviour, so existing sets are unaffected)
*
* This is the same answer the `/me/permissions` per-object merge produces
* (`if (v === true) acc[k] = true; else if (acc[k] === undefined) acc[k] = v`,
* read back as `acc.allowExport !== false`). That equality is load-bearing, not
* incidental: the client hides its Export button on the merged map while this
* decides the server's 403, and the two disagreeing is exactly the
* `declared ≠ enforced` gap the axis exists to close.
*/
export function resolveUserExportAllowed(
objectName: string,
permissionSets: PermissionSet[],
opts: { isPrivate?: boolean } = {},
): boolean | undefined {
let denied = false;
for (const ps of permissionSets) {
const objPerm = resolveObjectPermission(ps, objectName, opts.isPrivate ?? false);
const bit = objPerm?.allowExport;
if (bit === true) return true;
if (bit === false) denied = true;
}
return denied ? false : undefined;
}

/**
* PermissionEvaluator
*
Expand All @@ -119,6 +154,17 @@ export class PermissionEvaluator {
/** [ADR-0066 D2] When the object is `private`, the `'*'` wildcard only covers it if it is a super-user grant. */
opts: { isPrivate?: boolean } = {},
): boolean {
// [#3544] User-level export axis. `export` is NOT a bit lookup: per the
// spec's derivation table (`API_METHOD_DERIVATION`) it is `list ∧
// userExportAllowed`, so it requires READ and is then vetoed by an explicit
// `allowExport: false`. Handled here rather than in OPERATION_TO_PERMISSION
// so an UNSET bit keeps inheriting read — otherwise every permission set
// written before the axis existed would silently lose export.
if (operation === 'export') {
if (resolveUserExportAllowed(objectName, permissionSets, opts) === false) return false;
return this.checkObjectPermission('find', objectName, permissionSets, opts);
}

const permKey = OPERATION_TO_PERMISSION[operation];
if (!permKey) {
// Fail CLOSED for the destructive operation class (ADR-0049): an
Expand Down
Loading
Loading