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
33 changes: 33 additions & 0 deletions .changeset/security-get-readable-fields.md
Original file line number Diff line number Diff line change
@@ -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.
116 changes: 116 additions & 0 deletions packages/plugins/plugin-security/src/get-readable-fields.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {};
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<string, any> = { 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');
});
});
73 changes: 72 additions & 1 deletion packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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<string[] | undefined> {
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
Expand Down
107 changes: 107 additions & 0 deletions packages/rest/src/export-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
}) {
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
});
});
Loading