Skip to content

Commit 7b52698

Browse files
committed
fix(metadata-protocol): refuse filter and sort on the stored metadata body column, and reconcile the shared seam
Builds on the merged #21086 seam. Two parts: - F3 (maintainer ruling A): the generic data door refuses a filter or sort on the stored body column of sys_metadata / sys_metadata_history, the sibling of its groupBy refusal — a predicate on the body evaluates it row by row (a withheld credential is otherwise recoverable by probing) and a sort orders by the same stored bytes, so neither is evaluated. Same family, shape and code (INVALID_FIELD / 400). Aggregation per-measure filters are covered too. - Seam reconciliation: the family's object set, its predicate, the column names and the body redactor now have ONE definition in @objectstack/spec/kernel; metadata-protocol's data-door wrappers (projection, dropType, the grouping and filter/sort refusals) consume it instead of a private copy, so the audit, analytics and realtime exits cannot drift from the data door about what a credential is. Adds the family enumeration pin and the per-surface tests (audit writer and migration, analytics refusal, realtime event, data-door filter/sort). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
1 parent 2f00601 commit 7b52698

9 files changed

Lines changed: 747 additions & 34 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/service-analytics': minor
4+
'@objectstack/plugin-audit': minor
5+
'@objectstack/objectql': minor
6+
---
7+
8+
fix(security)!: stored metadata bodies are projected or refused at the audit, analytics, realtime and data-door filter/sort exits too
9+
10+
Clause-②: no (narrowing)
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) further read/copy/evaluate exits for a stored metadata body (sys_metadata / sys_metadata_history), each routed through the one shared redactor or refused: the audit/activity write-time copy is projected, a data-door filter or sort on the body column is refused (the sibling of the already-registered-as-not-required groupBy refusal), an analytics query member on the body column is refused, and a data.record.* realtime event body is projected. No authorable key, spelling, export or stored shape moves, and no stored row is read differently by any metadata consumer; the published surfaces gain and lose nothing. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a filter/sort target, an analytics member or an event body (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
13+
14+
**BREAKING**: this narrows what three doors accept or serve for the two stored-metadata tables — the generic data door refuses a filter or sort on the body column, the analytics door refuses it as a dimension / measure / filter / sort member, and the realtime event and the audit/activity copy now carry the body as its type's read projection instead of the stored bytes. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
15+
16+
**What changes.**
17+
18+
- **Audit / activity copy (`@objectstack/plugin-audit`).** The audit writer copies a `sys_metadata` / `sys_metadata_history` row into `sys_audit_log.new_value` / `old_value` and `sys_activity.metadata`. That copy now projects the body through the shared redactor, so stored credential material is withheld from the second store too. A new `os migrate audit-metadata-bodies` command rewrites the copies already at rest (dry run by default, `--apply` to write, idempotent).
19+
- **Analytics (`@objectstack/service-analytics`).** A query naming the stored body column of these objects as a dimension, measure, filter or sort is refused with `400 INVALID_FIELD`, before any strategy runs — the posture analytics already takes for a member it will not evaluate.
20+
- **Realtime (`@objectstack/objectql`).** A `data.record.*` event projects its `after` / `changes` body through the same redactor, so a subscriber to these objects' events receives no stored credential.
21+
- **Data door filter / sort (`@objectstack/metadata-protocol`).** A filter or sort on the body column is refused with `400 INVALID_FIELD`, the same family and shape as the existing groupBy refusal.
22+
23+
**What stays answerable.** Every scalar column of these objects — `type`, `name`, `scope`, `state`, timestamps — is still grouped, filtered, sorted, counted and served; only the body column is affected. Every other object is unchanged.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
feat(spec,cli): shared seam for projecting stored metadata bodies, and the audit rewrite command
7+
8+
Clause-②: no
9+
10+
`@objectstack/spec/kernel` gains the family-wide primitives for the
11+
stored-metadata-body security invariant, beside the per-type redactor registry
12+
they build on: `STORED_METADATA_BODY_OBJECTS` / `isStoredMetadataBodyObject`,
13+
the `STORED_METADATA_BODY_COLUMN` / `STORED_METADATA_TYPE_COLUMN` names, and
14+
`redactStoredMetadataBody` / `redactStoredMetadataRow` / `redactStoredMetadataRows`.
15+
These project a stored row's body through the one `getMetadataTypeRedactor`
16+
definition, so every surface that serves, copies or evaluates such a body shares
17+
one rule rather than a copy per package. Additive — no existing export changes.
18+
19+
`@objectstack/cli` gains `os migrate audit-metadata-bodies`, the one-off rewrite
20+
of at-rest metadata-body copies in `sys_audit_log` / `sys_activity` (dry run by
21+
default, `--apply` to write, idempotent).

‎packages/metadata-protocol/src/metadata-redaction.ts‎

Lines changed: 81 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,23 @@
6868

6969
import { getMetadataTypeRedactor } from '@objectstack/spec/kernel';
7070
import type { MetadataTypeRedactor } from '@objectstack/spec/kernel';
71+
// [#21120] The family-wide stored-metadata-body primitives — the object set,
72+
// the object predicate, the column names and the body redactor — live in
73+
// `@objectstack/spec/kernel`, reachable by every surface in the family
74+
// (service-analytics, plugin-audit, the objectql engine) that does not depend
75+
// on this package. The data-door wrappers below (`storedMetadataBodyProjection`,
76+
// `redactStoredMetadataRow`'s `dropType`, `storedMetadataBodyGroupingRefusal`)
77+
// are this package's own, built ON that one definition — never a second one.
78+
import {
79+
isStoredMetadataBodyObject,
80+
redactStoredMetadataBody,
81+
STORED_METADATA_BODY_COLUMN,
82+
STORED_METADATA_TYPE_COLUMN,
83+
} from '@objectstack/spec/kernel';
7184
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
7285

86+
export { isStoredMetadataBodyObject };
87+
7388
/**
7489
* Resolve the redactor for a request-shaped type name.
7590
*
@@ -589,19 +604,14 @@ function planCarryForward<T>(type: string, incoming: T, stored: unknown): { out:
589604
* no write verb; `sys_metadata_history` is append-only), so a redacted body
590605
* read here can never be PUT back — no carry-forward inverse is owed, which is
591606
* what makes a pure read projection a complete answer on this door.
607+
*
608+
* [#21120] The object set, the `isStoredMetadataBodyObject` predicate and the
609+
* two column names are the family-wide definition in `@objectstack/spec/kernel`,
610+
* imported above and re-exported — one set, consumed by every surface, so the
611+
* audit, analytics and realtime exits cannot drift from this one.
592612
*/
593-
const STORED_METADATA_BODY_OBJECTS: ReadonlySet<string> = new Set(['sys_metadata', 'sys_metadata_history']);
594-
595-
/** The column holding the serialized body, on every {@link STORED_METADATA_BODY_OBJECTS} member. */
596-
const STORED_BODY_COLUMN = 'metadata';
597-
598-
/** The column naming the body's metadata type — what selects its redactor. */
599-
const STORED_TYPE_COLUMN = 'type';
600-
601-
/** Whether `object`'s rows carry a stored metadata body the generic data door must project. */
602-
export function isStoredMetadataBodyObject(object: string): boolean {
603-
return STORED_METADATA_BODY_OBJECTS.has(object);
604-
}
613+
const STORED_BODY_COLUMN = STORED_METADATA_BODY_COLUMN;
614+
const STORED_TYPE_COLUMN = STORED_METADATA_TYPE_COLUMN;
605615

606616
/**
607617
* The projection to hand the engine for a read of `object`, given the caller's
@@ -655,31 +665,20 @@ export function redactStoredMetadataRow<T>(object: string, row: T, opts?: { drop
655665
const { [STORED_TYPE_COLUMN]: _type, ...rest } = record;
656666
return rest;
657667
};
658-
const withheld = (): T => {
668+
669+
// [#21120] The body decision is the ONE shared primitive — same object set,
670+
// same per-type redactor, same fail-closed rules — so this door cannot
671+
// disagree with the audit / analytics / realtime exits about what a
672+
// credential is. This function adds only the door-local wrinkles on top: the
673+
// `dropType` strip of the type column `storedMetadataBodyProjection` asked
674+
// for, and omitting the body on a fail-closed outcome.
675+
const outcome = redactStoredMetadataBody(row[STORED_TYPE_COLUMN], row[STORED_BODY_COLUMN]);
676+
if (!outcome.ok) {
659677
const { [STORED_BODY_COLUMN]: _body, ...rest } = row;
660678
return strip(rest) as T;
661-
};
662-
663-
const body = row[STORED_BODY_COLUMN];
664-
if (body === undefined || body === null) return (dropType ? strip(row) : row) as T;
665-
const type = row[STORED_TYPE_COLUMN];
666-
if (typeof type !== 'string' || type === '') return withheld();
667-
if (!hasMetadataRedactor(type)) return (dropType ? strip(row) : row) as T;
668-
669-
let parsed: unknown = body;
670-
if (typeof body === 'string') {
671-
try {
672-
parsed = JSON.parse(body);
673-
} catch {
674-
return withheld();
675-
}
676679
}
677-
const served = redactMetadataItem(type, parsed);
678-
if (served === parsed) return (dropType ? strip(row) : row) as T;
679-
return strip({
680-
...row,
681-
[STORED_BODY_COLUMN]: typeof body === 'string' ? JSON.stringify(served) : served,
682-
}) as T;
680+
if (outcome.body === row[STORED_BODY_COLUMN]) return (dropType ? strip(row) : row) as T;
681+
return strip({ ...row, [STORED_BODY_COLUMN]: outcome.body }) as T;
683682
}
684683

685684
/** {@link redactStoredMetadataRow} over the rows of one read. Non-array input passes through. */
@@ -727,3 +726,51 @@ export function storedMetadataBodyGroupingRefusal(object: string, groupBy: unkno
727726
}
728727
return undefined;
729728
}
729+
730+
/**
731+
* [#21120] The data door's FILTER / SORT refusal on the stored body column —
732+
* maintainer ruling A, the further accept-set narrowing the grouping refusal
733+
* (#21086) began.
734+
*
735+
* A filter on the body column EVALUATES the stored body row by row: a credential
736+
* withheld from every served answer is still recoverable by prefix probing
737+
* (`?filter={"metadata":{"$contains":"<guess>"}}` returns the row only when the
738+
* guess is a prefix — a predicate oracle). A sort on it orders by the same
739+
* stored bytes. Neither serves the body, so projecting it is no answer; the only
740+
* answer is to refuse, the same posture the engine's own masked-field guard and
741+
* the grouping refusal above take. Same family, shape and code: `INVALID_FIELD`
742+
* / 400, naming the field, the object and the offending `param`.
743+
*
744+
* `filterFields` is the set of head field names the caller's `where` names
745+
* (`collectFilterFieldKeys`), and `sortFields` the fields its `orderBy` names.
746+
* Filter is judged before sort — a query that does both reads "the filter was
747+
* not run" first. `undefined` when neither names the body column.
748+
*/
749+
export function storedMetadataBodyPredicateRefusal(
750+
object: string,
751+
opts: { filterFields?: readonly unknown[]; sortFields?: readonly unknown[] },
752+
): Error | undefined {
753+
if (!isStoredMetadataBodyObject(object)) return undefined;
754+
const namesBody = (fields: readonly unknown[] | undefined): boolean =>
755+
Array.isArray(fields) && fields.some((f) => f === STORED_BODY_COLUMN);
756+
const make = (param: 'filter' | 'sort', verb: string): Error => {
757+
const err: any = new Error(
758+
`Cannot ${verb} '${object}' by '${STORED_BODY_COLUMN}' (${param}): the query was not run. The `
759+
+ `${STORED_BODY_COLUMN} column holds a stored metadata body, served only as its type's read `
760+
+ `projection with stored credential material withheld. ${param === 'filter'
761+
? 'A filter on it evaluates the stored body row by row, which rebuilds a withheld credential by probing'
762+
: 'A sort on it orders by the same stored bytes'}, so it is refused rather than evaluated. `
763+
+ `Filter or sort by '${STORED_TYPE_COLUMN}', 'name' or another scalar column instead.`,
764+
);
765+
err.code = 'INVALID_FIELD';
766+
err.status = 400;
767+
err.field = STORED_BODY_COLUMN;
768+
err.fields = [STORED_BODY_COLUMN];
769+
err.object = object;
770+
err.param = param;
771+
return err;
772+
};
773+
if (namesBody(opts.filterFields)) return make('filter', 'filter');
774+
if (namesBody(opts.sortFields)) return make('sort', 'sort');
775+
return undefined;
776+
}

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ import {
184184
redactStoredMetadataRow,
185185
redactStoredMetadataRows,
186186
storedMetadataBodyGroupingRefusal,
187+
storedMetadataBodyPredicateRefusal,
187188
storedMetadataBodyProjection,
188189
} from './metadata-redaction.js';
189190
import type {
@@ -11646,6 +11647,25 @@ export class ObjectStackProtocolImplementation implements
1164611647
// unknown name keeps its own answer.
1164711648
const bodyGroupingRefusal = storedMetadataBodyGroupingRefusal(request.object, options.groupBy);
1164811649
if (bodyGroupingRefusal) throw bodyGroupingRefusal;
11650+
// [#21120] …and the FILTER / SORT half of the same family (maintainer
11651+
// ruling A): a predicate or an order key on the stored body column
11652+
// evaluates the body — a filter oracle that rebuilds a withheld
11653+
// credential by probing, or an order over the same bytes — so it is
11654+
// refused here, in the same shape as the grouping refusal, before the
11655+
// engine is asked. Field keys are collected the same way
11656+
// `assertFilterFieldsExist` reads them, so a nested-relation filter whose
11657+
// HEAD segment is the body column is caught too.
11658+
const aggregationFilterFields = Array.isArray(options.aggregations)
11659+
? (options.aggregations as ReadonlyArray<{ filter?: unknown }>).flatMap((a) =>
11660+
collectFilterFieldKeys(a?.filter))
11661+
: [];
11662+
const bodyPredicateRefusal = storedMetadataBodyPredicateRefusal(request.object, {
11663+
filterFields: [...collectFilterFieldKeys(options.where), ...aggregationFilterFields],
11664+
sortFields: Array.isArray(options.orderBy)
11665+
? (options.orderBy as ReadonlyArray<{ field?: unknown }>).map((e) => e?.field)
11666+
: [],
11667+
});
11668+
if (bodyPredicateRefusal) throw bodyPredicateRefusal;
1164911669

1165011670
// Route to engine.aggregate() when the query has GROUP BY / aggregations.
1165111671
// engine.find() does not do in-memory aggregation fallback, so without

0 commit comments

Comments
 (0)