From 14be97223e5525c615c26abb25e4f423be760e4f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:23:05 +0000 Subject: [PATCH 1/3] feat(security): enforce the user-level export axis on the server (#3544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `allowExport` shipped as a spec bit plus a `/me/permissions` annotation, which hid the client's Export button and nothing more. Because `export ⊆ list`, the REST export route streams through `findData` and the engine middleware sees an ordinary `find` gated by `allowRead` — no code path ever read the bit, so a caller holding `allowExport: false` could still curl `/api/v1/data/:object/export` and drain the whole table. Declared, not enforced. - plugin-security: `checkObjectPermission('export', …)` becomes a real decision — read granted AND not explicitly denied. `allowExport` stays out of OPERATION_TO_PERMISSION on purpose (that map means "the bit must be truthy", which would deny export to every set authored before the axis existed). New exported `resolveUserExportAllowed()` folds the tri-state across sets exactly as the `/me/permissions` merge does. - spec: `ISecurityService.canExport(object, context)` — the question a bulk-egress door outside the engine middleware must ask before reading. Fails closed; `isSystem` and an empty set resolution bypass, mirroring the middleware. - rest: `GET /data/:object/export` answers 403 EXPORT_NOT_PERMITTED before the first chunk is fetched, distinct from the object-level 405 that still runs first (405 = the object exposes no export, 403 = this caller may not use it). - plugin-hono-server: the annotation falls back to the `'*'` entry's export bit, matching the evaluator's own wildcard fallback, so a set denying export wholesale no longer offers a button the server refuses. Backward-compatible: an unset bit still inherits read. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PDbwCy9Jrc1chhR2vnAUos --- .changeset/enforce-user-level-export-axis.md | 48 +++++ .../src/effective-api-operations.test.ts | 41 ++++ .../plugin-hono-server/src/hono-plugin.ts | 18 +- .../src/export-permission-axis.test.ts | 149 +++++++++++++ .../src/permission-evaluator.ts | 46 ++++ .../plugin-security/src/security-plugin.ts | 61 +++++- .../src/rest-export-permission-gate.test.ts | 198 ++++++++++++++++++ packages/rest/src/rest-server.ts | 50 +++++ .../spec/src/contracts/security-service.ts | 25 +++ 9 files changed, 632 insertions(+), 4 deletions(-) create mode 100644 .changeset/enforce-user-level-export-axis.md create mode 100644 packages/plugins/plugin-security/src/export-permission-axis.test.ts create mode 100644 packages/rest/src/rest-export-permission-gate.test.ts diff --git a/.changeset/enforce-user-level-export-axis.md b/.changeset/enforce-user-level-export-axis.md new file mode 100644 index 0000000000..95bb53738f --- /dev/null +++ b/.changeset/enforce-user-level-export-axis.md @@ -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. diff --git a/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts b/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts index 19ea80974a..b2aaa2239f 100644 --- a/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts +++ b/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts @@ -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 = { + '*': { 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 = { + '*': { 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 = { + '*': { modifyAllRecords: true }, + deal: { allowRead: true }, + }; + annotateEffectiveApiOperations(objects, schemaOf({ + deal: { name: 'deal', enable: {} }, + })); + expect('apiOperations' in objects.deal).toBe(false); + }); }); }); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 0330013e08..47b9bc9544 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -340,14 +340,26 @@ export function annotateEffectiveApiOperations( objects: Record, 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 diff --git a/packages/plugins/plugin-security/src/export-permission-axis.test.ts b/packages/plugins/plugin-security/src/export-permission-axis.test.ts new file mode 100644 index 0000000000..658424fce4 --- /dev/null +++ b/packages/plugins/plugin-security/src/export-permission-axis.test.ts @@ -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): 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); + }); +}); diff --git a/packages/plugins/plugin-security/src/permission-evaluator.ts b/packages/plugins/plugin-security/src/permission-evaluator.ts index ea99735f69..1ea55a1e31 100644 --- a/packages/plugins/plugin-security/src/permission-evaluator.ts +++ b/packages/plugins/plugin-security/src/permission-evaluator.ts @@ -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 * @@ -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 diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 02e6b00320..6b73efa4f4 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -643,6 +643,10 @@ export class SecurityPlugin implements Plugin { // route uses it to project columns instead of inferring readability // from already-masked data rows. See getReadableFields. getReadableFields: (object: string, context?: any) => this.getReadableFields(object, context), + // [#3544] User-level export axis. `export ⊆ list`, so a bulk export + // reaches the middleware as a plain `find` and `allowExport` would never + // be consulted — the REST export route asks HERE before it streams. + canExport: (object: string, context?: any) => this.canExport(object, context), // [ADR-0046 §6.7] Effective permission-set NAMES for a caller — the // primitive the REST read layer needs to evaluate a permission-set- // gated book/doc audience ({ permissionSet: '…' }). Same resolution @@ -692,7 +696,7 @@ export class SecurityPlugin implements Plugin { dismissAudienceBindingSuggestion(suggestionDeps, callerContext, id), }; ctx.registerService('security', securityService); - ctx.logger.info('[security] registered "security" service (getReadFilter, getReadableFields, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9 / #3547'); + ctx.logger.info('[security] registered "security" service (getReadFilter, getReadableFields, canExport, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9 / #3544 / #3547'); } catch (e) { ctx.logger.warn?.('[security] failed to register "security" service', { error: (e as Error).message, @@ -2254,6 +2258,61 @@ export class SecurityPlugin implements Plugin { return allFields.filter((f) => fieldPerms[f]?.readable !== false); } + /** + * [#3544] Whether `context` may EXPORT `object` — the user-level export axis. + * + * Export is READ-DERIVED (`export ⊆ list`), so a bulk export reaches the + * engine middleware as an ordinary `find` and is gated by `allowRead` alone. + * The `allowExport` bit is therefore invisible to the middleware, and a door + * that streams a whole table out (the REST `GET /data/:object/export` route) + * has to ask this question itself, BEFORE it reads. Same resolution as the + * middleware — {@link resolvePermissionSetsForContext} → the evaluator's + * `export` branch — so the axis cannot drift from data-plane enforcement. + * + * Fails CLOSED (an access-narrowing answer): a dangling on-behalf-of delegator + * denies, and callers must treat a throw as a denial. `isSystem` bypasses, and + * so does an empty set resolution — the middleware skips its CRUD gate + * entirely for a caller with no permission sets, and reporting a denial the + * data path would not enforce is its own kind of drift. + */ + async canExport(object: string, context?: any): Promise { + const objectName = String(object ?? ''); + if (!objectName) return false; + // System operations bypass (mirrors the middleware's isSystem skip). + if (context?.isSystem) return true; + + const permissionSets = await this.resolvePermissionSetsForContext(context); + // No sets resolved (e.g. unauthenticated, or a deployment with no sets) → + // no permission-set restriction applies, exactly as the middleware treats it + // (`if (permissionSets.length > 0)` guards its whole CRUD gate). + if (permissionSets.length === 0) return true; + + const { isPrivate } = await this.getObjectSecurityMeta(objectName); + if (!this.permissionEvaluator.checkObjectPermission('export', objectName, permissionSets, { isPrivate })) { + return false; + } + + // [ADR-0090 D10] An on-behalf-of caller may never export past what the + // DELEGATOR could have exported themselves — intersect the delegator's own + // answer. A dangling delegator fails CLOSED, the same stance the CRUD + // middleware and {@link getReadableFields} take. + if (context?.onBehalfOf?.userId) { + const del = await resolveDelegatorContext(this.ql, context); + if (del.kind === 'missing') return false; + if (del.kind === 'resolved') { + const delegatorSets = await this.resolvePermissionSetsForContext(del.context); + if ( + delegatorSets.length > 0 && + !this.permissionEvaluator.checkObjectPermission('export', objectName, delegatorSets, { isPrivate }) + ) { + return false; + } + } + } + + return true; + } + /** * Resolve the effective permission sets for an execution context — positions + * explicit permission sets, with the configured baseline applied both as an diff --git a/packages/rest/src/rest-export-permission-gate.test.ts b/packages/rest/src/rest-export-permission-gate.test.ts new file mode 100644 index 0000000000..5bb141e0fb --- /dev/null +++ b/packages/rest/src/rest-export-permission-gate.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3544 — the user-level EXPORT gate on `GET /data/:object/export`. + * + * The axis shipped as a spec bit plus a `/me/permissions` annotation, which hid + * the client's Export button and nothing more. `export ⊆ list`, so the route + * streams through `findData` and the engine middleware sees a plain `find` + * gated by `allowRead` — `allowExport` was never consulted on the wire, and a + * caller holding `allowExport: false` could still `curl` the whole table out. + * + * These pin the door itself: the 403, and — the assertion that actually matters + * — that `findData` is NEVER reached on a denial, so a refusal cannot leak the + * first chunk before it fires. Rows are stubbed on purpose; this is a test of + * the gate, not of the streaming path (`export-integration.test.ts` owns that). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +const TASK = { + name: 'task', + label: '任务', + fields: { + id: { type: 'text', label: 'ID' }, + title: { type: 'text', label: '标题' }, + }, +}; + +const ROWS = [ + { id: '1', title: '写代码' }, + { id: '2', title: '写文档' }, +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const chunks: string[] = []; + const headers: Record = {}; + let status = 200; + const res: any = { + write: (s: string) => { chunks.push(typeof s === 'string' ? s : String(s)); return true; }, + end: () => {}, + header: (n: string, v: string) => { headers[n] = v; return res; }, + status: (code: number) => { status = code; return res; }, + json: (body: any) => { (res as any)._json = body; return res; }, + }; + return { res, chunks, headers, getStatus: () => status, getJson: () => (res as any)._json }; +} + +/** + * Boot the export route over a stub protocol, with `security` resolved from + * `securityServiceProvider` (the 18th positional ctor arg). + * + * `enable` is left undefined on the object so the OBJECT-level gate + * (`apiAccessDenialFromEnable`) is fully open — every denial these tests observe + * is therefore the USER-level axis, never the `apiMethods` whitelist. + */ +function boot(security: any) { + const findData = vi.fn().mockResolvedValue({ object: 'task', records: ROWS }); + const protocol: any = { + getMetaItems: vi.fn().mockResolvedValue({ items: [TASK] }), + getMetaItem: vi.fn().mockResolvedValue({ type: 'object', name: 'task', item: TASK }), + findData, + }; + const rest = new RestServer( + createMockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + undefined, // kernelManager + undefined, // envRegistry + undefined, // defaultEnvironmentIdProvider + undefined, // authServiceProvider + undefined, // objectQLProvider + undefined, // emailServiceProvider + undefined, // sharingServiceProvider + undefined, // reportsServiceProvider + undefined, // approvalsServiceProvider + undefined, // sharingRulesServiceProvider + undefined, // i18nServiceProvider + undefined, // analyticsServiceProvider + undefined, // settingsServiceProvider + undefined, // serviceExistsProvider + security === undefined ? undefined : async () => security, + ); + rest.registerRoutes(); + const route = rest.getRoutes().find( + (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', + ); + expect(route).toBeDefined(); + return { route, findData }; +} + +const run = async (route: any) => { + const out = makeRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, out.res); + return out; +}; + +describe('export route — user-level export axis (#3544)', () => { + it('canExport:false → 403 EXPORT_NOT_PERMITTED, and NOTHING is read', async () => { + const { route, findData } = boot({ canExport: async () => false }); + const out = await run(route); + + expect(out.getStatus()).toBe(403); + expect(out.getJson()).toMatchObject({ code: 'EXPORT_NOT_PERMITTED', object: 'task' }); + // The whole point: the refusal lands BEFORE the first chunk is fetched, so + // a denied export cannot dribble rows out ahead of the status code. + expect(findData).not.toHaveBeenCalled(); + expect(out.chunks.join('')).toBe(''); + }); + + it('canExport:true → the export proceeds and streams rows', async () => { + const { route, findData } = boot({ canExport: async () => true }); + const out = await run(route); + + expect(out.getStatus()).toBe(200); + expect(findData).toHaveBeenCalled(); + expect(out.chunks.join('')).toContain('写代码'); + }); + + it('passes the object name and the request ExecutionContext to canExport', async () => { + // The decision is per-object AND per-caller: handing the service anything + // less would make it answer a different question than the one being asked. + const canExport = vi.fn().mockResolvedValue(true); + const { route } = boot({ canExport }); + await run(route); + + expect(canExport).toHaveBeenCalledTimes(1); + expect(canExport.mock.calls[0][0]).toBe('task'); + }); + + it('no security service at all → allowed (deployment has no permission sets)', async () => { + // Matches every other permission gate in this layer, and `/me/permissions`' + // documented fail-open: without plugin-security there is nothing to enforce. + const { route, findData } = boot(undefined); + const out = await run(route); + + expect(out.getStatus()).toBe(200); + expect(findData).toHaveBeenCalled(); + }); + + it('service present but without canExport → allowed (feature-detected)', async () => { + // An older or partial implementation degrades instead of hard-failing — + // the contract's stated posture for the whole `security` surface. + const { route, findData } = boot({ getReadableFields: async () => undefined }); + const out = await run(route); + + expect(out.getStatus()).toBe(200); + expect(findData).toHaveBeenCalled(); + }); + + it('canExport throws → 403 (fail CLOSED, ADR-0049)', async () => { + // It resolves permission sets to decide; a resolution failure must never + // read as a grant. + const { route, findData } = boot({ + canExport: async () => { throw new Error('permission set resolution failed'); }, + }); + const out = await run(route); + + expect(out.getStatus()).toBe(403); + expect(out.getJson()).toMatchObject({ code: 'EXPORT_NOT_PERMITTED' }); + expect(findData).not.toHaveBeenCalled(); + }); + + it('the object-level 405 still precedes the user-level 403', async () => { + // Two gates, two questions: `apiMethods` decides whether the OBJECT exposes + // export (405), this axis decides whether the CALLER may use it (403). An + // object that exposes no export must answer 405 whatever the user holds. + const findData = vi.fn().mockResolvedValue({ object: 'task', records: ROWS }); + const protocol: any = { + getMetaItems: vi.fn().mockResolvedValue({ items: [{ ...TASK, enable: { apiMethods: ['get'] } }] }), + getMetaItem: vi.fn().mockResolvedValue({ type: 'object', name: 'task', item: TASK }), + findData, + }; + const rest = new RestServer( + createMockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, + async () => ({ canExport: async () => false }), + ); + rest.registerRoutes(); + const route = rest.getRoutes().find( + (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', + ); + const out = await run(route); + + expect(out.getStatus()).toBe(405); + expect(out.getJson()).toMatchObject({ code: 'OBJECT_API_METHOD_NOT_ALLOWED' }); + expect(findData).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a059b919ad..11cb9329ab 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1147,6 +1147,53 @@ export class RestServer { return false; } + /** + * [#3544] Enforce the USER-LEVEL export axis on a bulk-egress route, after + * {@link enforceApiAccess} has cleared the object-level one. Returns `true` + * when a response was sent (the caller must return). + * + * The two gates answer different questions and so carry different statuses: + * `enforceApiAccess` is about the OBJECT ("does this object expose export at + * all" → 405), this one is about the CALLER ("may YOU export it" → 403). + * + * It has to exist as its own check because `export ⊆ list`: the export route + * streams through `findData`, which the engine middleware sees as a plain + * `find` and gates on `allowRead`. Nothing downstream ever looks at + * `allowExport`, so without this the bit would only hide the client's Export + * button while `curl` still drained the table — declared, not enforced + * (AGENTS.md Prime Directive #10). + * + * Fail stance mirrors the deployment's own posture. No security service (no + * `plugin-security`, so no permission sets exist anywhere) → allow, matching + * every other permission gate here and `/me/permissions`' documented + * fail-open. Service PRESENT but unable to answer → fail CLOSED: it resolves + * permission sets to decide, and a resolution failure must never read as a + * grant (ADR-0049). + */ + private async enforceExportPermission( + req: any, + res: any, + environmentId: string | undefined, + objectName: string, + context: any, + ): Promise { + const security = await this.resolveSecurityService(environmentId, req); + if (!security || typeof security.canExport !== 'function') return false; + let allowed: boolean; + try { + allowed = await security.canExport(objectName, context); + } catch { + allowed = false; // access-narrowing answer → a throw is a denial + } + if (allowed) return false; + res.status(403).json({ + code: 'EXPORT_NOT_PERMITTED', + error: `Export is not permitted on object '${objectName}' for this user`, + object: objectName, + }); + return true; + } + /** * Load the object metadata items for the current protocol/environment, * coerced to a plain array. Returns `[]` when metadata is unavailable so @@ -4250,6 +4297,9 @@ export class RestServer { return; } if (await this.enforceApiAccess(req, res, p, environmentId, 'export')) return; + // [#3544] …then the USER-level one. The object may expose + // export while THIS caller's permission sets deny it. + if (await this.enforceExportPermission(req, res, environmentId, objectName, context)) return; const q = req.query ?? {}; const fmtRaw = String(q.format ?? 'csv').toLowerCase(); const format: 'csv' | 'json' | 'xlsx' = diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index 21a2c90095..72f30050ae 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -192,6 +192,31 @@ export interface ISecurityService { */ resolvePermissionSetNames(context?: SecurityContext): Promise; + /** + * [#3544] Whether `context` may EXPORT `object` — the user-level export axis + * (`ObjectPermissionSchema.allowExport`). + * + * Export is a READ-DERIVED operation (`export ⊆ list` in the spec's + * `API_METHOD_DERIVATION`), so it reaches the engine middleware as an ordinary + * `find` and is gated by `allowRead` alone. That makes the export axis + * invisible to the middleware: without this method a caller holding + * `allowExport: false` still streams the whole table out of the REST export + * route, and the bit only ever hid a button. Bulk-egress doors must ask this + * BEFORE they read. + * + * The answer folds the tri-state bit across the caller's resolved sets exactly + * as the `/me/permissions` merge does — `true` beats `false` beats unset, and + * unset inherits read — so the button the client hides and the request the + * server refuses are the same decision. + * + * **Fails CLOSED.** This is an access-narrowing answer: implementations return + * `false` (and callers must treat a throw as `false`) rather than degrading to + * "allowed". A system context bypasses and returns `true`; so does a caller + * with no resolved permission sets, mirroring the middleware, whose CRUD gate + * is skipped entirely when set resolution comes back empty. + */ + canExport(object: string, context?: SecurityContext): Promise; + /** * Explain WHY access is granted or denied — the decision plus the layers that * produced it (permission sets, object permissions, RLS, sharing, field mask). From 85ec90d10cb38bbd8cc66f7de69a8e38dec817a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:26:07 +0000 Subject: [PATCH 2/3] docs(permissions): document the allowExport axis in permission-sets The hand-written object-permission-bits table never picked up `allowExport` (#3553 only regenerated the auto-gen reference). Now that the bit is actually enforced server-side, document what it is: the tri-state, the most-permissive merge with an explicit deny in the middle, that it narrows read and never widens it, and that the 403 and the hidden client button are one decision. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PDbwCy9Jrc1chhR2vnAUos --- content/docs/permissions/permission-sets.mdx | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 04aa712437..a43c58c735 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -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 From fe18ff2e30836f8cd3f869059153fb57b053b3c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:29:20 +0000 Subject: [PATCH 3/3] test(spec): cover canExport in the security-service contract suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stub that "implements the full surface" needs the new member, and the fail-closed posture is worth pinning next to getReadFilter's: `undefined` there means no restriction, `false` here means denied — a consumer that reads them the same way leaks. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PDbwCy9Jrc1chhR2vnAUos --- .../src/contracts/security-service.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/spec/src/contracts/security-service.test.ts b/packages/spec/src/contracts/security-service.test.ts index 9b8be0fa64..257b30964d 100644 --- a/packages/spec/src/contracts/security-service.test.ts +++ b/packages/spec/src/contracts/security-service.test.ts @@ -17,6 +17,7 @@ function makeService(overrides: Partial = {}): ISecurityServic return { getReadFilter: async () => undefined, getReadableFields: async () => [], + canExport: async () => true, resolvePermissionSetNames: async () => [], explain: async () => ({}) as any, listAudienceBindingSuggestions: async () => ({ @@ -35,6 +36,7 @@ describe('Security Service Contract', () => { for (const m of [ 'getReadFilter', 'getReadableFields', + 'canExport', 'resolvePermissionSetNames', 'explain', 'listAudienceBindingSuggestions', @@ -45,6 +47,22 @@ describe('Security Service Contract', () => { } }); + it('canExport: an access-narrowing answer — false denies, and absence is feature-detectable', async () => { + // [#3544] The bulk-egress question. It fails CLOSED, so a consumer must + // never read a `false` (or a throw) as "no restriction" the way it may + // read getReadFilter's `undefined`. + const denied = makeService({ canExport: async () => false }); + await expect(denied.canExport('deal', { userId: 'u1' })).resolves.toBe(false); + + const allowed = makeService({ canExport: async () => true }); + await expect(allowed.canExport('deal', { userId: 'u1' })).resolves.toBe(true); + + // A system context bypasses, mirroring the engine middleware's isSystem skip. + const service = makeService({ canExport: async (_object, context) => context?.isSystem === true }); + await expect(service.canExport('deal', { isSystem: true })).resolves.toBe(true); + await expect(service.canExport('deal', { userId: 'u1' })).resolves.toBe(false); + }); + it('getReadFilter: undefined means NO row restriction — the only thing it may mean', async () => { // A deny is expressed as a filter that matches nothing, never as `undefined`, // so a consumer can safely read `undefined` as "apply no filter".