Skip to content

Commit fb7d75f

Browse files
os-trumpclaude
andauthored
fix(plugin-security): tell a read that did not answer from one that answered nothing, per site (#15840) (#17156)
* fix(plugin-security): tell a read that did not answer from one that answered nothing, per site (#15840) `reconcileOrgAdminGrant`'s `sys_member` read swallowed a fault into `[]`, and `[]` is what that function reads as "this user is not an admin of this organization" — the input to a DELETE. A transient read fault therefore revoked a sitting admin's standing grant and the store kept it withdrawn after the fault cleared; only a `debug` line separated that run from a healthy one. That read now reports at `error` and returns `{ action: 'skipped', reason: 'membership_unreadable' }`, performing no write at all for the pair. `normalizeManagedByVocab` swallowed a catalog read fault into `[]` too, so an unreadable catalog and an already-canonical one were byte-identical on both channels while the row that needed healing stayed legacy. It now reports at `error` and refuses the pass rather than attesting counts it could not read. The refusal aborts at the first un-answered read — one line per refused boot, not the four the report-and-continue shape measured — and reaches the `kernel:ready` catch that was already declared for it. Per-site, not a sweep (#15840 ruling, decision batch #105 item 5, option A). A genuine EMPTY read keeps today's behaviour exactly at both seams, pinned by positive controls. `claim-seed-ownership.ts` is untouched — zero diff: its fault already propagates to a per-predicate handler that reports at `warn` and names the consequence, so the card's third row is falsified. The plugin's other reads keep their best-effort contract, where an unanswered read costs a grant that is not created rather than one that is destroyed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * fix(plugin-security): match the platform Logger contract's error() arity at both new report sites (#15840) `Logger.error` (packages/spec/src/contracts/logger.ts) takes the `Error` in its OWN second argument — `(message, error?, meta?)` — unlike `info`/`warn`/`debug`, which take `(message, meta?)`. Both new optional `error?` members were declared with the sibling two-parameter shape, which made the real `ctx.logger` unassignable: measured as three TS2322s in `security-plugin.ts`, at :3818, :3911 and :3937. Fixed at the declaration rather than by widening the consumer: the producer's spelling is the contract. Both call sites now hand the caught `Error` to the argument built for it, so a platform logger receives a real Error object rather than a message flattened into meta. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * docs(permissions): re-tally the isSystem declaration count the new read seam adds (#15840) `readRows`'s `context: { isSystem: true; tenantId?: string }` parameter is a 23rd `isSystem` declaration site, so `check:system-context-census` went red on its `[declared-count]` clause (table says 22, census says 23). Mechanical repair, applied by the gate's own `pnpm gen:system-context-census --fix`; one number in the decomposition table, no prose and no anchors touched. Attribution was MEASURED, not assumed — a first reading of the census JSON suggested the site was not mine. Reverting only this branch's three source files on top of the same merged tree gives declaration 22 and gate exit 0; restoring them gives 23 and exit 1. It is mine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * fix(plugin-security): guarantee MaybeLogger's `warn` channel now that `error` is optional (#9754) `check:optional-error-sink` went red the moment this branch added an `error?` member: that put `MaybeLogger` into the gate's population for the first time, and it declared every channel optional. An optional `error` with no declared alternative is a contract that permits silence — a value of this type could carry no channel at all, and the durability report this module owes (a standing capability withdrawn, or a revoke that did not land) would have nowhere to go. The gate's own remedy, taken exactly: `warn` loses its `?`. ⛔ Not by making `error` REQUIRED (#9754 option C, falsified — hosts inject reduced sinks), and ⛔ not by requiring `info`, which is the level AGENTS.md → "Degradation log levels" calls the reassuring half-truth. Narrowing the type forced NO call-site change: every existing caller already supplied `warn`, measured by tsc over both layers (typecheck exit 0, and check:test-typecheck 0 files / 0 errors), plus 105 files / 1977 tests green. The sibling `normalize-managed-by.ts` sink was checked rather than assumed: its `info`/`warn` were already non-optional, so it sits in the gate's compliant set and is not named. Census moves 33 -> 34 sinks with a guaranteed `warn`, and "permit silence" 2 -> 1, the remaining one being the pre-existing baseline entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6e9bee6 commit fb7d75f

5 files changed

Lines changed: 511 additions & 56 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
Tell a read that DID NOT ANSWER apart from a read that answered NOTHING at two boot-reconciler seams, so a transient storage fault can no longer withdraw a standing org-admin grant or report an unreadable catalog as an already-canonical one (#15840).
6+
7+
`reconcileOrgAdminGrant`'s `sys_member` read swallowed a fault into `[]`, and `[]` is what that function reads as "this user is not an admin of this organization" — the input to a DELETE. One transient read fault therefore revoked a sitting admin's standing grant, and the store kept it withdrawn after the fault cleared; only a `debug` line separated that run from a healthy one. That read now reports at `error` and returns `{ action: 'skipped', reason: 'membership_unreadable' }`, performing no write at all for the pair: nothing is granted, so nothing widens, and nothing standing is destroyed. The next `sys_member` write and the `kernel:ready` backfill ask again.
8+
9+
`normalizeManagedByVocab` swallowed a catalog read fault into `[]` too, so an unreadable catalog and an already-canonical one were byte-identical on both channels — the same `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while the row that needed healing stayed legacy. A read that does not answer now reports at `error` and refuses the pass instead of attesting counts it could not read. The refusal aborts at the first un-answered read, so it is one line per refused boot rather than the four the report-and-continue shape measured. Its only production consumer already declared the handling: the `kernel:ready` bootstrap catches it, reports it at `warn` as non-fatal, and boot proceeds.
10+
11+
⭐ Per-site, not a sweep. A genuine EMPTY read keeps today's behaviour EXACTLY at both seams — a demotion with no membership row still revokes, a membership still grants, an already-canonical catalog still answers `{ positions: 0, permissionSets: 0 }` in silence. `claim-seed-ownership.ts` is untouched: its fault already propagates to a per-predicate handler that reports at `warn` and names the consequence, which is the right disposition already. The plugin's other reads keep their existing best-effort contract, where an unanswered read costs a grant that is not created rather than one that is destroyed.
12+
13+
No exported symbol, published payload key or spec path changes: `action: 'skipped'` is already in the returned union, `reason` is already free text, and the two logger option types gain an optional `error` method a caller may omit. Healthy-path behaviour is byte-identical; only the fault path moves.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ still holds equal to the census on every pull request:
350350
| — in tests | 1013 ||
351351
| — in non-test sources | 798 ||
352352
| Appearances of the bare identifier `isSystem` in non-test sources | 813 ||
353-
| — parsed as a declaration | 22 ||
353+
| — parsed as a declaration | 23 ||
354354
| — parsed as an object-literal / type key (producers and option objects) | 310 ||
355355
| — parsed as a property **read** | 114 ||
356356
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 ||

packages/plugins/plugin-security/src/auto-org-admin-grant.ts

Lines changed: 111 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,32 @@ function supersededOrgAdminSetName(posture: TenancyPosture, suppressUnbounded =
9999

100100
interface MaybeLogger {
101101
info?: (message: string, meta?: Record<string, any>) => void;
102-
warn?: (message: string, meta?: Record<string, any>) => void;
102+
/**
103+
* [#9754] NON-optional, and it is the `error?` member below that makes it so.
104+
* An optional `error` with no declared alternative is a contract that permits
105+
* silence: a value of this type could carry no channel at all, and the
106+
* durability report this module owes — a standing capability withdrawn, or a
107+
* revoke that did not land — would have nowhere to go. `warn` is the level a
108+
* durability degradation degrades TO and no further (AGENTS.md, "Degradation
109+
* log levels"), so it is the one that must exist in every value of the type.
110+
* ⛔ Not solved by requiring `error` instead (hosts legitimately inject
111+
* reduced sinks), and ⛔ not by requiring `info`: a lost write reported at
112+
* `info` reads as normal operation.
113+
*/
114+
warn: (message: string, meta?: Record<string, any>) => void;
103115
debug?: (message: string, meta?: Record<string, any>) => void;
116+
/**
117+
* [#15840] The level the ruling names for the one read whose un-answered
118+
* value would otherwise DESTROY standing state. Optional like its siblings:
119+
* this is an input the caller supplies, not a channel this module publishes.
120+
*
121+
* ⚠️ Three parameters, not two: the platform `Logger` contract
122+
* (`packages/spec/src/contracts/logger.ts`) takes the `Error` in its OWN
123+
* second argument at this level and only this level. Declaring the sibling
124+
* `(message, meta)` shape here would make the real `ctx.logger` unassignable
125+
* — measured, as three TS2322s in `security-plugin.ts`.
126+
*/
127+
error?: (message: string, error?: Error, meta?: Record<string, any>) => void;
104128
}
105129

106130
function genId(prefix: string): string {
@@ -147,25 +171,66 @@ async function tryFind(
147171
*/
148172
context: { isSystem: true; tenantId?: string } = SYSTEM_CTX,
149173
): Promise<any[]> {
150-
try {
151-
const rows = await ql.find(object, { where, limit }, { context });
152-
// Bare array, driven — see `engine-find-bare-array.pin.test.ts`, which boots
153-
// a real engine over a real `SqlDriver` and pins this seam. The `{ records }`
154-
// limb removed from here was dead code that read as a contract.
155-
//
156-
// The `[]` arm is left exactly as it was: this function's whole contract is
157-
// `Promise<any[]>` best-effort, and turning it into a gap is a different
158-
// change with a different blast radius than removing an unreachable limb.
159-
return Array.isArray(rows) ? rows : [];
160-
} catch (e) {
174+
const answer = await readRows(ql, object, where, limit, context);
175+
if (answer.answered) return answer.rows;
176+
if (answer.why === 'threw') {
161177
// Reads legitimately fail before the tables exist (boot ordering), so this
162178
// is debug rather than warn — but it is no longer nothing (#4640).
163179
logger?.debug?.('[security] org-admin reconcile read failed — treated as no rows', {
164180
object,
165-
error: (e as Error)?.message,
181+
error: (answer.error as Error)?.message,
166182
});
167-
return [];
168183
}
184+
return [];
185+
}
186+
187+
/**
188+
* [#15840] What `tryFind` above throws away: WHETHER THE READ ANSWERED.
189+
*
190+
* `tryFind` maps both "the store answered nothing" and "the store did not
191+
* answer" onto the same `[]`. For most of this module's reads that is a
192+
* defensible best-effort contract — a missing answer means a grant is not
193+
* created, and the next boot sweep asks again. For exactly one read it is not:
194+
* the `sys_member` read that decides {@link reconcileOrgAdminGrant}'s
195+
* `shouldGrant`. There, `[]` means "this user is not an admin of this org",
196+
* which is the input to a DELETE — so an un-answered read withdraws a standing
197+
* capability, and the store keeps it withdrawn after the fault clears.
198+
*
199+
* This is the read-seam invention rule in AGENTS.md, and #15840's ruling
200+
* (decision batch #105 item 5, option A) is per-site, ⛔ NOT a uniform sweep:
201+
* only the caller that turns the value into a revoke asks this question. Every
202+
* other caller keeps going through `tryFind` and behaves exactly as it did.
203+
*
204+
* A non-array answer is reported as "did not answer" rather than as an empty
205+
* page for the same reason: an engine that handed back `{ records: [...] }`
206+
* would be carrying the memberships, and reading that envelope as "not a
207+
* member" is the same wrongful revoke by another route. It is unreached on the
208+
* shipped engine (#15598 drove every seam), and it stays unreachable-by-value
209+
* rather than being silently re-invented here.
210+
*/
211+
type ReadAnswer =
212+
| { answered: true; rows: any[] }
213+
| { answered: false; why: 'threw'; error: unknown }
214+
| { answered: false; why: 'not_an_array'; error?: undefined };
215+
216+
async function readRows(
217+
ql: any,
218+
object: string,
219+
where: any,
220+
limit: number,
221+
context: { isSystem: true; tenantId?: string },
222+
): Promise<ReadAnswer> {
223+
let rows: any;
224+
try {
225+
rows = await ql.find(object, { where, limit }, { context });
226+
} catch (e) {
227+
return { answered: false, why: 'threw', error: e };
228+
}
229+
// Bare array, driven — see `engine-find-bare-array.pin.test.ts`, which boots
230+
// a real engine over a real `SqlDriver` and pins this seam. The `{ records }`
231+
// limb removed from here was dead code that read as a contract.
232+
if (Array.isArray(rows)) return { answered: true, rows };
233+
return { answered: false, why: 'not_an_array' };
169234
}
170235

171236
async function tryInsert(ql: any, object: string, data: any, logger?: MaybeLogger): Promise<any | null> {
@@ -521,6 +586,11 @@ async function resolvePermissionSetIdsForName(
521586
* removal symmetrically).
522587
*
523588
* Returns a structured report for observability. Never throws.
589+
*
590+
* [#15840] One `skipped` reason is load-bearing rather than diagnostic:
591+
* `membership_unreadable` means the `sys_member` read did not answer, so this
592+
* call declined to decide at all. It is NOT `noop` and NOT `revoked` — the pair
593+
* is left exactly as it was found, and the caller's next round asks again.
524594
*/
525595
export async function reconcileOrgAdminGrant(
526596
ql: any,
@@ -599,13 +669,38 @@ export async function reconcileOrgAdminGrant(
599669
// in this org. Better-auth allows multiple membership rows per
600670
// pair under some edge cases (legacy data) — any qualifying row
601671
// is enough.
602-
const memberships = await tryFind(
672+
//
673+
// [#15840] This is the read the ruling names, and it is the only read in this
674+
// module asked through {@link readRows} instead of `tryFind`. `[]` here does
675+
// not mean "nothing to do": it means `shouldGrant === false`, which is the
676+
// input to the revoke branch below. So an un-answered read must NOT be spelled
677+
// `[]` — that is the difference between "this user is not an admin" and "the
678+
// store would not tell me", and the first of those DELETES a standing grant.
679+
//
680+
// ⛔ The skip lands HERE, before the superseded-revoke leg and before either
681+
// branch: the ruling's disposition is that a read fault "skips that user for
682+
// the round" and "never enters the revoke branch". A round that could not read
683+
// performs no write at all — nothing is granted, so nothing widens, and
684+
// nothing is revoked, so nothing standing is destroyed by a transient fault.
685+
// The next `sys_member` write and the `kernel:ready` backfill ask again.
686+
const membershipRead = await readRows(
603687
ql,
604688
'sys_member',
605689
{ user_id: userId, organization_id: orgId },
606690
10,
607-
logger,
691+
SYSTEM_CTX,
608692
);
693+
if (!membershipRead.answered) {
694+
logger?.error?.(
695+
'[security] org-admin reconcile SKIPPED — the sys_member read did not answer, so this ' +
696+
'round cannot tell "not a member" from "could not ask"; NOTHING was granted or revoked ' +
697+
'for this pair, and any standing grant is left exactly as it was',
698+
membershipRead.error instanceof Error ? membershipRead.error : undefined,
699+
{ object: 'sys_member', userId, orgId, why: membershipRead.why },
700+
);
701+
return { action: 'skipped', reason: 'membership_unreadable' };
702+
}
703+
const memberships = membershipRead.rows;
609704
// The row that QUALIFIES is also the row the grant is provenance-linked to
610705
// (#4586) — "this capability exists because of that membership".
611706
const qualifyingMembership = memberships.find((m: any) => isAdminRole(m?.role));

packages/plugins/plugin-security/src/normalize-managed-by.ts

Lines changed: 96 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323
* renaming a stored value without updating that map silently disarms the gate
2424
* (#2926 ①). Keep the two in lockstep whenever this vocabulary changes.
2525
* Idempotent: canonical rows are skipped, so a re-run is a no-op.
26-
* Best-effort and non-fatal, like the sibling boot reconcilers.
26+
* Non-fatal to boot, like the sibling boot reconcilers — but [#15840] no longer
27+
* best-effort about its own reads: a catalog read that does not answer refuses
28+
* the pass instead of reporting the same counts an already-canonical catalog
29+
* reports. The `kernel:ready` caller catches the refusal and carries on.
2730
*
2831
* Runs on `kernel:ready` after the seeders, as `isSystem` (the field is
2932
* `readonly`, so only a system write may set it).
@@ -45,20 +48,76 @@ interface NormalizeOptions {
4548
logger?: {
4649
info: (message: string, meta?: Record<string, any>) => void;
4750
warn: (message: string, meta?: Record<string, any>) => void;
51+
/**
52+
* [#15840] The level the ruling names for a refused pass. Optional, unlike
53+
* its two siblings, so every caller that compiles today still compiles: it
54+
* is an input this module asks for, not a channel it publishes.
55+
*
56+
* ⚠️ Three parameters, not two: the platform `Logger` contract
57+
* (`packages/spec/src/contracts/logger.ts`) takes the `Error` in its OWN
58+
* second argument at this level and only this level. Declaring the sibling
59+
* `(message, meta)` shape here makes the real `ctx.logger` unassignable.
60+
*/
61+
error?: (message: string, error?: Error, meta?: Record<string, any>) => void;
4862
};
4963
}
5064

51-
async function tryFind(ql: any, object: string, where: any): Promise<any[]> {
65+
/**
66+
* [#15840] Read the legacy rows, or REFUSE — never invent an empty catalog.
67+
*
68+
* The `catch { return []; }` that stood here is the read-seam invention rule's
69+
* worst case, and it was measured (report 5553806224): an unreadable catalog and
70+
* an already-canonical one were BYTE-IDENTICAL on both channels — the same
71+
* `{ positions: 0, permissionSets: 0 }` and zero log lines at any level — while
72+
* the row that needed healing stayed legacy. "I could not read the catalog" was
73+
* reported as "the catalog is already canonical".
74+
*
75+
* #15840's ruling (decision batch #105 item 5, option A) is that a read fault
76+
* REFUSES the normalisation pass for that batch and reports at `error`; ⛔ it
77+
* never answers "already canonical". So the fault leaves this function as a
78+
* throw, and {@link normalizeManagedByVocab} lets it out.
79+
*
80+
* The consumer contract for that throw already exists and is the reason a
81+
* refusal is decidable here at all: `security-plugin.ts`'s `kernel:ready`
82+
* bootstrap wraps this call in `try { … } catch { logger.warn('[security]
83+
* managed_by vocab normalization failed (non-fatal)') }`, so boot proceeds and
84+
* the remaining bootstrap steps still run. Nothing reached that handler before,
85+
* because the fault was swallowed one frame below.
86+
*
87+
* ⚠️ Refusing is also the CHEAPER report. The report-and-continue option was
88+
* measured at four lines per boot — this pass calls the read once per legacy
89+
* value, three for `sys_position` and one for `sys_permission_set` — so a
90+
* whole-catalog outage said the same thing four times. A refusal aborts at the
91+
* first un-answered read, which is exactly one `error` line per refused boot.
92+
*
93+
* ⛔ Not a relaxation: the system-row write gate's provenance map recognizes
94+
* BOTH the canonical and the legacy vocabulary (see the header note and
95+
* #2926 ①), so rows left un-normalised by a refusal are still gated. Nothing is
96+
* granted, widened or disarmed by declining to rewrite them; the next boot
97+
* asks again.
98+
*/
99+
function readRefused(object: string, cause?: unknown): Error {
100+
const why = cause === undefined ? 'the engine did not answer with a row array' : (cause as Error)?.message;
101+
return new Error(
102+
`[security] managed_by normalize REFUSED for ${object} — the catalog read did not answer, ` +
103+
`so this pass cannot tell "already canonical" from "could not ask": ${why}`,
104+
);
105+
}
106+
107+
async function findOrRefuse(ql: any, object: string, where: any): Promise<any[]> {
108+
let rows: any;
52109
try {
53-
const rows = await ql.find(object, { where, limit: 10_000, fields: ['id', 'managed_by'] }, { context: SYSTEM_CTX });
54-
// Bare array, driven — `engine-find-bare-array.pin.test.ts` boots a real
55-
// engine over a real `SqlDriver` and pins this seam. The `{ records }` limb
56-
// that stood here was dead code that read as a contract.
57-
if (Array.isArray(rows)) return rows;
58-
return [];
59-
} catch {
60-
return [];
110+
rows = await ql.find(object, { where, limit: 10_000, fields: ['id', 'managed_by'] }, { context: SYSTEM_CTX });
111+
} catch (e) {
112+
throw readRefused(object, e);
61113
}
114+
// Bare array, driven — `engine-find-bare-array.pin.test.ts` boots a real
115+
// engine over a real `SqlDriver` and pins this seam. The `{ records }` limb
116+
// that stood here was dead code that read as a contract; a non-array answer
117+
// is now a refusal for the same reason a throw is — the pass did not get the
118+
// rows, so it must not report on them.
119+
if (Array.isArray(rows)) return rows;
120+
throw readRefused(object);
62121
}
63122

64123
async function normalizeObject(
@@ -71,7 +130,24 @@ async function normalizeObject(
71130
for (const [legacy, canonical] of Object.entries(map)) {
72131
// Narrow equality scan per legacy value keeps the where-clause
73132
// driver-portable (no IN / OR predicate).
74-
const rows = await tryFind(ql, object, { managed_by: legacy });
133+
let rows: any[];
134+
try {
135+
rows = await findOrRefuse(ql, object, { managed_by: legacy });
136+
} catch (e) {
137+
// [#15840] The one report the ruling names, emitted where the count that
138+
// will NOT be returned is still known: rows healed before the refusal
139+
// stay healed, and saying so is the difference between a refusal and a
140+
// rollback. Reported once — the throw aborts the whole pass.
141+
logger?.error?.((e as Error).message, e instanceof Error ? e : undefined, {
142+
object,
143+
legacyValue: legacy,
144+
healedBeforeRefusal: updated,
145+
consequence:
146+
'the remaining legacy rows keep their legacy managed_by; the write gate recognizes ' +
147+
'both vocabularies, so nothing is disarmed, and the next boot asks again',
148+
});
149+
throw e;
150+
}
75151
for (const row of rows) {
76152
if (!row?.id) continue;
77153
try {
@@ -90,6 +166,15 @@ async function normalizeObject(
90166
/**
91167
* Rewrite legacy `managed_by` values on `sys_permission_set` and `sys_position`
92168
* to the unified tri-state vocab. Returns a per-object count of rows healed.
169+
*
170+
* [#15840] THROWS if a catalog read does not answer. The returned counts are an
171+
* attestation — "these rows were legacy and are now canonical" — and a pass that
172+
* could not read the catalog has nothing to attest, so it refuses rather than
173+
* reporting `{ positions: 0, permissionSets: 0 }`, which is what an
174+
* already-canonical catalog reports. Callers already handle this: the
175+
* `kernel:ready` bootstrap catches it, reports at `warn` as non-fatal, and
176+
* continues. An engine with no `find`/`update` at all is NOT a read fault and
177+
* still returns zeros.
93178
*/
94179
export async function normalizeManagedByVocab(
95180
ql: any,

0 commit comments

Comments
 (0)