proof-harness 0.1.0-next.11 → 0.1.0-next.12
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/COMPATIBILITY.md +63 -2
- package/PROOF_SDK_CONTRACT.md +10 -0
- package/README.md +6 -2
- package/cli/engines/proof_mutation_check.mjs +13 -12
- package/cli/engines/proof_table_acl.mjs +62 -0
- package/dist/playwright.d.ts +27 -2
- package/dist/playwright.js +135 -1
- package/dist/playwright.js.map +1 -1
- package/package.json +1 -1
- package/proof-consumer-fixtures/mutations/table-acl/snapshot.json +1 -0
package/COMPATIBILITY.md
CHANGED
|
@@ -27,9 +27,70 @@ versions rather than guessing how to interpret them.
|
|
|
27
27
|
|
|
28
28
|
## Prerelease migration notes
|
|
29
29
|
|
|
30
|
-
### 0.1.0-next.
|
|
30
|
+
### 0.1.0-next.12 — independent isolation fixtures and column ACL recovery (candidate)
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Not yet published. Keep the existing consumer pin until this release is reviewed
|
|
33
|
+
and published; then pin exactly `proof-harness@0.1.0-next.12`.
|
|
34
|
+
|
|
35
|
+
`assert.tenantIsolation` now accepts a caller-owned `isolationFixture` instead of
|
|
36
|
+
workspace seeding. Supply a stable fixture `id`, the matching `table`, an explicit
|
|
37
|
+
`scopeColumn`, `setup()` returning two distinct actors and scope values, and
|
|
38
|
+
`cleanup()` that can handle partial setup. Scope values can be user IDs or
|
|
39
|
+
independent account IDs. `scope: "account"` requires this fixture path. Existing
|
|
40
|
+
workspace fixtures and their reverse-direction behavior remain unchanged.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
await assert.tenantIsolation({
|
|
44
|
+
table: "notifications",
|
|
45
|
+
scope: "account",
|
|
46
|
+
scopeColumn: "account_id",
|
|
47
|
+
isolationFixture: {
|
|
48
|
+
id: "notifications-two-accounts-v1",
|
|
49
|
+
table: "notifications",
|
|
50
|
+
async setup() {
|
|
51
|
+
// Consumer creates real Auth customers, resolves account IDs, and seeds rows.
|
|
52
|
+
return {
|
|
53
|
+
a: { actor: { email: ownerA.email, password: ownerA.password }, scopeValue: accountA.id },
|
|
54
|
+
b: { actor: { email: ownerB.email, password: ownerB.password }, scopeValue: accountB.id },
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
async cleanup() { await cleanupOwnedResources(); },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The SDK creates the service client and authenticated probe clients itself. Both
|
|
63
|
+
sides must contain rows before probing; each owner must read all scoped rows,
|
|
64
|
+
and each outsider must see none. Both directions are mandatory. Scope/criterion
|
|
65
|
+
filters apply identically to ground truth and probes. Setup and cleanup run
|
|
66
|
+
without helper provenance; callers cannot stamp SDK assertions. Cleanup errors
|
|
67
|
+
fail the proof. Fixture IDs appear in probe detail; existing trace source hashes
|
|
68
|
+
bind tracked and nonignored fixture source to evidence. Keep fixtures under the
|
|
69
|
+
consumer's source identity, not an excluded artifact directory. This path is
|
|
70
|
+
database-only: `page` is unsupported. It does not extend account-policy SQL
|
|
71
|
+
classification; existing explicit classifier reviews remain separate.
|
|
72
|
+
|
|
73
|
+
`tablePrivilege` mutations now snapshot the relevant table and column ACL entries
|
|
74
|
+
(including grant options) and compare that complete state after restoration.
|
|
75
|
+
Column UPDATE grants removed by PostgreSQL's table REVOKE are re-established.
|
|
76
|
+
Owner-issued direct grants, including grant options, are supported; delegated
|
|
77
|
+
grant graphs, inherited/PUBLIC effective privileges, and owner-target mutations
|
|
78
|
+
remain blocked before planting. No CASCADE recovery is used. Recovery fails
|
|
79
|
+
closed on an old Boolean snapshot: it cannot reconstruct already-lost column
|
|
80
|
+
grants. Keep that journal and reconcile with the protected baseline rather than
|
|
81
|
+
claiming automatic recovery succeeded.
|
|
82
|
+
|
|
83
|
+
After upgrading: port fixtures to the public API, regenerate baseline evidence,
|
|
84
|
+
restore formal table-isolation coverage, and rerun frozen install, static/unit/
|
|
85
|
+
browser checks, the full proof/mutation/control gate, package parity, production
|
|
86
|
+
build, and smoke. Verify actual profile editing and denied privileged-column
|
|
87
|
+
writes after mutations. Package tests are not a substitute for that consumer run.
|
|
88
|
+
Trace, mission, and health protocol versions remain unchanged; the internal
|
|
89
|
+
ACL recovery snapshot has its own version 1.
|
|
90
|
+
|
|
91
|
+
### 0.1.0-next.11 — conservative service RPC assessment
|
|
92
|
+
|
|
93
|
+
Published release. Pin exactly `proof-harness@0.1.0-next.11`.
|
|
33
94
|
Regenerate
|
|
34
95
|
capabilities and rerun the complete baseline, coverage, inventory, and mutation
|
|
35
96
|
validation after adoption. Trace, mission, and health versions are unchanged.
|
package/PROOF_SDK_CONTRACT.md
CHANGED
|
@@ -1290,3 +1290,13 @@ action-targeted primary denial and allowed control with operation `invoke`,
|
|
|
1290
1290
|
including internal actions and actions without workspace inputs. Caller evidence
|
|
1291
1291
|
is not transitively inherited. See COMPATIBILITY.md for supported syntax and
|
|
1292
1292
|
remaining unassessable cases.
|
|
1293
|
+
|
|
1294
|
+
## Independent scope fixtures (0.1.0-next.12 candidate)
|
|
1295
|
+
|
|
1296
|
+
The Playwright entry point exports `IsolationFixture`, `IsolationParticipant`,
|
|
1297
|
+
and `TenantIsolationOptions`. `isolationFixture` is an alternative to the legacy
|
|
1298
|
+
workspace `setup`/`fixture` options. Its setup and cleanup are consumer-owned;
|
|
1299
|
+
clients, ground-truth checks, both owner controls, and both outsider probes stay
|
|
1300
|
+
SDK-owned. Missing either side, malformed actors/scopes, failed probes or cleanup
|
|
1301
|
+
prevent a passing proof. The SDK does not emit provenance on callback assertions.
|
|
1302
|
+
See COMPATIBILITY.md for the public call shape and source-identity requirements.
|
package/README.md
CHANGED
|
@@ -152,7 +152,11 @@ Release `0.1.0-next.10` restores explicit consumer primary assertions
|
|
|
152
152
|
for mutation detection while retaining strict control provenance. See the
|
|
153
153
|
[next.10 migration notes](COMPATIBILITY.md) before adopting it.
|
|
154
154
|
|
|
155
|
-
Release
|
|
155
|
+
Release `0.1.0-next.11` discovers direct service RPC calls as potentially
|
|
156
156
|
privileged writes and requires action-boundary evidence even without workspace
|
|
157
157
|
inputs or for internal actions. See [required migration steps](COMPATIBILITY.md)
|
|
158
|
-
before adopting the exact version
|
|
158
|
+
before adopting the exact version.
|
|
159
|
+
|
|
160
|
+
Release candidate `0.1.0-next.12` adds caller-owned account/user isolation fixtures
|
|
161
|
+
and restores column grants and grant options after table-privilege mutations.
|
|
162
|
+
Keep existing pins until publication; see [migration requirements](COMPATIBILITY.md).
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
tableAclSnapshotSql,
|
|
4
|
+
validateTableAcl,
|
|
5
|
+
restoreTableAclSql,
|
|
6
|
+
} from "./proof_table_acl.mjs";
|
|
2
7
|
import {
|
|
3
8
|
validateControlBaseline,
|
|
4
9
|
assertionSelectorsTurnedRed,
|
|
@@ -234,13 +239,13 @@ function assertDirectPrivilege(container, subject, functionSubject = false) {
|
|
|
234
239
|
`SELECT NOT EXISTS (
|
|
235
240
|
SELECT 1 FROM ${catalog} c, LATERAL aclexplode(coalesce(c.${acl},acldefault('${defaultKind}',c.${owner}))) a
|
|
236
241
|
WHERE c.oid = ${object} AND a.privilege_type = ${quoteLiteral(subject.privilege.toUpperCase())}
|
|
237
|
-
AND ((a.grantee = ${quoteLiteral(subject.role)}::regrole AND (a.is_grantable OR a.grantor <> c.${owner}))
|
|
242
|
+
AND ((a.grantee = ${quoteLiteral(subject.role)}::regrole AND (${functionSubject ? "a.is_grantable OR " : ""}a.grantor <> c.${owner}))
|
|
238
243
|
OR (a.grantee <> ${quoteLiteral(subject.role)}::regrole AND (a.grantee = 0 OR pg_has_role(${quoteLiteral(subject.role)},a.grantee,'USAGE'))))
|
|
239
244
|
) AND NOT EXISTS (SELECT 1 FROM ${catalog} WHERE oid = ${object} AND ${owner} = ${quoteLiteral(subject.role)}::regrole)`,
|
|
240
245
|
);
|
|
241
246
|
if (supported !== "t")
|
|
242
247
|
throw new Error(
|
|
243
|
-
|
|
248
|
+
`[PROOF_FAIL] mutation_unassessed: privilege restoration requires owner-issued direct grants${functionSubject ? " without grant options" : ""}; inherited, PUBLIC, and owner privileges require a dedicated catalog mutation`,
|
|
244
249
|
);
|
|
245
250
|
}
|
|
246
251
|
|
|
@@ -314,17 +319,13 @@ const SUBJECTS = {
|
|
|
314
319
|
},
|
|
315
320
|
|
|
316
321
|
tablePrivilege: {
|
|
317
|
-
preflight: (container, s) =>
|
|
322
|
+
preflight: (container, s) => {
|
|
323
|
+
assertDirectPrivilege(container, s);
|
|
324
|
+
validateTableAcl(queryScalar(container, tableAclSnapshotSql(s)));
|
|
325
|
+
},
|
|
318
326
|
describe: (s) => `${s.privilege} on ${s.table} for ${s.role}`,
|
|
319
|
-
snapshot: (container, s) =>
|
|
320
|
-
|
|
321
|
-
container,
|
|
322
|
-
`SELECT has_table_privilege(${quoteLiteral(s.role)}, ${quoteLiteral(s.table)}, ${quoteLiteral(s.privilege)})`,
|
|
323
|
-
),
|
|
324
|
-
restore: (s, snapshot) =>
|
|
325
|
-
snapshot === "t"
|
|
326
|
-
? `GRANT ${s.privilege} ON ${s.table} TO ${s.role};`
|
|
327
|
-
: `REVOKE ${s.privilege} ON ${s.table} FROM ${s.role};`,
|
|
327
|
+
snapshot: (container, s) => queryScalar(container, tableAclSnapshotSql(s)),
|
|
328
|
+
restore: restoreTableAclSql,
|
|
328
329
|
},
|
|
329
330
|
|
|
330
331
|
functionPrivilege: {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Canonical relevant ACL state, not effective has_table_privilege booleans.
|
|
2
|
+
const lit = (value) => "'" + String(value).replaceAll("'", "''") + "'";
|
|
3
|
+
const ident = (value) => '"' + String(value).replaceAll('"', '""') + '"';
|
|
4
|
+
export function tableAclSnapshotSql(subject) {
|
|
5
|
+
return `WITH target AS (SELECT oid, relowner, relacl FROM pg_class WHERE oid = ${lit(subject.table)}::regclass), entries AS (
|
|
6
|
+
SELECT NULL::text AS column_name, a.* FROM target t, LATERAL aclexplode(coalesce(t.relacl,acldefault('r',t.relowner))) a
|
|
7
|
+
UNION ALL
|
|
8
|
+
SELECT c.attname::text, a.* FROM target t JOIN pg_attribute c ON c.attrelid=t.oid AND c.attnum>0 AND NOT c.attisdropped,
|
|
9
|
+
LATERAL aclexplode(c.attacl) a
|
|
10
|
+
) SELECT json_build_object('version',1,'owner',(SELECT pg_get_userbyid(relowner) FROM target),
|
|
11
|
+
'entries',coalesce((SELECT json_agg(json_build_object('column',column_name,'grantor',pg_get_userbyid(grantor),
|
|
12
|
+
'grantee',CASE WHEN grantee=0 THEN 'PUBLIC' ELSE pg_get_userbyid(grantee) END,'grantable',is_grantable)
|
|
13
|
+
ORDER BY column_name NULLS FIRST,grantor,grantee,is_grantable)
|
|
14
|
+
FROM entries WHERE privilege_type=${lit(subject.privilege.toUpperCase())}), '[]'::json))::text`;
|
|
15
|
+
}
|
|
16
|
+
export function validateTableAcl(snapshot) {
|
|
17
|
+
const value = JSON.parse(snapshot);
|
|
18
|
+
if (
|
|
19
|
+
value?.version !== 1 ||
|
|
20
|
+
typeof value.owner !== "string" ||
|
|
21
|
+
!Array.isArray(value.entries) ||
|
|
22
|
+
value.entries.some(
|
|
23
|
+
(e) =>
|
|
24
|
+
!(e.column === null || typeof e.column === "string") ||
|
|
25
|
+
typeof e.grantee !== "string" ||
|
|
26
|
+
typeof e.grantable !== "boolean" ||
|
|
27
|
+
e.grantor !== value.owner,
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
throw new Error(
|
|
31
|
+
"[PROOF_FAIL] mutation_unassessed: table ACL requires versioned owner-issued grants; delegated grant graphs need a dedicated mutation",
|
|
32
|
+
);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
export function restoreTableAclSql(subject, snapshot) {
|
|
36
|
+
const value = validateTableAcl(snapshot);
|
|
37
|
+
const privilege = subject.privilege.toUpperCase();
|
|
38
|
+
if (
|
|
39
|
+
![
|
|
40
|
+
"SELECT",
|
|
41
|
+
"INSERT",
|
|
42
|
+
"UPDATE",
|
|
43
|
+
"DELETE",
|
|
44
|
+
"TRUNCATE",
|
|
45
|
+
"REFERENCES",
|
|
46
|
+
"TRIGGER",
|
|
47
|
+
"MAINTAIN",
|
|
48
|
+
].includes(privilege)
|
|
49
|
+
)
|
|
50
|
+
throw new Error("unsupported table privilege");
|
|
51
|
+
const role = subject.role === "PUBLIC" ? "PUBLIC" : ident(subject.role);
|
|
52
|
+
// Revoking a table privilege also removes this role's column grants. Restore
|
|
53
|
+
// those explicitly, preserving grant options. No CASCADE: unexpected dependent
|
|
54
|
+
// grants abort instead of destroying state not represented by the snapshot.
|
|
55
|
+
const sql = [`REVOKE ${privilege} ON ${subject.table} FROM ${role};`];
|
|
56
|
+
for (const entry of value.entries.filter((e) => e.grantee === subject.role)) {
|
|
57
|
+
sql.push(
|
|
58
|
+
`GRANT ${privilege}${entry.column === null ? "" : ` (${ident(entry.column)})`} ON ${subject.table} TO ${role}${entry.grantable ? " WITH GRANT OPTION" : ""};`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return sql.join("\n");
|
|
62
|
+
}
|
package/dist/playwright.d.ts
CHANGED
|
@@ -116,7 +116,7 @@ interface TenantIsolationBaseOptions {
|
|
|
116
116
|
*
|
|
117
117
|
* Tables classified `user_scoped` in `.proof/schema.json` need `"user"`.
|
|
118
118
|
*/
|
|
119
|
-
scope?: "workspace" | "user";
|
|
119
|
+
scope?: "workspace" | "user" | "account";
|
|
120
120
|
/**
|
|
121
121
|
* Column carrying the scope value. Defaults to `workspace_id` for workspace
|
|
122
122
|
* scope and `user_id` for user scope.
|
|
@@ -170,7 +170,31 @@ interface TenantIsolationBaseOptions {
|
|
|
170
170
|
* until the builder knows the final constraints. Supplying both is rejected by
|
|
171
171
|
* the type system so there is one source of fixture truth.
|
|
172
172
|
*/
|
|
173
|
+
interface IsolationParticipant {
|
|
174
|
+
actor: {
|
|
175
|
+
email: string;
|
|
176
|
+
password: string;
|
|
177
|
+
};
|
|
178
|
+
scopeValue: string;
|
|
179
|
+
}
|
|
180
|
+
/** Caller owns all resources, including cleanup after partially failed setup. */
|
|
181
|
+
interface IsolationFixture {
|
|
182
|
+
id: string;
|
|
183
|
+
table: string;
|
|
184
|
+
setup(): Promise<{
|
|
185
|
+
a: IsolationParticipant;
|
|
186
|
+
b: IsolationParticipant;
|
|
187
|
+
}>;
|
|
188
|
+
cleanup(): Promise<void>;
|
|
189
|
+
}
|
|
173
190
|
type TenantIsolationOptions = TenantIsolationBaseOptions & ({
|
|
191
|
+
isolationFixture: IsolationFixture;
|
|
192
|
+
scopeColumn: string;
|
|
193
|
+
page?: never;
|
|
194
|
+
setup?: never;
|
|
195
|
+
fixture?: never;
|
|
196
|
+
} | {
|
|
197
|
+
isolationFixture?: never;
|
|
174
198
|
/**
|
|
175
199
|
* Inline setup for an already-known schema. It must create at least one
|
|
176
200
|
* row for side A; side B is optional but required for a bidirectional
|
|
@@ -181,6 +205,7 @@ type TenantIsolationOptions = TenantIsolationBaseOptions & ({
|
|
|
181
205
|
} | {
|
|
182
206
|
/** Explicit table-owned factory from `e2e/fixtures/<table>.ts`. */
|
|
183
207
|
fixture: ProofFixtureFactory;
|
|
208
|
+
isolationFixture?: never;
|
|
184
209
|
setup?: never;
|
|
185
210
|
});
|
|
186
211
|
type AuthorizationOp = "select" | "insert" | "update" | "delete";
|
|
@@ -461,4 +486,4 @@ declare const trace: {
|
|
|
461
486
|
proof(arg: string | ProofOptions, fn: (t: TraceRecorderImpl) => Promise<void>): Promise<void>;
|
|
462
487
|
};
|
|
463
488
|
|
|
464
|
-
export { actAsUser, assert, recordAssertion, trace };
|
|
489
|
+
export { type IsolationFixture, type IsolationParticipant, type TenantIsolationOptions, actAsUser, assert, recordAssertion, trace };
|
package/dist/playwright.js
CHANGED
|
@@ -1826,7 +1826,7 @@ async function probeTenantIsolation(args) {
|
|
|
1826
1826
|
"a valid owner control query",
|
|
1827
1827
|
ownError.message
|
|
1828
1828
|
);
|
|
1829
|
-
if (ownError || (ownRows?.length ?? 0) === 0) {
|
|
1829
|
+
if (ownError || (ownRows?.length ?? 0) === 0 || args.strictOwner && ownRows?.length !== actualRowCount) {
|
|
1830
1830
|
recordAssertion({
|
|
1831
1831
|
kind: "tenant_isolation",
|
|
1832
1832
|
target: table,
|
|
@@ -1867,6 +1867,22 @@ async function probeTenantIsolation(args) {
|
|
|
1867
1867
|
"tenant_isolation_setup",
|
|
1868
1868
|
`the outsider's SELECT on ${table}`
|
|
1869
1869
|
);
|
|
1870
|
+
if (queryError && args.strictOwner) {
|
|
1871
|
+
recordAssertion({
|
|
1872
|
+
kind: "tenant_isolation",
|
|
1873
|
+
target: table,
|
|
1874
|
+
operation: "select",
|
|
1875
|
+
passed: false,
|
|
1876
|
+
status: "incomplete",
|
|
1877
|
+
role: "primary",
|
|
1878
|
+
detail: `outsider query failed before isolation could be assessed: ${queryError.message}`
|
|
1879
|
+
});
|
|
1880
|
+
throw proofFail(
|
|
1881
|
+
"tenant_isolation_setup",
|
|
1882
|
+
"a successful outsider query returning no rows",
|
|
1883
|
+
queryError.message
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1870
1886
|
if (queryError) {
|
|
1871
1887
|
recordAssertion({
|
|
1872
1888
|
kind: "tenant_isolation",
|
|
@@ -2021,6 +2037,124 @@ var assertMethods = {
|
|
|
2021
2037
|
*/
|
|
2022
2038
|
async tenantIsolation(opts) {
|
|
2023
2039
|
const { table, page } = opts;
|
|
2040
|
+
if (opts.isolationFixture) {
|
|
2041
|
+
const fixture = await withoutAssertionProvenance(async () => {
|
|
2042
|
+
const supplied = opts.isolationFixture;
|
|
2043
|
+
return {
|
|
2044
|
+
id: supplied.id,
|
|
2045
|
+
table: supplied.table,
|
|
2046
|
+
setup: typeof supplied.setup === "function" ? supplied.setup.bind(supplied) : supplied.setup,
|
|
2047
|
+
cleanup: typeof supplied.cleanup === "function" ? supplied.cleanup.bind(supplied) : supplied.cleanup
|
|
2048
|
+
};
|
|
2049
|
+
});
|
|
2050
|
+
if (page)
|
|
2051
|
+
throw proofFail(
|
|
2052
|
+
"tenant_isolation_setup",
|
|
2053
|
+
"a database-only caller-owned fixture",
|
|
2054
|
+
"page is unsupported for isolationFixture"
|
|
2055
|
+
);
|
|
2056
|
+
if (!fixture.id?.trim() || fixture.table !== table || !opts.scopeColumn?.trim() || typeof fixture.setup !== "function" || typeof fixture.cleanup !== "function")
|
|
2057
|
+
throw proofFail(
|
|
2058
|
+
"tenant_isolation_setup",
|
|
2059
|
+
"a named, table-matched isolation fixture and scope column",
|
|
2060
|
+
"malformed fixture"
|
|
2061
|
+
);
|
|
2062
|
+
validateTenantIsolationCriterion(table, opts.scopeColumn, opts.criterion);
|
|
2063
|
+
const failures = [];
|
|
2064
|
+
try {
|
|
2065
|
+
const participants = await withoutAssertionProvenance(async () => {
|
|
2066
|
+
const sides = await fixture.setup();
|
|
2067
|
+
const valid = (side) => side && typeof side.scopeValue === "string" && side.scopeValue.trim() && typeof side.actor?.email === "string" && side.actor.email.trim() && typeof side.actor?.password === "string" && side.actor.password.length;
|
|
2068
|
+
if (!sides || !valid(sides.a) || !valid(sides.b) || sides.a.scopeValue === sides.b.scopeValue || sides.a.actor.email.toLowerCase() === sides.b.actor.email.toLowerCase())
|
|
2069
|
+
throw proofFail(
|
|
2070
|
+
"tenant_isolation_setup",
|
|
2071
|
+
"two distinct actors and scope values",
|
|
2072
|
+
"malformed isolation participants"
|
|
2073
|
+
);
|
|
2074
|
+
return [sides.a, sides.b].map((side) => ({
|
|
2075
|
+
scopeValue: side.scopeValue,
|
|
2076
|
+
actor: { email: side.actor.email, password: side.actor.password }
|
|
2077
|
+
}));
|
|
2078
|
+
});
|
|
2079
|
+
const sb = createProofServiceClient();
|
|
2080
|
+
for (const side of participants) {
|
|
2081
|
+
const count = await countRowsAsService(sb, table, {
|
|
2082
|
+
[opts.scopeColumn]: side.scopeValue,
|
|
2083
|
+
...opts.criterion?.where ?? {}
|
|
2084
|
+
});
|
|
2085
|
+
if (!count)
|
|
2086
|
+
throw proofFail(
|
|
2087
|
+
"tenant_isolation_vacuous",
|
|
2088
|
+
"existing rows for both isolation participants",
|
|
2089
|
+
`empty side in fixture ${fixture.id}`
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
for (const [owner, viewer] of [
|
|
2093
|
+
[participants[0], participants[1]],
|
|
2094
|
+
[participants[1], participants[0]]
|
|
2095
|
+
]) {
|
|
2096
|
+
const outcome = await probeTenantIsolation({
|
|
2097
|
+
table,
|
|
2098
|
+
owner: owner.actor,
|
|
2099
|
+
viewer: viewer.actor,
|
|
2100
|
+
ownerLabel: `fixture ${fixture.id} owner`,
|
|
2101
|
+
viewerLabel: `fixture ${fixture.id} outsider`,
|
|
2102
|
+
scopeColumn: opts.scopeColumn,
|
|
2103
|
+
scopeValue: owner.scopeValue,
|
|
2104
|
+
scopeLabel: owner.scopeValue,
|
|
2105
|
+
criterion: opts.criterion,
|
|
2106
|
+
scopePattern: "the consumer-owned owner/account policy",
|
|
2107
|
+
sb,
|
|
2108
|
+
strictOwner: true
|
|
2109
|
+
});
|
|
2110
|
+
if (outcome !== "proved")
|
|
2111
|
+
throw proofFail(
|
|
2112
|
+
"tenant_isolation_vacuous",
|
|
2113
|
+
"existing target rows throughout both probes",
|
|
2114
|
+
"rows disappeared"
|
|
2115
|
+
);
|
|
2116
|
+
}
|
|
2117
|
+
} catch (error) {
|
|
2118
|
+
recordAssertion({
|
|
2119
|
+
kind: "tenant_isolation",
|
|
2120
|
+
target: table,
|
|
2121
|
+
operation: "select",
|
|
2122
|
+
passed: false,
|
|
2123
|
+
status: "incomplete",
|
|
2124
|
+
role: "primary",
|
|
2125
|
+
detail: `fixture ${fixture.id} did not complete both isolation directions`
|
|
2126
|
+
});
|
|
2127
|
+
failures.push(error);
|
|
2128
|
+
} finally {
|
|
2129
|
+
try {
|
|
2130
|
+
await withoutAssertionProvenance(() => fixture.cleanup());
|
|
2131
|
+
} catch (error) {
|
|
2132
|
+
recordAssertion({
|
|
2133
|
+
kind: "tenant_isolation",
|
|
2134
|
+
target: table,
|
|
2135
|
+
operation: "select",
|
|
2136
|
+
passed: false,
|
|
2137
|
+
status: "incomplete",
|
|
2138
|
+
role: "primary",
|
|
2139
|
+
detail: `fixture ${fixture.id} cleanup failed`
|
|
2140
|
+
});
|
|
2141
|
+
failures.push(error);
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
if (failures.length === 1) throw failures[0];
|
|
2145
|
+
if (failures.length > 1)
|
|
2146
|
+
throw new AggregateError(
|
|
2147
|
+
failures,
|
|
2148
|
+
"Isolation probe and cleanup failed"
|
|
2149
|
+
);
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
if (opts.scope === "account")
|
|
2153
|
+
throw proofFail(
|
|
2154
|
+
"tenant_isolation_setup",
|
|
2155
|
+
"isolationFixture for account scope",
|
|
2156
|
+
"workspace fixture selected"
|
|
2157
|
+
);
|
|
2024
2158
|
const tag = opts.tag ?? randomSuffix();
|
|
2025
2159
|
const scope = opts.scope ?? "workspace";
|
|
2026
2160
|
const scopeColumn = opts.scopeColumn ?? (scope === "user" ? "user_id" : "workspace_id");
|