Skip to content
15 changes: 15 additions & 0 deletions .changeset/by-id-write-row-reread-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-security': patch
---

fix(plugin-security): a by-id write's row re-read propagates a store fault, and judges the platform's own predicates without the caller-predicate guard

Clause-②: yes (widening)
Metadata-protocol: none

Before an update or delete by id, the security middleware re-reads the addressed row under the caller's context with the platform's write predicates for that row (the tenancy wall and the row-level write predicates). Two rules hold for that re-read, and the second for the controlled-by-parent master read a detail write asks as well:

- **A store fault propagates as raised.** A re-read the store cannot answer (an unavailable datasource, a driver failure) is the write's answer, unchanged — for `ERR_DATASOURCE_UNAVAILABLE` the transports answer `503` — and nothing is written. Only a declared refusal (a 4xx) keeps the record-level `403 PERMISSION_DENIED`, one answer whatever the id names. The controlled-by-parent master read keeps its `403` for every failure.
- **The platform's own predicates are not the caller's predicate.** The field-level predicate guard refuses a caller's filter on a field its field rules do not let it read, and judges only the caller's predicate: a predicate subtree the platform composed and marked as its own is not judged. That holds for every such subtree that reaches the guard inside a caller-context read — the by-id write re-read and the controlled-by-parent master read, the analytics read-scope merge handed to an aggregate under the caller's context, and a record-sharing read scope composed before the security middleware. Only platform-composed predicates are exempt; nothing the caller writes is. Under the `group` and `isolated` tenancy postures, a caller whose field rules hide the organization column updates and deletes the records of its organization by id, writes a controlled-by-parent detail under a master of its organization, and aggregates over the records of its organization. A record of another organization and an id that names no record answer that caller exactly as they answer any other caller.

Unchanged: a caller's own filter on a field it cannot read is refused, on a read, an analytics query and a predicate write; a record the caller reads but may not write keeps its `403`; a system write is not the gate's.
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,11 @@ async function writeFilter(cell: any, roleCtx: any): Promise<unknown> {
if (!reRead) {
return threw ? `CRUD_DENY:${threw?.name ?? 'err'}` : 'BYPASS(no-write-filter)';
}
return reRead[1].where.$and.slice(1);
// The re-read is `{ $and: [{ id }, PLATFORM] }`: PLATFORM is one `$and` of
// the write predicates, marked as the platform's own (never the caller's
// predicate), and its members are the matrix this gate locks.
const [, platform] = reRead[1].where.$and;
return platform.$and;
}

/**
Expand Down

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions packages/plugins/plugin-security/src/predicate-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
assertReadableQueryFields,
} from './predicate-guard.js';
import { isPermissionDeniedError } from './errors.js';
import { markFilterSubtreeProvenance } from '@objectstack/spec/data';

const HIDDEN_SALARY = { salary: { readable: false, editable: false } };

Expand Down Expand Up @@ -198,3 +199,41 @@ describe('a cross-field comparand is collected and judged like a condition key',
}
});
});

describe("the guard judges the caller's own predicate, never a subtree the platform composed and marked 'policy'", () => {
const policy = <T extends object>(subtree: T): T => markFilterSubtreeProvenance(subtree, 'policy');
const refusal = (ast: Record<string, unknown>) => {
try {
assertReadableQueryFields(ast, HIDDEN_SALARY, 'employee');
return undefined;
} catch (e) {
return e as { code?: string; statusCode?: number };
}
};

it("a hidden field inside a 'policy' subtree is not collected, nor anything beneath it", () => {
const where = { $and: [{ id: 'e1' }, policy({ $and: [{ salary: { $gte: 1 } }, { $or: [{ salary: 2 }] }] })] };
expect([...collectConditionFields(where)]).toEqual(['id']);
expect(refusal({ where })).toBeUndefined();
});

it("CONTROL the caller's own predicate on the hidden field beside a 'policy' subtree is still refused", () => {
const where = { $and: [{ salary: { $gte: 100000 } }, policy({ salary: { $gte: 1 } })] };
const r = refusal({ where });
expect(isPermissionDeniedError(r)).toBe(true);
expect([r?.code, r?.statusCode]).toEqual(['PERMISSION_DENIED', 403]);
});

it("CONTROL an unmarked subtree, an 'author' subtree, and a copy of a 'policy' subtree are judged as the caller's", () => {
const marked = policy({ salary: { $gte: 1 } });
for (const subtree of [
{ salary: { $gte: 1 } },
markFilterSubtreeProvenance({ salary: { $gte: 1 } }, 'author'),
{ ...marked },
JSON.parse(JSON.stringify(marked)) as Record<string, unknown>,
]) {
const r = refusal({ where: { $and: [{ id: 'e1' }, subtree] } });
expect([r?.code, r?.statusCode]).toEqual(['PERMISSION_DENIED', 403]);
}
});
});
18 changes: 17 additions & 1 deletion packages/plugins/plugin-security/src/predicate-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,20 @@
* Ordering contract: this guard MUST run against the CALLER-supplied AST,
* before RLS filter injection — RLS policies legitimately reference fields
* the caller cannot read (e.g. `owner_id`), and must not be rejected.
*
* Provenance contract: where a predicate the platform composed reaches a read
* INSIDE the caller's own `where` — the security middleware's by-id write
* re-read carries the tenancy wall and the row-level write predicates that
* way — the composing code marks that subtree `'policy'` with the spec's
* filter-subtree provenance mark (`markFilterSubtreeProvenance`, the mark the
* read-scope merge boundaries already stamp on every scope they inject), and
* this guard does not judge a subtree so marked: it is not the caller's
* predicate. The fail direction is closed: a subtree that carries no mark, or
* any other mark, is judged as the caller's — the mark never survives a copy,
* a serialization or the wire, so a request cannot carry one in.
*/

import { FieldReferenceSchema } from '@objectstack/spec/data';
import { FieldReferenceSchema, filterSubtreeProvenanceOf } from '@objectstack/spec/data';
import { PermissionDeniedError } from './errors.js';

interface FieldPermissionLike {
Expand All @@ -43,9 +54,14 @@ const LOGICAL_KEYS = new Set(['$and', '$or', '$not']);
* column of the same row"): the comparison reads the named field's value, so
* row presence discloses it exactly as a filter on the field itself would.
* Both are collected here, into the one set the one field rule judges.
*
* A subtree the platform composed and marked `'policy'` is not collected,
* nor anything beneath it (see the provenance contract above): its fields
* are the platform's predicate, not the caller's.
*/
export function collectConditionFields(condition: unknown, out: Set<string> = new Set()): Set<string> {
if (!condition || typeof condition !== 'object' || Array.isArray(condition)) return out;
if (filterSubtreeProvenanceOf(condition) === 'policy') return out;
for (const [key, value] of Object.entries(condition as Record<string, unknown>)) {
if (LOGICAL_KEYS.has(key)) {
if (Array.isArray(value)) for (const sub of value) collectConditionFields(sub, out);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ import type { II18nService, IMetadataService, IObjectQLEngine, SharingWriteVerdi
import { crossFieldClassRefusalCarriedBy, findCrossFieldClassRefusal } from '@objectstack/formula';
import { declaredHttpStatus } from '@objectstack/types';
import type { SecurityPlugin } from './security-plugin.js';
import { isGuestEnvelope, requiredCapsForOperation } from './security-plugin.js';
import { isGuestEnvelope, readUnlessRefused, requiredCapsForOperation } from './security-plugin.js';
import { assertFlowSystemIdentityWriteAllowed } from './flow-system-identity-write-refusal.js';
import { isUnreadableAddressedRowRefusal } from './delegated-admin-gate.js';
import { isRowWriteProbe, logGateRefusal, type AuthzMiddlewareFn } from './record-row-write-probe.js';
Expand Down Expand Up @@ -1161,24 +1161,32 @@ export function registerAuthzMiddleware(
// may not write it keeps the 403 below; nothing is disclosed to them.
const addressedId = this.addressedByIdWriteId(opCtx, nestedInOperation, referentialFieldClearWrite);
if (writeParts.length > 0) {
let visible: unknown = null;
// A re-read that THREW is not an absent row: a store fault or a
// read-time policy refusal keeps today's denial below (#7505).
let reReadThrew = false;
try {
visible = await this.ql.findOne(opCtx.object, {
where: { $and: [{ id: targetId }, ...writeParts] },
// The write predicates are the PLATFORM's, composed here — never the
// caller's own predicate. They ride into the caller-context re-read
// under one fresh `$and` marked `'policy'` (the filter-subtree
// provenance mark step 3 stamps on every scope it injects), so the
// re-read's caller-predicate guard (step 2.9) judges the caller's
// part, `{ id }`, and not a wall or row-level predicate on a field
// the caller cannot read. The mark goes on a node built here, so a
// frozen or shared filter object cannot leave it unmarked.
const platformWritePredicate = markFilterSubtreeProvenance({ $and: writeParts }, 'policy');
// Three outcomes of the re-read, classified by `readUnlessRefused`
// (#7505): a row or no row; a declared 4xx refusal (the read door
// refuses this caller), which keeps the record-level denial below,
// one answer for every id; anything else is a store fault and
// propagates as raised — an outage is neither a hidden row nor a
// refusal, and answering it as either would relabel it.
const reRead = await readUnlessRefused(() =>
this.ql.findOne(opCtx.object, {
where: { $and: [{ id: targetId }, platformWritePredicate] },
context: opCtx.context,
});
} catch {
// A read denial (e.g. no read permission) is itself a "cannot
// touch this row" signal — fall through to the deny below.
visible = null;
reReadThrew = true;
}
}),
);
const reReadRefused = reRead.refused;
const visible: unknown = reRead.refused ? null : reRead.value;
if (
!visible &&
!reReadThrew &&
!reReadRefused &&
addressedId !== undefined &&
(await this.callerCannotReadAddressedRow(opCtx, addressedId))
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
type EngineUpdateDispatchData,
} from '@objectstack/metadata-core';
import type { ControlledByParentWriteDenialLeg, ControlledByParentWriteOutcome } from '@objectstack/spec/contracts';
import { markFilterSubtreeProvenance } from '@objectstack/spec/data';
import {
PermissionDeniedError,
MasterDetailRelationMissingError,
Expand Down Expand Up @@ -870,9 +871,16 @@ export async function assertMasterRowEditable(
// is what the 403 below says. The ruling names this probe (and the
// sharing half beneath it) as the house fail-closed posture to match,
// not as a site to change.
//
// The master's write predicates are the platform's, composed above — not
// the caller's own predicate — so they ride into this caller-context read
// under one fresh `$and` marked `'policy'`, exactly as step 2.7's by-id
// re-read carries its own: the read's caller-predicate guard (step 2.9)
// judges `{ id }` only, never a wall or row-level predicate on a master
// field the caller cannot read.
try {
visible = await this.ql.findOne(rel.master, {
where: { $and: [{ id: masterId }, ...masterWriteParts] },
where: { $and: [{ id: masterId }, markFilterSubtreeProvenance({ $and: masterWriteParts }, 'policy')] },
context,
});
} catch {
Expand Down
153 changes: 153 additions & 0 deletions packages/rest/src/analytics-platform-scope-predicate-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The field-level predicate guard judges the caller's own predicate, never the
* read scope the platform composes — on the analytics face.
*
* The analytics strategy that aggregates through the engine merges the
* caller's read scope (here the tenancy wall on `organization_id`) into the
* filter it hands to `engine.aggregate` under the caller's context, marked as
* the platform's own. A caller whose field rules hide that column aggregates
* over the records of its organization; its own filter on the column is still
* refused.
*
* Real stack: `ObjectQL` over SQLite, the real `SecurityPlugin` under the
* `group` and `isolated` tenancy postures, the real `AnalyticsServicePlugin`
* with the engine-aggregate strategy.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { PermissionSetSchema } from '@objectstack/spec/security';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { SecurityPlugin } from '@objectstack/plugin-security';
import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics';

const OBJECT = 'rest_an_scope_ledger';
const ORG_A = 'org_a';
const ORG_B = 'org_b';
const SYS = { context: { isSystem: true, userId: 'usr_system' } };

const MEMBER_SET = PermissionSetSchema.parse({
name: 'rest_an_scope_member',
label: 'Member',
objects: { [OBJECT]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
});
/** Field rules alone: the organization column hidden from the holder's reads. */
const ORG_HIDDEN_SET = PermissionSetSchema.parse({
name: 'rest_an_scope_org_hidden',
label: 'Organization hidden',
objects: {},
fields: { [`${OBJECT}.organization_id`]: { readable: false, editable: false } },
});

const caller = (...extra: string[]) => ({
userId: 'usr_member',
positions: [] as string[],
permissions: [MEMBER_SET.name, ...extra],
posture: 'MEMBER',
tenantId: ORG_A,
accessible_org_ids: [ORG_A],
});
const READS_COLUMN = caller();
const COLUMN_HIDDEN = caller(ORG_HIDDEN_SET.name);

const quiet: any = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };

const engines: ObjectQL[] = [];
afterEach(async () => {
vi.restoreAllMocks();
while (engines.length) {
try { await engines.pop()?.destroy(); } catch { /* noop */ }
}
});

async function boot(posture: 'group' | 'isolated'): Promise<{ engine: ObjectQL; service: AnalyticsService }> {
const engine = new ObjectQL({ logger: quiet } as any);
engine.registerDriver(
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any),
true,
);
await engine.init();
engine.registerApp({
id: 'com.objectstack.qa.analytics-platform-scope-predicate-guard',
name: 'Analytics platform scope and the predicate guard',
version: '1.0.0',
type: 'plugin',
scope: 'system',
objects: [{ name: OBJECT, label: 'Ledger', fields: { title: { name: 'title', type: 'text' } } }],
} as never);
await engine.syncSchemas();
engines.push(engine);

const services: Record<string, unknown> = {
manifest: { register: vi.fn() },
objectql: engine,
data: engine,
metadata: {
get: async (type: string, name: string) =>
(type === 'permission' ? [MEMBER_SET, ORG_HIDDEN_SET].find((p) => p.name === name)
: type === 'position' || type === 'capability' ? undefined
: engine.getSchema(name)) ?? null,
list: async (type: string) => (type === 'permission' ? [MEMBER_SET, ORG_HIDDEN_SET] : []),
},
'org-scoping': { name: 'com.objectstack.org-scoping' },
tenancy: { posture },
};
const ctx: any = {
logger: quiet,
hook: () => {},
registerService: (name: string, svc: unknown) => { services[name] = svc; },
replaceService: (name: string, svc: unknown) => { services[name] = svc; },
getService: (name: string) => {
if (!(name in services)) throw new Error(`service not registered: ${name}`);
return services[name];
},
};
const security = new SecurityPlugin({ fallbackPermissionSet: MEMBER_SET.name });
await security.init(ctx);
await security.start(ctx);
vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined);

await engine.insert(OBJECT, [
{ id: 'a1', title: 't', organization_id: ORG_A },
{ id: 'a2', title: 't', organization_id: ORG_A },
], { context: { ...SYS.context, tenantId: ORG_A } } as never);
await engine.insert(OBJECT, [{ id: 'b1', title: 't', organization_id: ORG_B }], { context: { ...SYS.context, tenantId: ORG_B } } as never);

await new AnalyticsServicePlugin({
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
}).init(ctx);
return { engine, service: services.analytics as AnalyticsService };
}

type Thrown = { code?: string; status?: number; statusCode?: number; message?: string } | null;
const answerOf = (run: () => Promise<unknown>) =>
run().then(
(r) => ({ answered: r as { rows?: Array<Record<string, unknown>> } }),
(e: Thrown) => ({ refused: { code: e?.code, status: e?.status ?? e?.statusCode, message: String(e?.message) } }),
);
const countOf = (r: { answered?: { rows?: Array<Record<string, unknown>> } }) =>
Number(Object.values(r.answered?.rows?.[0] ?? {})[0]);

describe("the predicate guard judges the caller's own predicate, never the read scope the analytics merge composes", () => {
for (const posture of ['group', 'isolated'] as const) {
it(`posture '${posture}': a caller whose field rules hide the scoped column aggregates over the platform scope; its own filter on the column is refused`, async () => {
const { service } = await boot(posture);
const query = { cube: OBJECT, measures: ['count'] };

const reads = await answerOf(() => service.query(query as never, structuredClone(READS_COLUMN) as never));
expect(reads, JSON.stringify(reads)).toHaveProperty('answered');
expect(countOf(reads as never), 'the caller who reads the column counts its organization').toBe(2);

const hidden = await answerOf(() => service.query(query as never, structuredClone(COLUMN_HIDDEN) as never));
expect(hidden, JSON.stringify(hidden)).toHaveProperty('answered');
expect(countOf(hidden as never), 'the caller who cannot read the column counts the same records').toBe(2);

const own = await answerOf(() => service.query(
{ ...query, where: { organization_id: ORG_A } } as never, structuredClone(COLUMN_HIDDEN) as never));
expect(own, JSON.stringify(own)).toHaveProperty('refused');
expect([(own as any).refused.code, (own as any).refused.status]).toEqual(['PERMISSION_DENIED', 403]);
});
}
});
Loading