diff --git a/packages/dogfood/package.json b/packages/dogfood/package.json index 4909305c6a..d90d1be138 100644 --- a/packages/dogfood/package.json +++ b/packages/dogfood/package.json @@ -21,7 +21,8 @@ "@objectstack/service-settings": "workspace:*", "@objectstack/service-analytics": "workspace:*", "@objectstack/example-crm": "workspace:*", - "@objectstack/example-showcase": "workspace:*" + "@objectstack/example-showcase": "workspace:*", + "@objectstack/plugin-sharing": "workspace:*" }, "devDependencies": { "@types/node": "^25.9.3", diff --git a/packages/dogfood/src/harness.ts b/packages/dogfood/src/harness.ts index e10c8475ed..415a610b14 100644 --- a/packages/dogfood/src/harness.ts +++ b/packages/dogfood/src/harness.ts @@ -22,6 +22,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createRestApiPlugin } from '@objectstack/rest'; import { AuthPlugin } from '@objectstack/plugin-auth'; import { SecurityPlugin } from '@objectstack/plugin-security'; +import { SharingServicePlugin } from '@objectstack/plugin-sharing'; import { SettingsServicePlugin, LocalCryptoProvider } from '@objectstack/service-settings'; import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; @@ -43,6 +44,10 @@ export interface DogfoodStack { raw(path: string, init?: RequestInit): Promise; /** Sign in through the real auth route; returns a bearer token. Defaults to the dev admin. */ signIn(email?: string, password?: string): Promise; + /** Sign up a NEW user through the real auth route; returns their bearer token. + * The first user is the seeded dev admin, so a fresh sign-up is a plain member + * (no roles/grants) — exactly what RLS cross-owner proofs need. */ + signUp(email: string, password?: string, name?: string): Promise; /** Convenience: an authed JSON request relative to `/api/v1`. */ apiAs(token: string, method: string, path: string, body?: unknown): Promise; /** Tear down the kernel (close DB / HTTP handles). */ @@ -86,6 +91,10 @@ export async function bootDogfoodStack( await kernel.use(new AnalyticsServicePlugin()); await kernel.use(new AuthPlugin({ secret: 'dogfood-regression-secret' })); await kernel.use(new SecurityPlugin()); + // Sharing service — apps that declare `requires: ['sharing']` rely on it for + // record-share grants; without it their RLS/sharing rules are inert and the + // verifier would under-report authorization. + await kernel.use(new SharingServicePlugin()); // REST + dispatcher route surfaces (mount onto the http-server service). await kernel.use(createRestApiPlugin({ api: { api: { requireAuth: true } } as never })); @@ -133,6 +142,24 @@ export async function bootDogfoodStack( return data.token; }; + const signUp = async ( + email: string, + password = 'Member-Pass-123', + name?: string, + ): Promise => { + const res = await api('/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, name: name ?? email.split('@')[0] }), + }); + if (!res.ok) { + throw new Error(`dogfood signUp failed: ${res.status} ${await res.text()}`); + } + const data = (await res.json()) as { token?: string }; + if (!data.token) throw new Error('dogfood signUp: no token in response'); + return data.token; + }; + const apiAs = (token: string, method: string, path: string, body?: unknown) => api(path, { method, @@ -157,5 +184,5 @@ export async function bootDogfoodStack( } }; - return { kernel, api, raw, signIn, apiAs, stop }; + return { kernel, api, raw, signIn, signUp, apiAs, stop }; } diff --git a/packages/dogfood/src/rls.ts b/packages/dogfood/src/rls.ts new file mode 100644 index 0000000000..8d7c26dfbc --- /dev/null +++ b/packages/dogfood/src/rls.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Metadata-driven RLS cross-owner proofs — the #1994 invariant. +// +// #1994 ("member edits others' records") was a by-id write that skipped the +// row-level predicate: `driver.update(object, id, …)` builds no AST, so RLS +// never scoped it. The clean, app-agnostic invariant that catches it without +// interpreting each sharing rule: +// +// A user who CANNOT READ a record must not be able to WRITE it. +// ("You can't mutate what you can't see.") +// +// Derivation, per object: admin creates a record; a fresh member (no roles or +// grants) tries to read it, then tries to mutate it by id; we re-read as admin +// to see if the row actually changed. If the member couldn't see it yet changed +// it, that's the #1994 class of hole — regardless of the app's sharing config. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { DogfoodStack } from './harness.js'; +import { deriveCrudCases } from './derive.js'; + +const PROBE_TYPES = new Set(['text', 'textarea', 'string']); +const MUTATION = 'rls-mutated-by-B'; + +export interface RlsResult { + object: string; + status: 'rls-consistent' | 'rls-hole' | 'member-visible' | 'skipped'; + detail?: string; +} + +export interface RlsReport { + app: string; + results: RlsResult[]; + summary: { objects: number; consistent: number; holes: number; memberVisible: number; skipped: number }; +} + +export async function runRlsProofs( + stack: DogfoodStack, + adminToken: string, + memberToken: string, + config: any, +): Promise { + const cases = deriveCrudCases(config); + const results: RlsResult[] = []; + + for (const c of cases) { + if (c.blocked) { results.push({ object: c.object, status: 'skipped', detail: c.blocked }); continue; } + + // A plain-text field to mutate (avoid email/url/phone — their format checks + // would reject the probe for a benign reason, masking the RLS signal). + const probe = (c.asserts ?? []).find((a) => PROBE_TYPES.has(a.type)); + if (!probe) { results.push({ object: c.object, status: 'skipped', detail: 'no plain-text probe field' }); continue; } + + // Admin (owner) creates the record. + const created = await stack.apiAs(adminToken, 'POST', `/data/${c.object}`, c.body); + if (created.status >= 300) { + results.push({ object: c.object, status: 'skipped', detail: `admin create failed (${created.status})` }); + continue; + } + const cj = (await created.json()) as any; + const id = cj?.id ?? cj?.record?.id; + if (!id) { results.push({ object: c.object, status: 'skipped', detail: 'no id from create' }); continue; } + + // Member B: can they SEE it? + const bRead = await stack.apiAs(memberToken, 'GET', `/data/${c.object}/${id}`); + let canRead = false; + if (bRead.status === 200) { + const rec = ((await bRead.json()) as any)?.record; + canRead = !!rec && rec.id === id; + } + + // Member B: try to MUTATE it by id. + const bWrite = await stack.apiAs(memberToken, 'PATCH', `/data/${c.object}/${id}`, { [probe.field]: MUTATION }); + + // Ground truth: re-read as admin — did the row actually change? + const after = await stack.apiAs(adminToken, 'GET', `/data/${c.object}/${id}`); + const afterVal = (((await after.json()) as any)?.record ?? {})[probe.field]; + const changed = afterVal === MUTATION; + + if (canRead) { + 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)' }); + } else if (changed) { + results.push({ + object: c.object, + status: 'rls-hole', + detail: `member B cannot read it (GET ${bRead.status}) yet MUTATED it by id (PATCH ${bWrite.status}) — by-id write bypassed RLS (#1994 class)`, + }); + } else { + results.push({ + object: c.object, + status: 'rls-consistent', + detail: `member B cannot read (GET ${bRead.status}) and could not mutate (PATCH ${bWrite.status}, row unchanged)`, + }); + } + } + + const summary = { + objects: results.length, + consistent: results.filter((r) => r.status === 'rls-consistent').length, + holes: results.filter((r) => r.status === 'rls-hole').length, + memberVisible: results.filter((r) => r.status === 'member-visible').length, + skipped: results.filter((r) => r.status === 'skipped').length, + }; + return { app: config?.manifest?.id ?? 'app', results, summary }; +} + +export function formatRlsReport(report: RlsReport): string { + const lines: string[] = [`\n=== objectstack verify (RLS / #1994) — ${report.app} ===`]; + for (const r of report.results) { + const mark = r.status === 'rls-hole' ? '✗✗' : r.status === 'rls-consistent' ? '✓' : r.status === 'member-visible' ? '·' : '–'; + lines.push(` ${mark} ${r.object} [${r.status}] ${r.detail ?? ''}`); + } + const s = report.summary; + lines.push(` ── ${s.consistent} consistent, ${s.holes} HOLES, ${s.memberVisible} member-visible, ${s.skipped} skipped`); + return lines.join('\n'); +} diff --git a/packages/dogfood/test/auto-verify-rls.dogfood.test.ts b/packages/dogfood/test/auto-verify-rls.dogfood.test.ts new file mode 100644 index 0000000000..16543f4cb7 --- /dev/null +++ b/packages/dogfood/test/auto-verify-rls.dogfood.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Live RLS cross-owner smoke (#1994) over the framework example apps, with a +// real second user. The runner's hole-detection logic is unit-proven in +// `rls-runner.test.ts`; this exercises it end-to-end against real apps. +// +// 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 +// every object reports `member-visible` and the #1994 by-id-write path isn't +// exercised here. A hard, revert-provable gate needs an owner-scoped fixture +// (a private-default object + a member permission set carrying RLS.ownerPolicy +// + SecurityPlugin.fallbackPermissionSet) — tracked as the next step. The +// invariant asserted now (zero holes) still guards against a regression that +// makes a member able to mutate a record it cannot read. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootDogfoodStack, type DogfoodStack } from '../src/harness.js'; +import { runRlsProofs, formatRlsReport, type RlsReport } from '../src/rls.js'; + +const APPS: Array<[string, unknown]> = [ + ['crm', crmStack], + ['showcase', showcaseStack], +]; + +for (const [name, config] of APPS) { + describe(`objectstack verify RLS: ${name} (#1994 cross-owner)`, () => { + let stack: DogfoodStack; + let report: RlsReport; + + beforeAll(async () => { + stack = await bootDogfoodStack(config as never); + const adminToken = await stack.signIn(); + const memberToken = await stack.signUp(`member-${name}@verify.test`); + report = await runRlsProofs(stack, adminToken, memberToken, config); + // eslint-disable-next-line no-console + console.error(formatRlsReport(report)); + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('boots with two distinct users and runs cross-owner proofs', () => { + expect(report.summary.objects).toBeGreaterThan(0); + }); + + it('has ZERO by-id-write RLS holes (#1994 invariant)', () => { + const holes = report.results.filter((r) => r.status === 'rls-hole'); + expect(holes, formatRlsReport(report)).toHaveLength(0); + }); + }); +} diff --git a/packages/dogfood/test/rls-runner.test.ts b/packages/dogfood/test/rls-runner.test.ts new file mode 100644 index 0000000000..ab3843438b --- /dev/null +++ b/packages/dogfood/test/rls-runner.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Unit proof that the RLS runner's #1994 classification is correct — driven by a +// scripted fake stack so we can exercise the three outcomes deterministically +// (a live owner-isolated fixture to exercise them end-to-end is the next step). +// +// The invariant: a user who CANNOT READ a record must not be able to WRITE it. + +import { describe, it, expect } from 'vitest'; +import { runRlsProofs } from '../src/rls.js'; +import type { DogfoodStack } from '../src/harness.js'; + +const CONFIG = { + manifest: { id: 'fixture' }, + objects: [{ name: 'note', fields: { name: { type: 'text', required: true } } }], +}; + +/** A fake stack: admin always sees/owns; member behaviour is scripted per scenario. */ +function fakeStack(opts: { + memberCanRead: boolean; + memberWriteMutates: boolean; // does member's PATCH actually change the row? +}): DogfoodStack { + const store: Record = {}; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + + const apiAs: DogfoodStack['apiAs'] = async (token, method, path, body) => { + const isAdmin = token === 'admin'; + const [, , object, id] = path.split('/'); // /data// + if (method === 'POST') { + const newId = 'rec1'; + store[newId] = { id: newId, ...(body as object) }; + return json({ object, id: newId, record: store[newId] }, 201); + } + if (method === 'GET') { + if (!isAdmin && !opts.memberCanRead) return json({ error: 'not found' }, 404); + return json({ object, id, record: store[id] ?? null }); + } + if (method === 'PATCH') { + // Admin always writes. Member writes only "land" when the scenario says so + // (i.e. RLS failed to scope the by-id write — the #1994 bug). + if (isAdmin || opts.memberWriteMutates) Object.assign(store[id], body as object); + return json({ object, id, record: store[id] }, isAdmin || opts.memberWriteMutates ? 200 : 403); + } + return json({}, 405); + }; + + return { + apiAs, + kernel: {} as never, + api: (async () => new Response()) as never, + raw: (async () => new Response()) as never, + signIn: async () => 'admin', + signUp: async () => 'member', + stop: async () => {}, + }; +} + +describe('runRlsProofs #1994 classification', () => { + it('flags a HOLE when a member who cannot read a record still mutates it by id', async () => { + const stack = fakeStack({ memberCanRead: false, memberWriteMutates: true }); + const report = await runRlsProofs(stack, 'admin', 'member', CONFIG); + expect(report.summary.holes).toBe(1); + expect(report.results[0].status).toBe('rls-hole'); + }); + + it('passes (consistent) when a member who cannot read also cannot mutate', async () => { + const stack = fakeStack({ memberCanRead: false, memberWriteMutates: false }); + const report = await runRlsProofs(stack, 'admin', 'member', CONFIG); + expect(report.summary.holes).toBe(0); + expect(report.results[0].status).toBe('rls-consistent'); + }); + + it('reports member-visible (inconclusive) when the member can read the record', async () => { + const stack = fakeStack({ memberCanRead: true, memberWriteMutates: true }); + const report = await runRlsProofs(stack, 'admin', 'member', CONFIG); + expect(report.summary.holes).toBe(0); + expect(report.results[0].status).toBe('member-visible'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9248d4615b..5c4e44d644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -841,6 +841,9 @@ importers: '@objectstack/plugin-security': specifier: workspace:* version: link:../plugins/plugin-security + '@objectstack/plugin-sharing': + specifier: workspace:* + version: link:../plugins/plugin-sharing '@objectstack/rest': specifier: workspace:* version: link:../rest