Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 28 additions & 1 deletion packages/dogfood/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -43,6 +44,10 @@ export interface DogfoodStack {
raw(path: string, init?: RequestInit): Promise<Response>;
/** Sign in through the real auth route; returns a bearer token. Defaults to the dev admin. */
signIn(email?: string, password?: string): Promise<string>;
/** 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<string>;
/** Convenience: an authed JSON request relative to `/api/v1`. */
apiAs(token: string, method: string, path: string, body?: unknown): Promise<Response>;
/** Tear down the kernel (close DB / HTTP handles). */
Expand Down Expand Up @@ -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 }));
Expand Down Expand Up @@ -133,6 +142,24 @@ export async function bootDogfoodStack(
return data.token;
};

const signUp = async (
email: string,
password = 'Member-Pass-123',
name?: string,
): Promise<string> => {
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,
Expand All @@ -157,5 +184,5 @@ export async function bootDogfoodStack(
}
};

return { kernel, api, raw, signIn, apiAs, stop };
return { kernel, api, raw, signIn, signUp, apiAs, stop };
}
117 changes: 117 additions & 0 deletions packages/dogfood/src/rls.ts
Original file line number Diff line number Diff line change
@@ -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<RlsReport> {
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');
}
54 changes: 54 additions & 0 deletions packages/dogfood/test/auto-verify-rls.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
80 changes: 80 additions & 0 deletions packages/dogfood/test/rls-runner.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {};
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/<object>/<id>
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');
});
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading