kekkai-cli 1.1.0__py3-none-any.whl → 1.1.1__py3-none-any.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.
portal/ops/secrets.py DELETED
@@ -1,408 +0,0 @@
1
- """Secret rotation utilities for Kekkai Portal.
2
-
3
- Provides:
4
- - API key rotation without downtime
5
- - Database credential rotation support
6
- - Rotation schedule management
7
-
8
- ASVS 5.0 Requirements:
9
- - V13.1.4: Secret rotation schedule
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import hashlib
15
- import logging
16
- import os
17
- import secrets
18
- from collections.abc import Callable
19
- from dataclasses import dataclass, field
20
- from datetime import UTC, datetime, timedelta
21
- from enum import Enum
22
- from pathlib import Path
23
- from typing import Any
24
-
25
- logger = logging.getLogger(__name__)
26
-
27
-
28
- class SecretType(Enum):
29
- """Types of secrets that can be rotated."""
30
-
31
- API_KEY = "api_key"
32
- DATABASE_PASSWORD = "database_password" # noqa: S105
33
- ENCRYPTION_KEY = "encryption_key"
34
- SESSION_SECRET = "session_secret" # noqa: S105
35
- JWT_SECRET = "jwt_secret" # noqa: S105
36
- SAML_SIGNING_KEY = "saml_signing_key"
37
-
38
-
39
- class RotationStatus(Enum):
40
- """Status of a rotation operation."""
41
-
42
- PENDING = "pending"
43
- IN_PROGRESS = "in_progress"
44
- COMPLETED = "completed"
45
- FAILED = "failed"
46
- ROLLED_BACK = "rolled_back"
47
-
48
-
49
- @dataclass
50
- class RotationSchedule:
51
- """Schedule for secret rotation."""
52
-
53
- secret_type: SecretType
54
- interval_days: int
55
- last_rotated: datetime | None = None
56
- next_rotation: datetime | None = None
57
- enabled: bool = True
58
- notify_days_before: int = 7
59
-
60
- def __post_init__(self) -> None:
61
- if self.last_rotated and not self.next_rotation:
62
- self.next_rotation = self.last_rotated + timedelta(days=self.interval_days)
63
-
64
- def is_due(self) -> bool:
65
- """Check if rotation is due."""
66
- if not self.enabled:
67
- return False
68
- if not self.next_rotation:
69
- return True
70
- return datetime.now(UTC) >= self.next_rotation
71
-
72
- def should_notify(self) -> bool:
73
- """Check if notification should be sent."""
74
- if not self.enabled or not self.next_rotation:
75
- return False
76
- notification_date = self.next_rotation - timedelta(days=self.notify_days_before)
77
- return datetime.now(UTC) >= notification_date
78
-
79
- def to_dict(self) -> dict[str, Any]:
80
- """Convert to dictionary."""
81
- return {
82
- "secret_type": self.secret_type.value,
83
- "interval_days": self.interval_days,
84
- "last_rotated": self.last_rotated.isoformat() if self.last_rotated else None,
85
- "next_rotation": self.next_rotation.isoformat() if self.next_rotation else None,
86
- "enabled": self.enabled,
87
- "notify_days_before": self.notify_days_before,
88
- }
89
-
90
-
91
- @dataclass
92
- class RotationResult:
93
- """Result of a rotation operation."""
94
-
95
- success: bool
96
- secret_type: SecretType
97
- status: RotationStatus
98
- timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
99
- old_key_hash: str = ""
100
- new_key_hash: str = ""
101
- error: str | None = None
102
- rollback_available: bool = False
103
-
104
- def to_dict(self) -> dict[str, Any]:
105
- """Convert to dictionary."""
106
- return {
107
- "success": self.success,
108
- "secret_type": self.secret_type.value,
109
- "status": self.status.value,
110
- "timestamp": self.timestamp.isoformat(),
111
- "old_key_hash": self.old_key_hash,
112
- "new_key_hash": self.new_key_hash,
113
- "error": self.error,
114
- "rollback_available": self.rollback_available,
115
- }
116
-
117
-
118
- class SecretRotation:
119
- """Manages secret rotation for Kekkai Portal."""
120
-
121
- def __init__(
122
- self,
123
- schedules: list[RotationSchedule] | None = None,
124
- state_path: Path | None = None,
125
- ) -> None:
126
- self._schedules = {s.secret_type: s for s in (schedules or [])}
127
- self._state_path = state_path
128
- self._rotation_handlers: dict[SecretType, Callable[[str, str], bool]] = {}
129
- self._rollback_store: dict[SecretType, str] = {}
130
-
131
- if not self._schedules:
132
- self._schedules = self._get_default_schedules()
133
-
134
- def _get_default_schedules(self) -> dict[SecretType, RotationSchedule]:
135
- """Get default rotation schedules per ASVS recommendations."""
136
- return {
137
- SecretType.API_KEY: RotationSchedule(
138
- secret_type=SecretType.API_KEY,
139
- interval_days=90,
140
- enabled=True,
141
- ),
142
- SecretType.DATABASE_PASSWORD: RotationSchedule(
143
- secret_type=SecretType.DATABASE_PASSWORD,
144
- interval_days=90,
145
- enabled=True,
146
- ),
147
- SecretType.SESSION_SECRET: RotationSchedule(
148
- secret_type=SecretType.SESSION_SECRET,
149
- interval_days=30,
150
- enabled=True,
151
- ),
152
- SecretType.JWT_SECRET: RotationSchedule(
153
- secret_type=SecretType.JWT_SECRET,
154
- interval_days=90,
155
- enabled=True,
156
- ),
157
- SecretType.ENCRYPTION_KEY: RotationSchedule(
158
- secret_type=SecretType.ENCRYPTION_KEY,
159
- interval_days=365,
160
- enabled=True,
161
- ),
162
- }
163
-
164
- def register_handler(
165
- self, secret_type: SecretType, handler: Callable[[str, str], bool]
166
- ) -> None:
167
- """Register a rotation handler for a secret type.
168
-
169
- Handler receives (old_value, new_value) and returns success bool.
170
- """
171
- self._rotation_handlers[secret_type] = handler
172
-
173
- def generate_api_key(self, prefix: str = "kk") -> str:
174
- """Generate a new API key."""
175
- random_part = secrets.token_urlsafe(32)
176
- return f"{prefix}_{random_part}"
177
-
178
- def generate_password(self, length: int = 32) -> str:
179
- """Generate a secure random password."""
180
- alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
181
- return "".join(secrets.choice(alphabet) for _ in range(length))
182
-
183
- def generate_encryption_key(self, bits: int = 256) -> bytes:
184
- """Generate a secure encryption key."""
185
- return secrets.token_bytes(bits // 8)
186
-
187
- def rotate_api_key(
188
- self,
189
- tenant_id: str,
190
- current_key: str | None = None,
191
- grace_period_hours: int = 24,
192
- ) -> RotationResult:
193
- """Rotate an API key with optional grace period.
194
-
195
- During grace period, both old and new keys are valid.
196
- """
197
- start_time = datetime.now(UTC)
198
- new_key = self.generate_api_key()
199
- old_key_hash = self._hash_secret(current_key) if current_key else ""
200
- new_key_hash = self._hash_secret(new_key)
201
-
202
- try:
203
- if current_key:
204
- self._rollback_store[SecretType.API_KEY] = current_key
205
-
206
- handler = self._rotation_handlers.get(SecretType.API_KEY)
207
- if handler:
208
- success = handler(current_key or "", new_key)
209
- if not success:
210
- return RotationResult(
211
- success=False,
212
- secret_type=SecretType.API_KEY,
213
- status=RotationStatus.FAILED,
214
- old_key_hash=old_key_hash,
215
- error="Handler returned failure",
216
- rollback_available=bool(current_key),
217
- )
218
-
219
- schedule = self._schedules.get(SecretType.API_KEY)
220
- if schedule:
221
- schedule.last_rotated = start_time
222
- schedule.next_rotation = start_time + timedelta(days=schedule.interval_days)
223
-
224
- logger.info(
225
- "secret.rotated type=api_key tenant=%s new_hash=%s",
226
- tenant_id,
227
- new_key_hash[:16],
228
- )
229
-
230
- return RotationResult(
231
- success=True,
232
- secret_type=SecretType.API_KEY,
233
- status=RotationStatus.COMPLETED,
234
- old_key_hash=old_key_hash,
235
- new_key_hash=new_key_hash,
236
- rollback_available=bool(current_key),
237
- )
238
-
239
- except Exception as e:
240
- logger.error("secret.rotation.failed type=api_key error=%s", str(e))
241
- return RotationResult(
242
- success=False,
243
- secret_type=SecretType.API_KEY,
244
- status=RotationStatus.FAILED,
245
- old_key_hash=old_key_hash,
246
- error=f"Rotation failed: {type(e).__name__}",
247
- rollback_available=bool(current_key),
248
- )
249
-
250
- def rotate_database_password(
251
- self,
252
- current_password: str | None = None,
253
- ) -> RotationResult:
254
- """Rotate database password."""
255
- new_password = self.generate_password()
256
- old_hash = self._hash_secret(current_password) if current_password else ""
257
- new_hash = self._hash_secret(new_password)
258
-
259
- try:
260
- if current_password:
261
- self._rollback_store[SecretType.DATABASE_PASSWORD] = current_password
262
-
263
- handler = self._rotation_handlers.get(SecretType.DATABASE_PASSWORD)
264
- if handler:
265
- success = handler(current_password or "", new_password)
266
- if not success:
267
- return RotationResult(
268
- success=False,
269
- secret_type=SecretType.DATABASE_PASSWORD,
270
- status=RotationStatus.FAILED,
271
- old_key_hash=old_hash,
272
- error="Handler returned failure",
273
- rollback_available=bool(current_password),
274
- )
275
-
276
- schedule = self._schedules.get(SecretType.DATABASE_PASSWORD)
277
- if schedule:
278
- schedule.last_rotated = datetime.now(UTC)
279
- schedule.next_rotation = schedule.last_rotated + timedelta(
280
- days=schedule.interval_days
281
- )
282
-
283
- logger.info("secret.rotated type=database_password")
284
-
285
- return RotationResult(
286
- success=True,
287
- secret_type=SecretType.DATABASE_PASSWORD,
288
- status=RotationStatus.COMPLETED,
289
- old_key_hash=old_hash,
290
- new_key_hash=new_hash,
291
- rollback_available=bool(current_password),
292
- )
293
-
294
- except Exception as e:
295
- logger.error("secret.rotation.failed type=database_password error=%s", str(e))
296
- return RotationResult(
297
- success=False,
298
- secret_type=SecretType.DATABASE_PASSWORD,
299
- status=RotationStatus.FAILED,
300
- old_key_hash=old_hash,
301
- error=f"Rotation failed: {type(e).__name__}",
302
- rollback_available=bool(current_password),
303
- )
304
-
305
- def rollback(self, secret_type: SecretType) -> RotationResult:
306
- """Rollback a recent rotation."""
307
- old_value = self._rollback_store.get(secret_type)
308
- if not old_value:
309
- return RotationResult(
310
- success=False,
311
- secret_type=secret_type,
312
- status=RotationStatus.FAILED,
313
- error="No rollback value available",
314
- )
315
-
316
- try:
317
- handler = self._rotation_handlers.get(secret_type)
318
- if handler:
319
- success = handler("", old_value)
320
- if not success:
321
- return RotationResult(
322
- success=False,
323
- secret_type=secret_type,
324
- status=RotationStatus.FAILED,
325
- error="Rollback handler failed",
326
- )
327
-
328
- del self._rollback_store[secret_type]
329
-
330
- logger.info("secret.rollback type=%s", secret_type.value)
331
-
332
- return RotationResult(
333
- success=True,
334
- secret_type=secret_type,
335
- status=RotationStatus.ROLLED_BACK,
336
- new_key_hash=self._hash_secret(old_value),
337
- )
338
-
339
- except Exception as e:
340
- logger.error("secret.rollback.failed type=%s error=%s", secret_type.value, str(e))
341
- return RotationResult(
342
- success=False,
343
- secret_type=secret_type,
344
- status=RotationStatus.FAILED,
345
- error=f"Rollback failed: {type(e).__name__}",
346
- )
347
-
348
- def get_due_rotations(self) -> list[RotationSchedule]:
349
- """Get list of secrets that are due for rotation."""
350
- return [s for s in self._schedules.values() if s.is_due()]
351
-
352
- def get_upcoming_rotations(self, days: int = 30) -> list[RotationSchedule]:
353
- """Get list of secrets that will be due within the given days."""
354
- cutoff = datetime.now(UTC) + timedelta(days=days)
355
- return [
356
- s
357
- for s in self._schedules.values()
358
- if s.enabled and s.next_rotation and s.next_rotation <= cutoff
359
- ]
360
-
361
- def get_all_schedules(self) -> list[dict[str, Any]]:
362
- """Get all rotation schedules."""
363
- return [s.to_dict() for s in self._schedules.values()]
364
-
365
- def check_rotation_health(self) -> dict[str, Any]:
366
- """Check overall rotation health."""
367
- now = datetime.now(UTC)
368
- overdue = []
369
- warnings = []
370
- healthy = []
371
-
372
- for schedule in self._schedules.values():
373
- if not schedule.enabled:
374
- continue
375
-
376
- if schedule.is_due():
377
- overdue.append(schedule.secret_type.value)
378
- elif schedule.should_notify():
379
- warnings.append(schedule.secret_type.value)
380
- else:
381
- healthy.append(schedule.secret_type.value)
382
-
383
- return {
384
- "status": "critical" if overdue else ("warning" if warnings else "healthy"),
385
- "overdue": overdue,
386
- "warnings": warnings,
387
- "healthy": healthy,
388
- "checked_at": now.isoformat(),
389
- }
390
-
391
- def _hash_secret(self, secret: str | None) -> str:
392
- """Hash a secret for logging (never log actual secrets)."""
393
- if not secret:
394
- return ""
395
- return hashlib.sha256(secret.encode()).hexdigest()[:16]
396
-
397
-
398
- def create_secret_rotation(
399
- state_path: Path | str | None = None,
400
- ) -> SecretRotation:
401
- """Create a configured SecretRotation instance."""
402
- path = None
403
- if state_path:
404
- path = Path(state_path)
405
- elif env_path := os.environ.get("SECRET_ROTATION_STATE"):
406
- path = Path(env_path)
407
-
408
- return SecretRotation(state_path=path)