fraiseql-confiture 0.3.4__cp311-cp311-win_amd64.whl
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.
- confiture/__init__.py +48 -0
- confiture/_core.cp311-win_amd64.pyd +0 -0
- confiture/cli/__init__.py +0 -0
- confiture/cli/dry_run.py +116 -0
- confiture/cli/lint_formatter.py +193 -0
- confiture/cli/main.py +1656 -0
- confiture/config/__init__.py +0 -0
- confiture/config/environment.py +263 -0
- confiture/core/__init__.py +51 -0
- confiture/core/anonymization/__init__.py +0 -0
- confiture/core/anonymization/audit.py +485 -0
- confiture/core/anonymization/benchmarking.py +372 -0
- confiture/core/anonymization/breach_notification.py +652 -0
- confiture/core/anonymization/compliance.py +617 -0
- confiture/core/anonymization/composer.py +298 -0
- confiture/core/anonymization/data_subject_rights.py +669 -0
- confiture/core/anonymization/factory.py +319 -0
- confiture/core/anonymization/governance.py +737 -0
- confiture/core/anonymization/performance.py +1092 -0
- confiture/core/anonymization/profile.py +284 -0
- confiture/core/anonymization/registry.py +195 -0
- confiture/core/anonymization/security/kms_manager.py +547 -0
- confiture/core/anonymization/security/lineage.py +888 -0
- confiture/core/anonymization/security/token_store.py +686 -0
- confiture/core/anonymization/strategies/__init__.py +41 -0
- confiture/core/anonymization/strategies/address.py +359 -0
- confiture/core/anonymization/strategies/credit_card.py +374 -0
- confiture/core/anonymization/strategies/custom.py +161 -0
- confiture/core/anonymization/strategies/date.py +218 -0
- confiture/core/anonymization/strategies/differential_privacy.py +398 -0
- confiture/core/anonymization/strategies/email.py +141 -0
- confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
- confiture/core/anonymization/strategies/hash.py +150 -0
- confiture/core/anonymization/strategies/ip_address.py +235 -0
- confiture/core/anonymization/strategies/masking_retention.py +252 -0
- confiture/core/anonymization/strategies/name.py +298 -0
- confiture/core/anonymization/strategies/phone.py +119 -0
- confiture/core/anonymization/strategies/preserve.py +85 -0
- confiture/core/anonymization/strategies/redact.py +101 -0
- confiture/core/anonymization/strategies/salted_hashing.py +322 -0
- confiture/core/anonymization/strategies/text_redaction.py +183 -0
- confiture/core/anonymization/strategies/tokenization.py +334 -0
- confiture/core/anonymization/strategy.py +241 -0
- confiture/core/anonymization/syncer_audit.py +357 -0
- confiture/core/blue_green.py +683 -0
- confiture/core/builder.py +500 -0
- confiture/core/checksum.py +358 -0
- confiture/core/connection.py +132 -0
- confiture/core/differ.py +522 -0
- confiture/core/drift.py +564 -0
- confiture/core/dry_run.py +182 -0
- confiture/core/health.py +313 -0
- confiture/core/hooks/__init__.py +87 -0
- confiture/core/hooks/base.py +232 -0
- confiture/core/hooks/context.py +146 -0
- confiture/core/hooks/execution_strategies.py +57 -0
- confiture/core/hooks/observability.py +220 -0
- confiture/core/hooks/phases.py +53 -0
- confiture/core/hooks/registry.py +295 -0
- confiture/core/large_tables.py +775 -0
- confiture/core/linting/__init__.py +70 -0
- confiture/core/linting/composer.py +192 -0
- confiture/core/linting/libraries/__init__.py +17 -0
- confiture/core/linting/libraries/gdpr.py +168 -0
- confiture/core/linting/libraries/general.py +184 -0
- confiture/core/linting/libraries/hipaa.py +144 -0
- confiture/core/linting/libraries/pci_dss.py +104 -0
- confiture/core/linting/libraries/sox.py +120 -0
- confiture/core/linting/schema_linter.py +491 -0
- confiture/core/linting/versioning.py +151 -0
- confiture/core/locking.py +389 -0
- confiture/core/migration_generator.py +298 -0
- confiture/core/migrator.py +793 -0
- confiture/core/observability/__init__.py +44 -0
- confiture/core/observability/audit.py +323 -0
- confiture/core/observability/logging.py +187 -0
- confiture/core/observability/metrics.py +174 -0
- confiture/core/observability/tracing.py +192 -0
- confiture/core/pg_version.py +418 -0
- confiture/core/pool.py +406 -0
- confiture/core/risk/__init__.py +39 -0
- confiture/core/risk/predictor.py +188 -0
- confiture/core/risk/scoring.py +248 -0
- confiture/core/rollback_generator.py +388 -0
- confiture/core/schema_analyzer.py +769 -0
- confiture/core/schema_to_schema.py +590 -0
- confiture/core/security/__init__.py +32 -0
- confiture/core/security/logging.py +201 -0
- confiture/core/security/validation.py +416 -0
- confiture/core/signals.py +371 -0
- confiture/core/syncer.py +540 -0
- confiture/exceptions.py +192 -0
- confiture/integrations/__init__.py +0 -0
- confiture/models/__init__.py +0 -0
- confiture/models/lint.py +193 -0
- confiture/models/migration.py +180 -0
- confiture/models/schema.py +203 -0
- confiture/scenarios/__init__.py +36 -0
- confiture/scenarios/compliance.py +586 -0
- confiture/scenarios/ecommerce.py +199 -0
- confiture/scenarios/financial.py +253 -0
- confiture/scenarios/healthcare.py +315 -0
- confiture/scenarios/multi_tenant.py +340 -0
- confiture/scenarios/saas.py +295 -0
- confiture/testing/FRAMEWORK_API.md +722 -0
- confiture/testing/__init__.py +38 -0
- confiture/testing/fixtures/__init__.py +11 -0
- confiture/testing/fixtures/data_validator.py +229 -0
- confiture/testing/fixtures/migration_runner.py +167 -0
- confiture/testing/fixtures/schema_snapshotter.py +352 -0
- confiture/testing/frameworks/__init__.py +10 -0
- confiture/testing/frameworks/mutation.py +587 -0
- confiture/testing/frameworks/performance.py +479 -0
- confiture/testing/utils/__init__.py +0 -0
- fraiseql_confiture-0.3.4.dist-info/METADATA +438 -0
- fraiseql_confiture-0.3.4.dist-info/RECORD +119 -0
- fraiseql_confiture-0.3.4.dist-info/WHEEL +4 -0
- fraiseql_confiture-0.3.4.dist-info/entry_points.txt +2 -0
- fraiseql_confiture-0.3.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Immutable audit logging for anonymization operations.
|
|
2
|
+
|
|
3
|
+
This module provides an append-only audit trail for compliance with:
|
|
4
|
+
- GDPR Article 30 (Records of Processing Activities)
|
|
5
|
+
- Data protection audit requirements
|
|
6
|
+
- Operational accountability
|
|
7
|
+
|
|
8
|
+
Security Features:
|
|
9
|
+
- Immutable database table (append-only, no UPDATE/DELETE)
|
|
10
|
+
- HMAC signatures prevent tampering
|
|
11
|
+
- Timestamp tracking for all operations
|
|
12
|
+
- JSON serialization for portability
|
|
13
|
+
- User and hostname tracking
|
|
14
|
+
- Verification status recording
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> from datetime import datetime, timezone
|
|
18
|
+
>>> from uuid import uuid4
|
|
19
|
+
>>> from confiture.core.anonymization.audit import AuditEntry, AuditLogger
|
|
20
|
+
>>>
|
|
21
|
+
>>> # Create audit entry
|
|
22
|
+
>>> entry = AuditEntry(
|
|
23
|
+
... id=uuid4(),
|
|
24
|
+
... timestamp=datetime.now(timezone.utc),
|
|
25
|
+
... user="admin@example.com",
|
|
26
|
+
... hostname="sync-server-01",
|
|
27
|
+
... source_database="prod_main",
|
|
28
|
+
... target_database="staging_copy",
|
|
29
|
+
... profile_name="production",
|
|
30
|
+
... profile_version="1.0",
|
|
31
|
+
... profile_hash="abc123def456",
|
|
32
|
+
... tables_synced=["users", "orders"],
|
|
33
|
+
... rows_anonymized={"users": 1000, "orders": 5000},
|
|
34
|
+
... strategies_applied={"email": 1000, "phone": 1000},
|
|
35
|
+
... verification_passed=True,
|
|
36
|
+
... verification_report="{}",
|
|
37
|
+
... signature="hmac_sig_here"
|
|
38
|
+
... )
|
|
39
|
+
>>>
|
|
40
|
+
>>> # Log to database
|
|
41
|
+
>>> logger = AuditLogger(database_connection)
|
|
42
|
+
>>> logger.log_sync(entry)
|
|
43
|
+
>>> print(f"Logged {entry.id}")
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
import hashlib
|
|
47
|
+
import hmac
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import socket
|
|
51
|
+
from dataclasses import asdict, dataclass
|
|
52
|
+
from datetime import UTC, datetime
|
|
53
|
+
from uuid import UUID, uuid4
|
|
54
|
+
|
|
55
|
+
import psycopg
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class AuditEntry:
|
|
60
|
+
"""Immutable audit log entry for compliance.
|
|
61
|
+
|
|
62
|
+
Each entry records a complete anonymization operation with:
|
|
63
|
+
- Identity: WHO (user, hostname) and WHEN (timestamp)
|
|
64
|
+
- Operation: WHAT (which tables, which strategies)
|
|
65
|
+
- Verification: DID IT WORK (verification_passed, verification_report)
|
|
66
|
+
- Integrity: SIGNATURE (HMAC to prevent tampering)
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
id: Unique audit entry ID (UUID)
|
|
70
|
+
timestamp: When the operation occurred (ISO 8601)
|
|
71
|
+
user: User who performed the operation (email or system account)
|
|
72
|
+
hostname: Server hostname where operation was performed
|
|
73
|
+
source_database: Source database URL (for reference)
|
|
74
|
+
target_database: Target database URL (for reference)
|
|
75
|
+
profile_name: Anonymization profile name (for identification)
|
|
76
|
+
profile_version: Profile version (for tracking changes)
|
|
77
|
+
profile_hash: SHA256 hash of profile (for integrity check)
|
|
78
|
+
tables_synced: List of table names processed
|
|
79
|
+
rows_anonymized: Dict of table → count anonymized
|
|
80
|
+
strategies_applied: Dict of strategy → count applied
|
|
81
|
+
verification_passed: Whether verification checks passed
|
|
82
|
+
verification_report: Detailed verification results (JSON string)
|
|
83
|
+
signature: HMAC signature for tamper detection
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
id: UUID
|
|
87
|
+
"""Unique entry ID (UUID4)."""
|
|
88
|
+
|
|
89
|
+
timestamp: datetime
|
|
90
|
+
"""Operation timestamp (UTC, ISO 8601)."""
|
|
91
|
+
|
|
92
|
+
user: str
|
|
93
|
+
"""User who performed the operation."""
|
|
94
|
+
|
|
95
|
+
hostname: str
|
|
96
|
+
"""Hostname where operation was performed."""
|
|
97
|
+
|
|
98
|
+
source_database: str
|
|
99
|
+
"""Source database identifier."""
|
|
100
|
+
|
|
101
|
+
target_database: str
|
|
102
|
+
"""Target database identifier."""
|
|
103
|
+
|
|
104
|
+
profile_name: str
|
|
105
|
+
"""Anonymization profile name."""
|
|
106
|
+
|
|
107
|
+
profile_version: str
|
|
108
|
+
"""Profile version number."""
|
|
109
|
+
|
|
110
|
+
profile_hash: str
|
|
111
|
+
"""SHA256 hash of the profile (integrity check)."""
|
|
112
|
+
|
|
113
|
+
tables_synced: list[str]
|
|
114
|
+
"""Tables that were anonymized."""
|
|
115
|
+
|
|
116
|
+
rows_anonymized: dict[str, int]
|
|
117
|
+
"""Count of rows anonymized per table."""
|
|
118
|
+
|
|
119
|
+
strategies_applied: dict[str, int]
|
|
120
|
+
"""Count of strategy applications per type."""
|
|
121
|
+
|
|
122
|
+
verification_passed: bool
|
|
123
|
+
"""Whether verification checks passed."""
|
|
124
|
+
|
|
125
|
+
verification_report: str
|
|
126
|
+
"""Verification details (JSON string)."""
|
|
127
|
+
|
|
128
|
+
signature: str
|
|
129
|
+
"""HMAC signature for tamper detection."""
|
|
130
|
+
|
|
131
|
+
def to_json(self) -> str:
|
|
132
|
+
"""Serialize entry to JSON for storage.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
JSON string representation of the audit entry
|
|
136
|
+
"""
|
|
137
|
+
data = asdict(self)
|
|
138
|
+
data["id"] = str(self.id)
|
|
139
|
+
data["timestamp"] = self.timestamp.isoformat()
|
|
140
|
+
return json.dumps(data)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_json(cls, json_str: str) -> "AuditEntry":
|
|
144
|
+
"""Deserialize entry from JSON.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
json_str: JSON string representation
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Reconstructed AuditEntry instance
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ValueError: If JSON is invalid
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
data = json.loads(json_str)
|
|
157
|
+
data["id"] = UUID(data["id"])
|
|
158
|
+
data["timestamp"] = datetime.fromisoformat(data["timestamp"])
|
|
159
|
+
return cls(**data)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
raise ValueError(f"Invalid audit entry JSON: {e}") from e
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class AuditLogger:
|
|
165
|
+
"""Append-only audit log for anonymization operations.
|
|
166
|
+
|
|
167
|
+
Provides secure logging with:
|
|
168
|
+
- Immutable database table (no UPDATE/DELETE)
|
|
169
|
+
- Automatic table creation
|
|
170
|
+
- Entry signing with HMAC
|
|
171
|
+
- Tamper detection via signatures
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
>>> import psycopg
|
|
175
|
+
>>> conn = psycopg.connect("postgresql://localhost/confiture")
|
|
176
|
+
>>> logger = AuditLogger(conn)
|
|
177
|
+
>>> entry = AuditEntry(...)
|
|
178
|
+
>>> logger.log_sync(entry)
|
|
179
|
+
>>> log = logger.get_audit_log(limit=100)
|
|
180
|
+
>>> print(f"Found {len(log)} audit entries")
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, target_conn: psycopg.Connection):
|
|
184
|
+
"""Initialize audit logger with database connection.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
target_conn: PostgreSQL connection for audit table
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
psycopg.OperationalError: If connection fails
|
|
191
|
+
"""
|
|
192
|
+
self.conn = target_conn
|
|
193
|
+
self._ensure_audit_table()
|
|
194
|
+
|
|
195
|
+
def _ensure_audit_table(self) -> None:
|
|
196
|
+
"""Create audit table if not exists (idempotent).
|
|
197
|
+
|
|
198
|
+
This method creates the confiture_audit_log table with:
|
|
199
|
+
- UUID primary key for entry identification
|
|
200
|
+
- TIMESTAMPTZ for accurate time tracking
|
|
201
|
+
- JSONB for flexible audit data
|
|
202
|
+
- PostgreSQL-enforced append-only constraints
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
psycopg.DatabaseError: If table creation fails
|
|
206
|
+
"""
|
|
207
|
+
with self.conn.cursor() as cursor:
|
|
208
|
+
cursor.execute(
|
|
209
|
+
"""
|
|
210
|
+
CREATE TABLE IF NOT EXISTS confiture_audit_log (
|
|
211
|
+
id UUID PRIMARY KEY,
|
|
212
|
+
timestamp TIMESTAMPTZ NOT NULL,
|
|
213
|
+
user_name TEXT NOT NULL,
|
|
214
|
+
hostname TEXT NOT NULL,
|
|
215
|
+
source_database TEXT NOT NULL,
|
|
216
|
+
target_database TEXT NOT NULL,
|
|
217
|
+
profile_name TEXT NOT NULL,
|
|
218
|
+
profile_version TEXT NOT NULL,
|
|
219
|
+
profile_hash TEXT NOT NULL,
|
|
220
|
+
tables_synced TEXT[] NOT NULL,
|
|
221
|
+
rows_anonymized JSONB NOT NULL,
|
|
222
|
+
strategies_applied JSONB NOT NULL,
|
|
223
|
+
verification_passed BOOLEAN NOT NULL,
|
|
224
|
+
verification_report TEXT NOT NULL,
|
|
225
|
+
signature TEXT NOT NULL,
|
|
226
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
-- Ensure table is append-only by revoking dangerous permissions
|
|
230
|
+
REVOKE UPDATE, DELETE ON confiture_audit_log FROM PUBLIC;
|
|
231
|
+
"""
|
|
232
|
+
)
|
|
233
|
+
self.conn.commit()
|
|
234
|
+
|
|
235
|
+
def log_sync(self, entry: AuditEntry) -> None:
|
|
236
|
+
"""Append entry to audit log (immutable append-only).
|
|
237
|
+
|
|
238
|
+
This method appends a new audit entry. The entry cannot be
|
|
239
|
+
modified or deleted after insertion due to database constraints.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
entry: AuditEntry to log
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
psycopg.DatabaseError: If insertion fails
|
|
246
|
+
"""
|
|
247
|
+
with self.conn.cursor() as cursor:
|
|
248
|
+
# Truncate microseconds to ensure consistent signature verification
|
|
249
|
+
ts = entry.timestamp.replace(microsecond=0)
|
|
250
|
+
cursor.execute(
|
|
251
|
+
"""
|
|
252
|
+
INSERT INTO confiture_audit_log (
|
|
253
|
+
id, timestamp, user_name, hostname,
|
|
254
|
+
source_database, target_database,
|
|
255
|
+
profile_name, profile_version, profile_hash,
|
|
256
|
+
tables_synced, rows_anonymized, strategies_applied,
|
|
257
|
+
verification_passed, verification_report, signature
|
|
258
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
259
|
+
""",
|
|
260
|
+
(
|
|
261
|
+
str(entry.id),
|
|
262
|
+
ts,
|
|
263
|
+
entry.user,
|
|
264
|
+
entry.hostname,
|
|
265
|
+
entry.source_database,
|
|
266
|
+
entry.target_database,
|
|
267
|
+
entry.profile_name,
|
|
268
|
+
entry.profile_version,
|
|
269
|
+
entry.profile_hash,
|
|
270
|
+
entry.tables_synced,
|
|
271
|
+
json.dumps(entry.rows_anonymized),
|
|
272
|
+
json.dumps(entry.strategies_applied),
|
|
273
|
+
entry.verification_passed,
|
|
274
|
+
entry.verification_report,
|
|
275
|
+
entry.signature,
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
self.conn.commit()
|
|
279
|
+
|
|
280
|
+
def get_audit_log(self, limit: int = 100) -> list[AuditEntry]:
|
|
281
|
+
"""Get recent audit log entries (for reporting).
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
limit: Maximum number of entries to return
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
List of recent AuditEntry instances, newest first
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
psycopg.DatabaseError: If query fails
|
|
291
|
+
"""
|
|
292
|
+
with self.conn.cursor() as cursor:
|
|
293
|
+
cursor.execute(
|
|
294
|
+
"""
|
|
295
|
+
SELECT
|
|
296
|
+
id, timestamp, user_name, hostname,
|
|
297
|
+
source_database, target_database,
|
|
298
|
+
profile_name, profile_version, profile_hash,
|
|
299
|
+
tables_synced, rows_anonymized, strategies_applied,
|
|
300
|
+
verification_passed, verification_report, signature
|
|
301
|
+
FROM confiture_audit_log
|
|
302
|
+
ORDER BY timestamp DESC
|
|
303
|
+
LIMIT %s
|
|
304
|
+
""",
|
|
305
|
+
(limit,),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
entries = []
|
|
309
|
+
for row in cursor.fetchall():
|
|
310
|
+
# Normalize timestamp to UTC and remove microseconds for consistent signing
|
|
311
|
+
ts = row[1]
|
|
312
|
+
if ts and hasattr(ts, "astimezone"):
|
|
313
|
+
# Convert to UTC and truncate microseconds
|
|
314
|
+
ts = ts.astimezone(UTC).replace(microsecond=0)
|
|
315
|
+
entries.append(
|
|
316
|
+
AuditEntry(
|
|
317
|
+
id=row[0],
|
|
318
|
+
timestamp=ts,
|
|
319
|
+
user=row[2],
|
|
320
|
+
hostname=row[3],
|
|
321
|
+
source_database=row[4],
|
|
322
|
+
target_database=row[5],
|
|
323
|
+
profile_name=row[6],
|
|
324
|
+
profile_version=row[7],
|
|
325
|
+
profile_hash=row[8],
|
|
326
|
+
tables_synced=list(row[9]),
|
|
327
|
+
rows_anonymized=json.loads(row[10])
|
|
328
|
+
if isinstance(row[10], str)
|
|
329
|
+
else row[10],
|
|
330
|
+
strategies_applied=json.loads(row[11])
|
|
331
|
+
if isinstance(row[11], str)
|
|
332
|
+
else row[11],
|
|
333
|
+
verification_passed=row[12],
|
|
334
|
+
verification_report=row[13],
|
|
335
|
+
signature=row[14],
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return entries
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def sign_audit_entry(entry: AuditEntry, secret: str | None = None) -> str:
|
|
343
|
+
"""Create HMAC signature for audit entry (prevents tampering).
|
|
344
|
+
|
|
345
|
+
The signature is computed over key fields of the audit entry.
|
|
346
|
+
If the entry is modified after signing, the signature will
|
|
347
|
+
no longer match, indicating tampering.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
entry: AuditEntry to sign
|
|
351
|
+
secret: Secret key for HMAC (or uses AUDIT_LOG_SECRET env var)
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
HMAC-SHA256 signature as hex string
|
|
355
|
+
|
|
356
|
+
Example:
|
|
357
|
+
>>> entry = AuditEntry(...)
|
|
358
|
+
>>> sig = sign_audit_entry(entry, secret="my-secret")
|
|
359
|
+
>>> # Later, verify by recomputing:
|
|
360
|
+
>>> sig2 = sign_audit_entry(modified_entry, secret="my-secret")
|
|
361
|
+
>>> assert sig == sig2 # Should fail if entry was modified
|
|
362
|
+
"""
|
|
363
|
+
if secret is None:
|
|
364
|
+
secret = os.getenv("AUDIT_LOG_SECRET", "default-secret")
|
|
365
|
+
|
|
366
|
+
# Create deterministic JSON for signing
|
|
367
|
+
# Include only immutable/important fields
|
|
368
|
+
# Truncate microseconds to avoid precision issues when round-tripping through database
|
|
369
|
+
ts = entry.timestamp.replace(microsecond=0)
|
|
370
|
+
data = {
|
|
371
|
+
"id": str(entry.id),
|
|
372
|
+
"timestamp": ts.isoformat(),
|
|
373
|
+
"user": entry.user,
|
|
374
|
+
"hostname": entry.hostname,
|
|
375
|
+
"source_database": entry.source_database,
|
|
376
|
+
"target_database": entry.target_database,
|
|
377
|
+
"profile_name": entry.profile_name,
|
|
378
|
+
"profile_hash": entry.profile_hash,
|
|
379
|
+
"tables_synced": ",".join(sorted(entry.tables_synced)),
|
|
380
|
+
"rows_anonymized": sum(entry.rows_anonymized.values()),
|
|
381
|
+
"verification_passed": entry.verification_passed,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
json_str = json.dumps(data, sort_keys=True)
|
|
385
|
+
signature = hmac.new(
|
|
386
|
+
secret.encode(),
|
|
387
|
+
json_str.encode(),
|
|
388
|
+
hashlib.sha256,
|
|
389
|
+
).hexdigest()
|
|
390
|
+
|
|
391
|
+
return signature
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def verify_audit_entry(entry: AuditEntry, secret: str | None = None) -> bool:
|
|
395
|
+
"""Verify HMAC signature of audit entry (detect tampering).
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
entry: AuditEntry to verify
|
|
399
|
+
secret: Secret key for HMAC (or uses AUDIT_LOG_SECRET env var)
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
True if signature is valid, False otherwise
|
|
403
|
+
|
|
404
|
+
Example:
|
|
405
|
+
>>> entry = logger.get_audit_log()[0]
|
|
406
|
+
>>> if verify_audit_entry(entry, secret="my-secret"):
|
|
407
|
+
... print("Entry is authentic")
|
|
408
|
+
... else:
|
|
409
|
+
... print("Entry may have been tampered with!")
|
|
410
|
+
"""
|
|
411
|
+
expected_sig = sign_audit_entry(entry, secret)
|
|
412
|
+
return entry.signature == expected_sig
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def create_audit_entry(
|
|
416
|
+
user: str,
|
|
417
|
+
source_db: str,
|
|
418
|
+
target_db: str,
|
|
419
|
+
profile_name: str,
|
|
420
|
+
profile_version: str,
|
|
421
|
+
profile_hash: str,
|
|
422
|
+
tables: list[str],
|
|
423
|
+
rows_by_table: dict[str, int],
|
|
424
|
+
strategies_by_type: dict[str, int],
|
|
425
|
+
verification_passed: bool,
|
|
426
|
+
verification_report: str | None = None,
|
|
427
|
+
secret: str | None = None,
|
|
428
|
+
) -> AuditEntry:
|
|
429
|
+
"""Create and sign an audit entry (convenience function).
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
user: User who performed the operation
|
|
433
|
+
source_db: Source database identifier
|
|
434
|
+
target_db: Target database identifier
|
|
435
|
+
profile_name: Anonymization profile name
|
|
436
|
+
profile_version: Profile version
|
|
437
|
+
profile_hash: SHA256 hash of profile
|
|
438
|
+
tables: List of tables processed
|
|
439
|
+
rows_by_table: Dict of table → count anonymized
|
|
440
|
+
strategies_by_type: Dict of strategy → count applied
|
|
441
|
+
verification_passed: Whether verification succeeded
|
|
442
|
+
verification_report: Detailed verification results (JSON)
|
|
443
|
+
secret: Secret key for signature (or AUDIT_LOG_SECRET env var)
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Signed AuditEntry ready for logging
|
|
447
|
+
|
|
448
|
+
Example:
|
|
449
|
+
>>> entry = create_audit_entry(
|
|
450
|
+
... user="admin@example.com",
|
|
451
|
+
... source_db="prod_main",
|
|
452
|
+
... target_db="staging_copy",
|
|
453
|
+
... profile_name="production",
|
|
454
|
+
... profile_version="1.0",
|
|
455
|
+
... profile_hash="abc123",
|
|
456
|
+
... tables=["users", "orders"],
|
|
457
|
+
... rows_by_table={"users": 1000, "orders": 5000},
|
|
458
|
+
... strategies_by_type={"email": 1000, "phone": 1000},
|
|
459
|
+
... verification_passed=True,
|
|
460
|
+
... verification_report="{}"
|
|
461
|
+
... )
|
|
462
|
+
>>> logger.log_sync(entry)
|
|
463
|
+
"""
|
|
464
|
+
entry = AuditEntry(
|
|
465
|
+
id=uuid4(),
|
|
466
|
+
timestamp=datetime.now(UTC),
|
|
467
|
+
user=user,
|
|
468
|
+
hostname=socket.gethostname(),
|
|
469
|
+
source_database=source_db,
|
|
470
|
+
target_database=target_db,
|
|
471
|
+
profile_name=profile_name,
|
|
472
|
+
profile_version=profile_version,
|
|
473
|
+
profile_hash=profile_hash,
|
|
474
|
+
tables_synced=tables,
|
|
475
|
+
rows_anonymized=rows_by_table,
|
|
476
|
+
strategies_applied=strategies_by_type,
|
|
477
|
+
verification_passed=verification_passed,
|
|
478
|
+
verification_report=verification_report or "{}",
|
|
479
|
+
signature="", # Will be computed below
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Sign the entry
|
|
483
|
+
entry.signature = sign_audit_entry(entry, secret)
|
|
484
|
+
|
|
485
|
+
return entry
|