diff --git a/.changeset/27884-datasource-stored-row-read-admission.md b/.changeset/27884-datasource-stored-row-read-admission.md new file mode 100644 index 00000000000..4f7d4a6e574 --- /dev/null +++ b/.changeset/27884-datasource-stored-row-read-admission.md @@ -0,0 +1,21 @@ +--- +'@objectstack/metadata-core': patch +'@objectstack/objectql': patch +'@objectstack/rest': patch +--- + +fix(objectql,rest,metadata-core)!: a datasource definition's stored row is served only to datasource administrators, whichever exit reads it (#27884) + +Clause-②: no (narrowing for readers below datasource administration) +Metadata-protocol: none + + + +**BREAKING** read-set narrowing for callers below datasource administration. The metadata read door already served a `datasource` or `external_catalog` definition only to holders of `manage_platform_settings`, the capability the datasource family's own door requires. The stored row each definition lives in now admits the same readers, so one document admits one set of callers at every exit. + +- **One row.** The type-to-capability table moves to `@objectstack/metadata-core` as `STORED_METADATA_TYPE_READ_CAPABILITIES`, with `storedMetadataTypesWithheldFrom(caller)` and `isCapabilityGatedStoredMetadataType(type)`. `@objectstack/rest` keeps exporting `META_TYPE_READ_CAPABILITIES`, now that same frozen object, and the metadata read door answers exactly as before. +- **Now.** A caller-context read of a stored metadata row of a listed type, by a caller without the type's capability, is served no row: a list or count leaves it out, and a read of its id answers as a read of a row that does not exist. Holders of the capability, platform administrators among them, are served everything as before. System reads are unchanged. +- **Events.** A record-change event of such a row carries no metadata body to any subscriber; the row's other columns are unchanged. +- **Who sees it.** A reader below datasource administration is no longer served stored datasource or federation-catalog rows. + +**The remedy.** Grant `manage_platform_settings` to a caller that needs a datasource definition, or read it through the datasource admin door (`GET /api/v1/datasources/:name`) as such a caller. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 62b29794ac3..ca25bd2c3c3 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -122,6 +122,7 @@ bugs — it is the protection or the side effect that silently does not happen. | 18 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `enforceAuth` | | 18b | Settings namespace read scope not applied | service-settings | Get: a read of `sys_setting`, `sys_setting_audit` or `sys_platform_setting` across every namespace, whatever each namespace's `readPermission` names — the settings service's own reads of its stores take this path. Lose: the per-namespace read gate the generic data API applies to every other caller, the same rule the settings routes enforce | `settingsReadDoorMiddleware` | | 18c | **Credential-column filter / sort refusal not applied** — a `where`, `orderBy`, per-aggregation `filter` or `expand` entry naming a `password` / `secret` column is evaluated | objectql | Get: the server's own match on a stored credential value, a lookup that never returns it. Every other caller, administrators included, is refused `400 INVALID_FIELD` for anything but a has-a-value check (ADR-0100). Lose: nothing a wire caller reaches — `isSystem` is never set on an inbound request | `rejectCredentialEvaluation` | +| 18d | **Stored-metadata type read gate not applied** — a read of `sys_metadata` / `sys_metadata_history` is not narrowed by the capability its row's type requires | objectql | Get: every row of the family on `find` / `findOne` / `count` / `aggregate` — the metadata loader, the `/meta` doors' own store reads, boot (a read with no execution context is not narrowed either). Lose: the narrowing that serves a stored row of a capability-gated type (`datasource`, `external_catalog`) only to holders of the capability the `/meta` type gate names for it | `storedMetadataTypeReadFilter` | ### 2. Write pipeline and data integrity @@ -543,6 +544,7 @@ and CI refuses one that differs from what the tree produces. | `packages/objectql/src/engine.ts#insert` | 3 | 21, 22 | | `packages/objectql/src/engine.ts#isSystem` | 1 | 64 | | `packages/objectql/src/engine.ts#update` | 4 | 19, 20 | +| `packages/objectql/src/stored-metadata-type-read-gate.ts#storedMetadataTypeReadFilter` | 1 | 18d | | `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | 1 | 13 | | `packages/plugins/organizations/src/organizations-plugin.ts#start` | 1 | 62 | | `packages/plugins/plugin-approvals/src/approval-service.ts#comment` | 1 | 43 | diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index f827c9a1086..e5c279e79c5 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -101,6 +101,11 @@ export * from './item-key-discriminators.js'; // axis. Sunk here because the doors live in `@objectstack/runtime` and // `@objectstack/rest`, which share no other common home. export * from './meta-write-capability.js'; +// [#27884] The read-side twin: which readers a stored metadata document of a +// capability-gated type (datasource, external_catalog) is served to, asked by the +// `/meta` type gate (`@objectstack/rest`) and the engine's stored-row read gate +// (`@objectstack/objectql`) alike. +export * from './stored-metadata-type-read-admission.js'; // [commit 1408fe385 / #10101] The shared platform-row organization resolver — sunk here // from `@objectstack/plugin-audit` per the maintainer ruling recorded on diff --git a/packages/metadata-core/src/stored-metadata-type-read-admission.ts b/packages/metadata-core/src/stored-metadata-type-read-admission.ts new file mode 100644 index 00000000000..1f387746db7 --- /dev/null +++ b/packages/metadata-core/src/stored-metadata-type-read-admission.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27884] Which READERS a stored metadata document of a capability-gated type + * is served to — the ONE row both exits that serve the document ask. + * + * ## The two exits, one row + * + * A datasource document (a driver, its connection configuration and the handle + * of its bound secret) is stored as a row of the stored-metadata family + * (`sys_metadata`, and its version snapshots in `sys_metadata_history`), typed + * by the row's `type` column. It is served through two exits: + * + * - the `/meta` read doors, whose type-level read admission + * (`metaTypeReadRefusal`, `@objectstack/rest`) refuses a caller without the + * row's capability before any store read; and + * - every caller-context engine read of the family's rows, whichever door + * issues it, narrowed by + * {@link storedMetadataTypesWithheldFrom}, which the engine composes into + * the read's predicate (`stored-metadata-type-read-gate.ts`, + * `@objectstack/objectql`). + * + * One operation reached through two mounted routes cannot admit two different + * sets of callers, so both read THIS table. It lives here because the `/meta` + * gate lives in `@objectstack/rest` and the engine gate in + * `@objectstack/objectql`, neither of which depends on the other, and this + * package is the one both already depend on. `@objectstack/rest` re-exports it + * as `META_TYPE_READ_CAPABILITIES`, the same frozen object. + * + * ## The rows, and why each capability + * + * - `datasource` → `manage_platform_settings`. The datasource family's own + * door — `GET /api/v1/datasources[/:name]` (`DATASOURCE_ADMIN_CAPABILITY`, + * `@objectstack/service-datasource`) — serves exactly that configuration and + * admits only this capability. + * - `external_catalog` → `manage_platform_settings`. The cached remote-schema + * snapshot of a federated datasource: the remote tables and columns the + * federation family's read door introspects live and admits only this + * capability for. + * + * ⛔ A row names the capability the type's OWN door already requires — + * matched, never minted; a type with no such door has no row. ⛔ Never a + * second copy of this table at either exit. + */ +export const STORED_METADATA_TYPE_READ_CAPABILITIES: Readonly> = Object.freeze({ + datasource: 'manage_platform_settings', + external_catalog: 'manage_platform_settings', +}); + +/** + * [#27884] The stored metadata types a caller is NOT served, read from + * {@link STORED_METADATA_TYPE_READ_CAPABILITIES}: every type whose capability + * is absent from the caller's resolved `systemPermissions` (the capability set + * the request's authorization resolver stamps on the execution context). + * + * Empty for a holder of every row's capability. Every listed type for a caller + * that carries no capability set at all, an anonymous one included: absence is + * never a grant. The caller's `isSystem` is not read here — an elevated read is + * the platform's own, and the engine gate admits it before asking. + */ +export function storedMetadataTypesWithheldFrom(caller: unknown): string[] { + const held = caller && typeof caller === 'object' + ? (caller as { systemPermissions?: unknown }).systemPermissions + : undefined; + const holds = Array.isArray(held) ? held : []; + return Object.entries(STORED_METADATA_TYPE_READ_CAPABILITIES) + .filter(([, capability]) => !holds.includes(capability)) + .map(([type]) => type); +} + +/** + * [#27884] Whether stored documents of `type` are served only to holders of a + * capability — the question a caller-blind exit asks (a realtime event, whose + * subscribers are unknown at publish, withholds such a document's body from + * everyone). + */ +export function isCapabilityGatedStoredMetadataType(type: unknown): boolean { + return typeof type === 'string' + && Object.prototype.hasOwnProperty.call(STORED_METADATA_TYPE_READ_CAPABILITIES, type); +} diff --git a/packages/objectql/src/engine-realtime-publish.ts b/packages/objectql/src/engine-realtime-publish.ts index a8ff206ffaf..1de04be5931 100644 --- a/packages/objectql/src/engine-realtime-publish.ts +++ b/packages/objectql/src/engine-realtime-publish.ts @@ -63,6 +63,9 @@ import { omitInternalFieldsFromWriteResponse, } from '@objectstack/core'; import { TenantLayer0VerdictSchema } from '@objectstack/spec/security'; +// [#27884] The one row of capability-gated stored metadata types — the row the +// `/meta` type gate and the engine's stored-row read gate read. +import { isCapabilityGatedStoredMetadataType } from '@objectstack/metadata-core'; import { resolveTenantFieldName, carriesOrganization } from './tenancy/system-write-organization.js'; import type { ObjectQL } from './engine.js'; @@ -131,7 +134,9 @@ function eventRecordBody(value: unknown): Record | undefined { * * `fullRow` is the whole written record (`input.after`), read for the `type` * column that selects the redactor — a `changes` patch need not carry it. A - * body the redactor cannot judge is DROPPED from the event (fail-closed): the + * body of a capability-gated type (`isCapabilityGatedStoredMetadataType`, + * #27884) is DROPPED for every subscriber. A body the redactor cannot judge is + * DROPPED from the event (fail-closed): the * key is omitted rather than published raw. A non-record body, and every object * outside the set, pass through unchanged. Pure: the event body is a fresh * object, never the stored row. @@ -149,7 +154,13 @@ function redactEventMetadataBody( : (fullRow && typeof fullRow === 'object' && !Array.isArray(fullRow) ? (fullRow as Record)[STORED_METADATA_TYPE_COLUMN] : undefined); - const outcome = redactStoredMetadataBody(type, body[STORED_METADATA_BODY_COLUMN]); + // [#27884] A document of a capability-gated type (a datasource's connection + // configuration and secret handle) is served only to holders of the type's + // read capability, and the event's subscribers are not known here — so its + // body is withheld from every subscriber, the fail-closed arm below. + const outcome = isCapabilityGatedStoredMetadataType(type) + ? ({ ok: false } as const) + : redactStoredMetadataBody(type, body[STORED_METADATA_BODY_COLUMN]); if (outcome.ok) { if (outcome.body === body[STORED_METADATA_BODY_COLUMN]) return body; return { ...body, [STORED_METADATA_BODY_COLUMN]: outcome.body }; diff --git a/packages/objectql/src/engine-realtime-stored-metadata-body.test.ts b/packages/objectql/src/engine-realtime-stored-metadata-body.test.ts index 39361e9da6d..4f709080220 100644 --- a/packages/objectql/src/engine-realtime-stored-metadata-body.test.ts +++ b/packages/objectql/src/engine-realtime-stored-metadata-body.test.ts @@ -109,16 +109,18 @@ describe('[#21120] realtime data.record.* events redact a stored metadata body', expect(published).toHaveLength(1); const payload = published[0].payload as Record; expect(JSON.stringify(payload)).not.toContain(CRED); - // The event still describes the row — type survives, the body is still present but projected. + // The event still describes the row — type survives. [#27884] A datasource + // body is withheld whole (the connection configuration with it): the + // type's documents are served only to holders of its read capability, and + // an event's subscribers are unknown at publish. expect(payload.after.type).toBe('datasource'); - expect(payload.after.metadata).toBeDefined(); - expect(JSON.parse(payload.after.metadata).config.authToken).toBeUndefined(); - expect(JSON.parse(payload.after.metadata).config.url).toBe('libsql://db.turso.io'); + expect(payload.after).not.toHaveProperty('metadata'); + expect(JSON.stringify(payload)).not.toContain('libsql://db.turso.io'); }); - it('a credential-free sys_metadata body rides the event unchanged', async () => { - const clean = JSON.stringify({ name: 'v', driver: 'postgres', config: { host: 'h', database: 'd' } }); - await engine.insert('sys_metadata', { id: 'm2', type: 'datasource', name: 'v', metadata: clean }); + it('a credential-free sys_metadata body of an ungated type rides the event unchanged', async () => { + const clean = JSON.stringify({ name: 'v', label: 'V', type: 'grid' }); + await engine.insert('sys_metadata', { id: 'm2', type: 'view', name: 'v', metadata: clean }); const payload = published[0].payload as Record; expect(payload.after.metadata).toBe(clean); }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a50936956b9..ccf50a31322 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -158,6 +158,8 @@ import { buildDriverOptions, driverReadScope, driverWriteTarget, engineLayer0Ver import { announceOpenMigrationGates, isOrganizationOwnershipCertified, isValueShapesMigrationVerified, noteAdmittedValueShapeViolation, readFileReferencesFlagRow, readMigrationFlagVerified, supplyTenantKeyCertifiedResolver, wasDatastoreCreatedFromEmpty } from './engine-migration-flags.js'; import { ExecutionContext, ExecutionContextSchema, type InstalledPackageSource } from '@objectstack/spec/kernel'; import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; +// [#27884] The stored-row read gate, registered on the family's tables below. +import { STORED_METADATA_TYPE_READ_GATE_OBJECTS, storedMetadataTypeReadGate } from './stored-metadata-type-read-gate.js'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; // Imported from spec directly rather than through `@objectstack/core`'s // re-export block: that block is labelled backward-compatibility, and this @@ -5125,6 +5127,14 @@ export class ObjectQL implements IObjectQLEngine { if (process?.env?.OBJECTQL_WARN_LEGACY_HANDLER === '1') { (this as any)._warnLegacyHandler = true; } + // [#27884] The stored-row read gate: a caller-context read of a + // stored-metadata table serves a capability-gated type's documents only to + // holders of that capability — the row the `/meta` type gate reads. In the + // constructor, so no engine serves those tables without it. See + // `stored-metadata-type-read-gate.ts`. + for (const object of STORED_METADATA_TYPE_READ_GATE_OBJECTS) { + this.registerMiddleware(storedMetadataTypeReadGate, { object }); + } this.logger.info('ObjectQL Engine Instance Created'); } diff --git a/packages/objectql/src/stored-metadata-type-read-gate.test.ts b/packages/objectql/src/stored-metadata-type-read-gate.test.ts new file mode 100644 index 00000000000..f8714d15dea --- /dev/null +++ b/packages/objectql/src/stored-metadata-type-read-gate.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27884] The engine's stored-row read gate (`stored-metadata-type-read-gate.ts`): + * a caller-context read of `sys_metadata` / `sys_metadata_history` serves a + * capability-gated type's documents (a datasource's connection configuration + * and secret handle) only to holders of the capability the `/meta` type gate + * names for that type. Every pin here is a negative or a preservation pin + * against a driver that really evaluates the composed predicate. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts'; +import { STORED_METADATA_TYPE_READ_CAPABILITIES, storedMetadataTypesWithheldFrom } from '@objectstack/metadata-core'; +import { ObjectQL } from './engine.js'; +import { storedMetadataTypeReadFilter } from './stored-metadata-type-read-gate.js'; + +const CAPABILITY = 'manage_platform_settings'; +const REF = 'sys_secret:sec_gate_pin_0001'; +const HOST = 'db.gate-pin.internal'; +const datasourceBody = JSON.stringify({ + name: 'ds_pin', + driver: 'postgres', + config: { host: HOST, port: 5432, database: 'pin_db', username: 'pin_user' }, + external: { credentialsRef: REF }, +}); +const viewBody = JSON.stringify({ name: 'v_pin', label: 'V', type: 'grid' }); + +const tableOf = (name: string) => ({ + name, + label: name, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + type: { name: 'type', type: 'text' as const }, + name: { name: 'name', type: 'text' as const }, + metadata: { name: 'metadata', type: 'textarea' as const }, + }, +}); + +/** A predicate evaluator for the shapes these reads compose: equality, `$in`, `$nin`, `$and`. */ +function matches(row: Record, where: unknown): boolean { + if (!where || typeof where !== 'object') return true; + return Object.entries(where as Record).every(([key, cond]) => { + if (key === '$and') return (cond as unknown[]).every((c) => matches(row, c)); + if (key.startsWith('$')) throw new Error(`pin driver: unsupported combinator ${key}`); + const value = row[key] ?? null; + if (cond && typeof cond === 'object' && !Array.isArray(cond)) { + const c = cond as Record; + if ('$nin' in c) return !(c.$nin as unknown[]).includes(value); + if ('$in' in c) return (c.$in as unknown[]).includes(value); + if ('$eq' in c) return value === c.$eq; + throw new Error(`pin driver: unsupported condition on ${key}: ${JSON.stringify(c)}`); + } + return value === (cond ?? null); + }); +} + +function makeDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const rows = (o: string, ast: any) => Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(o: string, ast: any) { + const matched = rows(o, ast); + return typeof ast?.limit === 'number' ? matched.slice(0, ast.limit) : matched; + }, + findStream() { throw new Error('ns'); }, + async findOne(o: string, ast: any) { return rows(o, ast)[0] ?? null; }, + async count(o: string, ast: any) { return rows(o, ast).length; }, + async aggregate(o: string, ast: any) { return [{ n: rows(o, ast).length }]; }, + async create(o: string, data: Record) { + const row = { ...data }; + storeFor(o).set(String(data.id), row); + return row; + }, + async update(o: string, id: string, data: Record) { + const s = storeFor(o); const up = { ...s.get(id), ...data, id }; s.set(id, up); return up; + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async bulkCreate(o: string, rs: Record[]) { return Promise.all(rs.map((r) => this.create(o, r))); }, + async updateMany() { return 0; }, async deleteMany() { return 0; }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async upsert(o: string, data: Record) { return this.create(o, data); }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return driver; +} + +/** A datasource administrator: holds the row's capability (platform administrators do). */ +const ADMIN = { userId: 'u_admin', systemPermissions: [CAPABILITY, 'manage_metadata', 'studio.access'] }; +/** A caller below datasource administration. */ +const ORG_ADMIN = { userId: 'u_org_admin', systemPermissions: ['manage_org_users', 'setup.access', 'setup.write', 'manage_mailboxes'] }; +/** A caller that may enter Studio (and so read the history table) without datasource administration. */ +const STUDIO_AUTHOR = { userId: 'u_author', systemPermissions: ['studio.access', 'manage_metadata'] }; +/** A plain reader: no capability set at all. */ +const READER = { userId: 'u_reader' }; + +describe('[#27884] stored-row read gate on the stored-metadata tables', () => { + let engine: ObjectQL; + + beforeEach(async () => { + engine = new ObjectQL(); + engine.registerDriver(makeDriver(), true); + await engine.init(); + for (const table of ['sys_metadata', 'sys_metadata_history']) { + engine.registry.registerObject(tableOf(table)); + await engine.insert(table, { id: `${table}_ds`, type: 'datasource', name: 'ds_pin', metadata: datasourceBody }, { context: { isSystem: true } }); + await engine.insert(table, { id: `${table}_cat`, type: 'external_catalog', name: 'ds_pin', metadata: '{}' }, { context: { isSystem: true } }); + await engine.insert(table, { id: `${table}_view`, type: 'view', name: 'v_pin', metadata: viewBody }, { context: { isSystem: true } }); + } + vi.spyOn((engine as any).logger, 'debug').mockImplementation(() => undefined); + }); + + it('a datasource administrator reads the row whole', async () => { + const rows = await engine.find('sys_metadata', { where: { type: 'datasource' }, context: ADMIN }); + expect(rows).toHaveLength(1); + const body = JSON.parse(rows[0].metadata); + expect(body.external.credentialsRef).toBe(REF); + expect(body.config).toMatchObject({ host: HOST, port: 5432, database: 'pin_db', username: 'pin_user' }); + const one = await engine.findOne('sys_metadata', { where: { id: 'sys_metadata_ds' }, context: ADMIN }); + expect(one?.type).toBe('datasource'); + expect(await engine.count('sys_metadata', { context: ADMIN })).toBe(3); + }); + + for (const [label, caller] of [['a caller below datasource administration', ORG_ADMIN], ['a plain reader', READER]] as const) { + it(`${label} is served no datasource-family row on sys_metadata (find, findOne, count, aggregate)`, async () => { + const listed = await engine.find('sys_metadata', { context: caller }); + expect(listed.map((r: any) => r.type).sort()).toEqual(['view']); + expect(JSON.stringify(listed)).not.toContain(REF); + expect(JSON.stringify(listed)).not.toContain(HOST); + + // Asked for by type: nothing, not a refusal that names the row. + expect(await engine.find('sys_metadata', { where: { type: 'datasource' }, context: caller })).toEqual([]); + // A get of the row's id answers as a get of a row that does not exist. + expect(await engine.findOne('sys_metadata', { where: { id: 'sys_metadata_ds' }, context: caller })).toBeNull(); + expect(await engine.count('sys_metadata', { where: { type: 'datasource' }, context: caller })).toBe(0); + expect(await engine.count('sys_metadata', { context: caller })).toBe(1); + const agg: any = await engine.aggregate('sys_metadata', { where: { type: 'external_catalog' }, groupBy: ['type'], aggregations: [{ function: 'count', alias: 'n' }], context: caller }); + expect(JSON.stringify(agg)).toContain('"n":0'); + }); + } + + it('the history table (the same document, versioned) is gated by the same row', async () => { + const author = await engine.find('sys_metadata_history', { context: STUDIO_AUTHOR }); + expect(author.map((r: any) => r.type)).toEqual(['view']); + expect(await engine.findOne('sys_metadata_history', { where: { id: 'sys_metadata_history_ds' }, context: STUDIO_AUTHOR })).toBeNull(); + const admin = await engine.find('sys_metadata_history', { context: ADMIN }); + expect(admin.map((r: any) => r.type).sort()).toEqual(['datasource', 'external_catalog', 'view']); + }); + + it('a system read is the platform\'s own and is not narrowed', async () => { + const rows = await engine.find('sys_metadata', { context: { isSystem: true } }); + expect(rows).toHaveLength(3); + }); + + it('the composed predicate names exactly the types whose capability the caller lacks', () => { + expect(storedMetadataTypeReadFilter({ object: 'sys_metadata', operation: 'find', context: ORG_ADMIN as any })) + .toEqual({ type: { $nin: ['datasource', 'external_catalog'] } }); + expect(storedMetadataTypeReadFilter({ object: 'sys_metadata', operation: 'find', context: ADMIN as any })).toBeUndefined(); + // Outside the family, and on a write, nothing is composed. + expect(storedMetadataTypeReadFilter({ object: 'task', operation: 'find', context: READER as any })).toBeUndefined(); + expect(storedMetadataTypeReadFilter({ object: 'sys_metadata', operation: 'update', context: READER as any })).toBeUndefined(); + // Every type in the row is withheld from a caller with no capability set. + expect(storedMetadataTypesWithheldFrom(READER)).toEqual(Object.keys(STORED_METADATA_TYPE_READ_CAPABILITIES)); + }); +}); + +describe('[#27884] realtime events of a datasource-family row carry no body to any subscriber', () => { + it('insert and update events of a datasource row withhold the body; an ungated row keeps it', async () => { + const published: RealtimeEventPayload[] = []; + const realtime: IRealtimeService = { + publish: vi.fn(async (event: RealtimeEventPayload) => { published.push(event); }), + subscribe: vi.fn(async () => 'sub-1'), + unsubscribe: vi.fn(async () => undefined), + }; + const engine = new ObjectQL(); + engine.registerDriver(makeDriver(), true); + await engine.init(); + engine.registry.registerObject(tableOf('sys_metadata')); + engine.setRealtimeService(realtime); + vi.spyOn((engine as any).logger, 'debug').mockImplementation(() => undefined); + + await engine.insert('sys_metadata', { id: 'e_ds', type: 'datasource', name: 'ds_pin', metadata: datasourceBody }, { context: { isSystem: true } }); + await engine.update('sys_metadata', { id: 'e_ds', metadata: datasourceBody }, { context: { isSystem: true } }); + await engine.insert('sys_metadata', { id: 'e_cat', type: 'external_catalog', name: 'ds_pin', metadata: '{"tables":[]}' }, { context: { isSystem: true } }); + await engine.insert('sys_metadata', { id: 'e_view', type: 'view', name: 'v_pin', metadata: viewBody }, { context: { isSystem: true } }); + + expect(published).toHaveLength(4); + for (const event of published.slice(0, 3)) { + const payload = event.payload as Record; + expect(JSON.stringify(payload)).not.toContain(REF); + expect(JSON.stringify(payload)).not.toContain(HOST); + expect(payload.after).not.toHaveProperty('metadata'); + if (payload.changes) expect(payload.changes).not.toHaveProperty('metadata'); + } + expect((published[3].payload as Record).after.metadata).toBe(viewBody); + }); +}); diff --git a/packages/objectql/src/stored-metadata-type-read-gate.ts b/packages/objectql/src/stored-metadata-type-read-gate.ts new file mode 100644 index 00000000000..7ca2661de13 --- /dev/null +++ b/packages/objectql/src/stored-metadata-type-read-gate.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27884] The engine's stored-row read gate: a caller is served a + * stored-metadata row of a capability-gated type (`datasource`, + * `external_catalog`) only when it holds the capability the `/meta` type gate + * names for that type. + * + * ## Why here, and why one middleware + * + * A datasource document is a row of the stored-metadata family + * (`sys_metadata`, and its version snapshots in `sys_metadata_history`). The + * `/meta` read doors admit a caller to a datasource-family type only when it + * holds `manage_platform_settings` (`metaTypeReadRefusal`, `@objectstack/rest`). + * The rule is that one document admits one set of callers at every exit that + * serves it, so the stored row admits exactly those callers too. + * + * Every caller-context read of the family passes this engine's middleware + * chain, so the rule is applied once, here, from the one table both exits read + * (`STORED_METADATA_TYPE_READ_CAPABILITIES`, `@objectstack/metadata-core`): + * for a caller missing a row's capability, `type NOT IN (those types)` is + * AND-composed into the read's predicate. ⛔ Never a second copy of the table, + * and never a per-door restatement of this gate. + * + * ## What it answers + * + * No row: a list and a count exclude the withheld types, and a get of a + * withheld row answers as a get of a row that does not exist, so the read is no + * existence oracle either. The object's own grants, field-level security, the + * tenant wall and the family's body projection still apply to every row it + * leaves in. `find`, `findOne`, `count` and `aggregate` are the reads; writes + * are not judged here (the generic door admits none on these tables, and the + * metadata protocol writes them under its own admission). + * + * A holder of every row's capability is not narrowed at all. A system read + * (`isSystem`) and a read with no execution context are the platform's own — + * the metadata loader, the `/meta` doors' store reads, boot — and are not + * narrowed, the posture every engine read gate takes. + */ + +import { isStoredMetadataBodyObject, STORED_METADATA_BODY_OBJECTS, STORED_METADATA_TYPE_COLUMN } from '@objectstack/spec/kernel'; +import { storedMetadataTypesWithheldFrom } from '@objectstack/metadata-core'; +import type { EngineMiddleware, OperationContext } from './engine.js'; + +/** The engine reads this gate narrows. */ +export const STORED_METADATA_TYPE_READ_GATE_OPS: ReadonlySet = new Set(['find', 'findOne', 'count', 'aggregate']); + +/** + * The predicate this gate AND-composes into a caller's read of a + * stored-metadata table, or `undefined` when it composes nothing: an object + * outside the family, a write, a system or context-less read, or a caller that + * holds every row's capability. + */ +export function storedMetadataTypeReadFilter( + op: Pick, +): Record | undefined { + if (!isStoredMetadataBodyObject(op.object)) return undefined; + if (!STORED_METADATA_TYPE_READ_GATE_OPS.has(op.operation)) return undefined; + if (!op.context || op.context.isSystem) return undefined; + const withheld = storedMetadataTypesWithheldFrom(op.context); + if (withheld.length === 0) return undefined; + return { [STORED_METADATA_TYPE_COLUMN]: { $nin: withheld } }; +} + +/** The gate itself: compose the filter into the read's WHERE, then go on. */ +export const storedMetadataTypeReadGate: EngineMiddleware = async (ctx, next) => { + const filter = storedMetadataTypeReadFilter(ctx); + if (filter) { + // A read with no AST would have nothing to narrow; fail closed rather than + // serve it unnarrowed. + const ast = (ctx.ast ??= { object: ctx.object } as NonNullable); + ast.where = ast.where ? { $and: [ast.where, filter] } : filter; + } + return next(); +}; + +/** The tables the gate is registered on — the family, by its one definition. */ +export const STORED_METADATA_TYPE_READ_GATE_OBJECTS: readonly string[] = Object.freeze([...STORED_METADATA_BODY_OBJECTS]); diff --git a/packages/qa/dogfood/test/datasource-stored-row-read-admission.dogfood.test.ts b/packages/qa/dogfood/test/datasource-stored-row-read-admission.dogfood.test.ts new file mode 100644 index 00000000000..493e8cd57d0 --- /dev/null +++ b/packages/qa/dogfood/test/datasource-stored-row-read-admission.dogfood.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27884] A datasource definition is served only to datasource +// administrators, at every exit that serves it: the stored row the definition +// lives in admits the same callers the metadata read door +// (`GET /api/v1/meta/datasource`) admits. Pinned at the REST doors of a booted +// stack, with the platform's own security composition: +// +// - the platform administrator reads the row whole; +// - a caller below datasource administration and a plain member are served +// no row, and the metadata door refuses them as before; +// - parity: for each caller, the stored row is served exactly when the +// metadata door serves the definition. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { catalogPermissionSet } from './catalog-permission-set.js'; + +const fixture = defineStack({ + manifest: { + id: 'com.dogfood.datasource-stored-row-read', namespace: 'dsr', version: '0.0.0', type: 'app', + name: 'Datasource stored-row read fixture', description: 'An app with one object, for the data and metadata doors.', + }, + objects: [ + ObjectSchema.create({ + name: 'dsr_note', label: 'Note', pluralLabel: 'Notes', + fields: { name: Field.text({ label: 'Name', required: true }) }, + }), + ], +} as never); + +const DS = 'dsr_pinned_27884'; +const REF = 'sys_secret:dsr_pinned_27884'; +const HOST = 'warehouse.dsr-27884.internal'; +const ORG_ADMIN = 'dsr-org-admin@verify.test'; +const MEMBER = 'dsr-member@verify.test'; +const SYS = { isSystem: true }; + +type Answer = { status: number; json: any; text: string }; + +describe('[#27884] the stored row of a datasource definition admits only datasource administrators', () => { + let stack: VerifyStack; + let ql: any; + let rowId = ''; + const tokens: Record<'admin' | 'orgAdmin' | 'member', string> = { admin: '', orgAdmin: '', member: '' }; + + const call = async (token: string, method: string, path: string, payload?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, payload); + const text = await res.text(); + let json: any = {}; + try { json = JSON.parse(text); } catch { /* not JSON */ } + return { status: res.status, json, text }; + }; + const listRows = (a: Answer): any[] => (Array.isArray(a.json?.records) ? a.json.records : []); + + beforeAll(async () => { + stack = await bootStack(fixture); + ql = await stack.kernel.getServiceAsync('objectql'); + const protocol: any = await stack.kernel.getServiceAsync('protocol'); + await protocol.saveMetaItem({ + type: 'datasource', + name: DS, + item: { + name: DS, label: 'Pinned', driver: 'postgres', + config: { host: HOST, port: 5432, database: 'dsr_db', username: 'dsr_user' }, + external: { credentialsRef: REF }, + }, + packageId: null, + source: 'migrate-stored', + }); + const [row] = await ql.find('sys_metadata', { where: { type: 'datasource', name: DS }, context: SYS }); + rowId = String(row?.id ?? ''); + expect(rowId).not.toBe(''); + + tokens.admin = await stack.signIn(); + tokens.orgAdmin = await stack.signUp(ORG_ADMIN); + tokens.member = await stack.signUp(MEMBER); + const set = 'organization_admin_no_bypass'; + expect(await catalogPermissionSet(ql, set), `${set} is served by the catalog`).toBeTruthy(); + const user = await ql.findOne('sys_user', { where: { email: ORG_ADMIN }, context: SYS }); + await ql.insert('sys_user_permission_set', { user_id: String(user?.id ?? ''), permission_set: set }, { context: SYS }); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('the platform administrator reads the row whole', async () => { + const list = await call(tokens.admin, 'GET', `/data/sys_metadata?type=datasource&name=${DS}&fields=name,type,metadata`); + expect(list.status, list.text).toBe(200); + const [row] = listRows(list); + const served = JSON.parse(String(row?.metadata ?? '{}')); + expect(served.external?.credentialsRef).toBe(REF); + expect(served.config).toMatchObject({ host: HOST, port: 5432, database: 'dsr_db', username: 'dsr_user' }); + const got = await call(tokens.admin, 'GET', `/data/sys_metadata/${encodeURIComponent(rowId)}`); + expect(got.status, got.text).toBe(200); + }); + + for (const who of ['orgAdmin', 'member'] as const) { + it(`${who === 'orgAdmin' ? 'a caller below datasource administration' : 'a plain member'} is served no row`, async () => { + const token = tokens[who]; + const list = await call(token, 'GET', `/data/sys_metadata?type=datasource&fields=name,type,metadata`); + // 403 (the object is not readable at all) or 200 with no datasource row. + expect([200, 403]).toContain(list.status); + expect(listRows(list).filter((r) => r.type === 'datasource' || r.name === DS)).toEqual([]); + const query = await call(token, 'POST', '/data/sys_metadata/query', { where: { name: DS }, fields: ['name', 'metadata'] }); + expect([200, 403]).toContain(query.status); + expect(listRows(query)).toEqual([]); + const got = await call(token, 'GET', `/data/sys_metadata/${encodeURIComponent(rowId)}`); + expect([403, 404]).toContain(got.status); + for (const answer of [list, query, got]) { + expect(answer.text).not.toContain(REF); + expect(answer.text).not.toContain(HOST); + } + const meta = await call(token, 'GET', `/meta/datasource/${DS}`); + expect(meta.status, meta.text).toBe(403); + }); + } + + it('parity: each caller is served the stored row exactly when the metadata door serves the definition', async () => { + for (const who of ['admin', 'orgAdmin', 'member'] as const) { + const meta = await call(tokens[who], 'GET', `/meta/datasource/${DS}`); + const data = await call(tokens[who], 'GET', `/data/sys_metadata/${encodeURIComponent(rowId)}`); + expect({ who, data: data.status === 200 }).toEqual({ who, data: meta.status === 200 }); + } + }); +}); diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 30fe35f0812..238ecd76a23 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -77,6 +77,9 @@ import { relateObjectSchemaMaskPosture, resolveObjectSchemaRuntimeView, type ObjectSchemaMaskPosture, + // [#27884] The type-level read row, shared with the engine's stored-row + // read gate — see `META_TYPE_READ_CAPABILITIES` below. + STORED_METADATA_TYPE_READ_CAPABILITIES, } from '@objectstack/metadata-core'; import { ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS } from '@objectstack/core'; // [#22614] The registry's one spelling of a container's binding, through its @@ -2070,11 +2073,15 @@ export function isPublicAudienceRead( * (every route `registerMetadataEndpoints` mounts) and the runtime * dispatcher's `handleMetadataRequest`. ⛔ A row added here reaches both; a * check added in one transport alone is the split this module exists to close. + * + * [#27884] The table itself is `STORED_METADATA_TYPE_READ_CAPABILITIES` + * (`@objectstack/metadata-core`), and this name is that same frozen object. The + * engine's stored-row read gate (`@objectstack/objectql`) narrows every + * caller-context read of `sys_metadata` / `sys_metadata_history` by the same + * row, so no exit serves a document of a listed type to a caller this gate + * refuses. A row is added THERE, and reaches both exits. */ -export const META_TYPE_READ_CAPABILITIES: Readonly> = Object.freeze({ - datasource: 'manage_platform_settings', - external_catalog: 'manage_platform_settings', -}); +export const META_TYPE_READ_CAPABILITIES: Readonly> = STORED_METADATA_TYPE_READ_CAPABILITIES; /** * [#21087] Why a `/meta` read of a {@link META_TYPE_READ_CAPABILITIES} type is diff --git a/packages/rest/src/meta-type-read-stored-row-parity.test.ts b/packages/rest/src/meta-type-read-stored-row-parity.test.ts new file mode 100644 index 00000000000..a0eb4fa19d3 --- /dev/null +++ b/packages/rest/src/meta-type-read-stored-row-parity.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27884] Parity: the `/meta` type-level read admission and the engine's + * stored-row read gate admit the same callers to a datasource-family document. + * + * The `/meta` gate (`metaTypeReadRefusal`) reads `META_TYPE_READ_CAPABILITIES`; + * the engine gate (`stored-metadata-type-read-gate.ts`, `@objectstack/objectql`) + * composes its predicate from `storedMetadataTypesWithheldFrom` + * (`@objectstack/metadata-core`). Both must be the ONE row, and for every caller + * a type the `/meta` gate refuses must be a type the stored-row exit withholds, + * and the reverse. + */ + +import { describe, it, expect } from 'vitest'; +import { STORED_METADATA_TYPE_READ_CAPABILITIES, storedMetadataTypesWithheldFrom } from '@objectstack/metadata-core'; +import { META_TYPE_READ_CAPABILITIES, metaTypeReadRefusal } from './meta-item-read-gate.js'; + +const CALLERS: Record = { + platformAdmin: { userId: 'u_admin', systemPermissions: ['manage_platform_settings', 'manage_metadata', 'studio.access', 'setup.access'] }, + datasourceAdminOnly: { userId: 'u_ds', systemPermissions: ['manage_platform_settings'] }, + belowDatasourceAdmin: { userId: 'u_org', systemPermissions: ['manage_org_users', 'setup.access', 'setup.write', 'manage_mailboxes'] }, + studioAuthor: { userId: 'u_author', systemPermissions: ['studio.access', 'manage_metadata'] }, + member: { userId: 'u_member', systemPermissions: [] }, + noCapabilitySet: { userId: 'u_bare' }, +}; + +describe('[#27884] one read row for a datasource-family document, at both exits', () => { + it('the /meta row IS the stored-row row — the same frozen object, not a copy', () => { + expect(META_TYPE_READ_CAPABILITIES).toBe(STORED_METADATA_TYPE_READ_CAPABILITIES); + expect(Object.isFrozen(META_TYPE_READ_CAPABILITIES)).toBe(true); + }); + + it.each(Object.keys(CALLERS))('%s: a type the /meta gate refuses is exactly a type the stored-row exit withholds', (name) => { + const caller = CALLERS[name]; + const withheld = storedMetadataTypesWithheldFrom(caller); + for (const type of Object.keys(META_TYPE_READ_CAPABILITIES)) { + const refusedOnMeta = metaTypeReadRefusal('GET', type, caller) !== undefined; + expect({ type, withheld: withheld.includes(type) }).toEqual({ type, withheld: refusedOnMeta }); + } + }); + + it('a holder is withheld nothing; a caller below datasource administration is withheld every gated type', () => { + expect(storedMetadataTypesWithheldFrom(CALLERS.platformAdmin)).toEqual([]); + expect(storedMetadataTypesWithheldFrom(CALLERS.belowDatasourceAdmin).sort()).toEqual(['datasource', 'external_catalog']); + }); +}); diff --git a/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts index 4295cfc885f..c7ab3093ce6 100644 --- a/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts @@ -17,8 +17,8 @@ * row, not a withheld one); * - the served hash equal to the one the door serves for the same row. * - * Two exits are pinned, under BOTH run identities (`runAs: 'system'`, which - * reads elevated, and `runAs: 'user'`, which reads as the triggering user): + * Two exits are pinned, under the system run identity (`runAs: 'system'`, + * which reads elevated): * - the run's declared OUTPUT (the node's `outputVariable`, declared * `isOutput` on the flow), which a flow caller is handed back; * - an ordinary RECORD the same flow writes from what the node read (a @@ -29,6 +29,11 @@ * provider (the process-scoped ephemeral key) and with one registered on the * engine (the provider's keyed digest), the node's hash equals the door's. * + * [#27884] A datasource definition's stored row is served only to holders of + * the capability the `/meta` type gate names for it (`manage_platform_settings`). + * The triggering user here holds none, so under `runAs: 'user'` both branches + * pin the other side of that rule: the node is served no row at all. + * * Composition: the real stack the other `*.integration.test.ts` files in this * package boot: `ObjectKernel`, `ObjectQLPlugin` (which registers the * family's two tables), `driver-sql` on better-sqlite3 `:memory:`, and the @@ -127,7 +132,23 @@ function familyReadFlow(name: string, object: string, runAs: RunAs, branch: Bran }; } -/** The trigger a user-identity run needs: a real acting user. */ +/** + * start → get_record(family) → end: the read alone, for a run whose node is + * served no row (nothing downstream can copy what was not served). + */ +function familyReadOnlyFlow(name: string, object: string, runAs: RunAs, branch: Branch) { + const def = familyReadFlow(name, object, runAs, branch); + return { + ...def, + nodes: def.nodes.filter((n) => n.id !== 'copy'), + edges: [ + { id: 'e1', source: 'start', target: 'read' }, + { id: 'e2', source: 'read', target: 'end' }, + ], + }; +} + +/** The trigger a user-identity run needs: a real acting user, below datasource administration. */ const TRIGGER = { userId: 'usr_flow_reader', tenantId: 'org_1', positions: [] as string[], permissions: [] as string[] }; describe('flow get_record serves the stored-metadata family the way the data door does (#21519)', () => { @@ -206,7 +227,7 @@ describe('flow get_record serves the stored-metadata family the way the data doo } }); - for (const runAs of ['system', 'user'] as const) { + for (const runAs of ['system'] as const) { for (const branch of ['one', 'list'] as const) { it(`runAs:'${runAs}', ${branch === 'one' ? 'findOne' : 'find'} branch: the output and the written record carry the projected body and the door's keyed hash`, async () => { const control = await doorRow('sys_metadata'); @@ -229,6 +250,24 @@ describe('flow get_record serves the stored-metadata family the way the data doo } } + // [#27884] The user-identity run of a caller below datasource administration. + for (const branch of ['one', 'list'] as const) { + it(`runAs:'user' below datasource administration, ${branch === 'one' ? 'findOne' : 'find'} branch: the node is served no datasource row`, async () => { + for (const object of ['sys_metadata', 'sys_metadata_history']) { + const def = familyReadOnlyFlow(`fam_user_${branch}_${object}`, object, 'user', branch); + automation.registerFlow(def.name, def as any); + const res: any = await automation.execute(def.name, { ...TRIGGER }); + expect(res.success, `run failed: ${JSON.stringify(res.error ?? res)}`).toBe(true); + const rec = res.output?.rec; + if (branch === 'one') expect(rec ?? null, `${object}: findOne served a row`).toBeNull(); + else expect(rec ?? [], `${object}: find served a row`).toEqual([]); + const text = JSON.stringify(res); + expect(text.includes(SENTINEL), `${object}: the stored credential reached the run`).toBe(false); + expect(text.includes(DS_URL), `${object}: the row's configuration reached the run`).toBe(false); + } + }); + } + it('the history table is served the same way, its parent hash included', async () => { const control = await doorRow('sys_metadata_history'); const { output, copy } = await runFlow(familyReadFlow('fam_history', 'sys_metadata_history', 'system', 'one')); @@ -259,7 +298,7 @@ describe('flow get_record serves the stored-metadata family the way the data doo ql.setCryptoProvider({ keyedDigest } as any); const control = await doorRow('sys_metadata'); expect(control.checksum, 'control: the door serves the provider digest').toBe(await keyedDigest(STORED_HASH)); - const { output, copy } = await runFlow(familyReadFlow('fam_provider', 'sys_metadata', 'user', 'one')); + const { output, copy } = await runFlow(familyReadFlow('fam_provider', 'sys_metadata', 'system', 'one')); expectServedLikeTheDoor(output, control, 'the run output'); expect(output.checksum).toBe(control.checksum); expect(copy.hash).toBe(control.checksum); diff --git a/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts index b6907448dd8..9495763f411 100644 --- a/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts @@ -30,7 +30,10 @@ * when the resolved list reads the body. * * Controls: the data door refuses the same filters; a scalar-column filter on - * a family table is served projected, as #21519 serves it; and an ordinary + * a family table is served projected to a system run, as #21519 serves it, and + * no row to a user-identity run below datasource administration (#27884: a + * datasource definition's stored row is served only to holders of + * `manage_platform_settings`, which this triggering user is not); and an ordinary * object whose columns share the family's column names is filtered and served * exactly as stored. * @@ -334,7 +337,18 @@ describe("flow get_record refuses a filter that evaluates the stored-metadata fa }); } - for (const runAs of ['system', 'user'] as const) { + it("runAs:'user' below datasource administration: a scalar-column filter on a family read runs and is served no datasource row (#27884)", async () => { + const run = await runWatched(readThenCopyFlow(`scalar_${seq++}`, 'sys_metadata', 'user', 'one', { name: DS_NAME, type: 'datasource' })); + expect(run.res.success, `run failed: ${JSON.stringify(run.res.error)}`).toBe(true); + expect(run.familyReads).toBeGreaterThan(0); + expect(run.copy, 'the node downstream of the read ran').toBeTruthy(); + expect(run.res.output?.rec ?? null, 'the node served a row').toBeNull(); + const text = JSON.stringify(run.res); + expect(text.includes(SENTINEL), 'the stored credential reached the run').toBe(false); + expect(text.includes(DS_URL), "the row's configuration reached the run").toBe(false); + }); + + for (const runAs of ['system'] as const) { it(`runAs:'${runAs}': a scalar-column filter on a family read is served projected, with the door's keyed hash`, async () => { const control: any = await door.findData({ object: 'sys_metadata', query: { where: { name: DS_NAME } } }); expect(control.records).toHaveLength(1);