Skip to content

Commit 982eeb4

Browse files
author
Philip Z
committed
test: require complete private lineage at Python generated save boundaries
1 parent a40690a commit 982eeb4

2 files changed

Lines changed: 93 additions & 2 deletions

File tree

‎examples/trace-chain/mutation_privacy.py‎

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Generated save privacy; no generated source inspection or synthetic traces."""
22
import asyncio
3+
import json
34
import os
45
import uuid
56

@@ -9,7 +10,84 @@
910
from teaql.provider.sqlite import create_sqlite_service
1011
from teaql.runtime import UserContext
1112

12-
from main import AuditSink, ObservedService, new, reset
13+
from main import AuditSink, ObservedService, new, reset, readback_fact, scalar
14+
15+
16+
def complete_lineage(nodes, root_id, reason, boundary):
17+
"""Empty/redacted-away responsibility is not a successful privacy projection."""
18+
assert len(nodes) == 1, boundary + ': complete typed root lineage required'
19+
node = nodes[0]
20+
assert (node.kind, node.name, node.entity_id, node.comment) == (
21+
'auditReason', 'CustomerOrder', root_id, reason), boundary + ': wrong root responsibility'
22+
23+
24+
def assert_private_lineage(service, sink, context, root_id, reason, identities, secrets,
25+
expected_bindings, phase):
26+
safe_reason = reason
27+
for secret in secrets:
28+
safe_reason = safe_reason.replace(secret, '[REDACTED]')
29+
expected = set(identities)
30+
assert len(expected) == len(identities), 'privacy graph needs distinct typed identities'
31+
count = len(identities)
32+
assert len(service.raw_requests) == len(service.results) == len(sink.events) == count
33+
assert service.finishes == ['commit'], 'privacy graph must commit before audit delivery'
34+
assert len(context.sql_logs()) == count * 2
35+
commands = []
36+
seen = set()
37+
for request, result in zip(service.raw_requests, service.results):
38+
command = request._data
39+
identity = (command.entity, scalar(getattr(command, 'id', None) or command.values.get('id')))
40+
assert identity in expected and identity not in seen, 'actual privacy command identity mismatch'
41+
seen.add(identity)
42+
assert request.comment() == result.comment == reason, 'privacy must not rewrite command reason'
43+
complete_lineage(request.mutation_lineage, root_id, reason, 'raw privacy command')
44+
complete_lineage(result.mutation_lineage, root_id, reason, 'raw provider mutation')
45+
assert len(result.statements) == 2 and result.statements[0].affected_rows == 1
46+
for field, value in expected_bindings.get(command.entity, {}).items():
47+
assert scalar(command.values[field]) == value, 'privacy must not rewrite command bindings'
48+
assert value in [scalar(parameter) for parameter in result.statements[0].parameters], 'privacy must not rewrite provider parameters'
49+
commands.append({'entity': identity[0], 'id': identity[1], 'comment': request.comment(),
50+
'lineage': readback_nodes(request.mutation_lineage)})
51+
assert seen == expected
52+
for index, (request, result) in enumerate(zip(service.raw_requests, service.results)):
53+
write, read = context.sql_logs()[index * 2:index * 2 + 2]
54+
for fact in (write, read):
55+
complete_lineage(fact.mutation_lineage, root_id, safe_reason, 'safe privacy SQL')
56+
assert fact.comment == fact.audit_reason == safe_reason, 'safe SQL must retain public reason'
57+
assert fact.execution_outcome == 'success'
58+
assert [node.kind for node in fact.trace_path] == [
59+
'operation', 'entity' if fact is write else 'request', 'provider', 'sql']
60+
assert fact.trace_path[0].name == 'CustomerOrder'
61+
assert fact.trace_path[2].name == 'sqlite'
62+
assert write.trace_path[1].name == request._data.entity
63+
assert write.trace_path[-1].name == service.actions[index]
64+
assert read.trace_path[-1].name == 'select'
65+
assert write.affected_rows == read.result_count == 1 and read.affected_rows is None
66+
assert read.purpose == 'verify the persisted mutation result'
67+
assert write.ended_at <= read.started_at
68+
assert not result.statements[0].statements and not result.statements[1].statements
69+
audits = []
70+
seen = set()
71+
for event in sink.events:
72+
identity = (event.entity, scalar(event.entity_id))
73+
assert identity in expected and identity not in seen, 'committed privacy audit identity mismatch'
74+
seen.add(identity)
75+
complete_lineage(event.trace_chain, root_id, safe_reason, 'safe privacy audit')
76+
audits.append({'entity': identity[0], 'id': identity[1],
77+
'lineage': readback_nodes(event.trace_chain)})
78+
assert seen == expected
79+
for secret in secrets:
80+
assert secret not in repr(context.sql_logs()) and secret not in repr(sink.events)
81+
print('PRIVATE_LINEAGE_OBSERVED ' + json.dumps({
82+
'phase': phase, 'rootId': root_id, 'rawReason': reason, 'safeReason': safe_reason,
83+
'commands': commands, 'sql': [readback_fact(fact) for fact in context.sql_logs()],
84+
'audit': audits}, sort_keys=True))
85+
print('PASS Python complete private lineage: raw commands, safe SQL/readback and committed audit')
86+
87+
88+
def readback_nodes(nodes):
89+
return [{'Kind': node.kind, 'Name': node.name, 'EntityId': node.entity_id,
90+
'Comment': node.comment} for node in nodes]
1391

1492

1593
async def main():
@@ -52,6 +130,10 @@ async def main():
52130
for metadata in service.results:
53131
assert len(metadata.statements) == 2
54132
assert metadata.comment == reason # raw owned intent is not rewritten
133+
assert_private_lineage(service, sink, context, E.customer_order(root).id().eval(), reason,
134+
[('CustomerOrder', E.customer_order(root).id().eval()),
135+
('OrderItem', E.order_item(item).id().eval()), ('Payment', E.payment(payment).id().eval())],
136+
[secret, old], {'Payment': {'reference_code': secret}}, phase)
55137
loaded = await Q.payments().with_id_is(E.payment(payment).id().eval()).limit(1).comment(
56138
'reload authoritative payment').purpose('verify safe logs do not alter business data').execute_for_one(context)
57139
assert E.payment(loaded).reference_code().eval() == secret
@@ -77,6 +159,9 @@ async def main():
77159
assert secret not in repr(context.sql_logs()), 'loaded delete value leaked into SQL intent'
78160
assert secret not in repr(sink.events), 'loaded delete value leaked into committed audit'
79161
assert all(metadata.comment == reason for metadata in service.results)
162+
assert_private_lineage(service, sink, context, E.customer_order(root).id().eval(), reason,
163+
[('CustomerOrder', E.customer_order(root).id().eval()), ('Payment', payment_id)],
164+
[secret], {}, 'delete')
80165
missing = await Q.payments().with_id_is(payment_id).limit(1).comment('verify deleted payment').purpose(
81166
'prove soft delete hides the row').execute_for_one(context)
82167
assert missing is None
@@ -94,10 +179,14 @@ async def main():
94179
'load all order fields').purpose('retain an unchanged private description').execute_for_one(context)
95180
root.update_order_number(label + '-revised')
96181
reset(service, sink, context)
97-
await root.audit_as(f'renumber {private_description}').save(context)
182+
reason = f'renumber {private_description}'
183+
await root.audit_as(reason).save(context)
98184
assert len(service.requests) == len(sink.events) == 1
99185
assert private_description not in repr(context.sql_logs()), 'unchanged loaded scalar leaked into SQL intent'
100186
assert private_description not in repr(sink.events), 'unchanged loaded scalar leaked into audit'
187+
assert_private_lineage(service, sink, context, E.customer_order(root).id().eval(), reason,
188+
[('CustomerOrder', E.customer_order(root).id().eval())], [private_description],
189+
{'CustomerOrder': {'order_number': label + '-revised'}}, 'unchanged')
101190
reloaded = await Q.customer_orders().with_id_is(E.customer_order(root).id().eval()).limit(1).comment(
102191
'verify unchanged description').purpose('prove provenance did not alter business values').execute_for_one(context)
103192
assert E.customer_order(reloaded).description().eval() == private_description

‎examples/trace-chain/verify.sh‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ for attempt in first second; do
8181
python "$example/mutation_privacy.py" | tee "$privacy_log"
8282
rg -Fq 'PASS: Python generated loaded delete privacy; 2 writes/2 reads/2 audits; independent next request' "$privacy_log"
8383
rg -Fq 'PASS: Python generated unchanged scalar privacy; 1 write/1 read/1 audit' "$privacy_log"
84+
[[ "$(rg -c '^PRIVATE_LINEAGE_OBSERVED ' "$privacy_log")" == 4 ]]
85+
[[ "$(rg -c '^PASS Python complete private lineage:' "$privacy_log")" == 4 ]]
8486
echo "Assigned identities $attempt on $TEAQL_TRACE_CHAIN_SHARED_DB without cleanup"
8587
assigned_log="$(mktemp -t teaql-python-assigned.XXXXXX.log)"
8688
TEAQL_TRACE_CHAIN_DB="$TEAQL_TRACE_CHAIN_SHARED_DB" \

0 commit comments

Comments
 (0)