fraiseql-confiture 0.3.7__cp311-cp311-macosx_11_0_arm64.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.
Files changed (124) hide show
  1. confiture/__init__.py +48 -0
  2. confiture/_core.cpython-311-darwin.so +0 -0
  3. confiture/cli/__init__.py +0 -0
  4. confiture/cli/dry_run.py +116 -0
  5. confiture/cli/lint_formatter.py +193 -0
  6. confiture/cli/main.py +1893 -0
  7. confiture/config/__init__.py +0 -0
  8. confiture/config/environment.py +263 -0
  9. confiture/core/__init__.py +51 -0
  10. confiture/core/anonymization/__init__.py +0 -0
  11. confiture/core/anonymization/audit.py +485 -0
  12. confiture/core/anonymization/benchmarking.py +372 -0
  13. confiture/core/anonymization/breach_notification.py +652 -0
  14. confiture/core/anonymization/compliance.py +617 -0
  15. confiture/core/anonymization/composer.py +298 -0
  16. confiture/core/anonymization/data_subject_rights.py +669 -0
  17. confiture/core/anonymization/factory.py +319 -0
  18. confiture/core/anonymization/governance.py +737 -0
  19. confiture/core/anonymization/performance.py +1092 -0
  20. confiture/core/anonymization/profile.py +284 -0
  21. confiture/core/anonymization/registry.py +195 -0
  22. confiture/core/anonymization/security/kms_manager.py +547 -0
  23. confiture/core/anonymization/security/lineage.py +888 -0
  24. confiture/core/anonymization/security/token_store.py +686 -0
  25. confiture/core/anonymization/strategies/__init__.py +41 -0
  26. confiture/core/anonymization/strategies/address.py +359 -0
  27. confiture/core/anonymization/strategies/credit_card.py +374 -0
  28. confiture/core/anonymization/strategies/custom.py +161 -0
  29. confiture/core/anonymization/strategies/date.py +218 -0
  30. confiture/core/anonymization/strategies/differential_privacy.py +398 -0
  31. confiture/core/anonymization/strategies/email.py +141 -0
  32. confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
  33. confiture/core/anonymization/strategies/hash.py +150 -0
  34. confiture/core/anonymization/strategies/ip_address.py +235 -0
  35. confiture/core/anonymization/strategies/masking_retention.py +252 -0
  36. confiture/core/anonymization/strategies/name.py +298 -0
  37. confiture/core/anonymization/strategies/phone.py +119 -0
  38. confiture/core/anonymization/strategies/preserve.py +85 -0
  39. confiture/core/anonymization/strategies/redact.py +101 -0
  40. confiture/core/anonymization/strategies/salted_hashing.py +322 -0
  41. confiture/core/anonymization/strategies/text_redaction.py +183 -0
  42. confiture/core/anonymization/strategies/tokenization.py +334 -0
  43. confiture/core/anonymization/strategy.py +241 -0
  44. confiture/core/anonymization/syncer_audit.py +357 -0
  45. confiture/core/blue_green.py +683 -0
  46. confiture/core/builder.py +500 -0
  47. confiture/core/checksum.py +358 -0
  48. confiture/core/connection.py +184 -0
  49. confiture/core/differ.py +522 -0
  50. confiture/core/drift.py +564 -0
  51. confiture/core/dry_run.py +182 -0
  52. confiture/core/health.py +313 -0
  53. confiture/core/hooks/__init__.py +87 -0
  54. confiture/core/hooks/base.py +232 -0
  55. confiture/core/hooks/context.py +146 -0
  56. confiture/core/hooks/execution_strategies.py +57 -0
  57. confiture/core/hooks/observability.py +220 -0
  58. confiture/core/hooks/phases.py +53 -0
  59. confiture/core/hooks/registry.py +295 -0
  60. confiture/core/large_tables.py +775 -0
  61. confiture/core/linting/__init__.py +70 -0
  62. confiture/core/linting/composer.py +192 -0
  63. confiture/core/linting/libraries/__init__.py +17 -0
  64. confiture/core/linting/libraries/gdpr.py +168 -0
  65. confiture/core/linting/libraries/general.py +184 -0
  66. confiture/core/linting/libraries/hipaa.py +144 -0
  67. confiture/core/linting/libraries/pci_dss.py +104 -0
  68. confiture/core/linting/libraries/sox.py +120 -0
  69. confiture/core/linting/schema_linter.py +491 -0
  70. confiture/core/linting/versioning.py +151 -0
  71. confiture/core/locking.py +389 -0
  72. confiture/core/migration_generator.py +298 -0
  73. confiture/core/migrator.py +882 -0
  74. confiture/core/observability/__init__.py +44 -0
  75. confiture/core/observability/audit.py +323 -0
  76. confiture/core/observability/logging.py +187 -0
  77. confiture/core/observability/metrics.py +174 -0
  78. confiture/core/observability/tracing.py +192 -0
  79. confiture/core/pg_version.py +418 -0
  80. confiture/core/pool.py +406 -0
  81. confiture/core/risk/__init__.py +39 -0
  82. confiture/core/risk/predictor.py +188 -0
  83. confiture/core/risk/scoring.py +248 -0
  84. confiture/core/rollback_generator.py +388 -0
  85. confiture/core/schema_analyzer.py +769 -0
  86. confiture/core/schema_to_schema.py +590 -0
  87. confiture/core/security/__init__.py +32 -0
  88. confiture/core/security/logging.py +201 -0
  89. confiture/core/security/validation.py +416 -0
  90. confiture/core/signals.py +371 -0
  91. confiture/core/syncer.py +540 -0
  92. confiture/exceptions.py +192 -0
  93. confiture/integrations/__init__.py +0 -0
  94. confiture/models/__init__.py +24 -0
  95. confiture/models/lint.py +193 -0
  96. confiture/models/migration.py +265 -0
  97. confiture/models/schema.py +203 -0
  98. confiture/models/sql_file_migration.py +225 -0
  99. confiture/scenarios/__init__.py +36 -0
  100. confiture/scenarios/compliance.py +586 -0
  101. confiture/scenarios/ecommerce.py +199 -0
  102. confiture/scenarios/financial.py +253 -0
  103. confiture/scenarios/healthcare.py +315 -0
  104. confiture/scenarios/multi_tenant.py +340 -0
  105. confiture/scenarios/saas.py +295 -0
  106. confiture/testing/FRAMEWORK_API.md +722 -0
  107. confiture/testing/__init__.py +100 -0
  108. confiture/testing/fixtures/__init__.py +11 -0
  109. confiture/testing/fixtures/data_validator.py +229 -0
  110. confiture/testing/fixtures/migration_runner.py +167 -0
  111. confiture/testing/fixtures/schema_snapshotter.py +352 -0
  112. confiture/testing/frameworks/__init__.py +10 -0
  113. confiture/testing/frameworks/mutation.py +587 -0
  114. confiture/testing/frameworks/performance.py +479 -0
  115. confiture/testing/loader.py +225 -0
  116. confiture/testing/pytest/__init__.py +38 -0
  117. confiture/testing/pytest_plugin.py +190 -0
  118. confiture/testing/sandbox.py +304 -0
  119. confiture/testing/utils/__init__.py +0 -0
  120. fraiseql_confiture-0.3.7.dist-info/METADATA +438 -0
  121. fraiseql_confiture-0.3.7.dist-info/RECORD +124 -0
  122. fraiseql_confiture-0.3.7.dist-info/WHEEL +4 -0
  123. fraiseql_confiture-0.3.7.dist-info/entry_points.txt +4 -0
  124. fraiseql_confiture-0.3.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,686 @@
1
+ """Encrypted token store with RBAC and audit trails.
2
+
3
+ Provides secure storage for reversible anonymization tokens (from tokenization
4
+ strategies) with encryption at rest, role-based access control, and comprehensive
5
+ audit logging.
6
+
7
+ Security Features:
8
+ - AES-256-GCM encryption for all stored tokens
9
+ - RBAC enforcement for token reversals (only authorized users can reverse)
10
+ - Comprehensive audit trail for all reversals (WHO, WHEN, WHY)
11
+ - KMS key management with automatic rotation support
12
+ - Time-based token expiration and lifecycle management
13
+ - Database-level constraints (append-only reversal log)
14
+
15
+ Example:
16
+ >>> from confiture.core.anonymization.security.token_store import (
17
+ ... EncryptedTokenStore, TokenReversalRequest, TokenAccessLevel
18
+ ... )
19
+ >>> from confiture.core.anonymization.security.kms_manager import (
20
+ ... KMSFactory, KMSProvider
21
+ ... )
22
+ >>>
23
+ >>> # Initialize KMS (AWS example)
24
+ >>> kms = KMSFactory.create(KMSProvider.AWS, region="us-east-1")
25
+ >>>
26
+ >>> # Initialize token store
27
+ >>> store = EncryptedTokenStore(database_connection, kms_client=kms)
28
+ >>>
29
+ >>> # Store a token
30
+ >>> store.store_token(
31
+ ... original_value="john.doe@example.com",
32
+ ... token="TOKEN_abc123xyz789",
33
+ ... column_name="users.email",
34
+ ... strategy_name="tokenization"
35
+ ... )
36
+ >>>
37
+ >>> # Reverse a token (with RBAC check)
38
+ >>> result = store.reverse_token(
39
+ ... token="TOKEN_abc123xyz789",
40
+ ... requester_id="admin@example.com",
41
+ ... reason="Customer support request"
42
+ ... )
43
+ >>> print(result.original_value) # john.doe@example.com
44
+ >>> print(result.audit_id) # UUID of audit entry
45
+ """
46
+
47
+ import hashlib
48
+ import json
49
+ import logging
50
+ from dataclasses import dataclass
51
+ from datetime import UTC, datetime, timedelta
52
+ from enum import Enum
53
+ from typing import Any
54
+ from uuid import UUID, uuid4
55
+
56
+ import psycopg
57
+
58
+ from .kms_manager import KMSClient
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+
63
+ class TokenAccessLevel(Enum):
64
+ """Access levels for token reversal."""
65
+
66
+ NONE = 0
67
+ """No access to reversals."""
68
+
69
+ READ_ONLY = 1
70
+ """Can read token metadata but not reverse."""
71
+
72
+ REVERSE_WITH_REASON = 2
73
+ """Can reverse tokens but requires audit reason."""
74
+
75
+ REVERSE_WITHOUT_REASON = 3
76
+ """Can reverse tokens without requiring audit reason (use with caution)."""
77
+
78
+ UNRESTRICTED = 4
79
+ """Full access (reserved for emergency recovery, minimal use)."""
80
+
81
+
82
+ @dataclass
83
+ class TokenMetadata:
84
+ """Metadata for a stored token."""
85
+
86
+ token: str
87
+ """Token identifier (reversible, not the original value)."""
88
+
89
+ column_name: str
90
+ """Column this token represents (e.g., 'users.email')."""
91
+
92
+ strategy_name: str
93
+ """Strategy used to generate token (e.g., 'tokenization')."""
94
+
95
+ created_at: datetime
96
+ """When token was created (UTC)."""
97
+
98
+ expires_at: datetime | None = None
99
+ """When token expires (optional)."""
100
+
101
+ key_version: int = 1
102
+ """KMS key version used for encryption."""
103
+
104
+ is_active: bool = True
105
+ """Whether token is still valid and in use."""
106
+
107
+
108
+ @dataclass
109
+ class TokenReversalRequest:
110
+ """Request to reverse a token."""
111
+
112
+ token: str
113
+ """Token to reverse."""
114
+
115
+ requester_id: str
116
+ """User ID requesting reversal (email or system account)."""
117
+
118
+ reason: str | None = None
119
+ """Reason for reversal (required for audit trail)."""
120
+
121
+ department: str | None = None
122
+ """Department requesting reversal (optional, for audit trail)."""
123
+
124
+ ticket_id: str | None = None
125
+ """Support ticket or case ID (optional, for traceability)."""
126
+
127
+
128
+ @dataclass
129
+ class TokenReversalResult:
130
+ """Result of a token reversal."""
131
+
132
+ original_value: str
133
+ """The original value that was tokenized."""
134
+
135
+ token: str
136
+ """The token that was reversed."""
137
+
138
+ audit_id: UUID
139
+ """UUID of the audit entry for this reversal."""
140
+
141
+ reversed_at: datetime
142
+ """When the reversal was performed."""
143
+
144
+ requested_by: str
145
+ """User who performed the reversal."""
146
+
147
+
148
+ class EncryptedTokenStore:
149
+ """Secure storage for reversible anonymization tokens.
150
+
151
+ Provides encryption at rest, RBAC enforcement, and comprehensive audit
152
+ logging for all token reversals. Designed for tokenization strategies
153
+ where reversibility is needed under controlled conditions.
154
+
155
+ Attributes:
156
+ conn: PostgreSQL connection for token storage
157
+ kms_client: KMS client for encryption key management
158
+ allowed_reversers: Dict of user ID → access level
159
+ log_secret: Secret for HMAC signing of reversal audit entries
160
+ """
161
+
162
+ # Default allowed reversers (override in init)
163
+ ALLOWED_REVERSERS = {
164
+ # "admin@example.com": TokenAccessLevel.UNRESTRICTED,
165
+ # "support@example.com": TokenAccessLevel.REVERSE_WITH_REASON,
166
+ }
167
+
168
+ def __init__(
169
+ self,
170
+ conn: psycopg.Connection,
171
+ kms_client: KMSClient,
172
+ key_id: str = "token-store-key",
173
+ allowed_reversers: dict[str, TokenAccessLevel] | None = None,
174
+ log_secret: str | None = None,
175
+ ):
176
+ """Initialize encrypted token store.
177
+
178
+ Args:
179
+ conn: PostgreSQL connection
180
+ kms_client: KMS client for encryption/decryption
181
+ key_id: KMS key ID for token encryption (default: "token-store-key")
182
+ allowed_reversers: Dict of user ID → access level for reversals
183
+ log_secret: Secret for HMAC signing (uses env var if not provided)
184
+
185
+ Raises:
186
+ psycopg.OperationalError: If connection fails
187
+ """
188
+ self.conn = conn
189
+ self.kms_client = kms_client
190
+ self.key_id = key_id
191
+ self.allowed_reversers = allowed_reversers or self.ALLOWED_REVERSERS
192
+ self.log_secret = log_secret or "default-token-store-secret"
193
+
194
+ self._ensure_tables()
195
+
196
+ def _ensure_tables(self) -> None:
197
+ """Create token store tables if not exists (idempotent).
198
+
199
+ Creates:
200
+ 1. confiture_tokens - Main token storage with encryption
201
+ 2. confiture_token_reversals - Append-only audit log for reversals
202
+
203
+ Raises:
204
+ psycopg.DatabaseError: If table creation fails
205
+ """
206
+ with self.conn.cursor() as cursor:
207
+ # Main token storage
208
+ cursor.execute(
209
+ """
210
+ CREATE TABLE IF NOT EXISTS confiture_tokens (
211
+ token TEXT PRIMARY KEY,
212
+ encrypted_original BYTEA NOT NULL,
213
+ column_name TEXT NOT NULL,
214
+ strategy_name TEXT NOT NULL,
215
+ created_at TIMESTAMPTZ NOT NULL,
216
+ expires_at TIMESTAMPTZ,
217
+ key_version INTEGER NOT NULL,
218
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
219
+ created_by TEXT NOT NULL,
220
+ created_at_idx TIMESTAMPTZ DEFAULT NOW()
221
+ );
222
+
223
+ CREATE INDEX IF NOT EXISTS idx_tokens_column_name
224
+ ON confiture_tokens(column_name);
225
+ CREATE INDEX IF NOT EXISTS idx_tokens_strategy_name
226
+ ON confiture_tokens(strategy_name);
227
+ CREATE INDEX IF NOT EXISTS idx_tokens_expires_at
228
+ ON confiture_tokens(expires_at)
229
+ WHERE expires_at IS NOT NULL;
230
+ CREATE INDEX IF NOT EXISTS idx_tokens_active
231
+ ON confiture_tokens(is_active)
232
+ WHERE is_active = TRUE;
233
+ """
234
+ )
235
+
236
+ # Append-only reversal audit log
237
+ cursor.execute(
238
+ """
239
+ CREATE TABLE IF NOT EXISTS confiture_token_reversals (
240
+ id UUID PRIMARY KEY,
241
+ token TEXT NOT NULL,
242
+ requester_id TEXT NOT NULL,
243
+ reason TEXT,
244
+ department TEXT,
245
+ ticket_id TEXT,
246
+ reversed_at TIMESTAMPTZ NOT NULL,
247
+ success BOOLEAN NOT NULL,
248
+ error_message TEXT,
249
+ signature TEXT NOT NULL,
250
+ created_at TIMESTAMPTZ DEFAULT NOW()
251
+ );
252
+
253
+ CREATE INDEX IF NOT EXISTS idx_reversals_token
254
+ ON confiture_token_reversals(token);
255
+ CREATE INDEX IF NOT EXISTS idx_reversals_requester
256
+ ON confiture_token_reversals(requester_id);
257
+ CREATE INDEX IF NOT EXISTS idx_reversals_timestamp
258
+ ON confiture_token_reversals(reversed_at DESC);
259
+
260
+ -- Ensure reversal log is append-only
261
+ REVOKE UPDATE, DELETE ON confiture_token_reversals FROM PUBLIC;
262
+ """
263
+ )
264
+
265
+ self.conn.commit()
266
+
267
+ def store_token(
268
+ self,
269
+ original_value: str,
270
+ token: str,
271
+ column_name: str,
272
+ strategy_name: str,
273
+ created_by: str = "system",
274
+ expires_in_days: int | None = None,
275
+ ) -> TokenMetadata:
276
+ """Store a token with encrypted original value.
277
+
278
+ Args:
279
+ original_value: The original value to encrypt and store
280
+ token: The token identifier
281
+ column_name: Column name this token represents (e.g., 'users.email')
282
+ strategy_name: Strategy name (e.g., 'tokenization')
283
+ created_by: User who created the token (default: 'system')
284
+ expires_in_days: Optional expiration in days from now
285
+
286
+ Returns:
287
+ TokenMetadata with encryption details
288
+
289
+ Raises:
290
+ psycopg.DatabaseError: If storage fails
291
+ """
292
+ try:
293
+ # Encrypt the original value using KMS
294
+ encrypted = self.kms_client.encrypt(original_value.encode(), self.key_id)
295
+
296
+ # Calculate expiration if provided
297
+ expires_at = None
298
+ if expires_in_days:
299
+ expires_at = datetime.now(UTC) + timedelta(days=expires_in_days)
300
+
301
+ # Get current key version from metadata
302
+ key_metadata = self.kms_client.get_key_metadata(self.key_id)
303
+ key_version = key_metadata.version
304
+
305
+ # Store in database
306
+ with self.conn.cursor() as cursor:
307
+ cursor.execute(
308
+ """
309
+ INSERT INTO confiture_tokens (
310
+ token, encrypted_original, column_name, strategy_name,
311
+ created_at, expires_at, key_version, is_active, created_by
312
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
313
+ ON CONFLICT (token) DO NOTHING
314
+ """,
315
+ (
316
+ token,
317
+ encrypted,
318
+ column_name,
319
+ strategy_name,
320
+ datetime.now(UTC),
321
+ expires_at,
322
+ key_version,
323
+ True,
324
+ created_by,
325
+ ),
326
+ )
327
+ self.conn.commit()
328
+
329
+ logger.info(
330
+ f"Stored token {token[:8]}... for column {column_name} with strategy {strategy_name}"
331
+ )
332
+
333
+ return TokenMetadata(
334
+ token=token,
335
+ column_name=column_name,
336
+ strategy_name=strategy_name,
337
+ created_at=datetime.now(UTC),
338
+ expires_at=expires_at,
339
+ key_version=key_version,
340
+ is_active=True,
341
+ )
342
+
343
+ except Exception as e:
344
+ logger.error(f"Failed to store token: {e}")
345
+ raise
346
+
347
+ def reverse_token(self, request: TokenReversalRequest) -> TokenReversalResult:
348
+ """Reverse a token to get original value (with RBAC enforcement).
349
+
350
+ Args:
351
+ request: TokenReversalRequest with token, requester, reason
352
+
353
+ Returns:
354
+ TokenReversalResult with original value and audit info
355
+
356
+ Raises:
357
+ PermissionError: If requester not authorized
358
+ ValueError: If token not found or expired
359
+ psycopg.DatabaseError: If database operation fails
360
+ """
361
+ reversal_id = uuid4()
362
+ reversed_at = datetime.now(UTC)
363
+
364
+ try:
365
+ # 1. Check RBAC
366
+ self._check_rbac(request.requester_id, request.reason)
367
+
368
+ # 2. Fetch encrypted token from database
369
+ with self.conn.cursor() as cursor:
370
+ cursor.execute(
371
+ """
372
+ SELECT encrypted_original, expires_at, is_active
373
+ FROM confiture_tokens
374
+ WHERE token = %s
375
+ """,
376
+ (request.token,),
377
+ )
378
+ row = cursor.fetchone()
379
+
380
+ if not row:
381
+ self._log_reversal(
382
+ reversal_id,
383
+ request,
384
+ reversed_at,
385
+ success=False,
386
+ error="Token not found",
387
+ )
388
+ raise ValueError(f"Token not found: {request.token}")
389
+
390
+ encrypted_original, expires_at, is_active = row
391
+
392
+ # 3. Check if token is active
393
+ if not is_active:
394
+ self._log_reversal(
395
+ reversal_id,
396
+ request,
397
+ reversed_at,
398
+ success=False,
399
+ error="Token is inactive",
400
+ )
401
+ raise ValueError(f"Token is inactive: {request.token}")
402
+
403
+ # 4. Check expiration
404
+ if expires_at and datetime.now(UTC) > expires_at:
405
+ self._log_reversal(
406
+ reversal_id,
407
+ request,
408
+ reversed_at,
409
+ success=False,
410
+ error="Token has expired",
411
+ )
412
+ raise ValueError(f"Token has expired: {request.token}")
413
+
414
+ # 5. Decrypt using KMS
415
+ decrypted = self.kms_client.decrypt(encrypted_original, self.key_id)
416
+ original_value = decrypted.decode()
417
+
418
+ # 6. Log successful reversal
419
+ self._log_reversal(reversal_id, request, reversed_at, success=True, error=None)
420
+
421
+ logger.info(
422
+ f"Token reversed by {request.requester_id}: {request.token[:8]}... "
423
+ f"(reason: {request.reason or 'not provided'})"
424
+ )
425
+
426
+ return TokenReversalResult(
427
+ original_value=original_value,
428
+ token=request.token,
429
+ audit_id=reversal_id,
430
+ reversed_at=reversed_at,
431
+ requested_by=request.requester_id,
432
+ )
433
+
434
+ except (PermissionError, ValueError) as e:
435
+ logger.warning(f"Token reversal failed for {request.requester_id}: {e}")
436
+ raise
437
+ except Exception as e:
438
+ logger.error(f"Unexpected error during token reversal: {e}")
439
+ self._log_reversal(
440
+ reversal_id,
441
+ request,
442
+ reversed_at,
443
+ success=False,
444
+ error=str(e),
445
+ )
446
+ raise
447
+
448
+ def _check_rbac(self, requester_id: str, reason: str | None = None) -> None:
449
+ """Check if requester is authorized to reverse tokens.
450
+
451
+ Args:
452
+ requester_id: User ID requesting reversal
453
+ reason: Reason for reversal
454
+
455
+ Raises:
456
+ PermissionError: If requester not authorized
457
+ """
458
+ if requester_id not in self.allowed_reversers:
459
+ raise PermissionError(f"User {requester_id} is not authorized to reverse tokens")
460
+
461
+ access_level = self.allowed_reversers[requester_id]
462
+
463
+ # Check if reason is required
464
+ if access_level == TokenAccessLevel.REVERSE_WITH_REASON and (
465
+ not reason or not reason.strip()
466
+ ):
467
+ raise PermissionError(f"User {requester_id} requires a reason for token reversal")
468
+
469
+ # NONE and READ_ONLY users can't reverse
470
+ if access_level in (TokenAccessLevel.NONE, TokenAccessLevel.READ_ONLY):
471
+ raise PermissionError(f"User {requester_id} does not have reversal permissions")
472
+
473
+ def _log_reversal(
474
+ self,
475
+ reversal_id: UUID,
476
+ request: TokenReversalRequest,
477
+ reversed_at: datetime,
478
+ success: bool,
479
+ error: str | None = None,
480
+ ) -> None:
481
+ """Log a token reversal attempt (append-only).
482
+
483
+ Args:
484
+ reversal_id: UUID for this reversal attempt
485
+ request: Original reversal request
486
+ reversed_at: When reversal was attempted
487
+ success: Whether reversal succeeded
488
+ error: Error message if failed
489
+
490
+ Raises:
491
+ psycopg.DatabaseError: If logging fails
492
+ """
493
+ # Create audit entry
494
+ audit_data = {
495
+ "token": request.token,
496
+ "requester_id": request.requester_id,
497
+ "reason": request.reason,
498
+ "department": request.department,
499
+ "ticket_id": request.ticket_id,
500
+ "success": success,
501
+ }
502
+
503
+ signature = self._sign_reversal(audit_data)
504
+
505
+ with self.conn.cursor() as cursor:
506
+ cursor.execute(
507
+ """
508
+ INSERT INTO confiture_token_reversals (
509
+ id, token, requester_id, reason, department, ticket_id,
510
+ reversed_at, success, error_message, signature
511
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
512
+ """,
513
+ (
514
+ str(reversal_id),
515
+ request.token,
516
+ request.requester_id,
517
+ request.reason,
518
+ request.department,
519
+ request.ticket_id,
520
+ reversed_at,
521
+ success,
522
+ error,
523
+ signature,
524
+ ),
525
+ )
526
+ self.conn.commit()
527
+
528
+ def _sign_reversal(self, audit_data: dict[str, Any]) -> str:
529
+ """Create HMAC signature for reversal audit entry.
530
+
531
+ Args:
532
+ audit_data: Audit data to sign
533
+
534
+ Returns:
535
+ HMAC-SHA256 signature as hex string
536
+ """
537
+ json_str = json.dumps(audit_data, sort_keys=True)
538
+ signature = hashlib.sha256(self.log_secret.encode() + json_str.encode()).hexdigest()
539
+ return signature
540
+
541
+ def get_reversal_audit_log(
542
+ self, token: str | None = None, limit: int = 100
543
+ ) -> list[dict[str, Any]]:
544
+ """Get reversal audit log (for compliance reporting).
545
+
546
+ Args:
547
+ token: Optional token to filter by
548
+ limit: Maximum number of entries to return
549
+
550
+ Returns:
551
+ List of reversal audit entries
552
+
553
+ Raises:
554
+ psycopg.DatabaseError: If query fails
555
+ """
556
+ with self.conn.cursor() as cursor:
557
+ if token:
558
+ cursor.execute(
559
+ """
560
+ SELECT id, token, requester_id, reason, department, ticket_id,
561
+ reversed_at, success, error_message
562
+ FROM confiture_token_reversals
563
+ WHERE token = %s
564
+ ORDER BY reversed_at DESC
565
+ LIMIT %s
566
+ """,
567
+ (token, limit),
568
+ )
569
+ else:
570
+ cursor.execute(
571
+ """
572
+ SELECT id, token, requester_id, reason, department, ticket_id,
573
+ reversed_at, success, error_message
574
+ FROM confiture_token_reversals
575
+ ORDER BY reversed_at DESC
576
+ LIMIT %s
577
+ """,
578
+ (limit,),
579
+ )
580
+
581
+ results = []
582
+ for row in cursor.fetchall():
583
+ results.append(
584
+ {
585
+ "id": str(row[0]),
586
+ "token": row[1],
587
+ "requester_id": row[2],
588
+ "reason": row[3],
589
+ "department": row[4],
590
+ "ticket_id": row[5],
591
+ "reversed_at": row[6],
592
+ "success": row[7],
593
+ "error_message": row[8],
594
+ }
595
+ )
596
+
597
+ return results
598
+
599
+ def deactivate_token(self, token: str, deactivated_by: str, reason: str | None = None) -> None:
600
+ """Deactivate a token (prevent further reversals).
601
+
602
+ Args:
603
+ token: Token to deactivate
604
+ deactivated_by: User who initiated deactivation
605
+ reason: Reason for deactivation
606
+
607
+ Raises:
608
+ psycopg.DatabaseError: If update fails
609
+ """
610
+ with self.conn.cursor() as cursor:
611
+ cursor.execute(
612
+ """
613
+ UPDATE confiture_tokens
614
+ SET is_active = FALSE
615
+ WHERE token = %s
616
+ """,
617
+ (token,),
618
+ )
619
+ self.conn.commit()
620
+
621
+ logger.info(
622
+ f"Token deactivated by {deactivated_by}: {token[:8]}... "
623
+ f"(reason: {reason or 'not provided'})"
624
+ )
625
+
626
+ def cleanup_expired_tokens(self) -> int:
627
+ """Remove expired tokens from storage (GDPR right to be forgotten).
628
+
629
+ Returns:
630
+ Number of tokens deleted
631
+
632
+ Raises:
633
+ psycopg.DatabaseError: If deletion fails
634
+ """
635
+ with self.conn.cursor() as cursor:
636
+ cursor.execute(
637
+ """
638
+ DELETE FROM confiture_tokens
639
+ WHERE expires_at IS NOT NULL
640
+ AND expires_at < NOW()
641
+ RETURNING token
642
+ """
643
+ )
644
+ deleted_tokens = [row[0] for row in cursor.fetchall()]
645
+
646
+ self.conn.commit()
647
+
648
+ logger.info(f"Cleaned up {len(deleted_tokens)} expired tokens")
649
+ return len(deleted_tokens)
650
+
651
+ def get_token_metadata(self, token: str) -> TokenMetadata | None:
652
+ """Get metadata for a token (without reversing it).
653
+
654
+ Args:
655
+ token: Token to lookup
656
+
657
+ Returns:
658
+ TokenMetadata or None if not found
659
+
660
+ Raises:
661
+ psycopg.DatabaseError: If query fails
662
+ """
663
+ with self.conn.cursor() as cursor:
664
+ cursor.execute(
665
+ """
666
+ SELECT token, column_name, strategy_name, created_at,
667
+ expires_at, key_version, is_active
668
+ FROM confiture_tokens
669
+ WHERE token = %s
670
+ """,
671
+ (token,),
672
+ )
673
+ row = cursor.fetchone()
674
+
675
+ if not row:
676
+ return None
677
+
678
+ return TokenMetadata(
679
+ token=row[0],
680
+ column_name=row[1],
681
+ strategy_name=row[2],
682
+ created_at=row[3],
683
+ expires_at=row[4],
684
+ key_version=row[5],
685
+ is_active=row[6],
686
+ )