proof-harness 0.1.0-next.11 → 0.1.0-next.13
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 +82 -2
- package/PROOF_SDK_CONTRACT.md +10 -0
- package/README.md +10 -2
- package/cli/engines/proof_mutation_check.mjs +13 -12
- package/cli/engines/proof_table_acl.mjs +62 -0
- package/dist/playwright.d.ts +31 -2
- package/dist/playwright.js +171 -8
- 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,89 @@ 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.13 — column-restricted tenant isolation (release candidate)
|
|
31
31
|
|
|
32
|
-
Prepared for release. After publication, pin exactly `proof-harness@0.1.0-next.
|
|
32
|
+
Prepared for release. After publication, pin exactly `proof-harness@0.1.0-next.13`
|
|
33
|
+
and update the lockfile. Add `columns` only where column grants require it, then
|
|
34
|
+
rerun the baseline, coverage, mutation and control checks against the real database.
|
|
35
|
+
Existing consumers that omit this option need no API changes.
|
|
36
|
+
|
|
37
|
+
`assert.tenantIsolation` accepts optional `columns: ["id", "account_id"]` for
|
|
38
|
+
private tables that grant customers only selected columns. The SDK uses the
|
|
39
|
+
same projection for service ground truth, owner controls, and outsider probes.
|
|
40
|
+
Only distinct plain column names are allowed; empty lists, wildcards, aliases,
|
|
41
|
+
aggregates, casts and joins are rejected before fixture setup. Omitting the
|
|
42
|
+
option retains the existing all-column query.
|
|
43
|
+
|
|
44
|
+
This proves row isolation for the selected columns. Consumers must separately
|
|
45
|
+
prove that private columns remain unreadable; do not grant extra columns to
|
|
46
|
+
make a fixture pass. Existing positive-control, bidirectional, non-vacuity and
|
|
47
|
+
provenance requirements are unchanged. No trace, mission, or health protocol migration is
|
|
48
|
+
required. Adopt only an exact reviewed published package version.
|
|
49
|
+
|
|
50
|
+
### 0.1.0-next.12 — independent isolation fixtures and column ACL recovery
|
|
51
|
+
|
|
52
|
+
Published release. Pin exactly `proof-harness@0.1.0-next.12` when adopting these changes.
|
|
53
|
+
|
|
54
|
+
`assert.tenantIsolation` now accepts a caller-owned `isolationFixture` instead of
|
|
55
|
+
workspace seeding. Supply a stable fixture `id`, the matching `table`, an explicit
|
|
56
|
+
`scopeColumn`, `setup()` returning two distinct actors and scope values, and
|
|
57
|
+
`cleanup()` that can handle partial setup. Scope values can be user IDs or
|
|
58
|
+
independent account IDs. `scope: "account"` requires this fixture path. Existing
|
|
59
|
+
workspace fixtures and their reverse-direction behavior remain unchanged.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
await assert.tenantIsolation({
|
|
63
|
+
table: "notifications",
|
|
64
|
+
scope: "account",
|
|
65
|
+
scopeColumn: "account_id",
|
|
66
|
+
isolationFixture: {
|
|
67
|
+
id: "notifications-two-accounts-v1",
|
|
68
|
+
table: "notifications",
|
|
69
|
+
async setup() {
|
|
70
|
+
// Consumer creates real Auth customers, resolves account IDs, and seeds rows.
|
|
71
|
+
return {
|
|
72
|
+
a: { actor: { email: ownerA.email, password: ownerA.password }, scopeValue: accountA.id },
|
|
73
|
+
b: { actor: { email: ownerB.email, password: ownerB.password }, scopeValue: accountB.id },
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
async cleanup() { await cleanupOwnedResources(); },
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The SDK creates the service client and authenticated probe clients itself. Both
|
|
82
|
+
sides must contain rows before probing; each owner must read all scoped rows,
|
|
83
|
+
and each outsider must see none. Both directions are mandatory. Scope/criterion
|
|
84
|
+
filters apply identically to ground truth and probes. Setup and cleanup run
|
|
85
|
+
without helper provenance; callers cannot stamp SDK assertions. Cleanup errors
|
|
86
|
+
fail the proof. Fixture IDs appear in probe detail; existing trace source hashes
|
|
87
|
+
bind tracked and nonignored fixture source to evidence. Keep fixtures under the
|
|
88
|
+
consumer's source identity, not an excluded artifact directory. This path is
|
|
89
|
+
database-only: `page` is unsupported. It does not extend account-policy SQL
|
|
90
|
+
classification; existing explicit classifier reviews remain separate.
|
|
91
|
+
|
|
92
|
+
`tablePrivilege` mutations now snapshot the relevant table and column ACL entries
|
|
93
|
+
(including grant options) and compare that complete state after restoration.
|
|
94
|
+
Column UPDATE grants removed by PostgreSQL's table REVOKE are re-established.
|
|
95
|
+
Owner-issued direct grants, including grant options, are supported; delegated
|
|
96
|
+
grant graphs, inherited/PUBLIC effective privileges, and owner-target mutations
|
|
97
|
+
remain blocked before planting. No CASCADE recovery is used. Recovery fails
|
|
98
|
+
closed on an old Boolean snapshot: it cannot reconstruct already-lost column
|
|
99
|
+
grants. Keep that journal and reconcile with the protected baseline rather than
|
|
100
|
+
claiming automatic recovery succeeded.
|
|
101
|
+
|
|
102
|
+
After upgrading: port fixtures to the public API, regenerate baseline evidence,
|
|
103
|
+
restore formal table-isolation coverage, and rerun frozen install, static/unit/
|
|
104
|
+
browser checks, the full proof/mutation/control gate, package parity, production
|
|
105
|
+
build, and smoke. Verify actual profile editing and denied privileged-column
|
|
106
|
+
writes after mutations. Package tests are not a substitute for that consumer run.
|
|
107
|
+
Trace, mission, and health protocol versions remain unchanged; the internal
|
|
108
|
+
ACL recovery snapshot has its own version 1.
|
|
109
|
+
|
|
110
|
+
### 0.1.0-next.11 — conservative service RPC assessment
|
|
111
|
+
|
|
112
|
+
Published release. Pin exactly `proof-harness@0.1.0-next.11`.
|
|
33
113
|
Regenerate
|
|
34
114
|
capabilities and rerun the complete baseline, coverage, inventory, and mutation
|
|
35
115
|
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,15 @@ 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 `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
|
+
See [migration requirements](COMPATIBILITY.md).
|
|
163
|
+
|
|
164
|
+
Release candidate `0.1.0-next.13` adds optional readable-column selection to
|
|
165
|
+
`assert.tenantIsolation` for tables with column grants. Private-column denial
|
|
166
|
+
remains a separate consumer proof; 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
|
@@ -97,6 +97,10 @@ declare const actAsUser: {
|
|
|
97
97
|
};
|
|
98
98
|
|
|
99
99
|
interface TenantIsolationBaseOptions {
|
|
100
|
+
/** Plain readable column names for column-restricted tables. Defaults to all
|
|
101
|
+
* columns. The same projection is used for ground truth and both actors.
|
|
102
|
+
* This proves row isolation only; test secret-column denial separately. */
|
|
103
|
+
columns?: readonly string[];
|
|
100
104
|
/**
|
|
101
105
|
* Table to query as userB. RLS on the table is what is being tested.
|
|
102
106
|
* Must be a table governed by a tenant-scoped RLS policy — either
|
|
@@ -116,7 +120,7 @@ interface TenantIsolationBaseOptions {
|
|
|
116
120
|
*
|
|
117
121
|
* Tables classified `user_scoped` in `.proof/schema.json` need `"user"`.
|
|
118
122
|
*/
|
|
119
|
-
scope?: "workspace" | "user";
|
|
123
|
+
scope?: "workspace" | "user" | "account";
|
|
120
124
|
/**
|
|
121
125
|
* Column carrying the scope value. Defaults to `workspace_id` for workspace
|
|
122
126
|
* scope and `user_id` for user scope.
|
|
@@ -170,7 +174,31 @@ interface TenantIsolationBaseOptions {
|
|
|
170
174
|
* until the builder knows the final constraints. Supplying both is rejected by
|
|
171
175
|
* the type system so there is one source of fixture truth.
|
|
172
176
|
*/
|
|
177
|
+
interface IsolationParticipant {
|
|
178
|
+
actor: {
|
|
179
|
+
email: string;
|
|
180
|
+
password: string;
|
|
181
|
+
};
|
|
182
|
+
scopeValue: string;
|
|
183
|
+
}
|
|
184
|
+
/** Caller owns all resources, including cleanup after partially failed setup. */
|
|
185
|
+
interface IsolationFixture {
|
|
186
|
+
id: string;
|
|
187
|
+
table: string;
|
|
188
|
+
setup(): Promise<{
|
|
189
|
+
a: IsolationParticipant;
|
|
190
|
+
b: IsolationParticipant;
|
|
191
|
+
}>;
|
|
192
|
+
cleanup(): Promise<void>;
|
|
193
|
+
}
|
|
173
194
|
type TenantIsolationOptions = TenantIsolationBaseOptions & ({
|
|
195
|
+
isolationFixture: IsolationFixture;
|
|
196
|
+
scopeColumn: string;
|
|
197
|
+
page?: never;
|
|
198
|
+
setup?: never;
|
|
199
|
+
fixture?: never;
|
|
200
|
+
} | {
|
|
201
|
+
isolationFixture?: never;
|
|
174
202
|
/**
|
|
175
203
|
* Inline setup for an already-known schema. It must create at least one
|
|
176
204
|
* row for side A; side B is optional but required for a bidirectional
|
|
@@ -181,6 +209,7 @@ type TenantIsolationOptions = TenantIsolationBaseOptions & ({
|
|
|
181
209
|
} | {
|
|
182
210
|
/** Explicit table-owned factory from `e2e/fixtures/<table>.ts`. */
|
|
183
211
|
fixture: ProofFixtureFactory;
|
|
212
|
+
isolationFixture?: never;
|
|
184
213
|
setup?: never;
|
|
185
214
|
});
|
|
186
215
|
type AuthorizationOp = "select" | "insert" | "update" | "delete";
|
|
@@ -461,4 +490,4 @@ declare const trace: {
|
|
|
461
490
|
proof(arg: string | ProofOptions, fn: (t: TraceRecorderImpl) => Promise<void>): Promise<void>;
|
|
462
491
|
};
|
|
463
492
|
|
|
464
|
-
export { actAsUser, assert, recordAssertion, trace };
|
|
493
|
+
export { type IsolationFixture, type IsolationParticipant, type TenantIsolationOptions, actAsUser, assert, recordAssertion, trace };
|
package/dist/playwright.js
CHANGED
|
@@ -1765,9 +1765,22 @@ async function runRlsProbe(probe, actorCreds) {
|
|
|
1765
1765
|
detail: `${actorCreds.label} UPDATE on ${probe.table} left ${columns.join(", ")} unchanged across ${before.length} targeted row(s)${error ? ` (${error.message})` : ""}; verified by service-role re-read (RLS honored)`
|
|
1766
1766
|
});
|
|
1767
1767
|
}
|
|
1768
|
-
|
|
1768
|
+
function tenantColumnSelection(columns) {
|
|
1769
|
+
if (columns === void 0) return "*";
|
|
1770
|
+
if (!Array.isArray(columns) || columns.length === 0 || columns.some(
|
|
1771
|
+
(column) => typeof column !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)
|
|
1772
|
+
) || new Set(columns).size !== columns.length) {
|
|
1773
|
+
throw proofFail(
|
|
1774
|
+
"tenant_isolation_setup",
|
|
1775
|
+
"a non-empty list of distinct plain column names",
|
|
1776
|
+
"invalid tenant column projection"
|
|
1777
|
+
);
|
|
1778
|
+
}
|
|
1779
|
+
return columns.join(",");
|
|
1780
|
+
}
|
|
1781
|
+
async function countRowsAsService(sb, table, filter, selection) {
|
|
1769
1782
|
const { count, error } = await applyEq(
|
|
1770
|
-
sb.from(table).select(
|
|
1783
|
+
sb.from(table).select(selection, { count: "exact", head: true }),
|
|
1771
1784
|
filter
|
|
1772
1785
|
);
|
|
1773
1786
|
if (error) {
|
|
@@ -1800,8 +1813,13 @@ async function probeTenantIsolation(args) {
|
|
|
1800
1813
|
[scopeColumn]: scopeValue,
|
|
1801
1814
|
...criterion?.where ?? {}
|
|
1802
1815
|
};
|
|
1803
|
-
const criterionDetail = criterion ? ` matching planner criterion "${criterion.description}" (${JSON.stringify(criterion.where)})` : "";
|
|
1804
|
-
const actualRowCount = await countRowsAsService(
|
|
1816
|
+
const criterionDetail = (criterion ? ` matching planner criterion "${criterion.description}" (${JSON.stringify(criterion.where)})` : "") + (args.selection === "*" ? "" : ` selecting columns ${args.selection}`);
|
|
1817
|
+
const actualRowCount = await countRowsAsService(
|
|
1818
|
+
sb,
|
|
1819
|
+
table,
|
|
1820
|
+
filter,
|
|
1821
|
+
args.selection
|
|
1822
|
+
);
|
|
1805
1823
|
if (actualRowCount === 0) {
|
|
1806
1824
|
return "skipped_no_data";
|
|
1807
1825
|
}
|
|
@@ -1813,7 +1831,7 @@ async function probeTenantIsolation(args) {
|
|
|
1813
1831
|
data: ownRows,
|
|
1814
1832
|
error: ownError,
|
|
1815
1833
|
status: ownStatus
|
|
1816
|
-
} = await applyEq(ownerClient.from(table).select(
|
|
1834
|
+
} = await applyEq(ownerClient.from(table).select(args.selection), filter);
|
|
1817
1835
|
failIfInfrastructure(
|
|
1818
1836
|
ownError,
|
|
1819
1837
|
ownStatus,
|
|
@@ -1826,7 +1844,7 @@ async function probeTenantIsolation(args) {
|
|
|
1826
1844
|
"a valid owner control query",
|
|
1827
1845
|
ownError.message
|
|
1828
1846
|
);
|
|
1829
|
-
if (ownError || (ownRows?.length ?? 0) === 0) {
|
|
1847
|
+
if (ownError || (ownRows?.length ?? 0) === 0 || args.strictOwner && ownRows?.length !== actualRowCount) {
|
|
1830
1848
|
recordAssertion({
|
|
1831
1849
|
kind: "tenant_isolation",
|
|
1832
1850
|
target: table,
|
|
@@ -1860,13 +1878,29 @@ async function probeTenantIsolation(args) {
|
|
|
1860
1878
|
data: leakedRows,
|
|
1861
1879
|
error: queryError,
|
|
1862
1880
|
status: queryStatus
|
|
1863
|
-
} = await applyEq(client.from(table).select(
|
|
1881
|
+
} = await applyEq(client.from(table).select(args.selection), filter);
|
|
1864
1882
|
failIfInfrastructure(
|
|
1865
1883
|
queryError,
|
|
1866
1884
|
queryStatus,
|
|
1867
1885
|
"tenant_isolation_setup",
|
|
1868
1886
|
`the outsider's SELECT on ${table}`
|
|
1869
1887
|
);
|
|
1888
|
+
if (queryError && args.strictOwner) {
|
|
1889
|
+
recordAssertion({
|
|
1890
|
+
kind: "tenant_isolation",
|
|
1891
|
+
target: table,
|
|
1892
|
+
operation: "select",
|
|
1893
|
+
passed: false,
|
|
1894
|
+
status: "incomplete",
|
|
1895
|
+
role: "primary",
|
|
1896
|
+
detail: `outsider query failed before isolation could be assessed: ${queryError.message}`
|
|
1897
|
+
});
|
|
1898
|
+
throw proofFail(
|
|
1899
|
+
"tenant_isolation_setup",
|
|
1900
|
+
"a successful outsider query returning no rows",
|
|
1901
|
+
queryError.message
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1870
1904
|
if (queryError) {
|
|
1871
1905
|
recordAssertion({
|
|
1872
1906
|
kind: "tenant_isolation",
|
|
@@ -1886,7 +1920,9 @@ async function probeTenantIsolation(args) {
|
|
|
1886
1920
|
);
|
|
1887
1921
|
}
|
|
1888
1922
|
if (leakedRows && leakedRows.length > 0) {
|
|
1889
|
-
const sampleIds = leakedRows.slice(0, 3).map(
|
|
1923
|
+
const sampleIds = leakedRows.slice(0, 3).map(
|
|
1924
|
+
(r) => r !== null && typeof r === "object" && "id" in r ? String(r.id) : "?"
|
|
1925
|
+
).join(", ");
|
|
1890
1926
|
recordAssertion({
|
|
1891
1927
|
kind: "tenant_isolation",
|
|
1892
1928
|
target: table,
|
|
@@ -2021,6 +2057,131 @@ var assertMethods = {
|
|
|
2021
2057
|
*/
|
|
2022
2058
|
async tenantIsolation(opts) {
|
|
2023
2059
|
const { table, page } = opts;
|
|
2060
|
+
const selection = tenantColumnSelection(opts.columns);
|
|
2061
|
+
if (opts.isolationFixture) {
|
|
2062
|
+
const fixture = await withoutAssertionProvenance(async () => {
|
|
2063
|
+
const supplied = opts.isolationFixture;
|
|
2064
|
+
return {
|
|
2065
|
+
id: supplied.id,
|
|
2066
|
+
table: supplied.table,
|
|
2067
|
+
setup: typeof supplied.setup === "function" ? supplied.setup.bind(supplied) : supplied.setup,
|
|
2068
|
+
cleanup: typeof supplied.cleanup === "function" ? supplied.cleanup.bind(supplied) : supplied.cleanup
|
|
2069
|
+
};
|
|
2070
|
+
});
|
|
2071
|
+
if (page)
|
|
2072
|
+
throw proofFail(
|
|
2073
|
+
"tenant_isolation_setup",
|
|
2074
|
+
"a database-only caller-owned fixture",
|
|
2075
|
+
"page is unsupported for isolationFixture"
|
|
2076
|
+
);
|
|
2077
|
+
if (!fixture.id?.trim() || fixture.table !== table || !opts.scopeColumn?.trim() || typeof fixture.setup !== "function" || typeof fixture.cleanup !== "function")
|
|
2078
|
+
throw proofFail(
|
|
2079
|
+
"tenant_isolation_setup",
|
|
2080
|
+
"a named, table-matched isolation fixture and scope column",
|
|
2081
|
+
"malformed fixture"
|
|
2082
|
+
);
|
|
2083
|
+
validateTenantIsolationCriterion(table, opts.scopeColumn, opts.criterion);
|
|
2084
|
+
const failures = [];
|
|
2085
|
+
try {
|
|
2086
|
+
const participants = await withoutAssertionProvenance(async () => {
|
|
2087
|
+
const sides = await fixture.setup();
|
|
2088
|
+
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;
|
|
2089
|
+
if (!sides || !valid(sides.a) || !valid(sides.b) || sides.a.scopeValue === sides.b.scopeValue || sides.a.actor.email.toLowerCase() === sides.b.actor.email.toLowerCase())
|
|
2090
|
+
throw proofFail(
|
|
2091
|
+
"tenant_isolation_setup",
|
|
2092
|
+
"two distinct actors and scope values",
|
|
2093
|
+
"malformed isolation participants"
|
|
2094
|
+
);
|
|
2095
|
+
return [sides.a, sides.b].map((side) => ({
|
|
2096
|
+
scopeValue: side.scopeValue,
|
|
2097
|
+
actor: { email: side.actor.email, password: side.actor.password }
|
|
2098
|
+
}));
|
|
2099
|
+
});
|
|
2100
|
+
const sb = createProofServiceClient();
|
|
2101
|
+
for (const side of participants) {
|
|
2102
|
+
const count = await countRowsAsService(
|
|
2103
|
+
sb,
|
|
2104
|
+
table,
|
|
2105
|
+
{
|
|
2106
|
+
[opts.scopeColumn]: side.scopeValue,
|
|
2107
|
+
...opts.criterion?.where ?? {}
|
|
2108
|
+
},
|
|
2109
|
+
selection
|
|
2110
|
+
);
|
|
2111
|
+
if (!count)
|
|
2112
|
+
throw proofFail(
|
|
2113
|
+
"tenant_isolation_vacuous",
|
|
2114
|
+
"existing rows for both isolation participants",
|
|
2115
|
+
`empty side in fixture ${fixture.id}`
|
|
2116
|
+
);
|
|
2117
|
+
}
|
|
2118
|
+
for (const [owner, viewer] of [
|
|
2119
|
+
[participants[0], participants[1]],
|
|
2120
|
+
[participants[1], participants[0]]
|
|
2121
|
+
]) {
|
|
2122
|
+
const outcome = await probeTenantIsolation({
|
|
2123
|
+
selection,
|
|
2124
|
+
table,
|
|
2125
|
+
owner: owner.actor,
|
|
2126
|
+
viewer: viewer.actor,
|
|
2127
|
+
ownerLabel: `fixture ${fixture.id} owner`,
|
|
2128
|
+
viewerLabel: `fixture ${fixture.id} outsider`,
|
|
2129
|
+
scopeColumn: opts.scopeColumn,
|
|
2130
|
+
scopeValue: owner.scopeValue,
|
|
2131
|
+
scopeLabel: owner.scopeValue,
|
|
2132
|
+
criterion: opts.criterion,
|
|
2133
|
+
scopePattern: "the consumer-owned owner/account policy",
|
|
2134
|
+
sb,
|
|
2135
|
+
strictOwner: true
|
|
2136
|
+
});
|
|
2137
|
+
if (outcome !== "proved")
|
|
2138
|
+
throw proofFail(
|
|
2139
|
+
"tenant_isolation_vacuous",
|
|
2140
|
+
"existing target rows throughout both probes",
|
|
2141
|
+
"rows disappeared"
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
} catch (error) {
|
|
2145
|
+
recordAssertion({
|
|
2146
|
+
kind: "tenant_isolation",
|
|
2147
|
+
target: table,
|
|
2148
|
+
operation: "select",
|
|
2149
|
+
passed: false,
|
|
2150
|
+
status: "incomplete",
|
|
2151
|
+
role: "primary",
|
|
2152
|
+
detail: `fixture ${fixture.id} did not complete both isolation directions`
|
|
2153
|
+
});
|
|
2154
|
+
failures.push(error);
|
|
2155
|
+
} finally {
|
|
2156
|
+
try {
|
|
2157
|
+
await withoutAssertionProvenance(() => fixture.cleanup());
|
|
2158
|
+
} catch (error) {
|
|
2159
|
+
recordAssertion({
|
|
2160
|
+
kind: "tenant_isolation",
|
|
2161
|
+
target: table,
|
|
2162
|
+
operation: "select",
|
|
2163
|
+
passed: false,
|
|
2164
|
+
status: "incomplete",
|
|
2165
|
+
role: "primary",
|
|
2166
|
+
detail: `fixture ${fixture.id} cleanup failed`
|
|
2167
|
+
});
|
|
2168
|
+
failures.push(error);
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
if (failures.length === 1) throw failures[0];
|
|
2172
|
+
if (failures.length > 1)
|
|
2173
|
+
throw new AggregateError(
|
|
2174
|
+
failures,
|
|
2175
|
+
"Isolation probe and cleanup failed"
|
|
2176
|
+
);
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
if (opts.scope === "account")
|
|
2180
|
+
throw proofFail(
|
|
2181
|
+
"tenant_isolation_setup",
|
|
2182
|
+
"isolationFixture for account scope",
|
|
2183
|
+
"workspace fixture selected"
|
|
2184
|
+
);
|
|
2024
2185
|
const tag = opts.tag ?? randomSuffix();
|
|
2025
2186
|
const scope = opts.scope ?? "workspace";
|
|
2026
2187
|
const scopeColumn = opts.scopeColumn ?? (scope === "user" ? "user_id" : "workspace_id");
|
|
@@ -2122,6 +2283,7 @@ var assertMethods = {
|
|
|
2122
2283
|
);
|
|
2123
2284
|
}
|
|
2124
2285
|
const forward = await probeTenantIsolation({
|
|
2286
|
+
selection,
|
|
2125
2287
|
table,
|
|
2126
2288
|
viewer: userB,
|
|
2127
2289
|
viewerLabel: "user B",
|
|
@@ -2157,6 +2319,7 @@ var assertMethods = {
|
|
|
2157
2319
|
);
|
|
2158
2320
|
}
|
|
2159
2321
|
const reverse = await probeTenantIsolation({
|
|
2322
|
+
selection,
|
|
2160
2323
|
table,
|
|
2161
2324
|
viewer: userA,
|
|
2162
2325
|
viewerLabel: "user A",
|