Skip to content

Commit d318b24

Browse files
os-zhuangclaude
andauthored
feat(security,rest): getReadableFields query surface for export column projection (#3547) (#3561)
The REST export route projected columns by inferring readability from the first chunk of already-masked data rows (#3498), which drops an all-null readable column and leaves an empty result set un-narrowed. Add the long-term-correct path: a security-service query surface that returns the readable field set for a context, derived from schema + FLS rather than from data rows. - plugin-security: the `security` service gains getReadableFields(object, context). Same evaluator + requiredPermissions fold + on-behalf-of delegator intersection the read middleware's FieldMasker uses (fail-closed on a dangling delegator), returning every schema field NOT masked non-readable — the exact complement of maskResults' delete set, so it can never drift from data-plane FLS. Computed from schema+context, never rows → immune to null/empty results. isSystem bypasses FLS; unresolvable schema → undefined (caller falls back). - rest: GET /data/:object/export asks the environment's security service for getReadableFields and projects the schema-derived header to that set before streaming. No service reachable → degrades to the existing masked-row inference (zero regression). Explicit ?fields= is honored verbatim. Tests: plugin-security getReadableFields (readable=complement of mask, isSystem bypass, no-restriction passthrough, unresolvable→undefined, schema-not-rows); rest export route projects from the service (drops a masked field present in rows; keeps a readable column absent from every row; honors explicit ?fields=). Claude-Session: https://claude.ai/code/session_012L8EfEa157Pe6C73qRnaJH Co-authored-by: Claude <noreply@anthropic.com>
1 parent c80aece commit d318b24

5 files changed

Lines changed: 392 additions & 13 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/rest": minor
4+
---
5+
6+
feat: `security.getReadableFields` query surface for export column projection (#3547, #3391 follow-up)
7+
8+
The REST export route projected its columns by inferring readability from the
9+
first chunk of already-masked data rows (#3498). That has two known
10+
compromises: a readable column whose first-chunk values are all null (and thus
11+
omitted by the driver) drops out of the header, and an empty result set leaves
12+
nothing to narrow. This adds the long-term-correct path.
13+
14+
- **plugin-security** — the `security` service gains
15+
`getReadableFields(object, context)`. It resolves the caller's permission
16+
sets and builds the field-permission map with the SAME evaluator +
17+
`requiredPermissions` fold the read middleware's `FieldMasker` uses (and the
18+
same on-behalf-of delegator intersection, fail-closed on a dangling
19+
delegator), then returns every schema field NOT masked non-readable — the
20+
exact complement of what the mask deletes, so it can never drift from
21+
data-plane FLS. Computed from schema + context, never from data rows: immune
22+
to null values and empty result sets. A system context bypasses FLS; an
23+
unresolvable schema returns `undefined` so callers fall back.
24+
- **rest** — the `GET /data/:object/export` route asks the environment's
25+
`security` service for `getReadableFields(object, context)` and projects the
26+
schema-derived header to that set BEFORE streaming. When no security service
27+
is reachable (no plugin-security / single-kernel without a provider) it
28+
degrades to the existing masked-row inference, so there is zero regression.
29+
Explicit `?fields=` requests are still honored verbatim.
30+
31+
Contract-neutral: export columns already equal list's readable columns
32+
(`export ⊆ list`, #3391); this makes the projection authoritative instead of
33+
inferred.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* getReadableFields query surface (#3547) — the authoritative column
5+
* projection for a read-derived export (`export ⊆ list`, #3391). It must
6+
* return exactly the fields the read middleware's FieldMasker would NOT delete
7+
* for the caller, computed from the object SCHEMA + context (never from data
8+
* rows) so it is immune to null values / empty result sets.
9+
*/
10+
11+
import { describe, it, expect, vi } from 'vitest';
12+
import { SecurityPlugin } from './security-plugin.js';
13+
import type { PermissionSet } from '@objectstack/spec/security';
14+
15+
/**
16+
* Minimal plugin harness: a metadata `list()` that surfaces the permission
17+
* sets, `get()`/`ql.getSchema()` that surface the object schema. Mirrors the
18+
* shape used by security-plugin.test.ts so the plugin boots identically.
19+
*/
20+
function bootPlugin(permissionSets: PermissionSet[], objectFields: string[], fallback = 'restricted') {
21+
const fields: Record<string, any> = {};
22+
for (const f of objectFields) fields[f] = { name: f };
23+
const schema: any = { name: 'deal', label: 'Deal', systemFields: false, fields };
24+
const ql: any = {
25+
registerMiddleware: () => {},
26+
getSchema: (name: string) => (name === 'deal' ? schema : null),
27+
findOne: async () => null,
28+
};
29+
const metadata: any = {
30+
get: async (_type: string, name: string) => (name === 'deal' ? schema : null),
31+
list: async () => permissionSets,
32+
};
33+
const services: Record<string, any> = { manifest: { register: vi.fn() }, objectql: ql, metadata };
34+
const ctx: any = {
35+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
36+
registerService: vi.fn(),
37+
getService: (name: string) => {
38+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
39+
return services[name];
40+
},
41+
};
42+
const plugin = new SecurityPlugin({ fallbackPermissionSet: fallback });
43+
return { plugin, ctx };
44+
}
45+
46+
// A permission set that reads `deal` but masks the `secret` field.
47+
const RESTRICTED: PermissionSet = {
48+
name: 'restricted',
49+
label: 'Restricted',
50+
objects: { deal: { allowRead: true } },
51+
fields: { 'deal.secret': { readable: false, editable: false } },
52+
} as any;
53+
54+
// A permission set with no field-level rules → every field readable.
55+
const OPEN: PermissionSet = {
56+
name: 'open',
57+
label: 'Open',
58+
objects: { deal: { allowRead: true } },
59+
} as any;
60+
61+
describe('SecurityPlugin.getReadableFields (#3547)', () => {
62+
it('returns all schema fields MINUS those masked non-readable — the FieldMasker complement', async () => {
63+
const { plugin, ctx } = bootPlugin([RESTRICTED], ['id', 'name', 'secret']);
64+
await plugin.init(ctx);
65+
await plugin.start(ctx);
66+
67+
const readable = await plugin.getReadableFields('deal', { userId: 'u1', permissions: ['restricted'] });
68+
expect(readable).toBeDefined();
69+
expect(readable).toContain('id');
70+
expect(readable).toContain('name');
71+
// `secret` is masked (readable:false) → excluded, exactly as maskResults deletes it.
72+
expect(readable).not.toContain('secret');
73+
});
74+
75+
it('a field with no permission entry passes through (allow-list only enumerates named fields)', async () => {
76+
// `open` names no fields → nothing is masked → all schema fields readable.
77+
const { plugin, ctx } = bootPlugin([OPEN], ['id', 'name', 'secret'], 'open');
78+
await plugin.init(ctx);
79+
await plugin.start(ctx);
80+
81+
const readable = await plugin.getReadableFields('deal', { userId: 'u1', permissions: ['open'] });
82+
expect(new Set(readable)).toEqual(new Set(['id', 'name', 'secret']));
83+
});
84+
85+
it('a system context bypasses FLS → the full field set (mirrors the middleware isSystem skip)', async () => {
86+
const { plugin, ctx } = bootPlugin([RESTRICTED], ['id', 'name', 'secret']);
87+
await plugin.init(ctx);
88+
await plugin.start(ctx);
89+
90+
const readable = await plugin.getReadableFields('deal', { isSystem: true });
91+
expect(new Set(readable)).toEqual(new Set(['id', 'name', 'secret']));
92+
});
93+
94+
it('returns undefined when the object schema cannot be resolved (caller falls back)', async () => {
95+
const { plugin, ctx } = bootPlugin([RESTRICTED], ['id', 'name', 'secret']);
96+
await plugin.init(ctx);
97+
await plugin.start(ctx);
98+
99+
const readable = await plugin.getReadableFields('unknown_object', { userId: 'u1', permissions: ['restricted'] });
100+
expect(readable).toBeUndefined();
101+
});
102+
103+
it('is computed from the schema, not from data rows — immune to null/empty result sets', async () => {
104+
// No data rows exist in this harness at all (ql.findOne → null); the
105+
// readable set is still complete, proving it is derived from schema+context.
106+
const { plugin, ctx } = bootPlugin([RESTRICTED], ['id', 'name', 'secret', 'amount']);
107+
await plugin.init(ctx);
108+
await plugin.start(ctx);
109+
110+
const readable = await plugin.getReadableFields('deal', { userId: 'u1', permissions: ['restricted'] });
111+
// `amount` is readable and would be entirely absent from an empty result —
112+
// it survives here because the projection never consults rows.
113+
expect(readable).toContain('amount');
114+
expect(readable).not.toContain('secret');
115+
});
116+
});

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,12 @@ export class SecurityPlugin implements Plugin {
546546
};
547547
ctx.registerService('security', {
548548
getReadFilter: (object: string, context?: any) => this.getReadFilter(object, context),
549+
// [#3547] Readable-field projection for a context — the authoritative
550+
// column set for a read-derived export (`export ⊆ list`, #3391).
551+
// Same field mask as the read middleware (no drift). The REST export
552+
// route uses it to project columns instead of inferring readability
553+
// from already-masked data rows. See getReadableFields.
554+
getReadableFields: (object: string, context?: any) => this.getReadableFields(object, context),
549555
// [ADR-0046 §6.7] Effective permission-set NAMES for a caller — the
550556
// primitive the REST read layer needs to evaluate a permission-set-
551557
// gated book/doc audience ({ permissionSet: '…' }). Same resolution
@@ -571,7 +577,7 @@ export class SecurityPlugin implements Plugin {
571577
dismissAudienceBindingSuggestion: (callerContext: any, id: string) =>
572578
dismissAudienceBindingSuggestion(suggestionDeps, callerContext, id),
573579
});
574-
ctx.logger.info('[security] registered "security" service (getReadFilter, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9');
580+
ctx.logger.info('[security] registered "security" service (getReadFilter, getReadableFields, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9 / #3547');
575581
} catch (e) {
576582
ctx.logger.warn?.('[security] failed to register "security" service', {
577583
error: (e as Error).message,
@@ -2044,6 +2050,71 @@ export class SecurityPlugin implements Plugin {
20442050
}
20452051
}
20462052

2053+
/**
2054+
* [#3547] Query surface: the field names the caller MAY READ on `object`
2055+
* under `context`. This is the authoritative column projection for a
2056+
* read-derived export (`export ⊆ list`, #3391) — the REST export route
2057+
* consumes it to project columns directly, instead of inferring readability
2058+
* from already-masked data rows (which loses an all-readable-but-all-null
2059+
* column and falls back to the full schema on an empty result set, #3498).
2060+
*
2061+
* Same-source-as-read-middleware, so it can never drift from data-plane FLS:
2062+
* it resolves the caller's permission sets via
2063+
* {@link resolvePermissionSetsForContext}, builds the field-permission map
2064+
* with the SAME evaluator + `requiredPermissions` fold the read mask uses,
2065+
* intersects the on-behalf-of delegator's mask (ADR-0090 D10, fail-closed on
2066+
* a dangling delegator), then returns every schema field NOT marked
2067+
* non-readable — the exact complement of what {@link FieldMasker.maskResults}
2068+
* deletes (fields without an explicit permission entry pass through).
2069+
*
2070+
* Returns `undefined` when the object schema can't be resolved, so the caller
2071+
* falls back to its own projection. A system context (`context.isSystem`)
2072+
* bypasses FLS and returns the full field set, mirroring the middleware's
2073+
* `isSystem` skip.
2074+
*/
2075+
async getReadableFields(object: string, context?: any): Promise<string[] | undefined> {
2076+
const objectName = String(object ?? '');
2077+
if (!objectName) return undefined;
2078+
// The field universe — the SAME source the RLS field pass uses (ObjectQL's
2079+
// live SchemaRegistry first, metadata artifact fallback). `null` → schema
2080+
// not resolvable → let the caller fall back rather than guess.
2081+
const fieldNameSet = await this.getObjectFieldNames(this.metadata, objectName, this.ql);
2082+
if (!fieldNameSet) return undefined;
2083+
const allFields = [...fieldNameSet];
2084+
// System operations bypass FLS (mirrors the middleware's isSystem skip).
2085+
if (context?.isSystem) return allFields;
2086+
2087+
const permissionSets = await this.resolvePermissionSetsForContext(context);
2088+
// No sets resolved (e.g. unauthenticated) → no field mask applies, exactly
2089+
// as the middleware (getFieldPermissions([]) === {} → nothing deleted).
2090+
if (permissionSets.length === 0) return allFields;
2091+
2092+
const secMeta = await this.getObjectSecurityMeta(objectName);
2093+
let fieldPerms = this.permissionEvaluator.getFieldPermissions(objectName, permissionSets);
2094+
fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets);
2095+
2096+
// [ADR-0090 D10] On an on-behalf-of read the readable set must NOT widen
2097+
// past what the DELEGATOR can read — intersect the delegator's field mask
2098+
// too. A dangling delegator fails CLOSED (expose no columns), the same
2099+
// fail-closed stance the CRUD middleware takes on a 'missing' delegator.
2100+
if (context?.onBehalfOf?.userId) {
2101+
const del = await resolveDelegatorContext(this.ql, context);
2102+
if (del.kind === 'missing') return [];
2103+
if (del.kind === 'resolved') {
2104+
const delegatorSets = await this.resolvePermissionSetsForContext(del.context);
2105+
let delFieldPerms = this.permissionEvaluator.getFieldPermissions(objectName, delegatorSets);
2106+
delFieldPerms = this.foldFieldRequiredPermissions(delFieldPerms, secMeta.fieldRequiredPermissions, delegatorSets);
2107+
fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms);
2108+
}
2109+
}
2110+
2111+
// Readable = every schema field NOT explicitly masked non-readable. A field
2112+
// with no permission entry passes through (the field allow-list only
2113+
// enumerates fields it names) — the exact complement of maskResults' delete
2114+
// set, so the export header matches list's readable columns by construction.
2115+
return allFields.filter((f) => fieldPerms[f]?.readable !== false);
2116+
}
2117+
20472118
/**
20482119
* Resolve the effective permission sets for an execution context — positions +
20492120
* explicit permission sets, with the configured baseline applied both as an

packages/rest/src/export-integration.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,3 +383,110 @@ describe('export route — FLS column projection (#3391 blocking)', () => {
383383
}
384384
});
385385
});
386+
387+
// ===========================================================================
388+
// #3547: export column projection via the security service's getReadableFields
389+
// — the LONG-TERM correct path that replaces inferring readability from masked
390+
// data rows (#3498). The route asks `security.getReadableFields(object, ctx)`
391+
// for the readable column set BEFORE streaming, so the header is derived from
392+
// the schema + context, never from row content — immune to an all-null
393+
// readable column and to an empty result set.
394+
// ===========================================================================
395+
describe('export route — FLS column projection via getReadableFields (#3547)', () => {
396+
// Boot a real engine + protocol, seed tasks, and wire a RestServer whose
397+
// `security` service (host provider) resolves to the supplied
398+
// getReadableFields — mirroring how plugin-security registers the service.
399+
async function bootWithSecurity(opts: {
400+
getReadableFields: (object: string, context?: any) => string[] | undefined;
401+
tasks?: Array<Record<string, unknown>>;
402+
}) {
403+
const { driver } = makeMemoryDriver();
404+
const engine = new ObjectQL();
405+
engine.registerDriver(driver, true);
406+
await engine.init();
407+
engine.registry.registerObject(USER as any);
408+
engine.registry.registerObject(TASK as any);
409+
await engine.insert('user', { id: 'u1', name: '张三' });
410+
const tasks = opts.tasks ?? [
411+
{ id: '1', title: '写代码', done: true, priority: 'high', due: '2026-06-30T00:00:00.000Z', owner: 'u1' },
412+
{ id: '2', title: '写文档', done: false, priority: 'low', due: '2026-07-01T00:00:00.000Z', owner: 'u1' },
413+
];
414+
for (const t of tasks) await engine.insert('task', t);
415+
const protocol = new ObjectStackProtocolImplementation(engine as any);
416+
// 16th positional ctor arg is `securityServiceProvider`.
417+
const securityServiceProvider = async () => ({ getReadableFields: opts.getReadableFields });
418+
const rest = new RestServer(
419+
createMockServer() as any,
420+
protocol as any,
421+
{ api: { requireAuth: false } } as any,
422+
undefined, // kernelManager
423+
undefined, // envRegistry
424+
undefined, // defaultEnvironmentIdProvider
425+
undefined, // authServiceProvider
426+
undefined, // objectQLProvider
427+
undefined, // emailServiceProvider
428+
undefined, // sharingServiceProvider
429+
undefined, // reportsServiceProvider
430+
undefined, // approvalsServiceProvider
431+
undefined, // sharingRulesServiceProvider
432+
undefined, // i18nServiceProvider
433+
undefined, // analyticsServiceProvider
434+
undefined, // settingsServiceProvider
435+
undefined, // serviceExistsProvider
436+
securityServiceProvider,
437+
);
438+
rest.registerRoutes();
439+
const route = rest.getRoutes().find(
440+
(r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export',
441+
);
442+
return { engine, route };
443+
}
444+
445+
it('projects columns from getReadableFields — drops a masked field even though rows still carry it', async () => {
446+
// The service says `title` is NOT readable; every row still HAS a title
447+
// value. The route must drop 标题 from the header — proving the projection
448+
// comes from the service, not the row keys (the masked-row inference would
449+
// have kept 标题 because it is present in the rows).
450+
const { route } = await bootWithSecurity({
451+
getReadableFields: () => ['id', 'done', 'priority', 'due', 'owner'],
452+
});
453+
const csv = makeRes();
454+
await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res);
455+
const header = parseCsv(csv.chunks.join(''))[0];
456+
expect(header).not.toContain('标题');
457+
expect(header).toEqual(['ID', '完成', '优先级', '截止', '负责人']);
458+
});
459+
460+
it('keeps a readable column that is absent from every row (null-value immunity)', async () => {
461+
// All tasks omit `due` → the masked-row inference would DROP 截止 from the
462+
// header (no row carries the key). getReadableFields lists it, so the
463+
// column survives — the header no longer depends on row content.
464+
const { route } = await bootWithSecurity({
465+
getReadableFields: () => ['id', 'title', 'done', 'priority', 'due', 'owner'],
466+
tasks: [
467+
{ id: '1', title: 'A', done: true, priority: 'high', owner: 'u1' }, // no `due`
468+
{ id: '2', title: 'B', done: false, priority: 'low', owner: 'u1' }, // no `due`
469+
],
470+
});
471+
const csv = makeRes();
472+
await route.handler({ params: { object: 'task' }, query: { format: 'csv' } } as any, csv.res);
473+
const header = parseCsv(csv.chunks.join(''))[0];
474+
expect(header).toContain('截止'); // survives despite being absent from every row
475+
expect(header).toEqual(['ID', '标题', '完成', '优先级', '截止', '负责人']);
476+
});
477+
478+
it('explicit ?fields= is still honored verbatim (service projection only narrows schema-derived headers)', async () => {
479+
// getReadableFields would drop `title`, but an explicit request wins — the
480+
// projection only applies to schema-derived headers (fieldsFromSchema).
481+
const { route } = await bootWithSecurity({
482+
getReadableFields: () => ['id', 'done'],
483+
});
484+
const csv = makeRes();
485+
await route.handler(
486+
{ params: { object: 'task' }, query: { format: 'csv', fields: 'id,title' } } as any,
487+
csv.res,
488+
);
489+
const header = parseCsv(csv.chunks.join(''))[0];
490+
expect(header).toEqual(['ID', '标题']); // requested columns kept as asked
491+
});
492+
});

0 commit comments

Comments
 (0)