Skip to content

Commit c3492fa

Browse files
os-zhuangclaude
andcommitted
feat(dogfood): multi-user harness + RLS cross-owner proof runner (#1994)
Adds the authorization dimension to the verifier — the machinery to catch the #1994 class (a by-id write that skips the row-level predicate, letting a member edit a record it cannot even see). - harness: `signUp(email)` mints a fresh member (the first user is the seeded admin, so a sign-up is a plain member with no grants — exactly what cross-owner proofs need). Also registers SharingServicePlugin so apps that declare `requires: ['sharing']` have their record-share grants enforced rather than silently inert (a harness-fidelity fix). - src/rls.ts: the app-agnostic invariant — "a user who cannot READ a record must not be able to WRITE it." Per object: admin creates a record; a fresh member tries to read it, then mutate it by id; re-read as admin decides if the row actually changed. Member-can't-read + row-changed = the #1994 hole. - test/rls-runner.test.ts: unit-proves the classifier flags a hole / passes when consistent / reports member-visible — so the detection logic is guaranteed. - test/auto-verify-rls.dogfood.test.ts: live smoke over CRM + showcase. HONEST CURRENT STATE: the harness boots single-tenant, so org-scoped RLS is stripped and a fresh member falls back to `member_default` (broad read). So both the example apps AND hotcrm report every object `member-visible` — the #1994 by-id-write path isn't exercised live yet. Making it a hard, revert-provable gate needs an owner-scoped fixture (private-default object + a member permission set carrying RLS.ownerPolicy + SecurityPlugin.fallbackPermissionSet). The runner is unit-proven and ready; the live invariant (zero holes) still guards against a regression. Fixture tracked as the next step. Private package — no changeset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 311bcc6 commit c3492fa

6 files changed

Lines changed: 284 additions & 2 deletions

File tree

packages/dogfood/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"@objectstack/service-settings": "workspace:*",
2222
"@objectstack/service-analytics": "workspace:*",
2323
"@objectstack/example-crm": "workspace:*",
24-
"@objectstack/example-showcase": "workspace:*"
24+
"@objectstack/example-showcase": "workspace:*",
25+
"@objectstack/plugin-sharing": "workspace:*"
2526
},
2627
"devDependencies": {
2728
"@types/node": "^25.9.3",

packages/dogfood/src/harness.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
2222
import { createRestApiPlugin } from '@objectstack/rest';
2323
import { AuthPlugin } from '@objectstack/plugin-auth';
2424
import { SecurityPlugin } from '@objectstack/plugin-security';
25+
import { SharingServicePlugin } from '@objectstack/plugin-sharing';
2526
import { SettingsServicePlugin, LocalCryptoProvider } from '@objectstack/service-settings';
2627
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
2728

@@ -43,6 +44,10 @@ export interface DogfoodStack {
4344
raw(path: string, init?: RequestInit): Promise<Response>;
4445
/** Sign in through the real auth route; returns a bearer token. Defaults to the dev admin. */
4546
signIn(email?: string, password?: string): Promise<string>;
47+
/** Sign up a NEW user through the real auth route; returns their bearer token.
48+
* The first user is the seeded dev admin, so a fresh sign-up is a plain member
49+
* (no roles/grants) — exactly what RLS cross-owner proofs need. */
50+
signUp(email: string, password?: string, name?: string): Promise<string>;
4651
/** Convenience: an authed JSON request relative to `/api/v1`. */
4752
apiAs(token: string, method: string, path: string, body?: unknown): Promise<Response>;
4853
/** Tear down the kernel (close DB / HTTP handles). */
@@ -86,6 +91,10 @@ export async function bootDogfoodStack(
8691
await kernel.use(new AnalyticsServicePlugin());
8792
await kernel.use(new AuthPlugin({ secret: 'dogfood-regression-secret' }));
8893
await kernel.use(new SecurityPlugin());
94+
// Sharing service — apps that declare `requires: ['sharing']` rely on it for
95+
// record-share grants; without it their RLS/sharing rules are inert and the
96+
// verifier would under-report authorization.
97+
await kernel.use(new SharingServicePlugin());
8998

9099
// REST + dispatcher route surfaces (mount onto the http-server service).
91100
await kernel.use(createRestApiPlugin({ api: { api: { requireAuth: true } } as never }));
@@ -133,6 +142,24 @@ export async function bootDogfoodStack(
133142
return data.token;
134143
};
135144

145+
const signUp = async (
146+
email: string,
147+
password = 'Member-Pass-123',
148+
name?: string,
149+
): Promise<string> => {
150+
const res = await api('/auth/sign-up/email', {
151+
method: 'POST',
152+
headers: { 'Content-Type': 'application/json' },
153+
body: JSON.stringify({ email, password, name: name ?? email.split('@')[0] }),
154+
});
155+
if (!res.ok) {
156+
throw new Error(`dogfood signUp failed: ${res.status} ${await res.text()}`);
157+
}
158+
const data = (await res.json()) as { token?: string };
159+
if (!data.token) throw new Error('dogfood signUp: no token in response');
160+
return data.token;
161+
};
162+
136163
const apiAs = (token: string, method: string, path: string, body?: unknown) =>
137164
api(path, {
138165
method,
@@ -157,5 +184,5 @@ export async function bootDogfoodStack(
157184
}
158185
};
159186

160-
return { kernel, api, raw, signIn, apiAs, stop };
187+
return { kernel, api, raw, signIn, signUp, apiAs, stop };
161188
}

packages/dogfood/src/rls.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Metadata-driven RLS cross-owner proofs — the #1994 invariant.
4+
//
5+
// #1994 ("member edits others' records") was a by-id write that skipped the
6+
// row-level predicate: `driver.update(object, id, …)` builds no AST, so RLS
7+
// never scoped it. The clean, app-agnostic invariant that catches it without
8+
// interpreting each sharing rule:
9+
//
10+
// A user who CANNOT READ a record must not be able to WRITE it.
11+
// ("You can't mutate what you can't see.")
12+
//
13+
// Derivation, per object: admin creates a record; a fresh member (no roles or
14+
// grants) tries to read it, then tries to mutate it by id; we re-read as admin
15+
// to see if the row actually changed. If the member couldn't see it yet changed
16+
// it, that's the #1994 class of hole — regardless of the app's sharing config.
17+
18+
/* eslint-disable @typescript-eslint/no-explicit-any */
19+
20+
import type { DogfoodStack } from './harness.js';
21+
import { deriveCrudCases } from './derive.js';
22+
23+
const PROBE_TYPES = new Set(['text', 'textarea', 'string']);
24+
const MUTATION = 'rls-mutated-by-B';
25+
26+
export interface RlsResult {
27+
object: string;
28+
status: 'rls-consistent' | 'rls-hole' | 'member-visible' | 'skipped';
29+
detail?: string;
30+
}
31+
32+
export interface RlsReport {
33+
app: string;
34+
results: RlsResult[];
35+
summary: { objects: number; consistent: number; holes: number; memberVisible: number; skipped: number };
36+
}
37+
38+
export async function runRlsProofs(
39+
stack: DogfoodStack,
40+
adminToken: string,
41+
memberToken: string,
42+
config: any,
43+
): Promise<RlsReport> {
44+
const cases = deriveCrudCases(config);
45+
const results: RlsResult[] = [];
46+
47+
for (const c of cases) {
48+
if (c.blocked) { results.push({ object: c.object, status: 'skipped', detail: c.blocked }); continue; }
49+
50+
// A plain-text field to mutate (avoid email/url/phone — their format checks
51+
// would reject the probe for a benign reason, masking the RLS signal).
52+
const probe = (c.asserts ?? []).find((a) => PROBE_TYPES.has(a.type));
53+
if (!probe) { results.push({ object: c.object, status: 'skipped', detail: 'no plain-text probe field' }); continue; }
54+
55+
// Admin (owner) creates the record.
56+
const created = await stack.apiAs(adminToken, 'POST', `/data/${c.object}`, c.body);
57+
if (created.status >= 300) {
58+
results.push({ object: c.object, status: 'skipped', detail: `admin create failed (${created.status})` });
59+
continue;
60+
}
61+
const cj = (await created.json()) as any;
62+
const id = cj?.id ?? cj?.record?.id;
63+
if (!id) { results.push({ object: c.object, status: 'skipped', detail: 'no id from create' }); continue; }
64+
65+
// Member B: can they SEE it?
66+
const bRead = await stack.apiAs(memberToken, 'GET', `/data/${c.object}/${id}`);
67+
let canRead = false;
68+
if (bRead.status === 200) {
69+
const rec = ((await bRead.json()) as any)?.record;
70+
canRead = !!rec && rec.id === id;
71+
}
72+
73+
// Member B: try to MUTATE it by id.
74+
const bWrite = await stack.apiAs(memberToken, 'PATCH', `/data/${c.object}/${id}`, { [probe.field]: MUTATION });
75+
76+
// Ground truth: re-read as admin — did the row actually change?
77+
const after = await stack.apiAs(adminToken, 'GET', `/data/${c.object}/${id}`);
78+
const afterVal = (((await after.json()) as any)?.record ?? {})[probe.field];
79+
const changed = afterVal === MUTATION;
80+
81+
if (canRead) {
82+
results.push({ object: c.object, status: 'member-visible', detail: 'member can read this object — not a cross-owner scenario (no RLS isolation, or read is granted)' });
83+
} else if (changed) {
84+
results.push({
85+
object: c.object,
86+
status: 'rls-hole',
87+
detail: `member B cannot read it (GET ${bRead.status}) yet MUTATED it by id (PATCH ${bWrite.status}) — by-id write bypassed RLS (#1994 class)`,
88+
});
89+
} else {
90+
results.push({
91+
object: c.object,
92+
status: 'rls-consistent',
93+
detail: `member B cannot read (GET ${bRead.status}) and could not mutate (PATCH ${bWrite.status}, row unchanged)`,
94+
});
95+
}
96+
}
97+
98+
const summary = {
99+
objects: results.length,
100+
consistent: results.filter((r) => r.status === 'rls-consistent').length,
101+
holes: results.filter((r) => r.status === 'rls-hole').length,
102+
memberVisible: results.filter((r) => r.status === 'member-visible').length,
103+
skipped: results.filter((r) => r.status === 'skipped').length,
104+
};
105+
return { app: config?.manifest?.id ?? 'app', results, summary };
106+
}
107+
108+
export function formatRlsReport(report: RlsReport): string {
109+
const lines: string[] = [`\n=== objectstack verify (RLS / #1994) — ${report.app} ===`];
110+
for (const r of report.results) {
111+
const mark = r.status === 'rls-hole' ? '✗✗' : r.status === 'rls-consistent' ? '✓' : r.status === 'member-visible' ? '·' : '–';
112+
lines.push(` ${mark} ${r.object} [${r.status}] ${r.detail ?? ''}`);
113+
}
114+
const s = report.summary;
115+
lines.push(` ── ${s.consistent} consistent, ${s.holes} HOLES, ${s.memberVisible} member-visible, ${s.skipped} skipped`);
116+
return lines.join('\n');
117+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Live RLS cross-owner smoke (#1994) over the framework example apps, with a
4+
// real second user. The runner's hole-detection logic is unit-proven in
5+
// `rls-runner.test.ts`; this exercises it end-to-end against real apps.
6+
//
7+
// HONEST CURRENT STATE: the harness boots single-tenant, so org-scoped RLS is
8+
// stripped and a fresh member falls back to `member_default` (broad read) — so
9+
// every object reports `member-visible` and the #1994 by-id-write path isn't
10+
// exercised here. A hard, revert-provable gate needs an owner-scoped fixture
11+
// (a private-default object + a member permission set carrying RLS.ownerPolicy
12+
// + SecurityPlugin.fallbackPermissionSet) — tracked as the next step. The
13+
// invariant asserted now (zero holes) still guards against a regression that
14+
// makes a member able to mutate a record it cannot read.
15+
16+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
17+
import crmStack from '@objectstack/example-crm';
18+
import showcaseStack from '@objectstack/example-showcase';
19+
import { bootDogfoodStack, type DogfoodStack } from '../src/harness.js';
20+
import { runRlsProofs, formatRlsReport, type RlsReport } from '../src/rls.js';
21+
22+
const APPS: Array<[string, unknown]> = [
23+
['crm', crmStack],
24+
['showcase', showcaseStack],
25+
];
26+
27+
for (const [name, config] of APPS) {
28+
describe(`objectstack verify RLS: ${name} (#1994 cross-owner)`, () => {
29+
let stack: DogfoodStack;
30+
let report: RlsReport;
31+
32+
beforeAll(async () => {
33+
stack = await bootDogfoodStack(config as never);
34+
const adminToken = await stack.signIn();
35+
const memberToken = await stack.signUp(`member-${name}@verify.test`);
36+
report = await runRlsProofs(stack, adminToken, memberToken, config);
37+
// eslint-disable-next-line no-console
38+
console.error(formatRlsReport(report));
39+
}, 60_000);
40+
41+
afterAll(async () => {
42+
await stack?.stop();
43+
});
44+
45+
it('boots with two distinct users and runs cross-owner proofs', () => {
46+
expect(report.summary.objects).toBeGreaterThan(0);
47+
});
48+
49+
it('has ZERO by-id-write RLS holes (#1994 invariant)', () => {
50+
const holes = report.results.filter((r) => r.status === 'rls-hole');
51+
expect(holes, formatRlsReport(report)).toHaveLength(0);
52+
});
53+
});
54+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Unit proof that the RLS runner's #1994 classification is correct — driven by a
4+
// scripted fake stack so we can exercise the three outcomes deterministically
5+
// (a live owner-isolated fixture to exercise them end-to-end is the next step).
6+
//
7+
// The invariant: a user who CANNOT READ a record must not be able to WRITE it.
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { runRlsProofs } from '../src/rls.js';
11+
import type { DogfoodStack } from '../src/harness.js';
12+
13+
const CONFIG = {
14+
manifest: { id: 'fixture' },
15+
objects: [{ name: 'note', fields: { name: { type: 'text', required: true } } }],
16+
};
17+
18+
/** A fake stack: admin always sees/owns; member behaviour is scripted per scenario. */
19+
function fakeStack(opts: {
20+
memberCanRead: boolean;
21+
memberWriteMutates: boolean; // does member's PATCH actually change the row?
22+
}): DogfoodStack {
23+
const store: Record<string, any> = {};
24+
const json = (body: unknown, status = 200) =>
25+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
26+
27+
const apiAs: DogfoodStack['apiAs'] = async (token, method, path, body) => {
28+
const isAdmin = token === 'admin';
29+
const [, , object, id] = path.split('/'); // /data/<object>/<id>
30+
if (method === 'POST') {
31+
const newId = 'rec1';
32+
store[newId] = { id: newId, ...(body as object) };
33+
return json({ object, id: newId, record: store[newId] }, 201);
34+
}
35+
if (method === 'GET') {
36+
if (!isAdmin && !opts.memberCanRead) return json({ error: 'not found' }, 404);
37+
return json({ object, id, record: store[id] ?? null });
38+
}
39+
if (method === 'PATCH') {
40+
// Admin always writes. Member writes only "land" when the scenario says so
41+
// (i.e. RLS failed to scope the by-id write — the #1994 bug).
42+
if (isAdmin || opts.memberWriteMutates) Object.assign(store[id], body as object);
43+
return json({ object, id, record: store[id] }, isAdmin || opts.memberWriteMutates ? 200 : 403);
44+
}
45+
return json({}, 405);
46+
};
47+
48+
return {
49+
apiAs,
50+
kernel: {} as never,
51+
api: (async () => new Response()) as never,
52+
raw: (async () => new Response()) as never,
53+
signIn: async () => 'admin',
54+
signUp: async () => 'member',
55+
stop: async () => {},
56+
};
57+
}
58+
59+
describe('runRlsProofs #1994 classification', () => {
60+
it('flags a HOLE when a member who cannot read a record still mutates it by id', async () => {
61+
const stack = fakeStack({ memberCanRead: false, memberWriteMutates: true });
62+
const report = await runRlsProofs(stack, 'admin', 'member', CONFIG);
63+
expect(report.summary.holes).toBe(1);
64+
expect(report.results[0].status).toBe('rls-hole');
65+
});
66+
67+
it('passes (consistent) when a member who cannot read also cannot mutate', async () => {
68+
const stack = fakeStack({ memberCanRead: false, memberWriteMutates: false });
69+
const report = await runRlsProofs(stack, 'admin', 'member', CONFIG);
70+
expect(report.summary.holes).toBe(0);
71+
expect(report.results[0].status).toBe('rls-consistent');
72+
});
73+
74+
it('reports member-visible (inconclusive) when the member can read the record', async () => {
75+
const stack = fakeStack({ memberCanRead: true, memberWriteMutates: true });
76+
const report = await runRlsProofs(stack, 'admin', 'member', CONFIG);
77+
expect(report.summary.holes).toBe(0);
78+
expect(report.results[0].status).toBe('member-visible');
79+
});
80+
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)