From 0394aded61c8df0270c6f49802b49312c535436d Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:43:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20ADR-0058=20D1=20follow-throug?= =?UTF-8?q?h=20=E2=80=94=20RLS=20predicates=20migrate=20to=20canonical=20C?= =?UTF-8?q?EL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expression surface now speaks ONE language. Migrate every seeded RLS `using`/`check` from the legacy SQL-ish dialect (`=`, `IN (...)`) to pure CEL (`==`, `in`): - default-permission-sets.ts (49) + app-showcase (2): all owner/tenant/self policies are CEL. - rls.zod.ts RLS.ownerPolicy/tenantPolicy/allowAllPolicy helper factories now emit CEL templates; doc examples updated to match. The `sqlPredicateToCel` bridge is downgraded to a DEPRECATED transitional shim: compileExpression warns when it actually rewrites a SQL-style predicate, so authored policies migrate, while stored/legacy SQL still compiles (no silent deny on existing data). The bridge is IDEMPOTENT on CEL input, so the migrated seeds pass through as no-ops with no warn. No runtime behavior change: CEL and the old SQL form compile to the identical FilterCondition (verified). New tests prove the bridge (SQL still compiles + warns; CEL is silent). Green: spec 6599, plugin-security 122, plugin-sharing 65, formula 201, dogfood 130. Co-Authored-By: Claude Opus 4.8 --- .changeset/rls-using-to-cel.md | 14 +++ examples/app-showcase/src/security/index.ts | 4 +- .../src/objects/default-permission-sets.ts | 98 +++++++++---------- .../src/objects/rbac-objects.test.ts | 6 +- .../plugin-security/src/rls-compiler.ts | 19 +++- .../src/security-plugin.test.ts | 41 +++++++- packages/spec/src/security/permission.zod.ts | 2 +- packages/spec/src/security/rls.test.ts | 42 ++++---- packages/spec/src/security/rls.zod.ts | 32 +++--- 9 files changed, 162 insertions(+), 96 deletions(-) create mode 100644 .changeset/rls-using-to-cel.md diff --git a/.changeset/rls-using-to-cel.md b/.changeset/rls-using-to-cel.md new file mode 100644 index 0000000000..a6e3890632 --- /dev/null +++ b/.changeset/rls-using-to-cel.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": minor +--- + +ADR-0058 D1 follow-through — RLS predicates are now canonical CEL. Migrated every +seeded RLS `using`/`check` (default permission sets, showcase, and the +`RLS.ownerPolicy`/`tenantPolicy`/`allowAllPolicy` helper factories) from the +legacy SQL-ish form (`=`, `IN (...)`) to pure CEL (`==`, `in`), so authors and AI +learn ONE expression language. The `sqlPredicateToCel` bridge is retained as a +DEPRECATED transitional shim: a stored SQL-style predicate still compiles (no +silent deny on legacy data) but emits a deprecation warn; canonical CEL passes +through as a no-op. No runtime behavior change — CEL and the old SQL form compile +to the identical FilterCondition. diff --git a/examples/app-showcase/src/security/index.ts b/examples/app-showcase/src/security/index.ts index 144e57ed62..61293c7a37 100644 --- a/examples/app-showcase/src/security/index.ts +++ b/examples/app-showcase/src/security/index.ts @@ -60,7 +60,7 @@ export const ContributorPermissionSet = { description: 'Contributors can only select tasks assigned to them.', object: 'showcase_task', operation: 'select' as const, - using: "assignee = current_user.email", + using: "assignee == current_user.email", roles: ['contributor'], enabled: true, priority: 10, @@ -75,7 +75,7 @@ export const ContributorPermissionSet = { description: "Contributors only see invoices they own; their lines follow via controlled_by_parent.", object: 'showcase_invoice', operation: 'select' as const, - using: "owner = current_user.email", + using: "owner == current_user.email", roles: ['contributor'], enabled: true, priority: 10, diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index 89b4487853..0963d8d3ae 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -157,7 +157,7 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'tenant_isolation', object: '*', operation: 'all', - using: 'organization_id = current_user.organization_id', + using: 'organization_id == current_user.organization_id', }, // ── better-auth system tables that lack `organization_id` and would // otherwise be denied by the wildcard policy. Same self-only @@ -167,79 +167,79 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'sys_organization_self', object: 'sys_organization', operation: 'all', - using: 'id = current_user.organization_id', + using: 'id == current_user.organization_id', }, { name: 'sys_user_self', object: 'sys_user', operation: 'select', - using: 'id = current_user.id', + using: 'id == current_user.id', }, { name: 'sys_user_org_members', object: 'sys_user', operation: 'select', - using: 'id IN (current_user.org_user_ids)', + using: 'id in current_user.org_user_ids', }, { name: 'sys_session_self', object: 'sys_session', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_account_self', object: 'sys_account', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_team_member_self', object: 'sys_team_member', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_two_factor_self', object: 'sys_two_factor', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_user_preference_self', object: 'sys_user_preference', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_api_key_self', object: 'sys_api_key', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_device_code_self', object: 'sys_device_code', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_oauth_access_token_self', object: 'sys_oauth_access_token', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_oauth_refresh_token_self', object: 'sys_oauth_refresh_token', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_oauth_consent_self', object: 'sys_oauth_consent', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, // OAuth applications a user has registered themselves (self-service // developer flow exposed in the Account app's Developer section). @@ -249,7 +249,7 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'sys_oauth_application_self', object: 'sys_oauth_application', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, // Org-scoped visibility for organization-owned identity-adjacent // tables. Org admins may inspect their own org's invitations and @@ -258,19 +258,19 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'sys_member_org', object: 'sys_member', operation: 'select', - using: 'organization_id = current_user.organization_id', + using: 'organization_id == current_user.organization_id', }, { name: 'sys_invitation_org', object: 'sys_invitation', operation: 'select', - using: 'organization_id = current_user.organization_id', + using: 'organization_id == current_user.organization_id', }, { name: 'sys_team_org', object: 'sys_team', operation: 'select', - using: 'organization_id = current_user.organization_id', + using: 'organization_id == current_user.organization_id', }, ], }), @@ -293,7 +293,7 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'tenant_isolation', object: '*', operation: 'all', - using: 'organization_id = current_user.organization_id', + using: 'organization_id == current_user.organization_id', }, // Owner-scoped writes/deletes for rank-and-file members: you may modify // and delete the records you created, not other users'. Keyed on @@ -311,13 +311,13 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'owner_only_writes', object: '*', operation: 'update', - using: 'created_by = current_user.id', + using: 'created_by == current_user.id', }, { name: 'owner_only_deletes', object: '*', operation: 'delete', - using: 'created_by = current_user.id', + using: 'created_by == current_user.id', }, // ── better-auth system tables that lack `organization_id` and would // otherwise be left unprotected by the wildcard rule above. ──── @@ -334,13 +334,13 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'sys_organization_self', object: 'sys_organization', operation: 'all', - using: 'id = current_user.organization_id', + using: 'id == current_user.organization_id', }, { name: 'sys_user_self', object: 'sys_user', operation: 'select', - using: 'id = current_user.id', + using: 'id == current_user.id', }, // Org collaborators: members can see other users in the same // organization. Without this, owner/assignee lookups, @-mention @@ -354,67 +354,67 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'sys_user_org_members', object: 'sys_user', operation: 'select', - using: 'id IN (current_user.org_user_ids)', + using: 'id in current_user.org_user_ids', }, { name: 'sys_session_self', object: 'sys_session', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_account_self', object: 'sys_account', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_team_member_self', object: 'sys_team_member', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_two_factor_self', object: 'sys_two_factor', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_user_preference_self', object: 'sys_user_preference', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_api_key_self', object: 'sys_api_key', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_device_code_self', object: 'sys_device_code', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_oauth_access_token_self', object: 'sys_oauth_access_token', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_oauth_refresh_token_self', object: 'sys_oauth_refresh_token', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_oauth_consent_self', object: 'sys_oauth_consent', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, // OAuth applications a user has registered themselves (Account → // Developer → OAuth Applications). `sys_oauth_application` has no @@ -424,7 +424,7 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'sys_oauth_application_self', object: 'sys_oauth_application', operation: 'all', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, ], }), @@ -449,19 +449,19 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'tenant_isolation', object: '*', operation: 'select', - using: 'organization_id = current_user.organization_id', + using: 'organization_id == current_user.organization_id', }, { name: 'sys_organization_self', object: 'sys_organization', operation: 'select', - using: 'id = current_user.organization_id', + using: 'id == current_user.organization_id', }, { name: 'sys_user_self', object: 'sys_user', operation: 'select', - using: 'id = current_user.id', + using: 'id == current_user.id', }, // Org collaborators (read-only): see `sys_user_org_members` in // `member_default` for rationale. @@ -469,67 +469,67 @@ export const defaultPermissionSets: PermissionSet[] = [ name: 'sys_user_org_members', object: 'sys_user', operation: 'select', - using: 'id IN (current_user.org_user_ids)', + using: 'id in current_user.org_user_ids', }, { name: 'sys_session_self', object: 'sys_session', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_account_self', object: 'sys_account', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_team_member_self', object: 'sys_team_member', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_two_factor_self', object: 'sys_two_factor', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_user_preference_self', object: 'sys_user_preference', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_api_key_self', object: 'sys_api_key', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_device_code_self', object: 'sys_device_code', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_oauth_access_token_self', object: 'sys_oauth_access_token', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_oauth_refresh_token_self', object: 'sys_oauth_refresh_token', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, { name: 'sys_oauth_consent_self', object: 'sys_oauth_consent', operation: 'select', - using: 'user_id = current_user.id', + using: 'user_id == current_user.id', }, ], }), diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index feaf0dca26..c35c16056c 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -82,13 +82,13 @@ describe('default permission sets', () => { 'tenant_isolation', ]); const tenantPolicy = (member.rowLevelSecurity ?? []).find((p) => p.name === 'tenant_isolation')!; - expect(tenantPolicy.using).toBe('organization_id = current_user.organization_id'); + expect(tenantPolicy.using).toBe('organization_id == current_user.organization_id'); const orgSelf = (member.rowLevelSecurity ?? []).find((p) => p.name === 'sys_organization_self')!; expect(orgSelf.object).toBe('sys_organization'); - expect(orgSelf.using).toBe('id = current_user.organization_id'); + expect(orgSelf.using).toBe('id == current_user.organization_id'); const sessionSelf = (member.rowLevelSecurity ?? []).find((p) => p.name === 'sys_session_self')!; expect(sessionSelf.object).toBe('sys_session'); - expect(sessionSelf.using).toBe('user_id = current_user.id'); + expect(sessionSelf.using).toBe('user_id == current_user.id'); }); it('viewer_readonly denies writes', () => { diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index f7ed792463..bde626f66f 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -85,9 +85,12 @@ export function isSupportedRlsExpression(expression: string): boolean { } /** - * Bridge the legacy SQL-ish RLS `using` subset to canonical CEL so it flows + * @deprecated Transitional bridge (ADR-0058 D1). Canonical RLS predicates are + * CEL; this exists ONLY so stored/legacy SQL-ish `using`/`check` keeps compiling + * until it is migrated. Bridge the legacy SQL subset to canonical CEL so it flows * through the ONE compiler: `=` → `==`, `IN` → `in`. Quoted string literals are - * left untouched. Only this historically-supported subset is bridged — compound + * left untouched. It is IDEMPOTENT on CEL input (a `==`/`in` predicate is + * unchanged), so authored-CEL seeds pass through as no-ops (no deprecation warn). Only this historically-supported subset is bridged — compound * predicates should be authored in canonical CEL (`&&` / `||`); anything outside * the subset (subqueries, SQL `AND`/`OR`, `LIKE`) stays unparseable and so fails * closed, exactly as before. @@ -243,7 +246,17 @@ export class RLSCompiler { userCtx: RLSUserContext ): Record | null { if (!expression) return null; - const result = compileCelToFilter(sqlPredicateToCel(expression), { + // [ADR-0058 D1] CEL is canonical. The legacy SQL-ish form still compiles via + // the transitional bridge, but we surface it so authored policies migrate to + // CEL — the bridge will be removed once no stored predicate needs it. + const cel = sqlPredicateToCel(expression); + if (cel !== expression) { + this.logger?.warn?.( + `[RLS] DEPRECATED SQL-style predicate "${expression}" — author RLS using/check in ` + + `canonical CEL (\`==\`, \`in\`). The SQL compatibility bridge is transitional.`, + ); + } + const result = compileCelToFilter(cel, { variables: { current_user: userCtx as Record }, }); // Any fault — unsupported shape, parse error, or an unresolved/null diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 8e146f57f6..749fe471ba 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -1321,8 +1321,47 @@ describe('RLSCompiler D4 — uncompilable predicates are surfaced', () => { const compiler = new RLSCompiler(); compiler.setLogger({ warn: (message: string) => warned.push(message) }); // valid shape; `department` simply isn't in the context → intentional fail-closed skip. - const policy: any = { name: 'dept', object: 'thing', operation: 'select', using: 'dept = current_user.department' }; + // CEL form (SQL `=` would now additionally emit a deprecation warn). + const policy: any = { name: 'dept', object: 'thing', operation: 'select', using: 'dept == current_user.department' }; compiler.compileFilter([policy], { userId: 'u1' } as any); expect(warned.length).toBe(0); }); }); + + +// --------------------------------------------------------------------------- +// ADR-0058 D1 — SQL→CEL deprecation bridge: legacy SQL-style RLS predicates +// still COMPILE (back-compat for stored predicates) but emit a deprecation warn; +// canonical CEL passes through silently (idempotent bridge). +// --------------------------------------------------------------------------- +describe('RLSCompiler — SQL→CEL deprecation bridge (ADR-0058 D1)', () => { + it('a legacy SQL-style predicate still compiles AND warns deprecation', () => { + const warned: string[] = []; + const compiler = new RLSCompiler(); + compiler.setLogger({ warn: (m: string) => warned.push(m) }); + const policy: any = { object: 'task', operation: 'select', using: 'owner_id = current_user.id' }; + const filter = compiler.compileFilter([policy], { userId: 'u1' } as any); + expect(filter).toEqual({ owner_id: 'u1' }); // bridge keeps it working + expect(warned.some((m) => m.includes('DEPRECATED SQL-style'))).toBe(true); + }); + + it('a legacy SQL `IN (...)` predicate still compiles AND warns', () => { + const warned: string[] = []; + const compiler = new RLSCompiler(); + compiler.setLogger({ warn: (m: string) => warned.push(m) }); + const policy: any = { object: 'p', operation: 'select', using: 'id IN (current_user.org_user_ids)' }; + const filter = compiler.compileFilter([policy], { userId: 'u1', org_user_ids: ['u1', 'u2'] } as any); + expect(filter).toEqual({ id: { $in: ['u1', 'u2'] } }); + expect(warned.some((m) => m.includes('DEPRECATED SQL-style'))).toBe(true); + }); + + it('canonical CEL passes through with NO deprecation warn (idempotent bridge)', () => { + const warned: string[] = []; + const compiler = new RLSCompiler(); + compiler.setLogger({ warn: (m: string) => warned.push(m) }); + const policy: any = { object: 'task', operation: 'select', using: 'owner_id == current_user.id' }; + const filter = compiler.compileFilter([policy], { userId: 'u1' } as any); + expect(filter).toEqual({ owner_id: 'u1' }); // identical result to the SQL form + expect(warned.length).toBe(0); + }); +}); diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index 97fa5f2ed4..e42f8d35e6 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -159,7 +159,7 @@ export const PermissionSetSchema = lazySchema(() => z.object({ * name: 'tenant_filter', * object: 'account', * operation: 'select', - * using: 'organization_id = current_user.organization_id' + * using: 'organization_id == current_user.organization_id' * }] * ``` */ diff --git a/packages/spec/src/security/rls.test.ts b/packages/spec/src/security/rls.test.ts index b95cdf000e..d52a39481e 100644 --- a/packages/spec/src/security/rls.test.ts +++ b/packages/spec/src/security/rls.test.ts @@ -33,7 +33,7 @@ describe('Row-Level Security (RLS) Protocol', () => { name: 'tenant_isolation', object: 'account', operation: 'select', - using: 'organization_id = current_user.organization_id', + using: 'organization_id == current_user.organization_id', }; const result = RowLevelSecurityPolicySchema.parse(policy); @@ -66,7 +66,7 @@ describe('Row-Level Security (RLS) Protocol', () => { name: 'valid_policy_name', object: 'account', operation: 'select', - using: 'owner_id = current_user.id', + using: 'owner_id == current_user.id', }; expect(() => RowLevelSecurityPolicySchema.parse(validPolicy)).not.toThrow(); @@ -75,7 +75,7 @@ describe('Row-Level Security (RLS) Protocol', () => { name: 'InvalidPolicyName', // camelCase not allowed object: 'account', operation: 'select', - using: 'owner_id = current_user.id', + using: 'owner_id == current_user.id', }; expect(() => RowLevelSecurityPolicySchema.parse(invalidPolicy)).toThrow(); @@ -113,7 +113,7 @@ describe('Row-Level Security (RLS) Protocol', () => { name: 'test_policy', object: 'account', operation: 'select', - using: 'owner_id = current_user.id', + using: 'owner_id == current_user.id', }; const result = RowLevelSecurityPolicySchema.parse(policy); @@ -125,7 +125,7 @@ describe('Row-Level Security (RLS) Protocol', () => { name: 'test_policy', object: 'account', operation: 'select', - using: 'owner_id = current_user.id', + using: 'owner_id == current_user.id', }; const result = RowLevelSecurityPolicySchema.parse(policy); @@ -137,8 +137,8 @@ describe('Row-Level Security (RLS) Protocol', () => { name: 'tenant_all_ops', object: 'account', operation: 'all', - using: 'organization_id = current_user.organization_id', - check: 'organization_id = current_user.organization_id', + using: 'organization_id == current_user.organization_id', + check: 'organization_id == current_user.organization_id', }; const result = RowLevelSecurityPolicySchema.parse(policy); @@ -360,14 +360,14 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(policy.name).toBe('opportunity_owner_access'); expect(policy.object).toBe('opportunity'); expect(policy.operation).toBe('all'); - expect(policy.using).toBe('owner_id = current_user.id'); + expect(policy.using).toBe('owner_id == current_user.id'); expect(policy.enabled).toBe(true); }); it('should create owner-based policy with custom owner field', () => { const policy = RLS.ownerPolicy('task', 'assigned_to_id'); - expect(policy.using).toBe('assigned_to_id = current_user.id'); + expect(policy.using).toBe('assigned_to_id == current_user.id'); }); }); @@ -378,16 +378,16 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(policy.name).toBe('account_tenant_isolation'); expect(policy.object).toBe('account'); expect(policy.operation).toBe('all'); - expect(policy.using).toBe('organization_id = current_user.organization_id'); - expect(policy.check).toBe('organization_id = current_user.organization_id'); + expect(policy.using).toBe('organization_id == current_user.organization_id'); + expect(policy.check).toBe('organization_id == current_user.organization_id'); expect(policy.enabled).toBe(true); }); it('should create tenant isolation policy with custom field', () => { const policy = RLS.tenantPolicy('order', 'workspace_id'); - expect(policy.using).toBe('workspace_id = current_user.organization_id'); - expect(policy.check).toBe('workspace_id = current_user.organization_id'); + expect(policy.using).toBe('workspace_id == current_user.organization_id'); + expect(policy.check).toBe('workspace_id == current_user.organization_id'); }); }); @@ -415,7 +415,7 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(policy.name).toBe('account_ceo_cfo_full_access'); expect(policy.object).toBe('account'); expect(policy.operation).toBe('all'); - expect(policy.using).toBe('1 = 1'); // Always true + expect(policy.using).toBe('1 == 1'); // Always true expect(policy.roles).toEqual(['ceo', 'cfo']); expect(policy.enabled).toBe(true); }); @@ -430,8 +430,8 @@ describe('Row-Level Security (RLS) Protocol', () => { description: 'Ensure users only access data from their own organization', object: 'customer', operation: 'all', - using: 'organization_id = current_user.organization_id', - check: 'organization_id = current_user.organization_id', + using: 'organization_id == current_user.organization_id', + check: 'organization_id == current_user.organization_id', enabled: true, tags: ['multi-tenant', 'security'], }; @@ -529,14 +529,14 @@ describe('Row-Level Security (RLS) Protocol', () => { description: 'C-level executives can view all data', object: 'financial_data', operation: 'all', - using: '1 = 1', // Always true - see everything + using: '1 == 1', // Always true - see everything roles: ['ceo', 'cfo', 'cto'], enabled: true, priority: 100, // Highest priority }; const result = RowLevelSecurityPolicySchema.parse(policy); - expect(result.using).toBe('1 = 1'); + expect(result.using).toBe('1 == 1'); expect(result.priority).toBe(100); }); }); @@ -561,7 +561,7 @@ describe('Row-Level Security (RLS) Protocol', () => { name: 'prevent_backdating', object: 'transaction', operation: 'insert', - using: '1 = 1', + using: '1 == 1', check: 'transaction_date >= CURRENT_DATE - INTERVAL "30 days"', enabled: true, }; @@ -599,13 +599,13 @@ describe('Row-Level Security (RLS) Protocol', () => { policyName: 'owner_access', granted: false, evaluationDurationMs: 15.3, - matchedCondition: 'owner_id = current_user.id', + matchedCondition: 'owner_id == current_user.id', rowCount: 0, metadata: { source: 'api', requestId: 'req-789' }, }); expect(event.granted).toBe(false); - expect(event.matchedCondition).toBe('owner_id = current_user.id'); + expect(event.matchedCondition).toBe('owner_id == current_user.id'); expect(event.rowCount).toBe(0); expect(event.metadata?.source).toBe('api'); }); diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index df7cf6b90a..dbbf5119ef 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -18,25 +18,25 @@ import { z } from 'zod'; * * 1. **Multi-Tenant Data Isolation** * - Users only see records from their organization - * - `using: "organization_id = current_user.organization_id"` + * - `using: "organization_id == current_user.organization_id"` * * 2. **Ownership-Based Access** * - Users only see records they own - * - `using: "owner_id = current_user.id"` + * - `using: "owner_id == current_user.id"` * * 3. **Organization Member Visibility** * - Users see fellow members of their active organization - * - `using: "id IN (current_user.org_user_ids)"` + * - `using: "id in current_user.org_user_ids"` * (`org_user_ids` is pre-resolved by the runtime) * * 4. **Territory / Regional Access (§7.3.1 dynamic membership)** * - Sales reps only see accounts in their assigned territories - * - `using: "account_id IN (current_user.territory_account_ids)"` + * - `using: "account_id in current_user.territory_account_ids"` * (the runtime stages `territory_account_ids` in `ExecutionContext.rlsMembership`) * * 5. **Manager / Hierarchy Access (§7.3.1 dynamic membership)** * - Managers see records assigned to anyone they manage - * - `using: "assigned_to_id IN (current_user.team_member_ids)"` + * - `using: "assigned_to_id in current_user.team_member_ids"` * (the runtime pre-resolves `team_member_ids`, no subquery needed) * * ## PostgreSQL RLS Comparison @@ -58,7 +58,7 @@ import { z } from 'zod'; * name: 'tenant_isolation', * object: 'account', * operation: 'select', - * using: 'organization_id = current_user.organization_id' + * using: 'organization_id == current_user.organization_id' * } * ``` * @@ -125,7 +125,7 @@ export type RLSOperation = z.infer; * label: 'Multi-Tenant Data Isolation', * object: 'account', * operation: 'select', - * using: 'organization_id = current_user.organization_id', + * using: 'organization_id == current_user.organization_id', * enabled: true * } * ``` @@ -137,7 +137,7 @@ export type RLSOperation = z.infer; * label: 'Users Can View Their Own Records', * object: 'opportunity', * operation: 'select', - * using: 'owner_id = current_user.id', + * using: 'owner_id == current_user.id', * enabled: true * } * ``` @@ -151,7 +151,7 @@ export type RLSOperation = z.infer; * operation: 'select', * // The runtime resolves the manager's reports into * // ExecutionContext.rlsMembership.team_member_ids — no subquery needed. - * using: 'assigned_to_id IN (current_user.team_member_ids)', + * using: 'assigned_to_id in current_user.team_member_ids', * roles: ['manager', 'director'], * enabled: true * } @@ -164,7 +164,7 @@ export type RLSOperation = z.infer; * label: 'Prevent Cross-Tenant Data Creation', * object: 'account', * operation: 'insert', - * check: 'organization_id = current_user.organization_id', + * check: 'organization_id == current_user.organization_id', * enabled: true * } * ``` @@ -178,7 +178,7 @@ export type RLSOperation = z.infer; * operation: 'select', * // The runtime stages the rep's territory accounts in * // ExecutionContext.rlsMembership.territory_account_ids. - * using: 'id IN (current_user.territory_account_ids)', + * using: 'id in current_user.territory_account_ids', * roles: ['sales_rep'], * enabled: true * } @@ -203,7 +203,7 @@ export type RLSOperation = z.infer; * label: 'Executives See All Records', * object: 'account', * operation: 'all', - * using: '1 = 1', // Always true - see everything + * using: '1 == 1', // Always true - see everything * roles: ['ceo', 'cfo', 'cto'], * enabled: true * } @@ -739,7 +739,7 @@ export const RLS = { label: `Owner Access for ${object}`, object, operation: 'all', - using: `${ownerField} = current_user.id`, + using: `${ownerField} == current_user.id`, enabled: true, priority: 0, }), @@ -758,8 +758,8 @@ export const RLS = { label: `Tenant Isolation for ${object}`, object, operation: 'all', - using: `${tenantField} = current_user.organization_id`, - check: `${tenantField} = current_user.organization_id`, + using: `${tenantField} == current_user.organization_id`, + check: `${tenantField} == current_user.organization_id`, enabled: true, priority: 0, }), @@ -786,7 +786,7 @@ export const RLS = { label: `Full Access for ${roles.join(', ')}`, object, operation: 'all', - using: '1 = 1', // Always true + using: '1 == 1', // Always true roles, enabled: true, priority: 0,