okengine 0.17.2 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/manifest.v1.schema.json +61 -2
- package/package.json +18 -4
- package/site/content/docs/elements/clock.mdx +1 -1
- package/site/content/docs/elements/flow.mdx +25 -1
- package/site/content/docs/elements/store.mdx +287 -341
- package/site/content/docs/elements/vault.mdx +5 -5
- package/site/content/docs/get-started/installation.mdx +1 -2
- package/site/content/docs/get-started/introduction.mdx +54 -110
- package/site/content/docs/get-started/meta.json +9 -1
- package/site/content/docs/get-started/testing.mdx +328 -0
- package/site/content/docs/get-started/why.mdx +94 -70
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/plugins/apple.mdx +151 -0
- package/site/content/docs/plugins/discord.mdx +139 -0
- package/site/content/docs/plugins/facebook.mdx +134 -0
- package/site/content/docs/plugins/figma.mdx +138 -0
- package/site/content/docs/plugins/github.mdx +138 -0
- package/site/content/docs/plugins/google.mdx +153 -0
- package/site/content/docs/plugins/index.mdx +47 -1
- package/site/content/docs/plugins/meta.json +10 -0
- package/site/content/docs/plugins/microsoft.mdx +151 -0
- package/site/content/docs/plugins/oauth.mdx +188 -0
- package/site/content/docs/plugins/x.mdx +125 -0
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/client.mdx +58 -1
- package/site/content/docs/reference/configuration.mdx +2 -4
- package/site/content/docs/reference/fx.mdx +3 -1
- package/site/content/docs/reference/index.mdx +0 -5
- package/site/content/docs/reference/meta.json +2 -2
- package/site/content/docs/reference/okid.mdx +137 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/config.ts +9 -0
- package/src/auth/identity-sql.ts +314 -0
- package/src/auth/identity.ts +140 -2
- package/src/auth/index.ts +17 -1
- package/src/auth/method-context.ts +3 -0
- package/src/auth/oauth-as/cimd.ts +132 -0
- package/src/auth/oauth-as/crypto.test.ts +101 -0
- package/src/auth/oauth-as/crypto.ts +393 -0
- package/src/auth/oauth-as/errors.ts +68 -0
- package/src/auth/oauth-as/http.test.ts +419 -0
- package/src/auth/oauth-as/http.ts +842 -0
- package/src/auth/oauth-as/stores.ts +61 -0
- package/src/auth/oauth-as/tables.ts +142 -0
- package/src/auth/tables.ts +0 -11
- package/src/bench/README.md +83 -0
- package/src/bench/REPORT.md +176 -0
- package/src/bench/g01-rls-stamp.bench.ts +194 -0
- package/src/bench/g02-clock-per-tenant.bench.ts +158 -0
- package/src/bench/g03-signal-once.bench.ts +157 -0
- package/src/bench/g03-signal-reconnect.bench.ts +254 -0
- package/src/bench/g03-signal-sse-memory.bench.ts +191 -0
- package/src/bench/g04-auth-vault-hotpath.bench.ts +170 -0
- package/src/bench/g05-sustained-full.bench.ts +265 -0
- package/src/bench/g06-mixed-load.bench.ts +260 -0
- package/src/bench/g07-vault-crypto.bench.ts +100 -0
- package/src/bench/g07-vault-rotate-under-read.bench.ts +285 -0
- package/src/bench/g08-conn-oversubscribe.bench.ts +194 -0
- package/src/bench/g08-store-kv-durable.bench.ts +133 -0
- package/src/bench/g08-store-sql.bench.ts +178 -0
- package/src/bench/g09-journal-sustained.bench.ts +203 -0
- package/src/bench/g10-observability-contention.bench.ts +246 -0
- package/src/bench/g11-cold-start-cycle.bench.ts +164 -0
- package/src/bench/g13-elements.bench.ts +427 -0
- package/src/bench/g14-graceful-shutdown.bench.ts +244 -0
- package/src/bench/g15-postgres-degradation.bench.ts +264 -0
- package/src/bench/g16-live-query-fanout.bench.ts +206 -0
- package/src/bench/lib/event-loop-lag.ts +26 -0
- package/src/bench/lib/infra.ts +60 -0
- package/src/bench/lib/report.ts +52 -0
- package/src/bench/lib/rss-sampler.ts +61 -0
- package/src/bench/lib/signal-pg.ts +88 -0
- package/src/bench/load-app.ts +337 -0
- package/src/bench/load-child.ts +108 -0
- package/src/bench/smoke.bench.ts +43 -0
- package/src/cli/competitor-mention-removal.test.ts +28 -0
- package/src/cli/doctor-fd.ts +117 -0
- package/src/cli/doctor.test.ts +192 -0
- package/src/cli/doctor.ts +129 -1
- package/src/client/create.ts +95 -1
- package/src/client/index.ts +9 -2
- package/src/client/transport.ts +11 -4
- package/src/client/use-live-query.ts +154 -0
- package/src/client-react/index.ts +15 -1
- package/src/client-react/live-resource.ts +246 -0
- package/src/client-react/use-live-query.test.ts +475 -0
- package/src/client-react/use-live-query.ts +530 -0
- package/src/compiler/extract.test.ts +518 -0
- package/src/compiler/extract.ts +386 -19
- package/src/console/server/invoke-user-flow.ts +2 -1
- package/src/console/ui-next/dist/assets/{access-page-DnWbnGzq.js → access-page-DY4N6nnk.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-BiZ4-6yQ.js → flows-page-CsPDMrVM.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-C8NRK2R-.js → index-CcTDXHuz.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-CrB6vd1T.js → observability-page-CKR595wP.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CS5-aETQ.js → store-page-02xOiqIK.js} +3 -3
- package/src/console/ui-next/dist/assets/{units-page-CjtdlW8l.js → units-page-CpPFFKyE.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-C6Xxm9SA.js → vault-page-BsMf-9_W.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/features/store/lib/fields-from-table.ts +36 -2
- package/src/drivers/cdc-outbox.ts +389 -0
- package/src/drivers/memory.ts +20 -0
- package/src/drivers/oauth-apple.ts +156 -0
- package/src/drivers/oauth-discord.ts +79 -0
- package/src/drivers/oauth-facebook.ts +80 -0
- package/src/drivers/oauth-figma.ts +116 -0
- package/src/drivers/oauth-github.ts +92 -0
- package/src/drivers/oauth-google.ts +142 -0
- package/src/drivers/oauth-microsoft.ts +174 -0
- package/src/drivers/oauth-oidc.ts +293 -0
- package/src/drivers/oauth-shared.ts +326 -0
- package/src/drivers/oauth-types.ts +159 -0
- package/src/drivers/oauth-x.ts +77 -0
- package/src/drivers/oauth2-common.ts +95 -0
- package/src/drivers/oauth2-token.ts +61 -0
- package/src/drivers/pg-rls-row-passes.ts +251 -0
- package/src/drivers/pg-rls.ts +2 -0
- package/src/drivers/postgres.ts +45 -2
- package/src/drivers/signal-postgres.ts +2 -1
- package/src/elements/channel/runtime.ts +29 -2
- package/src/elements/channel.test.ts +52 -0
- package/src/elements/gate/boot.ts +29 -2
- package/src/elements/store/emit-drizzle.ts +147 -14
- package/src/elements/store/field-ddl.test.ts +118 -0
- package/src/elements/store/field-types.test.ts +455 -0
- package/src/elements/store/list-query.golden.json +777 -0
- package/src/elements/store/list-query.parity.test.ts +396 -0
- package/src/elements/store/list-query.ts +792 -0
- package/src/elements/store/live-default.test.ts +136 -0
- package/src/elements/store/live-http.test.ts +160 -0
- package/src/elements/store/live-isolation.test.ts +291 -0
- package/src/elements/store/live-query-runtime.test.ts +323 -0
- package/src/elements/store/live-query-runtime.ts +403 -0
- package/src/elements/store/live-query-server.test.ts +377 -0
- package/src/elements/store/live-query-server.ts +102 -0
- package/src/elements/store/live-query.ts +97 -0
- package/src/elements/store/resource.ts +189 -680
- package/src/elements/store/rls-row-passes-policies.parity.test.ts +665 -0
- package/src/elements/store/schema-decl.ts +539 -41
- package/src/elements/store/sql-rls-stamp.test.ts +27 -0
- package/src/elements/store/sql-session.ts +297 -35
- package/src/elements/store/table.ts +102 -21
- package/src/elements/store.test.ts +3 -1
- package/src/elements/store.ts +12 -1
- package/src/elements/vault/chaos-child.ts +74 -1
- package/src/elements/vault/chaos.test.ts +4 -2
- package/src/elements/vault/storage.ts +4 -2
- package/src/index.ts +4 -1
- package/src/kernel/app-auth.ts +1 -0
- package/src/kernel/app.ts +116 -2
- package/src/kernel/auth-sharing.test.ts +196 -0
- package/src/kernel/boot.test.ts +3 -3
- package/src/kernel/errors.ts +8 -0
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +14 -2
- package/src/kernel/horizontal-child.ts +2 -1
- package/src/kernel/http-resource.ts +33 -7
- package/src/kernel/identity-host-persist.test.ts +119 -0
- package/src/kernel/instance-id.ts +4 -2
- package/src/kernel/journal.ts +2 -1
- package/src/kernel/mcp-tool.test.ts +95 -0
- package/src/kernel/on.ts +9 -0
- package/src/kernel/realtime-bind.ts +326 -0
- package/src/kernel/resource-live.ts +117 -0
- package/src/kernel/triggers.ts +86 -4
- package/src/manifest/diff.ts +37 -0
- package/src/manifest/types.ts +64 -2
- package/src/okid.bench.test.ts +64 -0
- package/src/okid.test.ts +338 -0
- package/src/okid.ts +245 -0
- package/src/plugins/anonymous.ts +19 -1
- package/src/plugins/auth/shared.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/magic-link.ts +10 -8
- package/src/plugins/mcp-oauth.ts +208 -0
- package/src/plugins/oauth/flow-store.ts +117 -0
- package/src/plugins/oauth/link.ts +69 -0
- package/src/plugins/oauth/shared.ts +108 -0
- package/src/plugins/oauth/token-vault.ts +100 -0
- package/src/plugins/oauth.security.test.ts +535 -0
- package/src/plugins/oauth.ts +532 -0
- package/src/plugins/otp.ts +48 -6
- package/src/plugins/passkey.ts +20 -1
- package/src/plugins/two-factor.ts +11 -0
- package/src/plugins/username.ts +40 -7
- package/src/release/build-lib.ts +7 -1
- package/src/release/measure.ts +1 -0
- package/src/release/official-plugins.ts +4 -1
- package/src/runs/collect.ts +2 -1
- package/src/runs/drivers/files.ts +2 -1
- package/src/test/create-test-app.ts +114 -5
- package/src/test/export-bundle.test.ts +33 -0
- package/src/test/live-signals.test.ts +83 -0
- package/src/test/tenant-isolation.test.ts +175 -0
- package/src/testing.ts +26 -0
- package/src/upgrade/codemods.ts +1 -1
- package/site/content/docs/reference/migrating-environments.mdx +0 -158
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RLS parity gate — `oke.row_passes_policies` replay vs native Postgres RLS.
|
|
3
|
+
*
|
|
4
|
+
* BLOCKING GATE for the Realtime round: every case constructs a real table,
|
|
5
|
+
* installs real `CREATE POLICY` DDL, opens a REAL stamped store handle per
|
|
6
|
+
* identity (same `rls` prelude production uses — `SET LOCAL ROLE oke_app`,
|
|
7
|
+
* `set_config('oke.*')`, pinned transaction), reads ground truth natively,
|
|
8
|
+
* then asserts the synthetic-JSONB replay returns the IDENTICAL boolean.
|
|
9
|
+
*
|
|
10
|
+
* Ground truth per command:
|
|
11
|
+
* - SELECT/UPDATE/DELETE visibility → stamped point lookup `WHERE pk = $1`.
|
|
12
|
+
* - INSERT / WITH CHECK → stamped INSERT attempt (accepted = policy passed).
|
|
13
|
+
*
|
|
14
|
+
* Policy families covered (adversarial, not happy-path):
|
|
15
|
+
* owner · tenant · gate · scope(string)
|
|
16
|
+
* plus two mandatory multi-policy compositions:
|
|
17
|
+
* A. gate + owner — two PERMISSIVE policies, Postgres ORs them
|
|
18
|
+
* ("member + admin firehose" family)
|
|
19
|
+
* B. gate∧tenant compound USING + scope WITH CHECK ("tenant + scope" family)
|
|
20
|
+
*
|
|
21
|
+
* Run: bun test src/elements/store/rls-row-passes-policies.parity.test.ts
|
|
22
|
+
*
|
|
23
|
+
* Live-Postgres leg (parity must hold on real PG, not just PGlite):
|
|
24
|
+
* OKE_TEST_DOCKER=1 DATABASE_URL=postgres://… bun test src/elements/store/rls-row-passes-policies.parity.test.ts
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
28
|
+
import { connectPglite } from "../../drivers/pglite.ts";
|
|
29
|
+
import { installOkeRlsHelpers, type RlsIdentity } from "../../drivers/pg-rls.ts";
|
|
30
|
+
import {
|
|
31
|
+
ROW_PASSES_POLICIES_STATEMENTS as ROW_PASSES_STATEMENTS,
|
|
32
|
+
buildInlineRowPassesSql,
|
|
33
|
+
} from "../../drivers/pg-rls-row-passes.ts";
|
|
34
|
+
import type { SqlConnection } from "../../drivers/types.ts";
|
|
35
|
+
import { createSqlStoreHandle, type SqlStoreHandle } from "./sql-session.ts";
|
|
36
|
+
|
|
37
|
+
/** Adversarial row image (JSONB payload shape mirrors table columns). */
|
|
38
|
+
type RowImage = Readonly<Record<string, unknown>>;
|
|
39
|
+
|
|
40
|
+
interface VisibilityCase {
|
|
41
|
+
readonly name: string;
|
|
42
|
+
readonly id: string;
|
|
43
|
+
readonly row: RowImage;
|
|
44
|
+
readonly visible: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const aliceMember: RlsIdentity = { gate: "member", userId: "alice", scopes: ["member"] };
|
|
48
|
+
const bobMember: RlsIdentity = { gate: "member", userId: "bob", scopes: ["member"] };
|
|
49
|
+
const acmeCreator: RlsIdentity = {
|
|
50
|
+
gate: "member",
|
|
51
|
+
userId: "u1",
|
|
52
|
+
scopes: ["member", "invoices:create"],
|
|
53
|
+
tenantId: "acme",
|
|
54
|
+
};
|
|
55
|
+
const acmeMemberNoScope: RlsIdentity = {
|
|
56
|
+
gate: "member",
|
|
57
|
+
userId: "u1",
|
|
58
|
+
scopes: ["member"],
|
|
59
|
+
tenantId: "acme",
|
|
60
|
+
};
|
|
61
|
+
const globexMember: RlsIdentity = {
|
|
62
|
+
gate: "member",
|
|
63
|
+
userId: "u1",
|
|
64
|
+
scopes: ["member"],
|
|
65
|
+
tenantId: "globex",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function handleFor(conn: SqlConnection, rls?: RlsIdentity): SqlStoreHandle {
|
|
69
|
+
return createSqlStoreHandle("sql:parity", {
|
|
70
|
+
connection: conn,
|
|
71
|
+
classifications: new Map(),
|
|
72
|
+
routedRole: "primary",
|
|
73
|
+
domainDdl: "off",
|
|
74
|
+
...(rls ? { rls } : {}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compare native ground truth and replay verdicts for one visibility case.
|
|
80
|
+
* Both probes run through the SAME stamped handle → identical GUC state.
|
|
81
|
+
*/
|
|
82
|
+
async function assertVisibilityParity(
|
|
83
|
+
conn: SqlConnection,
|
|
84
|
+
identity: RlsIdentity,
|
|
85
|
+
table: string,
|
|
86
|
+
testCase: VisibilityCase,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const h = handleFor(conn, identity);
|
|
89
|
+
const errs: unknown[] = [];
|
|
90
|
+
let native = false;
|
|
91
|
+
let replay = false;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const rows = await h.raw(`SELECT 1 AS ok FROM "${table}" WHERE id = ? LIMIT 1`, [testCase.id]);
|
|
95
|
+
native = rows.length > 0;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
errs.push(err);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const sql = buildInlineRowPassesSql(table, testCase.row, "SELECT");
|
|
101
|
+
const out = await h.raw(sql, []);
|
|
102
|
+
replay = out[0]?.ok === true;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
errs.push(err);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (errs.length > 0) throw errs[0];
|
|
108
|
+
expect(replay).toBe(testCase.visible);
|
|
109
|
+
expect(native).toBe(testCase.visible);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** WITH CHECK parity via a real INSERT attempt under the stamp. */
|
|
113
|
+
async function assertInsertParity(
|
|
114
|
+
conn: SqlConnection,
|
|
115
|
+
identity: RlsIdentity,
|
|
116
|
+
table: string,
|
|
117
|
+
columns: string,
|
|
118
|
+
placeholders: string,
|
|
119
|
+
row: RowImage,
|
|
120
|
+
values: readonly unknown[],
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const h = handleFor(conn, identity);
|
|
123
|
+
let accepted = false;
|
|
124
|
+
try {
|
|
125
|
+
await h.raw(`INSERT INTO "${table}" ${columns} VALUES ${placeholders}`, [...values]);
|
|
126
|
+
accepted = true;
|
|
127
|
+
} catch {
|
|
128
|
+
accepted = false;
|
|
129
|
+
}
|
|
130
|
+
const inline = buildInlineRowPassesSql(table, row, "INSERT");
|
|
131
|
+
const out = await h.raw(inline, []);
|
|
132
|
+
const replay = out[0]?.ok === true;
|
|
133
|
+
expect(replay).toBe(accepted);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe("row_passes_policies install statements", () => {
|
|
137
|
+
test("statements are single-command (no statement stacking)", () => {
|
|
138
|
+
for (const stmt of ROW_PASSES_STATEMENTS) {
|
|
139
|
+
expect(
|
|
140
|
+
stmt.includes("COMMIT") ||
|
|
141
|
+
stmt.includes(";") === false ||
|
|
142
|
+
countTopLevelSemicolons(stmt) <= 0,
|
|
143
|
+
).toBe(true);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
function countTopLevelSemicolons(sql: string): number {
|
|
149
|
+
let inDollar = false;
|
|
150
|
+
let count = 0;
|
|
151
|
+
for (let i = 0; i < sql.length; i += 1) {
|
|
152
|
+
if (sql.startsWith("$fn$", i)) {
|
|
153
|
+
inDollar = !inDollar;
|
|
154
|
+
i += 3;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (sql[i] === ";" && !inDollar) count += 1;
|
|
158
|
+
}
|
|
159
|
+
return count;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function freshPglite(name: string): Promise<SqlConnection> {
|
|
163
|
+
const c = await connectPglite({ url: `memory://${name}`, role: "primary" });
|
|
164
|
+
await installOkeRlsHelpers((sql) => c.exec(sql));
|
|
165
|
+
for (const stmt of ROW_PASSES_STATEMENTS) await c.exec(stmt);
|
|
166
|
+
return c;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
describe("RLS parity — owner family (pglite)", () => {
|
|
170
|
+
let conn: SqlConnection;
|
|
171
|
+
let seed: SqlStoreHandle;
|
|
172
|
+
|
|
173
|
+
beforeAll(async () => {
|
|
174
|
+
conn = await freshPglite("parity-owner");
|
|
175
|
+
await conn.exec(`CREATE TABLE notes (
|
|
176
|
+
id text PRIMARY KEY, owner text NOT NULL, body text NOT NULL)`);
|
|
177
|
+
await conn.exec(`GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO oke_app`);
|
|
178
|
+
await conn.exec(`ALTER TABLE notes ENABLE ROW LEVEL SECURITY`);
|
|
179
|
+
await conn.exec(`CREATE POLICY owner_all ON notes
|
|
180
|
+
AS PERMISSIVE FOR ALL TO public USING (owner = oke.user()) WITH CHECK (owner = oke.user())`);
|
|
181
|
+
// Seed as the table owner (connection user bypasses RLS on own tables).
|
|
182
|
+
seed = handleFor(conn);
|
|
183
|
+
await seed.raw(`INSERT INTO notes VALUES ('a','alice','hi'), ('b','bob','yo')`);
|
|
184
|
+
}, 30_000);
|
|
185
|
+
|
|
186
|
+
afterAll(async () => {
|
|
187
|
+
await conn.close();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("own row passes; other's row denied — SELECT and UPDATE contexts", async () => {
|
|
191
|
+
await assertVisibilityParity(conn, aliceMember, "notes", {
|
|
192
|
+
name: "alice sees own row",
|
|
193
|
+
id: "a",
|
|
194
|
+
row: { id: "a", owner: "alice", body: "hi" },
|
|
195
|
+
visible: true,
|
|
196
|
+
});
|
|
197
|
+
await assertVisibilityParity(conn, aliceMember, "notes", {
|
|
198
|
+
name: "alice denied bob row",
|
|
199
|
+
id: "b",
|
|
200
|
+
row: { id: "b", owner: "bob", body: "yo" },
|
|
201
|
+
visible: false,
|
|
202
|
+
});
|
|
203
|
+
await assertVisibilityParity(conn, bobMember, "notes", {
|
|
204
|
+
name: "bob before-image of alice-owned row invisible (revoked path)",
|
|
205
|
+
id: "a",
|
|
206
|
+
row: { id: "a", owner: "alice", body: "moved" },
|
|
207
|
+
visible: false,
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("empty userId denies everything (adversarial)", async () => {
|
|
212
|
+
await assertVisibilityParity(conn, { gate: "public", userId: "", scopes: [] }, "notes", {
|
|
213
|
+
name: "anonymous denied",
|
|
214
|
+
id: "a",
|
|
215
|
+
row: { id: "a", owner: "alice", body: "hi" },
|
|
216
|
+
visible: false,
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("RLS parity — tenant family (pglite)", () => {
|
|
222
|
+
let conn: SqlConnection;
|
|
223
|
+
|
|
224
|
+
beforeAll(async () => {
|
|
225
|
+
conn = await freshPglite("parity-tenant");
|
|
226
|
+
await conn.exec(`CREATE TABLE bookings (
|
|
227
|
+
id text PRIMARY KEY, tenant_id text NOT NULL, body text NOT NULL)`);
|
|
228
|
+
await conn.exec(`GRANT SELECT, INSERT, UPDATE, DELETE ON bookings TO oke_app`);
|
|
229
|
+
await conn.exec(`ALTER TABLE bookings ENABLE ROW LEVEL SECURITY`);
|
|
230
|
+
await conn.exec(`CREATE POLICY tenant_all ON bookings
|
|
231
|
+
AS PERMISSIVE FOR ALL TO public
|
|
232
|
+
USING (tenant_id = oke.tenant()) WITH CHECK (tenant_id = oke.tenant())`);
|
|
233
|
+
await handleFor(conn).raw(`INSERT INTO bookings VALUES ('1','acme','a'), ('2','globex','b')`);
|
|
234
|
+
}, 30_000);
|
|
235
|
+
|
|
236
|
+
afterAll(async () => {
|
|
237
|
+
await conn.close();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("matching tenant passes; cross-tenant denied", async () => {
|
|
241
|
+
await assertVisibilityParity(conn, acmeMemberNoScope, "bookings", {
|
|
242
|
+
name: "acme sees acme row",
|
|
243
|
+
id: "1",
|
|
244
|
+
row: { id: "1", tenant_id: "acme", body: "a" },
|
|
245
|
+
visible: true,
|
|
246
|
+
});
|
|
247
|
+
await assertVisibilityParity(conn, globexMember, "bookings", {
|
|
248
|
+
name: "globex denied acme row",
|
|
249
|
+
id: "1",
|
|
250
|
+
row: { id: "1", tenant_id: "acme", body: "a" },
|
|
251
|
+
visible: false,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("reassigned-away row: after-image invisible, before-image replayable (CDC semantics)", async () => {
|
|
256
|
+
// Real CDC scenario: id=2 belonged to acme ("was-mine"), then an
|
|
257
|
+
// unstamped/admin UPDATE reassigned it to globex. Classifying that event
|
|
258
|
+
// for an acme subscriber means:
|
|
259
|
+
// 1. after-image on heap → native stamped lookup is EMPTY (revoked)
|
|
260
|
+
// 2. BEFORE-image {id:'2',tenant_id:'acme'} was visible under the
|
|
261
|
+
// acme stamp — `row_passes_policies` must agree (delete path).
|
|
262
|
+
const acme = handleFor(conn, acmeMemberNoScope);
|
|
263
|
+
|
|
264
|
+
// Simulate the reassignment as the table owner (a stamped acme session
|
|
265
|
+
// cannot push its own row out of tenant scope — mirrors production,
|
|
266
|
+
// where cross-tenant moves happen through admin/unstamped writes).
|
|
267
|
+
await handleFor(conn).raw(`UPDATE bookings SET tenant_id = 'globex' WHERE id = '2'`);
|
|
268
|
+
|
|
269
|
+
// Native ground truth: after-image invisible.
|
|
270
|
+
const rows = await acme.raw(`SELECT 1 AS ok FROM bookings WHERE id = '2' LIMIT 1`);
|
|
271
|
+
expect(rows.length).toBe(0);
|
|
272
|
+
|
|
273
|
+
// Replay parity: before-image visible.
|
|
274
|
+
const probe = buildInlineRowPassesSql(
|
|
275
|
+
"bookings",
|
|
276
|
+
{ id: "2", tenant_id: "acme", body: "was-mine" },
|
|
277
|
+
"SELECT",
|
|
278
|
+
);
|
|
279
|
+
const out = await acme.raw(probe, []);
|
|
280
|
+
expect(out[0]?.ok).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("missing tenant GUC denies everything", async () => {
|
|
284
|
+
await assertVisibilityParity(
|
|
285
|
+
conn,
|
|
286
|
+
{ gate: "member", userId: "u1", scopes: ["member"] },
|
|
287
|
+
"bookings",
|
|
288
|
+
{
|
|
289
|
+
name: "no tenant stamp denied",
|
|
290
|
+
id: "1",
|
|
291
|
+
row: { id: "1", tenant_id: "acme", body: "a" },
|
|
292
|
+
visible: false,
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("RLS parity — gate + scope families (pglite)", () => {
|
|
299
|
+
let conn: SqlConnection;
|
|
300
|
+
|
|
301
|
+
beforeAll(async () => {
|
|
302
|
+
conn = await freshPglite("parity-gate-scope");
|
|
303
|
+
await conn.exec(`CREATE TABLE secrets (
|
|
304
|
+
id text PRIMARY KEY, visibility text NOT NULL, payload text NOT NULL)`);
|
|
305
|
+
await conn.exec(`GRANT SELECT, INSERT ON secrets TO oke_app`);
|
|
306
|
+
await conn.exec(`ALTER TABLE secrets ENABLE ROW LEVEL SECURITY`);
|
|
307
|
+
await conn.exec(`CREATE POLICY member_select ON secrets
|
|
308
|
+
AS PERMISSIVE FOR SELECT TO public USING (oke.gate() = 'member')`);
|
|
309
|
+
await conn.exec(`CREATE POLICY scope_insert ON secrets
|
|
310
|
+
AS PERMISSIVE FOR INSERT TO public WITH CHECK (oke.has_scope('secrets:write'))`);
|
|
311
|
+
await handleFor(conn).raw(`INSERT INTO secrets VALUES ('s1','public','x')`);
|
|
312
|
+
}, 30_000);
|
|
313
|
+
|
|
314
|
+
afterAll(async () => {
|
|
315
|
+
await conn.close();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("gate match passes; wrong gate denied", async () => {
|
|
319
|
+
await assertVisibilityParity(conn, aliceMember, "secrets", {
|
|
320
|
+
name: "member select allowed",
|
|
321
|
+
id: "s1",
|
|
322
|
+
row: { id: "s1", visibility: "public", payload: "x" },
|
|
323
|
+
visible: true,
|
|
324
|
+
});
|
|
325
|
+
await assertVisibilityParity(
|
|
326
|
+
conn,
|
|
327
|
+
{ gate: "partner", userId: "u9", scopes: ["partner"] },
|
|
328
|
+
"secrets",
|
|
329
|
+
{
|
|
330
|
+
name: "partner gate denied",
|
|
331
|
+
id: "s1",
|
|
332
|
+
row: { id: "s1", visibility: "public", payload: "x" },
|
|
333
|
+
visible: false,
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("scope literal survives rewrite ('secrets:write' is not a column)", async () => {
|
|
339
|
+
await assertInsertParity(
|
|
340
|
+
conn,
|
|
341
|
+
{ ...aliceMember, scopes: ["secrets:write"] },
|
|
342
|
+
"secrets",
|
|
343
|
+
"(id, visibility, payload)",
|
|
344
|
+
"(?, ?, ?)",
|
|
345
|
+
{ id: "s2", visibility: "internal", payload: "y" },
|
|
346
|
+
["s2", "internal", "y"],
|
|
347
|
+
);
|
|
348
|
+
await assertInsertParity(
|
|
349
|
+
conn,
|
|
350
|
+
{ ...aliceMember, scopes: [] },
|
|
351
|
+
"secrets",
|
|
352
|
+
"(id, visibility, payload)",
|
|
353
|
+
"(?, ?, ?)",
|
|
354
|
+
{ id: "s3", visibility: "internal", payload: "z" },
|
|
355
|
+
["s3", "internal", "z"],
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe("RLS parity — composition A: gate + owner, two PERMISSIVE (pglite)", () => {
|
|
361
|
+
let conn: SqlConnection;
|
|
362
|
+
|
|
363
|
+
beforeAll(async () => {
|
|
364
|
+
conn = await freshPglite("parity-comp-a");
|
|
365
|
+
await conn.exec(`CREATE TABLE docs (
|
|
366
|
+
id text PRIMARY KEY, owner text NOT NULL, title text NOT NULL)`);
|
|
367
|
+
await conn.exec(`GRANT SELECT ON docs TO oke_app`);
|
|
368
|
+
await conn.exec(`ALTER TABLE docs ENABLE ROW LEVEL SECURITY`);
|
|
369
|
+
await conn.exec(`CREATE POLICY member_select ON docs
|
|
370
|
+
AS PERMISSIVE FOR SELECT TO public USING (oke.gate() = 'member')`);
|
|
371
|
+
await conn.exec(`CREATE POLICY owner_select ON docs
|
|
372
|
+
AS PERMISSIVE FOR SELECT TO public USING (owner = oke.user())`);
|
|
373
|
+
await handleFor(conn).raw(`INSERT INTO docs VALUES ('d1','zara','hers'), ('d2','ali','his')`);
|
|
374
|
+
}, 30_000);
|
|
375
|
+
|
|
376
|
+
afterAll(async () => {
|
|
377
|
+
await conn.close();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("permissive OR — member non-owner via gate; guest owner via owner; guest stranger denied", async () => {
|
|
381
|
+
await assertVisibilityParity(conn, bobMember, "docs", {
|
|
382
|
+
name: "member non-owner sees via gate policy",
|
|
383
|
+
id: "d1",
|
|
384
|
+
row: { id: "d1", owner: "zara", title: "hers" },
|
|
385
|
+
visible: true,
|
|
386
|
+
});
|
|
387
|
+
await assertVisibilityParity(
|
|
388
|
+
conn,
|
|
389
|
+
{ gate: "guest", userId: "ali", scopes: ["guest"] },
|
|
390
|
+
"docs",
|
|
391
|
+
{
|
|
392
|
+
name: "guest owner sees via owner policy",
|
|
393
|
+
id: "d2",
|
|
394
|
+
row: { id: "d2", owner: "ali", title: "his" },
|
|
395
|
+
visible: true,
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
await assertVisibilityParity(
|
|
399
|
+
conn,
|
|
400
|
+
{ gate: "guest", userId: "mallory", scopes: ["guest"] },
|
|
401
|
+
"docs",
|
|
402
|
+
{
|
|
403
|
+
name: "guest stranger denied by both",
|
|
404
|
+
id: "d1",
|
|
405
|
+
row: { id: "d1", owner: "zara", title: "hers" },
|
|
406
|
+
visible: false,
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe("RLS parity — composition B: gate∧tenant compound + scope INSERT (pglite)", () => {
|
|
413
|
+
let conn: SqlConnection;
|
|
414
|
+
|
|
415
|
+
beforeAll(async () => {
|
|
416
|
+
conn = await freshPglite("parity-comp-b");
|
|
417
|
+
await conn.exec(`CREATE TABLE invoices (
|
|
418
|
+
id text PRIMARY KEY, tenant_id text NOT NULL, total integer NOT NULL)`);
|
|
419
|
+
await conn.exec(`GRANT SELECT, INSERT ON invoices TO oke_app`);
|
|
420
|
+
await conn.exec(`ALTER TABLE invoices ENABLE ROW LEVEL SECURITY`);
|
|
421
|
+
await conn.exec(`CREATE POLICY member_select ON invoices
|
|
422
|
+
AS PERMISSIVE FOR SELECT TO public
|
|
423
|
+
USING (oke.gate() = 'member' AND tenant_id = oke.tenant())`);
|
|
424
|
+
await conn.exec(`CREATE POLICY scope_insert ON invoices
|
|
425
|
+
AS PERMISSIVE FOR INSERT TO public WITH CHECK (oke.has_scope('invoices:create'))`);
|
|
426
|
+
await handleFor(conn).raw(`INSERT INTO invoices VALUES ('i1','acme',100)`);
|
|
427
|
+
}, 30_000);
|
|
428
|
+
|
|
429
|
+
afterAll(async () => {
|
|
430
|
+
await conn.close();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("compound expression respects tenant conjunct", async () => {
|
|
434
|
+
await assertVisibilityParity(conn, acmeCreator, "invoices", {
|
|
435
|
+
name: "acme member sees own invoice",
|
|
436
|
+
id: "i1",
|
|
437
|
+
row: { id: "i1", tenant_id: "acme", total: 100 },
|
|
438
|
+
visible: true,
|
|
439
|
+
});
|
|
440
|
+
await assertVisibilityParity(conn, globexMember, "invoices", {
|
|
441
|
+
name: "globex member denied acme invoice",
|
|
442
|
+
id: "i1",
|
|
443
|
+
row: { id: "i1", tenant_id: "acme", total: 100 },
|
|
444
|
+
visible: false,
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("INSERT requires invoices:create scope even when tenant matches", async () => {
|
|
449
|
+
await assertInsertParity(
|
|
450
|
+
conn,
|
|
451
|
+
acmeMemberNoScope,
|
|
452
|
+
"invoices",
|
|
453
|
+
"(id, tenant_id, total)",
|
|
454
|
+
"(?, ?, ?)",
|
|
455
|
+
{ id: "i2", tenant_id: "acme", total: 5 },
|
|
456
|
+
["i2", "acme", 5],
|
|
457
|
+
);
|
|
458
|
+
await assertInsertParity(
|
|
459
|
+
conn,
|
|
460
|
+
acmeCreator,
|
|
461
|
+
"invoices",
|
|
462
|
+
"(id, tenant_id, total)",
|
|
463
|
+
"(?, ?, ?)",
|
|
464
|
+
{ id: "i3", tenant_id: "acme", total: 7 },
|
|
465
|
+
["i3", "acme", 7],
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe("RLS parity — regression gate: TO-role + command-clause selection (pglite)", () => {
|
|
471
|
+
// Added after the adversarial audit (release-blocking finding round):
|
|
472
|
+
//
|
|
473
|
+
// 1. TO-ROLE LEAK — a PERMISSIVE policy granted `TO <role>` where the stamp
|
|
474
|
+
// role is NOT a member must be invisible to native evaluation. The old
|
|
475
|
+
// replay ignored pg_policies.roles entirely, so an internal wide policy
|
|
476
|
+
// (TO internal_role USING (true)) falsely widened every subscriber's
|
|
477
|
+
// visibility through the replay path.
|
|
478
|
+
//
|
|
479
|
+
// 2. INSERT CLAUSE SELECTION — native INSERT evaluates WITH CHECK only;
|
|
480
|
+
// the old replay evaluated qual-first and skipped INSERT-only policies,
|
|
481
|
+
// diverging whenever a table mixes ALL/UPDATE policies with distinct
|
|
482
|
+
// USING/WITH CHECK clauses.
|
|
483
|
+
|
|
484
|
+
let conn: SqlConnection;
|
|
485
|
+
|
|
486
|
+
beforeAll(async () => {
|
|
487
|
+
conn = await freshPglite("parity-regressions");
|
|
488
|
+
await conn.exec(`CREATE TABLE tenant_wide (
|
|
489
|
+
id text PRIMARY KEY, tenant_id text NOT NULL, note text NOT NULL)`);
|
|
490
|
+
await conn.exec(`GRANT SELECT, INSERT ON tenant_wide TO oke_app`);
|
|
491
|
+
await conn.exec(`ALTER TABLE tenant_wide ENABLE ROW LEVEL SECURITY`);
|
|
492
|
+
await conn.exec(`CREATE POLICY tenant_sel ON tenant_wide
|
|
493
|
+
AS PERMISSIVE FOR SELECT TO public
|
|
494
|
+
USING (tenant_id = oke.tenant())`);
|
|
495
|
+
// Non-applicable role: oke_app has no membership in oke_internal —
|
|
496
|
+
// natively this policy never applies to stamped sessions.
|
|
497
|
+
await conn.exec(`DO $do$ BEGIN
|
|
498
|
+
CREATE ROLE oke_internal NOLOGIN;
|
|
499
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $do$`);
|
|
500
|
+
await conn.exec(`CREATE POLICY wide_internal ON tenant_wide
|
|
501
|
+
AS PERMISSIVE FOR SELECT TO oke_internal
|
|
502
|
+
USING (true)`);
|
|
503
|
+
await handleFor(conn).raw(`INSERT INTO tenant_wide VALUES ('1','acme','secret')`);
|
|
504
|
+
|
|
505
|
+
await conn.exec(`CREATE TABLE mixed_cmds (
|
|
506
|
+
id text PRIMARY KEY, tenant_id text NOT NULL)`);
|
|
507
|
+
await conn.exec(`GRANT SELECT, INSERT, UPDATE, DELETE ON mixed_cmds TO oke_app`);
|
|
508
|
+
await conn.exec(`ALTER TABLE mixed_cmds ENABLE ROW LEVEL SECURITY`);
|
|
509
|
+
// ALL policy with deliberately DISTINCT USING vs WITH CHECK:
|
|
510
|
+
await conn.exec(`CREATE POLICY upsert_all ON mixed_cmds
|
|
511
|
+
AS PERMISSIVE FOR ALL TO public
|
|
512
|
+
USING (tenant_id = oke.tenant())
|
|
513
|
+
WITH CHECK (tenant_id <> 'locked')`);
|
|
514
|
+
await handleFor(conn).raw(`INSERT INTO mixed_cmds VALUES ('0','base')`);
|
|
515
|
+
}, 30_000);
|
|
516
|
+
|
|
517
|
+
afterAll(async () => {
|
|
518
|
+
await conn.close();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("regression: TO-restricted permissive policy must NOT widen replay visibility", async () => {
|
|
522
|
+
const globexMemberNoTenantMatch = { ...globexMember };
|
|
523
|
+
const h = handleFor(conn, globexMemberNoTenantMatch);
|
|
524
|
+
// Native ground truth: only tenant_sel applies (wide_internal is TO
|
|
525
|
+
// oke_internal); globex is not 'acme' → row invisible.
|
|
526
|
+
const rows = await h.raw(`SELECT 1 AS ok FROM "tenant_wide" WHERE id = ? LIMIT 1`, ["1"]);
|
|
527
|
+
expect(rows.length).toBe(0);
|
|
528
|
+
// Replay must agree — old fn returned true here (LEAK).
|
|
529
|
+
const inline = buildInlineRowPassesSql(
|
|
530
|
+
"tenant_wide",
|
|
531
|
+
{ id: "1", tenant_id: "acme", note: "secret" },
|
|
532
|
+
"SELECT",
|
|
533
|
+
);
|
|
534
|
+
const out = await h.raw(inline, []);
|
|
535
|
+
expect(out[0]?.ok === true).toBe(false);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("regression: INSERT evaluates WITH CHECK even when ALL-policy USING denies", async () => {
|
|
539
|
+
// Native: INSERT on mixed_cmds evaluates ONLY WC (tenant<>'locked') →
|
|
540
|
+
// a globex-stamped insert of an 'acme' row is ACCEPTED despite the
|
|
541
|
+
// USING clause disagreeing with the stamp's tenant.
|
|
542
|
+
let accepted = false;
|
|
543
|
+
try {
|
|
544
|
+
await handleFor(conn, globexMember).raw(
|
|
545
|
+
`INSERT INTO "mixed_cmds" ("id", "tenant_id") VALUES (?, ?)`,
|
|
546
|
+
["9", "acme"],
|
|
547
|
+
);
|
|
548
|
+
accepted = true;
|
|
549
|
+
} catch {
|
|
550
|
+
accepted = false;
|
|
551
|
+
}
|
|
552
|
+
expect(accepted).toBe(true);
|
|
553
|
+
// Replay must agree — old fn evaluated USING (qual) first and denied.
|
|
554
|
+
const out = await handleFor(conn, globexMember).raw(
|
|
555
|
+
buildInlineRowPassesSql("mixed_cmds", { id: "9", tenant_id: "acme" }, "INSERT"),
|
|
556
|
+
[],
|
|
557
|
+
);
|
|
558
|
+
expect(out[0]?.ok === true).toBe(true);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Live-Postgres leg — same parity matrix executed against real Postgres.
|
|
564
|
+
* The rewrite algorithm is pure SQL text manipulation; the risky deltas
|
|
565
|
+
* between PGlite and real PG are catalog contents (pg_policies.qual
|
|
566
|
+
* formatting) and regex behavior. Both must hold identically or the gate
|
|
567
|
+
* fails loudly here.
|
|
568
|
+
*/
|
|
569
|
+
const LIVE_PG =
|
|
570
|
+
process.env.OKE_TEST_DOCKER === "1" &&
|
|
571
|
+
(process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL)?.startsWith("postgres");
|
|
572
|
+
if (!LIVE_PG) {
|
|
573
|
+
console.log("skip: live Postgres parity needs OKE_TEST_DOCKER=1 and DATABASE_URL");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
describe.skipIf(!LIVE_PG)("RLS parity — owner family (live postgres)", () => {
|
|
577
|
+
// describe.skipIf also skips beforeAll/afterAll — plain `test.skip` would
|
|
578
|
+
// still open a live PG connection and fail loud on ECONNREFUSED.
|
|
579
|
+
let conn: SqlConnection;
|
|
580
|
+
|
|
581
|
+
beforeAll(async () => {
|
|
582
|
+
const { connectPostgres } = await import("../../drivers/postgres.ts");
|
|
583
|
+
conn = await connectPostgres({
|
|
584
|
+
url: process.env.DATABASE_URL ?? process.env.OKE_STORE_SQL_URL!,
|
|
585
|
+
role: "primary",
|
|
586
|
+
});
|
|
587
|
+
await installOkeRlsHelpers((sql) => conn.exec(sql));
|
|
588
|
+
for (const stmt of ROW_PASSES_STATEMENTS) await conn.exec(stmt);
|
|
589
|
+
await conn.exec(`DROP TABLE IF EXISTS oke_parity_notes`);
|
|
590
|
+
await conn.exec(`CREATE TABLE oke_parity_notes (
|
|
591
|
+
id text PRIMARY KEY, owner text NOT NULL, body text NOT NULL)`);
|
|
592
|
+
await conn.exec(`GRANT SELECT, INSERT, UPDATE, DELETE ON oke_parity_notes TO oke_app`);
|
|
593
|
+
await conn.exec(`ALTER TABLE oke_parity_notes ENABLE ROW LEVEL SECURITY`);
|
|
594
|
+
await conn.exec(`CREATE POLICY owner_all ON oke_parity_notes
|
|
595
|
+
AS PERMISSIVE FOR ALL TO public USING (owner = oke.user()) WITH CHECK (owner = oke.user())`);
|
|
596
|
+
const seed = handleFor(conn);
|
|
597
|
+
await seed.raw(`INSERT INTO oke_parity_notes VALUES ('a','alice','hi'), ('b','bob','yo')`);
|
|
598
|
+
}, 30_000);
|
|
599
|
+
|
|
600
|
+
afterAll(async () => {
|
|
601
|
+
await conn.exec(`DROP TABLE IF EXISTS oke_parity_notes`);
|
|
602
|
+
await conn.close();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("replay matches native stamped verdicts", async () => {
|
|
606
|
+
await assertVisibilityParity(conn, aliceMember, "oke_parity_notes", {
|
|
607
|
+
name: "alice sees own row",
|
|
608
|
+
id: "a",
|
|
609
|
+
row: { id: "a", owner: "alice", body: "hi" },
|
|
610
|
+
visible: true,
|
|
611
|
+
});
|
|
612
|
+
await assertVisibilityParity(conn, aliceMember, "oke_parity_notes", {
|
|
613
|
+
name: "alice denied bob row",
|
|
614
|
+
id: "b",
|
|
615
|
+
row: { id: "b", owner: "bob", body: "yo" },
|
|
616
|
+
visible: false,
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("compound + scope expressions survive PG catalog round-trip", async () => {
|
|
621
|
+
await conn.exec(`DROP TABLE IF EXISTS oke_parity_invoices`);
|
|
622
|
+
await conn.exec(`CREATE TABLE oke_parity_invoices (
|
|
623
|
+
id text PRIMARY KEY, tenant_id text NOT NULL, total integer NOT NULL)`);
|
|
624
|
+
await conn.exec(`GRANT SELECT, INSERT ON oke_parity_invoices TO oke_app`);
|
|
625
|
+
await conn.exec(`ALTER TABLE oke_parity_invoices ENABLE ROW LEVEL SECURITY`);
|
|
626
|
+
await conn.exec(`CREATE POLICY member_select ON oke_parity_invoices
|
|
627
|
+
AS PERMISSIVE FOR SELECT TO public
|
|
628
|
+
USING (oke.gate() = 'member' AND tenant_id = oke.tenant())`);
|
|
629
|
+
await conn.exec(`CREATE POLICY scope_insert ON oke_parity_invoices
|
|
630
|
+
AS PERMISSIVE FOR INSERT TO public WITH CHECK (oke.has_scope('invoices:create'))`);
|
|
631
|
+
await handleFor(conn).raw(`INSERT INTO oke_parity_invoices VALUES ('i1','acme',100)`);
|
|
632
|
+
|
|
633
|
+
await assertVisibilityParity(conn, acmeCreator, "oke_parity_invoices", {
|
|
634
|
+
name: "acme member sees own invoice",
|
|
635
|
+
id: "i1",
|
|
636
|
+
row: { id: "i1", tenant_id: "acme", total: 100 },
|
|
637
|
+
visible: true,
|
|
638
|
+
});
|
|
639
|
+
await assertVisibilityParity(conn, globexMember, "oke_parity_invoices", {
|
|
640
|
+
name: "globex member denied acme invoice",
|
|
641
|
+
id: "i1",
|
|
642
|
+
row: { id: "i1", tenant_id: "acme", total: 100 },
|
|
643
|
+
visible: false,
|
|
644
|
+
});
|
|
645
|
+
await assertInsertParity(
|
|
646
|
+
conn,
|
|
647
|
+
acmeMemberNoScope,
|
|
648
|
+
"oke_parity_invoices",
|
|
649
|
+
"(id, tenant_id, total)",
|
|
650
|
+
"(?, ?, ?)",
|
|
651
|
+
{ id: "i2", tenant_id: "acme", total: 5 },
|
|
652
|
+
["i2", "acme", 5],
|
|
653
|
+
);
|
|
654
|
+
await assertInsertParity(
|
|
655
|
+
conn,
|
|
656
|
+
acmeCreator,
|
|
657
|
+
"oke_parity_invoices",
|
|
658
|
+
"(id, tenant_id, total)",
|
|
659
|
+
"(?, ?, ?)",
|
|
660
|
+
{ id: "i4", tenant_id: "acme", total: 9 },
|
|
661
|
+
["i4", "acme", 9],
|
|
662
|
+
);
|
|
663
|
+
await conn.exec(`DROP TABLE IF EXISTS oke_parity_invoices`);
|
|
664
|
+
}, 30_000);
|
|
665
|
+
});
|