Skip to content

Commit e078506

Browse files
fix(verify): provision the RLS probe persona through the registry, not a set row (#15204 stage 4) (#22807)
Part of #15204 Clause-②: no Stage **4** of #15204 (stage plan 6094501866): `verify` provisions its RLS probe persona through the registry, not a set row. Claimed in the amendment 6106681412. The card stays open; its other stages are untouched here. ## What changes `packages/verify/src/rls.ts`, `provisionRlsProbePersona`: - **Before:** it found or created a `sys_permission_set` row named `verify_rls_probe`. When the row was missing, it minted one with the probe's object permissions and RLS serialized into row columns, then granted it. - **After:** it mints no permission set. 1. It asks the **security catalog**, through `securityCatalogReaderOf(ql).resolve('permission', 'verify_rls_probe')`. That is the read the authorization resolver itself uses (ADR-0131 D3/D4). `rlsProbeSecurity(config)` registers the set at boot. A stack booted without it now fails here, loudly, instead of granting a name that resolves to nothing. 2. It **reads** the id of the `sys_permission_set` row the boot already seeded for that registered set. `sys_user_permission_set.permission_set_id` is still a required column (it goes in stage 8 or C7b), and the platform derives the grant's name from it and refuses a disagreement (`grant-permission-set-name.ts`). This is no transition piece: no new read or write is added for an intermediate state. It is the one remaining existing read, narrowed from find-or-create to find. 3. It writes the one grant, carrying both columns as before. - Either missing piece (no catalog entry, or no seeded row) throws and writes nothing. `objectstack verify --rls` already turns any provisioning failure into `probe.degraded` plus a hard failure, so neither case can pass as a weaker persona. ## Measured before building (`origin/main` `9f5eca52b`) - **The boot seeds the row.** `SecurityPlugin` passes `options.defaultPermissionSets` (which `rlsProbeSecurity` extends with the probe set) as `bootstrapPermissionSets` to `bootstrapPlatformAdmin` at `kernel:ready`. That function inserts a row for every bootstrap set it does not find (`bootstrap-platform-admin.ts` around line 873). - A real `bootStack(showcase, { security: rlsProbeSecurity(config) })` held exactly one `verify_rls_probe` row afterwards: organization null, `managed_by: platform`. - **The catalog serves the set.** On the same boot, `securityCatalogReaderOf(ql).resolve('permission', 'verify_rls_probe')` answered an entry with 24 objects and 24 `rowLevelSecurity` rules. The control, an unregistered name, answered undefined. - **Multi-tenant was not measured.** A multi-tenant boot needs the enterprise `@objectstack/organizations` package, which this repository cannot load. The PR's CI is not shown to cover it either: no test drives `verify --rls` end to end. ## Verification All at `72cc4a67c8`: - **Build:** `pnpm --filter '@objectstack/example-showcase^...' --filter '@objectstack/cli...' build` exited 0, as did the `@objectstack/dogfood^...` build. - **Unit tests:** `pnpm --filter @objectstack/verify test` passed 29 files, 236 tests. `pnpm --filter @objectstack/verify typecheck` was clean, including `check:test-typecheck`. - **`rls-persona-grant.test.ts`, rewritten.** It pins four cases: the registered set is granted by name with the seeded row's id and no set row is inserted; no seeded row refuses and writes nothing; a row with no catalog entry refuses and writes nothing; no catalog bound refuses and writes nothing. - **Reverse verification (one-off, not kept):** - `main`'s `rls.ts` was restored under the new test with `git restore --source=9f5eca52b`. The mutant held `genId('ps')` once and `securityCatalogReaderOf` zero times. The result was **3 failed, 1 passed**: every refusal case went red, and the seeded-row case stays green because `main` also reuses a found row. - Restored with `git checkout HEAD`. Blob `edefdfbc2589` matched HEAD, and `git diff HEAD` was empty. - **End to end:** `objectstack verify --app ./objectstack.config.ts --rls --json` was run on `examples/app-showcase`, twice on `main`'s code and twice on this branch. - All four runs had `hardFailures: 0` and a probe that was not degraded (`grantedObjects: 24`). - All four had identical totals: 264 objects, 38 consistent, 0 holes, 131 member-visible, 62 probe-blocked, 33 skipped. - All four had identical per-object and per-position statuses. - The only difference was one run's probe target for `showcase_invoice`, adopted rather than created after an admin create answered 400. The second run on this branch created it again, so this is run-to-run noise. - **Dogfood suite, in full** (`pnpm --filter @objectstack/dogfood test`; its files import `@objectstack/verify`): 245 files passed, 1 skipped; 1977 tests passed, 9 skipped; exit 0. - **Gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE` derived 61; 60 ran green and 1 is NOT MEASURED. That one is `check:dual-build-cjs-loads`, which exited 3 (PREREQUISITE NOT MET: it needs a whole-repo build) and is left to CI. ## Landing order with S5c #22802 (#15196 S5c, comments only) edits the comment this PR replaces. Branching rules forbid stacked series, so this branch is cut from `main`, not from S5c. Whichever of the two lands second takes one textual conflict in `provisionRlsProbePersona`'s comment block, resolved by keeping this PR's text. #22802 also corrects `rlsProbeSecurity`'s docblock, which this PR leaves untouched for that reason. ## Acceptance notes - **Carried by stage 6b-2:** this read depends on the boot seeding a `sys_permission_set` row for every registered bootstrap set (`bootstrapPlatformAdmin`). Stage 6b deletes "platform-admin materialization". If 6b-2 lands before stage 8 or C7b drops the `permission_set_id` requirement, it must carry this read along with the other grant writers that read a seeded id. Otherwise `verify --rls` reports a degraded persona, loudly, which is the failure mode this PR chose over minting. --- _Generated by [Claude Code](https://claude.ai/code/session_01M5baMwzrfukawVWc496yxD)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9814035 commit e078506

3 files changed

Lines changed: 143 additions & 69 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/verify': patch
3+
---
4+
5+
fix(verify): the RLS probe persona is provisioned through the registry — `provisionRlsProbePersona` no longer mints a `sys_permission_set` row
6+
7+
Clause-②: no
8+
9+
`provisionRlsProbePersona` used to find-or-create a `sys_permission_set` row named `verify_rls_probe` and grant it. Under ADR-0131 D3/D4 a permission set's one home is the registry: the authorization resolver reads the grant's `permission_set` name and takes the definition from the security catalog, never from a row. So a row this function minted was a copy of a definition nothing read.
10+
11+
It now:
12+
13+
- asks the security catalog (`securityCatalogReaderOf`, the read the resolver itself uses) whether `verify_rls_probe` is registered. `rlsProbeSecurity(config)` registers it at boot;
14+
- reads the id of the row the boot already seeded for that registered set, because `sys_user_permission_set.permission_set_id` is still a required column;
15+
- writes the one grant, carrying both columns.
16+
17+
It throws, writing nothing, when the catalog does not serve the probe set or the boot seeded no row for it. `objectstack verify --rls` reports either as a degraded probe persona and fails the run, as it already does for any provisioning failure. Fix for a caller that hits it: boot the stack with `rlsProbeSecurity(config)` as the `security` option of `bootStack`.
Lines changed: 81 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* [ADR-0131 D4] The RLS probe persona's grant writes BOTH columns of
5-
* `sys_user_permission_set` — `permission_set_id` and `permission_set` — and
6-
* the two agree: the name is the `name` of the catalog row the id points at.
4+
* [ADR-0131 D3/D4] The RLS probe persona is provisioned through the REGISTRY:
5+
* `provisionRlsProbePersona` asks the security catalog whether the probe set is
6+
* registered, writes ONE grant naming it, and mints no `sys_permission_set`
7+
* row.
78
*
8-
* Driven through the SHIPPED writer, `provisionRlsProbePersona`, over a stack
9-
* double whose engine stores exactly the payloads it is handed: no platform
10-
* hook stands behind the writer here, so what lands is the writer's own
11-
* payload — a writer that drops the name turns this red even though, booted
12-
* for real, the platform would stamp the column behind it. Both of the
13-
* writer's branches are pinned: the probe set found by name, and the probe
14-
* set created because none existed.
9+
* The grant still writes BOTH columns of `sys_user_permission_set` —
10+
* `permission_set_id` (still a required column) and `permission_set` — and the
11+
* two agree: the id is the boot-seeded catalog row's, READ by name, and the name
12+
* is that row's `name`.
13+
*
14+
* Driven through the SHIPPED writer over a stack double whose engine stores
15+
* exactly the payloads it is handed and whose catalog is bound with core's own
16+
* `bindSecurityCatalogReader` — the binding `securityCatalogReaderOf` reads in
17+
* production. No platform hook stands behind the writer here, so what lands is
18+
* the writer's own payload. Every refusal is pinned to write NOTHING: a writer
19+
* that falls back to minting the set row, or that grants a set the catalog does
20+
* not serve, turns this red.
1521
*/
1622

1723
import { describe, expect, it } from 'vitest';
1824

25+
import {
26+
bindSecurityCatalogReader,
27+
type SecurityCatalogEntry,
28+
type SecurityCatalogReader,
29+
} from '@objectstack/core';
30+
1931
import { provisionRlsProbePersona, RLS_PROBE_EMAIL } from './rls.js';
2032

2133
const PROBE_SET = 'verify_rls_probe';
@@ -33,52 +45,92 @@ function matches(row: Record<string, unknown>, where: Record<string, unknown>):
3345
return true;
3446
}
3547

36-
function stackDouble(seed: { sys_permission_set?: any[] } = {}) {
48+
/** A catalog that serves exactly `names` as permission sets. */
49+
function catalogServing(names: string[]): SecurityCatalogReader {
50+
const entry = (name: string): SecurityCatalogEntry => ({
51+
type: 'permission',
52+
name,
53+
definition: { name },
54+
source: 'registry',
55+
});
56+
return {
57+
async resolve(type, name) {
58+
return type === 'permission' && names.includes(name) ? entry(name) : undefined;
59+
},
60+
async list(type) {
61+
return type === 'permission' ? names.map(entry) : [];
62+
},
63+
};
64+
}
65+
66+
function stackDouble(seed: { sys_permission_set?: any[]; catalog?: SecurityCatalogReader } = {}) {
3767
const tables: Record<string, any[]> = {
3868
sys_user: [{ id: 'usr_probe', email: RLS_PROBE_EMAIL }],
3969
sys_permission_set: seed.sys_permission_set ?? [],
4070
sys_user_permission_set: [],
4171
};
72+
const inserted: string[] = [];
4273
const ql = {
4374
async find(object: string, query: any) {
4475
const where = query?.where ?? {};
4576
const rows = (tables[object] ?? []).filter((r) => matches(r, where));
4677
return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows;
4778
},
4879
async insert(object: string, data: any) {
80+
inserted.push(object);
4981
(tables[object] ??= []).push({ ...data });
5082
return { ...data };
5183
},
5284
};
85+
if (seed.catalog) bindSecurityCatalogReader(ql, seed.catalog);
5386
const stack = {
5487
async signUp() { return 'tok_signup'; },
5588
async signIn() { return 'tok_signin'; },
5689
kernel: { async getServiceAsync(name: string) { return name === 'objectql' ? ql : undefined; } },
5790
};
58-
return { stack: stack as any, tables };
91+
return { stack: stack as any, tables, inserted };
5992
}
6093

61-
function expectBothColumnsAgree(tables: Record<string, any[]>): void {
62-
const grants = tables.sys_user_permission_set;
63-
expect(grants).toHaveLength(1);
64-
expect(grants[0]).toMatchObject({ user_id: 'usr_probe', permission_set: PROBE_SET });
65-
const setRow = tables.sys_permission_set.find((r) => r.id === grants[0].permission_set_id);
66-
expect(setRow?.name).toBe(grants[0].permission_set);
67-
}
94+
const SEEDED_ROW = { id: 'ps_seeded_probe', name: PROBE_SET };
6895

69-
describe('[ADR-0131 D4] the verify RLS persona grant writes both columns, and they agree', () => {
70-
it('when the probe set row already exists (found by name)', async () => {
71-
const { stack, tables } = stackDouble({ sys_permission_set: [{ id: 'ps_seeded_probe', name: PROBE_SET }] });
96+
describe('[ADR-0131 D3/D4] the verify RLS persona is provisioned through the registry', () => {
97+
it('grants the registered set by name, with the boot-seeded row id, and mints no set row', async () => {
98+
const { stack, tables, inserted } = stackDouble({
99+
sys_permission_set: [{ ...SEEDED_ROW }],
100+
catalog: catalogServing([PROBE_SET]),
101+
});
72102
const persona = await provisionRlsProbePersona(stack, { objects: [] });
103+
73104
expect(persona.permissionSet).toBe(PROBE_SET);
74-
expect(tables.sys_user_permission_set[0]?.permission_set_id).toBe('ps_seeded_probe');
75-
expectBothColumnsAgree(tables);
105+
expect(inserted).toEqual(['sys_user_permission_set']);
106+
expect(tables.sys_permission_set).toEqual([SEEDED_ROW]);
107+
const grants = tables.sys_user_permission_set;
108+
expect(grants).toHaveLength(1);
109+
expect(grants[0]).toMatchObject({
110+
user_id: 'usr_probe',
111+
permission_set: PROBE_SET,
112+
permission_set_id: SEEDED_ROW.id,
113+
});
114+
});
115+
116+
it('refuses, writing nothing, when the boot seeded no row for the registered set', async () => {
117+
const { stack, inserted } = stackDouble({ catalog: catalogServing([PROBE_SET]) });
118+
await expect(provisionRlsProbePersona(stack, { objects: [] })).rejects.toThrow(PROBE_SET);
119+
expect(inserted).toEqual([]);
120+
});
121+
122+
it('refuses, writing nothing, when the catalog does not serve the set — a row alone is not the probe set', async () => {
123+
const { stack, inserted } = stackDouble({
124+
sys_permission_set: [{ ...SEEDED_ROW }],
125+
catalog: catalogServing(['admin_full_access']),
126+
});
127+
await expect(provisionRlsProbePersona(stack, { objects: [] })).rejects.toThrow(PROBE_SET);
128+
expect(inserted).toEqual([]);
76129
});
77130

78-
it('when the probe set row is created by the writer', async () => {
79-
const { stack, tables } = stackDouble();
80-
await provisionRlsProbePersona(stack, { objects: [] });
81-
expect(tables.sys_permission_set).toHaveLength(1);
82-
expectBothColumnsAgree(tables);
131+
it('refuses, writing nothing, when no catalog is bound to the engine at all', async () => {
132+
const { stack, inserted } = stackDouble({ sys_permission_set: [{ ...SEEDED_ROW }] });
133+
await expect(provisionRlsProbePersona(stack, { objects: [] })).rejects.toThrow(PROBE_SET);
134+
expect(inserted).toEqual([]);
83135
});
84136
});

‎packages/verify/src/rls.ts‎

Lines changed: 45 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
// never be read as N× the reach.
8080

8181

82+
import { securityCatalogReaderOf } from '@objectstack/core';
8283
import { SecurityPlugin, securityDefaultPermissionSets, appSecurityPluginOptions } from '@objectstack/plugin-security';
8384
import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec/identity';
8485
import type { PermissionSet } from '@objectstack/spec/security';
@@ -299,9 +300,9 @@ export interface RlsProbePersona {
299300
token: string;
300301
email: string;
301302
userId: string;
302-
/** Name of the minted `sys_permission_set` row. */
303+
/** Name of the registered probe set the grant holds. */
303304
permissionSet: string;
304-
/** Objects the minted set grants read+edit on. */
305+
/** Objects the probe set grants read+edit on. */
305306
grantedObjects: number;
306307
}
307308

@@ -401,15 +402,20 @@ export function rlsProbeSecurity(config: any): SecurityPlugin {
401402
* select, nothing else).
402403
*
403404
* Requires the stack to have been booted with {@link rlsProbeSecurity}, which is
404-
* what puts the set's `rowLevelSecurity` on the resolution path. This function
405-
* only writes the two RBAC link rows, and it writes them through the kernel's
406-
* ObjectQL engine under a system context, the same way `bootstrapPlatformAdmin`
407-
* seeds them: the persona is test SETUP, not the surface under test, so routing
408-
* it through the data door would make the proof depend on whether this
409-
* deployment happens to let an admin POST `sys_permission_set` — a second thing
410-
* that can fail for reasons unrelated to RLS.
405+
* what REGISTERS the set (ADR-0131 D3: the registry is a permission set's one
406+
* home) and so puts its `rowLevelSecurity` on the resolution path. This function
407+
* mints no permission set: it asks the security catalog — the read the
408+
* authorization resolver itself uses — whether the probe set is registered, and
409+
* writes one RBAC row, the grant, naming that set. It writes through the
410+
* kernel's ObjectQL engine under a system context, the same way
411+
* `bootstrapPlatformAdmin` seeds grants: the persona is test SETUP, not the
412+
* surface under test, so routing it through the data door would make the proof
413+
* depend on whether this deployment happens to let an admin POST a grant — a
414+
* second thing that can fail for reasons unrelated to RLS.
411415
*
412-
* Throws when the stack has no ObjectQL engine or the user cannot be resolved.
416+
* Throws when the stack has no ObjectQL engine, the user cannot be resolved, the
417+
* catalog does not serve the probe set, or the boot left no catalog row whose id
418+
* the grant's still-required `permission_set_id` column can take.
413419
* Callers must NOT swallow that into a weaker persona silently: run degraded and
414420
* say so (`RlsProbeDescriptor.degraded`), or fail.
415421
*/
@@ -441,38 +447,37 @@ export async function provisionRlsProbePersona(
441447

442448
const probeSet = rlsProbePermissionSet(config);
443449

444-
// [ADR-0131 D4] The grant holds its set BY NAME: `resolveUserAuthzGrants`
445-
// reads the grant's `permission_set` column and takes the definition from
446-
// the registry (`rlsProbeSecurity`), the copy that carries the narrowing. It
447-
// reads neither `permission_set_id` nor this row. The row is read only for
448-
// its id, because `permission_set_id` is still a required column and the
449-
// platform derives the grant's name from it, refusing a disagreement.
450-
// Find-or-create so it works whether or not this boot already seeded the
451-
// registered set into the table.
452-
const existing = rowsOf(
450+
// [ADR-0131 D3/D4] The probe set's one home is the registry: `rlsProbeSecurity`
451+
// registers it at boot, and the authorization resolver takes its definition —
452+
// narrowing included — from the security catalog by the grant's NAME. Ask
453+
// that same catalog, so a stack booted without the probe set fails here,
454+
// loudly, instead of granting a name that resolves to nothing.
455+
const catalog = securityCatalogReaderOf(ql);
456+
const registered = catalog ? await catalog.resolve('permission', RLS_PROBE_PERMISSION_SET) : undefined;
457+
if (!registered) {
458+
throw new Error(
459+
`verify: cannot provision the RLS probe persona — the security catalog does not serve the ` +
460+
`'${RLS_PROBE_PERMISSION_SET}' permission set. Boot the stack with \`rlsProbeSecurity(config)\`, ` +
461+
'which registers it; a grant naming an unregistered set confers nothing.',
462+
);
463+
}
464+
465+
// `sys_user_permission_set.permission_set_id` is still a required column, and
466+
// the platform derives the grant's name from it and refuses a disagreement.
467+
// The boot seeds a catalog row for every set the security plugin registers
468+
// (`bootstrapPlatformAdmin`), the probe set included, so its id is READ here —
469+
// never minted: no resolver reads that row, and a row this function wrote
470+
// would be one more copy of a definition whose home is the registry.
471+
const seeded = rowsOf(
453472
await ql.find('sys_permission_set', { where: { name: RLS_PROBE_PERMISSION_SET }, limit: 1 }, sysCtx),
454473
);
455-
let permissionSetId = existing[0]?.id;
474+
const permissionSetId = seeded[0]?.id;
456475
if (!permissionSetId) {
457-
permissionSetId = genId('ps');
458-
await ql.insert(
459-
'sys_permission_set',
460-
{
461-
id: permissionSetId,
462-
name: RLS_PROBE_PERMISSION_SET,
463-
label: probeSet.label,
464-
description:
465-
'Ephemeral persona minted by `objectstack verify --rls`: object-level read+edit on every ' +
466-
'declared object plus an owner-scoped SELECT narrowing, so a by-id refusal is attributable ' +
467-
'to the record gate rather than the object gate.',
468-
object_permissions: JSON.stringify(probeSet.objects ?? {}),
469-
field_permissions: '{}',
470-
system_permissions: '[]',
471-
row_level_security: JSON.stringify(probeSet.rowLevelSecurity ?? []),
472-
tab_permissions: '{}',
473-
active: true,
474-
},
475-
sysCtx,
476+
throw new Error(
477+
`verify: cannot provision the RLS probe persona — the boot seeded no sys_permission_set row ` +
478+
`named '${RLS_PROBE_PERMISSION_SET}', so the grant has no id for its required ` +
479+
'permission_set_id column. The security plugin seeds one for every registered set at ' +
480+
"kernel:ready; read its '[security] platform bootstrap' log lines for why it did not.",
476481
);
477482
}
478483
await ql.insert(
@@ -481,7 +486,7 @@ export async function provisionRlsProbePersona(
481486
id: genId('ups'),
482487
user_id: userId,
483488
permission_set_id: permissionSetId,
484-
// [ADR-0131 D4] Both columns, agreeing: found or created under exactly
489+
// [ADR-0131 D4] Both columns, agreeing: the row was found under exactly
485490
// this name just above.
486491
permission_set: RLS_PROBE_PERMISSION_SET,
487492
organization_id: null,

0 commit comments

Comments
 (0)