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,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
|
+
)
|