|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// Owner-isolated RLS fixture — the live, revert-provable #1994 gate. |
| 4 | +// |
| 5 | +// The example apps and hotcrm boot single-tenant, where the wildcard |
| 6 | +// `organization_id` tenant policy is stripped and a fresh member falls back to |
| 7 | +// `member_default` (broad read). Result: every object reports `member-visible`, |
| 8 | +// so the #1994 cross-owner write invariant ("a user who cannot READ a record |
| 9 | +// must not be able to WRITE it") is never actually exercised. |
| 10 | +// |
| 11 | +// This fixture creates the missing precondition with ZERO dependence on |
| 12 | +// org-scoping: a single object `rls_note` whose member permission set carries an |
| 13 | +// OWNER policy keyed on `created_by` (`RLS.ownerPolicy`). `created_by` is stamped |
| 14 | +// on every record by the engine and the predicate references `current_user.id` |
| 15 | +// (not `current_user.organization_id`), so it survives single-tenant stripping. |
| 16 | +// A fresh member therefore genuinely CANNOT read a note the admin created — the |
| 17 | +// exact cross-owner scenario the runner needs. |
| 18 | +// |
| 19 | +// Two member permission sets, identical except for the scope of the owner |
| 20 | +// policy, drive the green gate and the automated red proof: |
| 21 | +// |
| 22 | +// ownerScopedMemberSet (operation: 'all') → reads AND writes owner-scoped. |
| 23 | +// The #1994 pre-image check enforces the by-id write → `rls-consistent`. |
| 24 | +// readOnlyScopedMemberSet (operation: 'select') → reads owner-scoped, but NO |
| 25 | +// write policy applies, so the pre-image check has nothing to enforce and |
| 26 | +// the by-id write lands → `rls-hole`. This reproduces the #1994 hole CLASS |
| 27 | +// at the policy layer ("can't read, yet can write") without touching |
| 28 | +// engine code, so the gate's red path is proven on every CI run. |
| 29 | + |
| 30 | +import { defineStack } from '@objectstack/spec'; |
| 31 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 32 | +import { PermissionSetSchema, RLS, type PermissionSet } from '@objectstack/spec/security'; |
| 33 | +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; |
| 34 | + |
| 35 | +/** The one object under test: a private note, owner-scoped via `created_by`. */ |
| 36 | +export const RlsNote = ObjectSchema.create({ |
| 37 | + name: 'rls_note', |
| 38 | + label: 'RLS Note', |
| 39 | + pluralLabel: 'RLS Notes', |
| 40 | + fields: { |
| 41 | + name: Field.text({ label: 'Name', required: true }), |
| 42 | + body: Field.text({ label: 'Body' }), |
| 43 | + }, |
| 44 | +}); |
| 45 | + |
| 46 | +/** A minimal, self-contained app config the dogfood harness can boot. */ |
| 47 | +export const rlsFixtureStack = defineStack({ |
| 48 | + manifest: { |
| 49 | + id: 'com.dogfood.rls_fixture', |
| 50 | + namespace: 'rls', |
| 51 | + version: '0.0.0', |
| 52 | + type: 'app', |
| 53 | + name: 'RLS Owner Fixture', |
| 54 | + description: 'Owner-isolated single-object app exercising the #1994 by-id-write invariant.', |
| 55 | + }, |
| 56 | + objects: [RlsNote], |
| 57 | +}); |
| 58 | + |
| 59 | +/** |
| 60 | + * The fallback permission set a fresh member resolves to. Both variants grant |
| 61 | + * CRUD on `rls_note` (so the request reaches the RLS layer rather than being |
| 62 | + * denied by RBAC) and carry an owner RLS policy keyed on `created_by`. They |
| 63 | + * SHARE a name so each can be the `fallbackPermissionSet` of its own boot. |
| 64 | + */ |
| 65 | +const FIXTURE_MEMBER_SET = 'rls_fixture_member'; |
| 66 | + |
| 67 | +const noteCrud = { |
| 68 | + rls_note: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, |
| 69 | +} as const; |
| 70 | + |
| 71 | +/** |
| 72 | + * GREEN. Owner policy on ALL operations — reads and writes are both |
| 73 | + * owner-scoped. A member cannot read another user's note, and the #1994 |
| 74 | + * pre-image check (security-plugin.ts) re-reads the target row under the |
| 75 | + * write-op owner filter before a by-id update/delete, so the write is denied. |
| 76 | + * Expected runner verdict: `rls-consistent`. |
| 77 | + */ |
| 78 | +export const ownerScopedMemberSet: PermissionSet = PermissionSetSchema.parse({ |
| 79 | + name: FIXTURE_MEMBER_SET, |
| 80 | + label: 'RLS Fixture Member — owner-scoped (all ops)', |
| 81 | + isProfile: true, |
| 82 | + objects: noteCrud, |
| 83 | + rowLevelSecurity: [RLS.ownerPolicy('rls_note', 'created_by')], |
| 84 | +}); |
| 85 | + |
| 86 | +/** |
| 87 | + * RED. Owner policy on SELECT only — reads stay owner-scoped (member still |
| 88 | + * can't see others' notes) but no UPDATE/DELETE policy applies, so |
| 89 | + * `computeRlsFilter` returns null for the write op and the pre-image check is |
| 90 | + * skipped → the by-id write lands. The member mutated a row it could not read: |
| 91 | + * the #1994 hole class. Expected runner verdict: `rls-hole`. |
| 92 | + */ |
| 93 | +export const readOnlyScopedMemberSet: PermissionSet = PermissionSetSchema.parse({ |
| 94 | + name: FIXTURE_MEMBER_SET, |
| 95 | + label: 'RLS Fixture Member — owner-scoped reads only (#1994 hole)', |
| 96 | + isProfile: true, |
| 97 | + objects: noteCrud, |
| 98 | + rowLevelSecurity: [{ ...RLS.ownerPolicy('rls_note', 'created_by'), operation: 'select' }], |
| 99 | +}); |
| 100 | + |
| 101 | +/** |
| 102 | + * Build a SecurityPlugin whose fallback (for a fresh, grant-less member) is the |
| 103 | + * given fixture permission set, layered on top of the real platform defaults so |
| 104 | + * the seeded admin keeps `admin_full_access`. |
| 105 | + */ |
| 106 | +export function rlsFixtureSecurity(memberSet: PermissionSet): SecurityPlugin { |
| 107 | + return new SecurityPlugin({ |
| 108 | + defaultPermissionSets: [...securityDefaultPermissionSets, memberSet], |
| 109 | + fallbackPermissionSet: memberSet.name, |
| 110 | + }); |
| 111 | +} |
0 commit comments