Skip to content

Commit 72cc4a6

Browse files
committed
fix(verify): provision the RLS probe persona through the registry, not a set row
provisionRlsProbePersona no longer mints a sys_permission_set row. It asks the security catalog, the same read the authorization resolver uses, whether the probe set is registered (rlsProbeSecurity registers it at boot). It reads the id of the row the boot seeds for that registered set, because sys_user_permission_set.permission_set_id is still required, and writes the one grant. A probe set the catalog does not serve, or a missing boot-seeded row, refuses and writes nothing. Claude-Session: https://claude.ai/code/session_01M5baMwzrfukawVWc496yxD Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f5eca5 commit 72cc4a6

3 files changed

Lines changed: 143 additions & 66 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 & 37 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,35 +447,37 @@ export async function provisionRlsProbePersona(
441447

442448
const probeSet = rlsProbePermissionSet(config);
443449

444-
// The grant is keyed by ROW ID, and `resolveUserAuthzGrants` reads the row
445-
// only to learn its NAME — the definition then resolves from the registered
446-
// bootstrap list (`rlsProbeSecurity`), which is the copy that carries the
447-
// narrowing. Find-or-create so it works whether or not this boot already
448-
// seeded the registered set into the table.
449-
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(
450472
await ql.find('sys_permission_set', { where: { name: RLS_PROBE_PERMISSION_SET }, limit: 1 }, sysCtx),
451473
);
452-
let permissionSetId = existing[0]?.id;
474+
const permissionSetId = seeded[0]?.id;
453475
if (!permissionSetId) {
454-
permissionSetId = genId('ps');
455-
await ql.insert(
456-
'sys_permission_set',
457-
{
458-
id: permissionSetId,
459-
name: RLS_PROBE_PERMISSION_SET,
460-
label: probeSet.label,
461-
description:
462-
'Ephemeral persona minted by `objectstack verify --rls`: object-level read+edit on every ' +
463-
'declared object plus an owner-scoped SELECT narrowing, so a by-id refusal is attributable ' +
464-
'to the record gate rather than the object gate.',
465-
object_permissions: JSON.stringify(probeSet.objects ?? {}),
466-
field_permissions: '{}',
467-
system_permissions: '[]',
468-
row_level_security: JSON.stringify(probeSet.rowLevelSecurity ?? []),
469-
tab_permissions: '{}',
470-
active: true,
471-
},
472-
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.",
473481
);
474482
}
475483
await ql.insert(
@@ -478,7 +486,7 @@ export async function provisionRlsProbePersona(
478486
id: genId('ups'),
479487
user_id: userId,
480488
permission_set_id: permissionSetId,
481-
// [ADR-0131 D4] Both columns, agreeing: found or created under exactly
489+
// [ADR-0131 D4] Both columns, agreeing: the row was found under exactly
482490
// this name just above.
483491
permission_set: RLS_PROBE_PERMISSION_SET,
484492
organization_id: null,

0 commit comments

Comments
 (0)