proof-harness 0.1.0-next.10 → 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 +98 -2
- package/PROOF_SDK_CONTRACT.md +22 -0
- package/README.md +49 -10
- package/cli/engines/proof_coverage.mjs +19 -5
- package/cli/engines/proof_mutation_check.mjs +13 -12
- package/cli/engines/proof_table_acl.mjs +62 -0
- package/cli/engines/scan_proof_capabilities.mjs +49 -1
- package/dist/node.d.ts +5 -0
- package/dist/node.js.map +1 -1
- 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/capabilities/service-rpc.json +4 -0
- package/proof-consumer-fixtures/mutations/table-acl/snapshot.json +1 -0
package/COMPATIBILITY.md
CHANGED
|
@@ -27,9 +27,105 @@ 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`.
|
|
94
|
+
Regenerate
|
|
95
|
+
capabilities and rerun the complete baseline, coverage, inventory, and mutation
|
|
96
|
+
validation after adoption. Trace, mission, and health versions are unchanged.
|
|
97
|
+
|
|
98
|
+
Capability schema v1 gains additive `serviceRoleRpcCalls` entries with a literal
|
|
99
|
+
`function` and `assessment: "potential_privileged_write"`. Direct const-bound
|
|
100
|
+
service-client `.rpc("name", args)` calls support awaiting/returning the result
|
|
101
|
+
and zero-argument `.single()` / `.maybeSingle()` result chains. Dynamic names,
|
|
102
|
+
escaped builders, unsupported chains, and RLS-client RPCs remain unassessed.
|
|
103
|
+
An RLS client can invoke a SECURITY DEFINER function; its privilege is not inferred
|
|
104
|
+
from the client name. Result shaping does not establish read-only behavior.
|
|
105
|
+
|
|
106
|
+
This is conservative action-boundary assessment, not SQL function-body analysis.
|
|
107
|
+
It does not infer affected tables, effective grants, overloads, nested functions,
|
|
108
|
+
or transactional correctness. Consumer SQL/function privilege proofs and mutation
|
|
109
|
+
catalogs remain necessary; do not interpret this field as complete SQL write coverage.
|
|
110
|
+
|
|
111
|
+
Every service RPC action requires passing primary denial and allowed control
|
|
112
|
+
assertions targeting the exact `module:action`, both with `operation: "invoke"`.
|
|
113
|
+
The requirement applies without workspace inputs and to `_BOT` actions. Existing
|
|
114
|
+
assertion-origin rules remain in force; explicit authorization assertions retain
|
|
115
|
+
the consumer-owned trust boundary. RPC requirements cannot be waived through
|
|
116
|
+
`acceptedActionGaps`. No new runtime helper or caller-to-callee evidence mapping
|
|
117
|
+
is introduced.
|
|
118
|
+
|
|
119
|
+
Evidence for a signed webhook caller does not automatically satisfy a separately
|
|
120
|
+
callable write action. Protect and test the actual action boundary, including
|
|
121
|
+
alternative invocation paths; a staged route, `_BOT` suffix, or a generic 500
|
|
122
|
+
response is not proof of authorization. If that boundary cannot be established,
|
|
123
|
+
coverage remains blocked. Do not hide RPCs from discovery to bypass the gate.
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
### 0.1.0-next.10 — explicit primary mutation evidence
|
|
127
|
+
|
|
128
|
+
Published release. Pin exactly
|
|
33
129
|
`proof-harness@0.1.0-next.10` and rerun a fresh baseline before mutation checks.
|
|
34
130
|
Trace, mission, and health protocol versions remain unchanged.
|
|
35
131
|
|
package/PROOF_SDK_CONTRACT.md
CHANGED
|
@@ -1278,3 +1278,25 @@ specs and protected catalogs remain responsible for these assertions' meaning.
|
|
|
1278
1278
|
Control sensitivity requires recognized SDK-helper provenance and cannot count
|
|
1279
1279
|
as primary detection. Skipped, incomplete, and setup-only failures are not
|
|
1280
1280
|
mapped decisive primary evidence. No trace protocol change is introduced.
|
|
1281
|
+
|
|
1282
|
+
## Conservative service RPC assessment (0.1.0-next.11)
|
|
1283
|
+
|
|
1284
|
+
Capability schema v1 adds `serviceRoleRpcCalls: { function: string,
|
|
1285
|
+
assessment: "potential_privileged_write" }[]`. Missing fields in older artifacts
|
|
1286
|
+
are not evidence that a fresh source scan contains no RPCs. Regenerate artifacts
|
|
1287
|
+
when adopting this change. The scanner deliberately over-approximates service
|
|
1288
|
+
RPC privilege without inventing SQL table effects. Coverage requires an exact
|
|
1289
|
+
action-targeted primary denial and allowed control with operation `invoke`,
|
|
1290
|
+
including internal actions and actions without workspace inputs. Caller evidence
|
|
1291
|
+
is not transitively inherited. See COMPATIBILITY.md for supported syntax and
|
|
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
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
# proof-harness
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Verification tools for making specific claims about agent-generated code—and
|
|
4
|
+
checking that deliberately broken behavior makes those claims fail.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
Developed alongside a consumer template, this is an opinionated prerelease,
|
|
7
|
+
not a universal agent acceptance system. It can help teams that already have
|
|
8
|
+
executable requirements and want structured evidence, coverage checks, and
|
|
9
|
+
mutation checks around them.
|
|
10
|
+
|
|
11
|
+
## Try it without the template
|
|
12
|
+
|
|
13
|
+
The [standalone authorization example](https://github.com/TiKa-Nexus/proof-harness/tree/main/examples/authorization)
|
|
14
|
+
uses a tiny JavaScript rule and the public package. It needs Node 24 and pnpm
|
|
15
|
+
11.9.0; no database, credentials, app server, or browser download.
|
|
8
16
|
|
|
9
17
|
```sh
|
|
10
|
-
|
|
11
|
-
|
|
18
|
+
git clone https://github.com/TiKa-Nexus/proof-harness.git
|
|
19
|
+
cd proof-harness/examples/authorization
|
|
20
|
+
pnpm install
|
|
21
|
+
pnpm demo
|
|
12
22
|
```
|
|
13
23
|
|
|
24
|
+
You will see a passing proof, an intentionally failed proof after the rule is
|
|
25
|
+
broken, and confirmation that the source was restored. The example validates
|
|
26
|
+
recorded evidence; it is an introduction, not the full CLI mutation workflow.
|
|
27
|
+
|
|
28
|
+
## What it does—and what it establishes
|
|
29
|
+
|
|
30
|
+
The harness records structured proof traces, validates evidence and missions,
|
|
31
|
+
and provides coverage, drift, mutation, and control-sensitivity engines.
|
|
32
|
+
It can establish that a configured claim was observed and that a mapped defect
|
|
33
|
+
was detected. The consuming project supplies the requirements and trust inputs.
|
|
34
|
+
|
|
35
|
+
A passing trace does not establish overall correctness, adequate requirements,
|
|
36
|
+
or security against an agent that can rewrite the acceptance gate. Keep code
|
|
37
|
+
review, conventional tests, and trusted CI alongside it. See the
|
|
38
|
+
[integration guide](https://github.com/TiKa-Nexus/proof-harness/blob/main/docs/INTEGRATING.md)
|
|
39
|
+
for ownership, adoption steps, and current environment constraints, and
|
|
40
|
+
[contributor guide](https://github.com/TiKa-Nexus/proof-harness/blob/main/CONTRIBUTING.md)
|
|
41
|
+
for setup and approachable contributions.
|
|
42
|
+
|
|
14
43
|
## Installation
|
|
15
44
|
|
|
16
45
|
Public prereleases are published under the npm `next` tag. Pin an exact
|
|
@@ -37,10 +66,11 @@ and consumer pinning requirements.
|
|
|
37
66
|
The package has two layers; consumers should know which one they are leaning
|
|
38
67
|
on:
|
|
39
68
|
|
|
40
|
-
- **
|
|
69
|
+
- **Reusable verification mechanics** — trace recording and the assertion
|
|
41
70
|
vocabulary, mission validation, coverage and its ratchet, drift, mutation
|
|
42
71
|
testing, and the CLI. These read your generated schema and config, or take
|
|
43
|
-
what you give them.
|
|
72
|
+
what you give them. CLI discovery supports specific source and SQL patterns;
|
|
73
|
+
configurable paths do not make every framework compatible.
|
|
44
74
|
- **Assumes the conventional Supabase/workspace layout** — `seed.*` (a
|
|
45
75
|
`workspaces` table with a `name` column, a `workspace_members` join table,
|
|
46
76
|
and a `public.users` mirror maintained by a `handle_new_user` trigger) and
|
|
@@ -118,6 +148,15 @@ separate positive-control sensitivity runner. It also distinguishes caller
|
|
|
118
148
|
workspace inputs from generated result IDs. See [next.9 setup and migration](COMPATIBILITY.md)
|
|
119
149
|
before upgrading; consumer invoker and catalog changes are required.
|
|
120
150
|
|
|
121
|
-
Release
|
|
151
|
+
Release `0.1.0-next.10` restores explicit consumer primary assertions
|
|
122
152
|
for mutation detection while retaining strict control provenance. See the
|
|
123
|
-
[next.10 migration notes](COMPATIBILITY.md) before adopting it
|
|
153
|
+
[next.10 migration notes](COMPATIBILITY.md) before adopting it.
|
|
154
|
+
|
|
155
|
+
Release `0.1.0-next.11` discovers direct service RPC calls as potentially
|
|
156
|
+
privileged writes and requires action-boundary evidence even without workspace
|
|
157
|
+
inputs or for internal actions. See [required migration steps](COMPATIBILITY.md)
|
|
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).
|
|
@@ -367,17 +367,23 @@ function buildReport() {
|
|
|
367
367
|
for (const capability of [...(capabilities.capabilities ?? [])].sort((a, b) =>
|
|
368
368
|
`${a.module}:${a.name}`.localeCompare(`${b.module}:${b.name}`),
|
|
369
369
|
)) {
|
|
370
|
+
const rpcCalls = capability.serviceRoleRpcCalls ?? [];
|
|
370
371
|
if (
|
|
371
|
-
|
|
372
|
+
!rpcCalls.length &&
|
|
373
|
+
((capability.acceptsWorkspaceId !== true &&
|
|
372
374
|
!(capability.serviceRoleAuthOperations ?? []).length) ||
|
|
373
|
-
|
|
375
|
+
capability.internalOnly === true)
|
|
374
376
|
) {
|
|
375
377
|
continue;
|
|
376
378
|
}
|
|
377
379
|
|
|
378
380
|
const workspaceMutations = capability.serviceRoleMutations ?? [];
|
|
379
381
|
const authOperations = capability.serviceRoleAuthOperations ?? [];
|
|
380
|
-
if (
|
|
382
|
+
if (
|
|
383
|
+
workspaceMutations.length === 0 &&
|
|
384
|
+
authOperations.length === 0 &&
|
|
385
|
+
rpcCalls.length === 0
|
|
386
|
+
)
|
|
381
387
|
continue;
|
|
382
388
|
|
|
383
389
|
const action = `${capability.module}:${capability.name}`;
|
|
@@ -385,6 +391,7 @@ function buildReport() {
|
|
|
385
391
|
(assertion) =>
|
|
386
392
|
assertion.passed === true &&
|
|
387
393
|
assertion.target === action &&
|
|
394
|
+
(!rpcCalls.length || assertion.operation === "invoke") &&
|
|
388
395
|
normalizedRole(assertion) === "primary" &&
|
|
389
396
|
denialKinds.has(assertion.kind) &&
|
|
390
397
|
verifiedOrigin(assertion, { isTable: false }),
|
|
@@ -393,13 +400,16 @@ function buildReport() {
|
|
|
393
400
|
(assertion) =>
|
|
394
401
|
assertion.passed === true &&
|
|
395
402
|
assertion.target === action &&
|
|
403
|
+
(!rpcCalls.length || assertion.operation === "invoke") &&
|
|
396
404
|
normalizedRole(assertion) === "control" &&
|
|
397
405
|
controlKinds.has(assertion.kind),
|
|
398
406
|
);
|
|
399
407
|
const denialProven = denialEvidence.length > 0;
|
|
400
408
|
const controlProven = controlEvidence.length > 0;
|
|
401
409
|
const proven = denialProven && controlProven;
|
|
402
|
-
const acceptance =
|
|
410
|
+
const acceptance = rpcCalls.length
|
|
411
|
+
? undefined
|
|
412
|
+
: acceptedActionGaps.get(action);
|
|
403
413
|
if (!proven && acceptance) usedActionAcceptances.add(action);
|
|
404
414
|
|
|
405
415
|
// These targets are now resolved by a derived action requirement.
|
|
@@ -410,6 +420,7 @@ function buildReport() {
|
|
|
410
420
|
tables: [...new Set(workspaceMutations.map((m) => m.table))].sort(),
|
|
411
421
|
mutations: workspaceMutations,
|
|
412
422
|
authOperations,
|
|
423
|
+
rpcCalls,
|
|
413
424
|
denial: denialProven ? "proven" : "gap",
|
|
414
425
|
control: controlProven ? "proven" : "gap",
|
|
415
426
|
status: proven ? "proven" : acceptance ? "accepted_gap" : "gap",
|
|
@@ -599,7 +610,7 @@ function printReport(report) {
|
|
|
599
610
|
);
|
|
600
611
|
heading(
|
|
601
612
|
"SERVICE-ROLE ACTION BOUNDARIES",
|
|
602
|
-
"Derived from workspace-targeted service-role writes
|
|
613
|
+
"Derived from workspace-targeted service-role writes, privileged Auth operations, or service RPC calls (including internal actions). Each action needs both an unauthorized/cross-tenant refusal and an allowed-path control; withProof metadata is not consulted.",
|
|
603
614
|
);
|
|
604
615
|
console.log("");
|
|
605
616
|
printTable(
|
|
@@ -611,6 +622,9 @@ function printReport(report) {
|
|
|
611
622
|
[
|
|
612
623
|
...r.tables,
|
|
613
624
|
...r.authOperations.map((op) => `auth.admin.${op}`),
|
|
625
|
+
...r.rpcCalls.map(
|
|
626
|
+
(call) => `rpc.${call.function} (potential write)`,
|
|
627
|
+
),
|
|
614
628
|
].join(","),
|
|
615
629
|
},
|
|
616
630
|
{ header: "denial", get: (r) => r.denial },
|
|
@@ -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
|
+
}
|
|
@@ -317,6 +317,7 @@ function scanFile(file, constants) {
|
|
|
317
317
|
: undefined;
|
|
318
318
|
}
|
|
319
319
|
const mutations = new Map();
|
|
320
|
+
const rpcCalls = new Set();
|
|
320
321
|
visit(source, (n) => {
|
|
321
322
|
if (ts.isIdentifier(n) && clientKind(n)) {
|
|
322
323
|
const parent = n.parent;
|
|
@@ -324,7 +325,7 @@ function scanFile(file, constants) {
|
|
|
324
325
|
const query =
|
|
325
326
|
ts.isPropertyAccessExpression(parent) &&
|
|
326
327
|
parent.expression === n &&
|
|
327
|
-
parent.name.text
|
|
328
|
+
["from", "rpc"].includes(parent.name.text) &&
|
|
328
329
|
ts.isCallExpression(parent.parent) &&
|
|
329
330
|
parent.parent.expression === parent;
|
|
330
331
|
const adminOperation =
|
|
@@ -360,6 +361,49 @@ function scanFile(file, constants) {
|
|
|
360
361
|
}
|
|
361
362
|
if (!ts.isCallExpression(n) || !ts.isPropertyAccessExpression(n.expression))
|
|
362
363
|
return;
|
|
364
|
+
if (n.expression.name.text === "rpc") {
|
|
365
|
+
const kind = clientKind(n.expression.expression);
|
|
366
|
+
const target = literal(n.arguments[0]);
|
|
367
|
+
if (kind !== "service")
|
|
368
|
+
problems.push(
|
|
369
|
+
"RPC privilege/effects cannot be resolved: only direct service-client RPC assessment is supported",
|
|
370
|
+
);
|
|
371
|
+
if (!target || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(target))
|
|
372
|
+
problems.push("RPC requires a literal unqualified function name");
|
|
373
|
+
let chain = n;
|
|
374
|
+
while (
|
|
375
|
+
ts.isPropertyAccessExpression(chain.parent) &&
|
|
376
|
+
chain.parent.expression === chain
|
|
377
|
+
) {
|
|
378
|
+
const member = chain.parent;
|
|
379
|
+
if (
|
|
380
|
+
!["single", "maybeSingle"].includes(member.name.text) ||
|
|
381
|
+
!ts.isCallExpression(member.parent) ||
|
|
382
|
+
member.parent.expression !== member ||
|
|
383
|
+
member.parent.arguments.length !== 0
|
|
384
|
+
) {
|
|
385
|
+
problems.push("RPC result chain is unsupported");
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
chain = member.parent;
|
|
389
|
+
}
|
|
390
|
+
let parent = chain.parent;
|
|
391
|
+
let awaited = false;
|
|
392
|
+
while (
|
|
393
|
+
ts.isAwaitExpression(parent) ||
|
|
394
|
+
ts.isParenthesizedExpression(parent)
|
|
395
|
+
) {
|
|
396
|
+
if (ts.isAwaitExpression(parent)) awaited = true;
|
|
397
|
+
parent = parent.parent;
|
|
398
|
+
}
|
|
399
|
+
if (
|
|
400
|
+
!awaited &&
|
|
401
|
+
!ts.isReturnStatement(parent) &&
|
|
402
|
+
!(ts.isArrowFunction(parent) && parent.body === chain)
|
|
403
|
+
)
|
|
404
|
+
problems.push("RPC query builder escapes its recognized chain");
|
|
405
|
+
if (kind === "service" && target) rpcCalls.add(target);
|
|
406
|
+
}
|
|
363
407
|
if (
|
|
364
408
|
n.expression.name.text === "from" &&
|
|
365
409
|
ts.isIdentifier(n.expression.expression) &&
|
|
@@ -507,6 +551,10 @@ function scanFile(file, constants) {
|
|
|
507
551
|
`${a.table}:${a.operation}`.localeCompare(`${b.table}:${b.operation}`),
|
|
508
552
|
),
|
|
509
553
|
serviceRoleAuthOperations: [...authOperations].sort(),
|
|
554
|
+
serviceRoleRpcCalls: [...rpcCalls].sort().map((name) => ({
|
|
555
|
+
function: name,
|
|
556
|
+
assessment: "potential_privileged_write",
|
|
557
|
+
})),
|
|
510
558
|
middleware: {
|
|
511
559
|
auth: /\bwithAuth\s*\(/.test(body),
|
|
512
560
|
tenantIsolation: /\bwithTenantIsolation\s*\(/.test(body),
|
package/dist/node.d.ts
CHANGED
|
@@ -26,6 +26,11 @@ interface CapabilitiesArtifact {
|
|
|
26
26
|
}>;
|
|
27
27
|
/** Direct supported privileged Auth API calls, separate from SQL writes. */
|
|
28
28
|
serviceRoleAuthOperations?: readonly "deleteUser"[];
|
|
29
|
+
/** Conservative RPC assessment; does not infer SQL tables, grants or read-only behavior. */
|
|
30
|
+
serviceRoleRpcCalls?: ReadonlyArray<{
|
|
31
|
+
function: string;
|
|
32
|
+
assessment: "potential_privileged_write";
|
|
33
|
+
}>;
|
|
29
34
|
/** Relevant guards observed in the action pipeline. */
|
|
30
35
|
middleware?: {
|
|
31
36
|
auth: boolean;
|