diff --git a/.changeset/security-get-readable-fields.md b/.changeset/security-get-readable-fields.md new file mode 100644 index 0000000000..de8d320f62 --- /dev/null +++ b/.changeset/security-get-readable-fields.md @@ -0,0 +1,33 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/rest": minor +--- + +feat: `security.getReadableFields` query surface for export column projection (#3547, #3391 follow-up) + +The REST export route projected its columns by inferring readability from the +first chunk of already-masked data rows (#3498). That has two known +compromises: a readable column whose first-chunk values are all null (and thus +omitted by the driver) drops out of the header, and an empty result set leaves +nothing to narrow. This adds the long-term-correct path. + +- **plugin-security** — the `security` service gains + `getReadableFields(object, context)`. It resolves the caller's permission + sets and builds the field-permission map with the SAME evaluator + + `requiredPermissions` fold the read middleware's `FieldMasker` uses (and the + same on-behalf-of delegator intersection, fail-closed on a dangling + delegator), then returns every schema field NOT masked non-readable — the + exact complement of what the mask deletes, so it can never drift from + data-plane FLS. Computed from schema + context, never from data rows: immune + to null values and empty result sets. A system context bypasses FLS; an + unresolvable schema returns `undefined` so callers fall back. +- **rest** — the `GET /data/:object/export` route asks the environment's + `security` service for `getReadableFields(object, context)` and projects the + schema-derived header to that set BEFORE streaming. When no security service + is reachable (no plugin-security / single-kernel without a provider) it + degrades to the existing masked-row inference, so there is zero regression. + Explicit `?fields=` requests are still honored verbatim. + +Contract-neutral: export columns already equal list's readable columns +(`export ⊆ list`, #3391); this makes the projection authoritative instead of +inferred. diff --git a/packages/plugins/plugin-security/src/get-readable-fields.test.ts b/packages/plugins/plugin-security/src/get-readable-fields.test.ts new file mode 100644 index 0000000000..4dbb2b0920 --- /dev/null +++ b/packages/plugins/plugin-security/src/get-readable-fields.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * getReadableFields query surface (#3547) — the authoritative column + * projection for a read-derived export (`export ⊆ list`, #3391). It must + * return exactly the fields the read middleware's FieldMasker would NOT delete + * for the caller, computed from the object SCHEMA + context (never from data + * rows) so it is immune to null values / empty result sets. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SecurityPlugin } from './security-plugin.js'; +import type { PermissionSet } from '@objectstack/spec/security'; + +/** + * Minimal plugin harness: a metadata `list()` that surfaces the permission + * sets, `get()`/`ql.getSchema()` that surface the object schema. Mirrors the + * shape used by security-plugin.test.ts so the plugin boots identically. + */ +function bootPlugin(permissionSets: PermissionSet[], objectFields: string[], fallback = 'restricted') { + const fields: Record = {}; + for (const f of objectFields) fields[f] = { name: f }; + const schema: any = { name: 'deal', label: 'Deal', systemFields: false, fields }; + const ql: any = { + registerMiddleware: () => {}, + getSchema: (name: string) => (name === 'deal' ? schema : null), + findOne: async () => null, + }; + const metadata: any = { + get: async (_type: string, name: string) => (name === 'deal' ? schema : null), + list: async () => permissionSets, + }; + const services: Record = { manifest: { register: vi.fn() }, objectql: ql, metadata }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: fallback }); + return { plugin, ctx }; +} + +// A permission set that reads `deal` but masks the `secret` field. +const RESTRICTED: PermissionSet = { + name: 'restricted', + label: 'Restricted', + objects: { deal: { allowRead: true } }, + fields: { 'deal.secret': { readable: false, editable: false } }, +} as any; + +// A permission set with no field-level rules → every field readable. +const OPEN: PermissionSet = { + name: 'open', + label: 'Open', + objects: { deal: { allowRead: true } }, +} as any; + +describe('SecurityPlugin.getReadableFields (#3547)', () => { + it('returns all schema fields MINUS those masked non-readable — the FieldMasker complement', async () => { + const { plugin, ctx } = bootPlugin([RESTRICTED], ['id', 'name', 'secret']); + await plugin.init(ctx); + await plugin.start(ctx); + + const readable = await plugin.getReadableFields('deal', { userId: 'u1', permissions: ['restricted'] }); + expect(readable).toBeDefined(); + expect(readable).toContain('id'); + expect(readable).toContain('name'); + // `secret` is masked (readable:false) → excluded, exactly as maskResults deletes it. + expect(readable).not.toContain('secret'); + }); + + it('a field with no permission entry passes through (allow-list only enumerates named fields)', async () => { + // `open` names no fields → nothing is masked → all schema fields readable. + const { plugin, ctx } = bootPlugin([OPEN], ['id', 'name', 'secret'], 'open'); + await plugin.init(ctx); + await plugin.start(ctx); + + const readable = await plugin.getReadableFields('deal', { userId: 'u1', permissions: ['open'] }); + expect(new Set(readable)).toEqual(new Set(['id', 'name', 'secret'])); + }); + + it('a system context bypasses FLS → the full field set (mirrors the middleware isSystem skip)', async () => { + const { plugin, ctx } = bootPlugin([RESTRICTED], ['id', 'name', 'secret']); + await plugin.init(ctx); + await plugin.start(ctx); + + const readable = await plugin.getReadableFields('deal', { isSystem: true }); + expect(new Set(readable)).toEqual(new Set(['id', 'name', 'secret'])); + }); + + it('returns undefined when the object schema cannot be resolved (caller falls back)', async () => { + const { plugin, ctx } = bootPlugin([RESTRICTED], ['id', 'name', 'secret']); + await plugin.init(ctx); + await plugin.start(ctx); + + const readable = await plugin.getReadableFields('unknown_object', { userId: 'u1', permissions: ['restricted'] }); + expect(readable).toBeUndefined(); + }); + + it('is computed from the schema, not from data rows — immune to null/empty result sets', async () => { + // No data rows exist in this harness at all (ql.findOne → null); the + // readable set is still complete, proving it is derived from schema+context. + const { plugin, ctx } = bootPlugin([RESTRICTED], ['id', 'name', 'secret', 'amount']); + await plugin.init(ctx); + await plugin.start(ctx); + + const readable = await plugin.getReadableFields('deal', { userId: 'u1', permissions: ['restricted'] }); + // `amount` is readable and would be entirely absent from an empty result — + // it survives here because the projection never consults rows. + expect(readable).toContain('amount'); + expect(readable).not.toContain('secret'); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 0058cbcd46..854717d29c 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -546,6 +546,12 @@ export class SecurityPlugin implements Plugin { }; ctx.registerService('security', { getReadFilter: (object: string, context?: any) => this.getReadFilter(object, context), + // [#3547] Readable-field projection for a context — the authoritative + // column set for a read-derived export (`export ⊆ list`, #3391). + // Same field mask as the read middleware (no drift). The REST export + // 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), // [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 @@ -571,7 +577,7 @@ export class SecurityPlugin implements Plugin { dismissAudienceBindingSuggestion: (callerContext: any, id: string) => dismissAudienceBindingSuggestion(suggestionDeps, callerContext, id), }); - ctx.logger.info('[security] registered "security" service (getReadFilter, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9'); + ctx.logger.info('[security] registered "security" service (getReadFilter, getReadableFields, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9 / #3547'); } catch (e) { ctx.logger.warn?.('[security] failed to register "security" service', { error: (e as Error).message, @@ -2044,6 +2050,71 @@ export class SecurityPlugin implements Plugin { } } + /** + * [#3547] Query surface: the field names the caller MAY READ on `object` + * under `context`. This is the authoritative column projection for a + * read-derived export (`export ⊆ list`, #3391) — the REST export route + * consumes it to project columns directly, instead of inferring readability + * from already-masked data rows (which loses an all-readable-but-all-null + * column and falls back to the full schema on an empty result set, #3498). + * + * Same-source-as-read-middleware, so it can never drift from data-plane FLS: + * it resolves the caller's permission sets via + * {@link resolvePermissionSetsForContext}, builds the field-permission map + * with the SAME evaluator + `requiredPermissions` fold the read mask uses, + * intersects the on-behalf-of delegator's mask (ADR-0090 D10, fail-closed on + * a dangling delegator), then returns every schema field NOT marked + * non-readable — the exact complement of what {@link FieldMasker.maskResults} + * deletes (fields without an explicit permission entry pass through). + * + * Returns `undefined` when the object schema can't be resolved, so the caller + * falls back to its own projection. A system context (`context.isSystem`) + * bypasses FLS and returns the full field set, mirroring the middleware's + * `isSystem` skip. + */ + async getReadableFields(object: string, context?: any): Promise { + const objectName = String(object ?? ''); + if (!objectName) return undefined; + // The field universe — the SAME source the RLS field pass uses (ObjectQL's + // live SchemaRegistry first, metadata artifact fallback). `null` → schema + // not resolvable → let the caller fall back rather than guess. + const fieldNameSet = await this.getObjectFieldNames(this.metadata, objectName, this.ql); + if (!fieldNameSet) return undefined; + const allFields = [...fieldNameSet]; + // System operations bypass FLS (mirrors the middleware's isSystem skip). + if (context?.isSystem) return allFields; + + const permissionSets = await this.resolvePermissionSetsForContext(context); + // No sets resolved (e.g. unauthenticated) → no field mask applies, exactly + // as the middleware (getFieldPermissions([]) === {} → nothing deleted). + if (permissionSets.length === 0) return allFields; + + const secMeta = await this.getObjectSecurityMeta(objectName); + let fieldPerms = this.permissionEvaluator.getFieldPermissions(objectName, permissionSets); + fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets); + + // [ADR-0090 D10] On an on-behalf-of read the readable set must NOT widen + // past what the DELEGATOR can read — intersect the delegator's field mask + // too. A dangling delegator fails CLOSED (expose no columns), the same + // fail-closed stance the CRUD middleware takes on a 'missing' delegator. + if (context?.onBehalfOf?.userId) { + const del = await resolveDelegatorContext(this.ql, context); + if (del.kind === 'missing') return []; + if (del.kind === 'resolved') { + const delegatorSets = await this.resolvePermissionSetsForContext(del.context); + let delFieldPerms = this.permissionEvaluator.getFieldPermissions(objectName, delegatorSets); + delFieldPerms = this.foldFieldRequiredPermissions(delFieldPerms, secMeta.fieldRequiredPermissions, delegatorSets); + fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms); + } + } + + // Readable = every schema field NOT explicitly masked non-readable. A field + // with no permission entry passes through (the field allow-list only + // enumerates fields it names) — the exact complement of maskResults' delete + // set, so the export header matches list's readable columns by construction. + return allFields.filter((f) => fieldPerms[f]?.readable !== false); + } + /** * 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/export-integration.test.ts b/packages/rest/src/export-integration.test.ts index 23b50dc716..9fcf228a21 100644 --- a/packages/rest/src/export-integration.test.ts +++ b/packages/rest/src/export-integration.test.ts @@ -383,3 +383,110 @@ describe('export route — FLS column projection (#3391 blocking)', () => { } }); }); + +// =========================================================================== +// #3547: export column projection via the security service's getReadableFields +// — the LONG-TERM correct path that replaces inferring readability from masked +// data rows (#3498). The route asks `security.getReadableFields(object, ctx)` +// for the readable column set BEFORE streaming, so the header is derived from +// the schema + context, never from row content — immune to an all-null +// readable column and to an empty result set. +// =========================================================================== +describe('export route — FLS column projection via getReadableFields (#3547)', () => { + // Boot a real engine + protocol, seed tasks, and wire a RestServer whose + // `security` service (host provider) resolves to the supplied + // getReadableFields — mirroring how plugin-security registers the service. + async function bootWithSecurity(opts: { + getReadableFields: (object: string, context?: any) => string[] | undefined; + tasks?: Array>; + }) { + const { driver } = makeMemoryDriver(); + const engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(USER as any); + engine.registry.registerObject(TASK as any); + await engine.insert('user', { id: 'u1', name: '张三' }); + const tasks = opts.tasks ?? [ + { id: '1', title: '写代码', done: true, priority: 'high', due: '2026-06-30T00:00:00.000Z', owner: 'u1' }, + { id: '2', title: '写文档', done: false, priority: 'low', due: '2026-07-01T00:00:00.000Z', owner: 'u1' }, + ]; + for (const t of tasks) await engine.insert('task', t); + const protocol = new ObjectStackProtocolImplementation(engine as any); + // 16th positional ctor arg is `securityServiceProvider`. + const securityServiceProvider = async () => ({ getReadableFields: opts.getReadableFields }); + 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 + securityServiceProvider, + ); + rest.registerRoutes(); + const route = rest.getRoutes().find( + (r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export', + ); + return { engine, route }; + } + + it('projects columns from getReadableFields — drops a masked field even though rows still carry it', async () => { + // The service says `title` is NOT readable; every row still HAS a title + // value. The route must drop 标题 from the header — proving the projection + // comes from the service, not the row keys (the masked-row inference would + // have kept 标题 because it is present in the rows). + const { route } = await bootWithSecurity({ + getReadableFields: () => ['id', 'done', 'priority', 'due', 'owner'], + }); + const csv = makeRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res); + const header = parseCsv(csv.chunks.join(''))[0]; + expect(header).not.toContain('标题'); + expect(header).toEqual(['ID', '完成', '优先级', '截止', '负责人']); + }); + + it('keeps a readable column that is absent from every row (null-value immunity)', async () => { + // All tasks omit `due` → the masked-row inference would DROP 截止 from the + // header (no row carries the key). getReadableFields lists it, so the + // column survives — the header no longer depends on row content. + const { route } = await bootWithSecurity({ + getReadableFields: () => ['id', 'title', 'done', 'priority', 'due', 'owner'], + tasks: [ + { id: '1', title: 'A', done: true, priority: 'high', owner: 'u1' }, // no `due` + { id: '2', title: 'B', done: false, priority: 'low', owner: 'u1' }, // no `due` + ], + }); + const csv = makeRes(); + await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res); + const header = parseCsv(csv.chunks.join(''))[0]; + expect(header).toContain('截止'); // survives despite being absent from every row + expect(header).toEqual(['ID', '标题', '完成', '优先级', '截止', '负责人']); + }); + + it('explicit ?fields= is still honored verbatim (service projection only narrows schema-derived headers)', async () => { + // getReadableFields would drop `title`, but an explicit request wins — the + // projection only applies to schema-derived headers (fieldsFromSchema). + const { route } = await bootWithSecurity({ + getReadableFields: () => ['id', 'done'], + }); + const csv = makeRes(); + await route.handler( + { params: { object: 'task' }, query: { format: 'csv', fields: 'id,title' } } as any, + csv.res, + ); + const header = parseCsv(csv.chunks.join(''))[0]; + expect(header).toEqual(['ID', '标题']); // requested columns kept as asked + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index c3dd61de0b..677914419e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -4325,6 +4325,35 @@ export class RestServer { // (and thus a name). Batched $in inside findData — no N+1. const expandFields = referenceFieldNames(metaMap); + // [#3547] Column projection ≡ list's field-level security — the + // LONG-TERM correct path. Ask the security service which fields the + // caller may READ under this context (the SAME field mask the read + // middleware applies, so it can never drift) and narrow the + // schema-derived header to that set BEFORE streaming. This replaces + // inferring readability from the first masked data chunk (#3498): it + // is immune to an all-readable-but-all-null column (which a driver may + // omit from every row) and to an empty result set (which left the + // masked-row inference with nothing to narrow). Explicit `?fields=` + // requests are honored as asked (fieldsFromSchema=false → untouched; + // values still masked to empty by the read path). When no security + // service is reachable (no plugin-security / single-kernel without a + // provider) the per-chunk masked-row inference below remains as the + // fallback, so there is zero regression. + let readableProjected = false; + if (fieldsFromSchema && fields && fields.length > 0) { + try { + const security = await this.resolveSecurityService(environmentId, req); + if (security && typeof security.getReadableFields === 'function') { + const readable = await security.getReadableFields(objectName, context); + if (Array.isArray(readable)) { + const readableSet = new Set(readable); + fields = fields.filter((f) => readableSet.has(f)); + readableProjected = true; + } + } + } catch { /* fall back to the masked-row inference below */ } + } + // Prepare streaming response. Set headers BEFORE first write. if (format === 'csv') { res.header('Content-Type', 'text/csv; charset=utf-8'); @@ -4378,18 +4407,19 @@ export class RestServer { fields = Object.keys(rows[0] ?? {}); } - // [#3391] Column projection ≡ list's field-level security. - // The read middleware (FieldMasker) DELETES unreadable keys from - // each row, so a schema-derived header would still leak the - // *names* of FLS-hidden columns as empty cells. Narrow the - // schema-derived header to the keys actually present across the - // first masked chunk (their ∩ with schema fields is implicit — - // `fields` already came from `metaMap.keys()`), so export headers - // match list's readable columns. Explicit `?fields=` requests are - // left untouched (values still masked to empty, as with list - // `$select`); a fully empty first chunk leaves the header as-is - // (same as today — no worse). - if (fieldsFromSchema && firstChunk && fields && fields.length > 0) { + // [#3391] Column projection ≡ list's field-level security — + // FALLBACK path when the #3547 security-service projection above + // was unavailable (`!readableProjected`). The read middleware + // (FieldMasker) DELETES unreadable keys from each row, so a + // schema-derived header would still leak the *names* of FLS-hidden + // columns as empty cells. Narrow the schema-derived header to the + // keys actually present across the first masked chunk (their ∩ + // with schema fields is implicit — `fields` already came from + // `metaMap.keys()`), so export headers match list's readable + // columns. Explicit `?fields=` requests are left untouched (values + // still masked to empty, as with list `$select`); a fully empty + // first chunk leaves the header as-is (same as today — no worse). + if (!readableProjected && fieldsFromSchema && firstChunk && fields && fields.length > 0) { const readable = new Set(); for (const row of rows) { if (row && typeof row === 'object') { @@ -4453,6 +4483,28 @@ export class RestServer { }); } + /** + * [#3547] Resolve the environment's `security` service — the ENVIRONMENT's + * kernel service first (its evaluator / FieldMasker are bound to that + * kernel's data engine), the host provider as the single-kernel fallback. + * Mirrors the resolver in registerSecurityExplainEndpoints. Returns + * `undefined` when no security service is reachable (no plugin-security / + * single-kernel without a provider), so callers degrade gracefully. + */ + private async resolveSecurityService(environmentId?: string, req?: any): Promise { + try { + const envId = await this.resolveRequestEnvironmentId(environmentId, req); + if (envId && envId !== 'platform' && this.kernelManager) { + const kernel = await this.kernelManager.getOrCreate(envId); + const svc = await kernel.getServiceAsync('security').catch(() => undefined); + if (svc) return svc; + } + } catch { /* fall back to the host provider */ } + if (!this.securityServiceProvider) return undefined; + try { return await this.securityServiceProvider(environmentId); } + catch { return undefined; } + } + /** * Register global cross-object search endpoint (M10.5). * GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5