proof-harness 0.1.0-next.12 → 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 CHANGED
@@ -27,10 +27,29 @@ versions rather than guessing how to interpret them.
27
27
 
28
28
  ## Prerelease migration notes
29
29
 
30
- ### 0.1.0-next.12 — independent isolation fixtures and column ACL recovery (candidate)
30
+ ### 0.1.0-next.13 — column-restricted tenant isolation (release candidate)
31
31
 
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`.
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.
34
53
 
35
54
  `assert.tenantIsolation` now accepts a caller-owned `isolationFixture` instead of
36
55
  workspace seeding. Supply a stable fixture `id`, the matching `table`, an explicit
package/README.md CHANGED
@@ -157,6 +157,10 @@ privileged writes and requires action-boundary evidence even without workspace
157
157
  inputs or for internal actions. See [required migration steps](COMPATIBILITY.md)
158
158
  before adopting the exact version.
159
159
 
160
- Release candidate `0.1.0-next.12` adds caller-owned account/user isolation fixtures
160
+ Release `0.1.0-next.12` adds caller-owned account/user isolation fixtures
161
161
  and restores column grants and grant options after table-privilege mutations.
162
- Keep existing pins until publication; see [migration requirements](COMPATIBILITY.md).
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).
@@ -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
@@ -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
- async function countRowsAsService(sb, table, filter) {
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("*", { count: "exact", head: true }),
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(sb, table, filter);
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("*"), filter);
1834
+ } = await applyEq(ownerClient.from(table).select(args.selection), filter);
1817
1835
  failIfInfrastructure(
1818
1836
  ownError,
1819
1837
  ownStatus,
@@ -1860,7 +1878,7 @@ async function probeTenantIsolation(args) {
1860
1878
  data: leakedRows,
1861
1879
  error: queryError,
1862
1880
  status: queryStatus
1863
- } = await applyEq(client.from(table).select("*"), filter);
1881
+ } = await applyEq(client.from(table).select(args.selection), filter);
1864
1882
  failIfInfrastructure(
1865
1883
  queryError,
1866
1884
  queryStatus,
@@ -1902,7 +1920,9 @@ async function probeTenantIsolation(args) {
1902
1920
  );
1903
1921
  }
1904
1922
  if (leakedRows && leakedRows.length > 0) {
1905
- const sampleIds = leakedRows.slice(0, 3).map((r) => r.id ?? "?").join(", ");
1923
+ const sampleIds = leakedRows.slice(0, 3).map(
1924
+ (r) => r !== null && typeof r === "object" && "id" in r ? String(r.id) : "?"
1925
+ ).join(", ");
1906
1926
  recordAssertion({
1907
1927
  kind: "tenant_isolation",
1908
1928
  target: table,
@@ -2037,6 +2057,7 @@ var assertMethods = {
2037
2057
  */
2038
2058
  async tenantIsolation(opts) {
2039
2059
  const { table, page } = opts;
2060
+ const selection = tenantColumnSelection(opts.columns);
2040
2061
  if (opts.isolationFixture) {
2041
2062
  const fixture = await withoutAssertionProvenance(async () => {
2042
2063
  const supplied = opts.isolationFixture;
@@ -2078,10 +2099,15 @@ var assertMethods = {
2078
2099
  });
2079
2100
  const sb = createProofServiceClient();
2080
2101
  for (const side of participants) {
2081
- const count = await countRowsAsService(sb, table, {
2082
- [opts.scopeColumn]: side.scopeValue,
2083
- ...opts.criterion?.where ?? {}
2084
- });
2102
+ const count = await countRowsAsService(
2103
+ sb,
2104
+ table,
2105
+ {
2106
+ [opts.scopeColumn]: side.scopeValue,
2107
+ ...opts.criterion?.where ?? {}
2108
+ },
2109
+ selection
2110
+ );
2085
2111
  if (!count)
2086
2112
  throw proofFail(
2087
2113
  "tenant_isolation_vacuous",
@@ -2094,6 +2120,7 @@ var assertMethods = {
2094
2120
  [participants[1], participants[0]]
2095
2121
  ]) {
2096
2122
  const outcome = await probeTenantIsolation({
2123
+ selection,
2097
2124
  table,
2098
2125
  owner: owner.actor,
2099
2126
  viewer: viewer.actor,
@@ -2256,6 +2283,7 @@ var assertMethods = {
2256
2283
  );
2257
2284
  }
2258
2285
  const forward = await probeTenantIsolation({
2286
+ selection,
2259
2287
  table,
2260
2288
  viewer: userB,
2261
2289
  viewerLabel: "user B",
@@ -2291,6 +2319,7 @@ var assertMethods = {
2291
2319
  );
2292
2320
  }
2293
2321
  const reverse = await probeTenantIsolation({
2322
+ selection,
2294
2323
  table,
2295
2324
  viewer: userA,
2296
2325
  viewerLabel: "user A",