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 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.11 (release candidate) — conservative service RPC assessment
30
+ ### 0.1.0-next.12 — independent isolation fixtures and column ACL recovery (candidate)
31
31
 
32
- Prepared for release. After publication, pin exactly `proof-harness@0.1.0-next.11`.
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.
@@ -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 candidate `0.1.0-next.11` discovers direct service RPC calls as potentially
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 after publication.
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
- "[PROOF_FAIL] mutation_unassessed: privilege restoration supports owner-issued direct grants without grant options; inherited, PUBLIC, and owner privileges require a dedicated catalog mutation",
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) => assertDirectPrivilege(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
- queryScalar(
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
+ }
@@ -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 };
@@ -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");