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/enterprise/saml.py DELETED
@@ -1,595 +0,0 @@
1
- """SAML 2.0 SSO integration for enterprise portal.
2
-
3
- Security controls:
4
- - ASVS V6.8.2: Validate assertion signatures
5
- - ASVS V6.8.3: SAML one-time use / replay defense
6
- - ASVS V7.6.1: Federated session lifetime
7
- - ASVS V9.1.1: Signed tokens
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import base64
13
- import hashlib
14
- import hmac
15
- import logging
16
- import re
17
- import secrets
18
- import time
19
- import xml.etree.ElementTree as ET # nosec B405 - validated SAML XML
20
- from dataclasses import dataclass, field
21
- from datetime import UTC, datetime, timedelta
22
- from enum import Enum
23
- from typing import TYPE_CHECKING, Any
24
- from urllib.parse import quote
25
-
26
- from kekkai_core import redact
27
-
28
- if TYPE_CHECKING:
29
- pass
30
-
31
- logger = logging.getLogger(__name__)
32
-
33
- SAML_NS = {
34
- "saml": "urn:oasis:names:tc:SAML:2.0:assertion",
35
- "samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
36
- "ds": "http://www.w3.org/2000/09/xmldsig#",
37
- }
38
-
39
- DEFAULT_SESSION_LIFETIME = 3600 * 8
40
- MAX_CLOCK_SKEW = 300
41
- ASSERTION_ID_TTL = 3600
42
-
43
-
44
- class SAMLError(Exception):
45
- """Base exception for SAML errors."""
46
-
47
- pass
48
-
49
-
50
- class SAMLValidationError(SAMLError):
51
- """SAML assertion validation failed."""
52
-
53
- pass
54
-
55
-
56
- class SAMLReplayError(SAMLError):
57
- """SAML assertion replay detected."""
58
-
59
- pass
60
-
61
-
62
- class SAMLSignatureError(SAMLError):
63
- """SAML signature validation failed."""
64
-
65
- pass
66
-
67
-
68
- class SAMLStatus(Enum):
69
- """SAML response status codes."""
70
-
71
- SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
72
- REQUESTER = "urn:oasis:names:tc:SAML:2.0:status:Requester"
73
- RESPONDER = "urn:oasis:names:tc:SAML:2.0:status:Responder"
74
- AUTHN_FAILED = "urn:oasis:names:tc:SAML:2.0:status:AuthnFailed"
75
-
76
-
77
- @dataclass(frozen=True)
78
- class SAMLConfig:
79
- """SAML IdP configuration."""
80
-
81
- entity_id: str
82
- sso_url: str
83
- slo_url: str | None = None
84
- certificate: str | None = None
85
- certificate_fingerprint: str | None = None
86
- name_id_format: str = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
87
- signing_key: str | None = None
88
- session_lifetime: int = DEFAULT_SESSION_LIFETIME
89
- want_assertions_signed: bool = True
90
- allow_clock_skew: int = MAX_CLOCK_SKEW
91
-
92
- def to_dict(self) -> dict[str, Any]:
93
- return {
94
- "entity_id": self.entity_id,
95
- "sso_url": self.sso_url,
96
- "slo_url": self.slo_url,
97
- "name_id_format": self.name_id_format,
98
- "session_lifetime": self.session_lifetime,
99
- "want_assertions_signed": self.want_assertions_signed,
100
- }
101
-
102
- @classmethod
103
- def from_dict(cls, data: dict[str, Any]) -> SAMLConfig:
104
- return cls(
105
- entity_id=str(data["entity_id"]),
106
- sso_url=str(data["sso_url"]),
107
- slo_url=data.get("slo_url"),
108
- certificate=data.get("certificate"),
109
- certificate_fingerprint=data.get("certificate_fingerprint"),
110
- name_id_format=data.get(
111
- "name_id_format", "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
112
- ),
113
- signing_key=data.get("signing_key"),
114
- session_lifetime=int(data.get("session_lifetime", DEFAULT_SESSION_LIFETIME)),
115
- want_assertions_signed=data.get("want_assertions_signed", True),
116
- allow_clock_skew=int(data.get("allow_clock_skew", MAX_CLOCK_SKEW)),
117
- )
118
-
119
-
120
- @dataclass
121
- class SAMLAssertion:
122
- """Parsed SAML assertion."""
123
-
124
- assertion_id: str
125
- issuer: str
126
- subject_name_id: str
127
- subject_name_id_format: str
128
- session_index: str | None
129
- authn_instant: datetime
130
- not_before: datetime | None
131
- not_on_or_after: datetime | None
132
- attributes: dict[str, list[str]] = field(default_factory=dict)
133
- audience: str | None = None
134
- is_signed: bool = False
135
-
136
- @property
137
- def email(self) -> str | None:
138
- """Extract email from NameID or attributes."""
139
- if "emailAddress" in self.subject_name_id_format:
140
- return self.subject_name_id
141
- emails = self.attributes.get("email", []) + self.attributes.get("mail", [])
142
- return emails[0] if emails else None
143
-
144
- @property
145
- def display_name(self) -> str | None:
146
- """Extract display name from attributes."""
147
- names = (
148
- self.attributes.get("displayName", [])
149
- + self.attributes.get("name", [])
150
- + self.attributes.get("cn", [])
151
- )
152
- return names[0] if names else None
153
-
154
- @property
155
- def roles(self) -> list[str]:
156
- """Extract roles from attributes."""
157
- return (
158
- self.attributes.get("role", [])
159
- + self.attributes.get("roles", [])
160
- + self.attributes.get("group", [])
161
- + self.attributes.get("groups", [])
162
- )
163
-
164
-
165
- class AssertionIDStore:
166
- """Tracks used assertion IDs for replay protection (ASVS V6.8.3)."""
167
-
168
- def __init__(self, ttl: int = ASSERTION_ID_TTL) -> None:
169
- self._used_ids: dict[str, float] = {}
170
- self._ttl = ttl
171
- self._last_cleanup = time.time()
172
-
173
- def _cleanup(self) -> None:
174
- """Remove expired assertion IDs."""
175
- now = time.time()
176
- if now - self._last_cleanup < 60:
177
- return
178
-
179
- cutoff = now - self._ttl
180
- self._used_ids = {k: v for k, v in self._used_ids.items() if v > cutoff}
181
- self._last_cleanup = now
182
-
183
- def check_and_mark(self, assertion_id: str) -> bool:
184
- """Check if assertion ID was used, mark it as used.
185
-
186
- Returns:
187
- True if this is a new assertion, False if replay detected
188
- """
189
- self._cleanup()
190
-
191
- if assertion_id in self._used_ids:
192
- logger.warning("saml.replay_detected assertion_id=%s", assertion_id[:16])
193
- return False
194
-
195
- self._used_ids[assertion_id] = time.time()
196
- return True
197
-
198
- def is_used(self, assertion_id: str) -> bool:
199
- """Check if assertion ID was already used."""
200
- self._cleanup()
201
- return assertion_id in self._used_ids
202
-
203
-
204
- class SAMLProcessor:
205
- """Process SAML authentication requests and responses."""
206
-
207
- def __init__(
208
- self,
209
- sp_entity_id: str,
210
- sp_acs_url: str,
211
- idp_config: SAMLConfig | None = None,
212
- ) -> None:
213
- self._sp_entity_id = sp_entity_id
214
- self._sp_acs_url = sp_acs_url
215
- self._idp_config = idp_config
216
- self._assertion_store = AssertionIDStore()
217
-
218
- def set_idp_config(self, config: SAMLConfig) -> None:
219
- """Update IdP configuration."""
220
- self._idp_config = config
221
-
222
- def create_authn_request(
223
- self,
224
- relay_state: str | None = None,
225
- ) -> tuple[str, str]:
226
- """Create a SAML AuthnRequest.
227
-
228
- Returns:
229
- Tuple of (redirect_url, request_id)
230
- """
231
- if not self._idp_config:
232
- raise SAMLError("IdP not configured")
233
-
234
- request_id = f"_kek_{secrets.token_hex(16)}"
235
- issue_instant = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
236
-
237
- authn_request = f"""<?xml version="1.0" encoding="UTF-8"?>
238
- <samlp:AuthnRequest
239
- xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
240
- xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
241
- ID="{request_id}"
242
- Version="2.0"
243
- IssueInstant="{issue_instant}"
244
- Destination="{self._idp_config.sso_url}"
245
- AssertionConsumerServiceURL="{self._sp_acs_url}"
246
- ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
247
- <saml:Issuer>{self._sp_entity_id}</saml:Issuer>
248
- <samlp:NameIDPolicy Format="{self._idp_config.name_id_format}" AllowCreate="true"/>
249
- </samlp:AuthnRequest>"""
250
-
251
- encoded = base64.b64encode(authn_request.encode()).decode()
252
- url = f"{self._idp_config.sso_url}?SAMLRequest={quote(encoded)}"
253
- if relay_state:
254
- url += f"&RelayState={quote(relay_state)}"
255
-
256
- return url, request_id
257
-
258
- def process_response(
259
- self,
260
- saml_response_b64: str,
261
- validate_signature: bool = True,
262
- ) -> SAMLAssertion:
263
- """Process a SAML Response and extract the assertion.
264
-
265
- Args:
266
- saml_response_b64: Base64-encoded SAML response
267
- validate_signature: Whether to validate the signature
268
-
269
- Returns:
270
- Parsed SAMLAssertion
271
-
272
- Raises:
273
- SAMLValidationError: If the response is invalid
274
- SAMLReplayError: If the assertion was already used
275
- SAMLSignatureError: If signature validation fails
276
- """
277
- try:
278
- xml_bytes = base64.b64decode(saml_response_b64)
279
- xml_str = xml_bytes.decode("utf-8")
280
- except (ValueError, UnicodeDecodeError) as e:
281
- raise SAMLValidationError(f"Invalid Base64 encoding: {e}") from e
282
-
283
- try:
284
- root = ET.fromstring(xml_str) # noqa: S314 # nosec B314
285
- except ET.ParseError as e:
286
- raise SAMLValidationError(f"Invalid XML: {e}") from e
287
-
288
- status = root.find(".//samlp:Status/samlp:StatusCode", SAML_NS)
289
- if status is not None:
290
- status_value = status.get("Value", "")
291
- if status_value != SAMLStatus.SUCCESS.value:
292
- raise SAMLValidationError(f"SAML authentication failed: {status_value}")
293
-
294
- assertion = root.find(".//saml:Assertion", SAML_NS)
295
- if assertion is None:
296
- raise SAMLValidationError("No assertion found in response")
297
-
298
- is_signed = self._has_signature(assertion) or self._has_signature(root)
299
- if validate_signature and self._idp_config and self._idp_config.want_assertions_signed:
300
- if not is_signed:
301
- raise SAMLSignatureError("Assertion is not signed")
302
- if self._idp_config.certificate and not self._validate_signature(
303
- root, self._idp_config.certificate
304
- ):
305
- raise SAMLSignatureError("Invalid signature")
306
-
307
- parsed = self._parse_assertion(assertion, is_signed)
308
-
309
- self._validate_timing(parsed)
310
-
311
- if not self._assertion_store.check_and_mark(parsed.assertion_id):
312
- raise SAMLReplayError(f"Assertion {parsed.assertion_id[:16]}... already used")
313
-
314
- logger.info(
315
- "saml.assertion_processed assertion_id=%s subject=%s",
316
- parsed.assertion_id[:16],
317
- redact(parsed.subject_name_id),
318
- )
319
-
320
- return parsed
321
-
322
- def _has_signature(self, element: ET.Element) -> bool:
323
- """Check if element contains a signature."""
324
- sig = element.find(".//ds:Signature", SAML_NS)
325
- return sig is not None
326
-
327
- def _validate_signature(self, root: ET.Element, certificate: str) -> bool:
328
- """Validate XML signature against certificate.
329
-
330
- Note: This is a simplified implementation. Production use should
331
- employ a proper XML signature library like signxml or xmlsec.
332
- """
333
- sig = root.find(".//ds:Signature", SAML_NS)
334
- if sig is None:
335
- return False
336
-
337
- digest_value = sig.find(".//ds:DigestValue", SAML_NS)
338
- signature_value = sig.find(".//ds:SignatureValue", SAML_NS)
339
-
340
- if digest_value is None or signature_value is None:
341
- return False
342
-
343
- cert_clean = certificate.replace("-----BEGIN CERTIFICATE-----", "")
344
- cert_clean = cert_clean.replace("-----END CERTIFICATE-----", "")
345
- cert_clean = cert_clean.replace("\n", "").replace(" ", "")
346
-
347
- if self._idp_config and self._idp_config.certificate_fingerprint:
348
- cert_bytes = base64.b64decode(cert_clean)
349
- fingerprint = hashlib.sha256(cert_bytes).hexdigest()
350
- expected = self._idp_config.certificate_fingerprint.lower().replace(":", "")
351
- if not hmac.compare_digest(fingerprint, expected):
352
- logger.warning("saml.certificate_fingerprint_mismatch")
353
- return False
354
-
355
- return True
356
-
357
- def _parse_assertion(self, assertion: ET.Element, is_signed: bool) -> SAMLAssertion:
358
- """Parse a SAML Assertion element."""
359
- assertion_id = assertion.get("ID", "")
360
- if not assertion_id:
361
- raise SAMLValidationError("Assertion missing ID")
362
-
363
- issuer_elem = assertion.find("saml:Issuer", SAML_NS)
364
- issuer = issuer_elem.text if issuer_elem is not None and issuer_elem.text else ""
365
-
366
- subject = assertion.find("saml:Subject", SAML_NS)
367
- if subject is None:
368
- raise SAMLValidationError("Assertion missing Subject")
369
-
370
- name_id = subject.find("saml:NameID", SAML_NS)
371
- if name_id is None or not name_id.text:
372
- raise SAMLValidationError("Assertion missing NameID")
373
-
374
- subject_name_id = name_id.text
375
- subject_name_id_format = name_id.get(
376
- "Format", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
377
- )
378
-
379
- conditions = assertion.find("saml:Conditions", SAML_NS)
380
- not_before = None
381
- not_on_or_after = None
382
- audience = None
383
-
384
- if conditions is not None:
385
- nb = conditions.get("NotBefore")
386
- if nb:
387
- not_before = self._parse_datetime(nb)
388
- noa = conditions.get("NotOnOrAfter")
389
- if noa:
390
- not_on_or_after = self._parse_datetime(noa)
391
-
392
- aud_elem = conditions.find(".//saml:Audience", SAML_NS)
393
- if aud_elem is not None and aud_elem.text:
394
- audience = aud_elem.text
395
-
396
- authn_statement = assertion.find("saml:AuthnStatement", SAML_NS)
397
- session_index = None
398
- authn_instant = datetime.now(UTC)
399
-
400
- if authn_statement is not None:
401
- session_index = authn_statement.get("SessionIndex")
402
- ai = authn_statement.get("AuthnInstant")
403
- if ai:
404
- authn_instant = self._parse_datetime(ai)
405
-
406
- attributes: dict[str, list[str]] = {}
407
- attr_statement = assertion.find("saml:AttributeStatement", SAML_NS)
408
- if attr_statement is not None:
409
- for attr in attr_statement.findall("saml:Attribute", SAML_NS):
410
- attr_name = attr.get("Name", "")
411
- if not attr_name:
412
- continue
413
- values = []
414
- for value_elem in attr.findall("saml:AttributeValue", SAML_NS):
415
- if value_elem.text:
416
- values.append(value_elem.text)
417
- if values:
418
- attributes[attr_name] = values
419
-
420
- return SAMLAssertion(
421
- assertion_id=assertion_id,
422
- issuer=issuer,
423
- subject_name_id=subject_name_id,
424
- subject_name_id_format=subject_name_id_format,
425
- session_index=session_index,
426
- authn_instant=authn_instant,
427
- not_before=not_before,
428
- not_on_or_after=not_on_or_after,
429
- attributes=attributes,
430
- audience=audience,
431
- is_signed=is_signed,
432
- )
433
-
434
- def _parse_datetime(self, dt_str: str) -> datetime:
435
- """Parse SAML datetime string."""
436
- dt_str = re.sub(r"\.\d+", "", dt_str)
437
- if dt_str.endswith("Z"):
438
- dt_str = dt_str[:-1] + "+00:00"
439
- return datetime.fromisoformat(dt_str)
440
-
441
- def _validate_timing(self, assertion: SAMLAssertion) -> None:
442
- """Validate assertion timing constraints."""
443
- now = datetime.now(UTC)
444
- skew = timedelta(
445
- seconds=self._idp_config.allow_clock_skew if self._idp_config else MAX_CLOCK_SKEW
446
- )
447
-
448
- if assertion.not_before and now < assertion.not_before - skew:
449
- raise SAMLValidationError("Assertion not yet valid")
450
-
451
- if assertion.not_on_or_after and now > assertion.not_on_or_after + skew:
452
- raise SAMLValidationError("Assertion has expired")
453
-
454
- def create_session_token(
455
- self,
456
- assertion: SAMLAssertion,
457
- tenant_id: str,
458
- secret_key: str,
459
- ) -> str:
460
- """Create a signed session token from SAML assertion.
461
-
462
- Returns:
463
- Signed session token
464
- """
465
- session_lifetime = (
466
- self._idp_config.session_lifetime if self._idp_config else DEFAULT_SESSION_LIFETIME
467
- )
468
- expires_at = int(time.time()) + session_lifetime
469
-
470
- payload = {
471
- "sub": assertion.subject_name_id,
472
- "tid": tenant_id,
473
- "sid": assertion.session_index or secrets.token_hex(8),
474
- "exp": expires_at,
475
- "iat": int(time.time()),
476
- "iss": self._sp_entity_id,
477
- }
478
-
479
- import json
480
-
481
- payload_json = json.dumps(payload, separators=(",", ":"))
482
- payload_b64 = base64.urlsafe_b64encode(payload_json.encode()).decode().rstrip("=")
483
-
484
- signature = hmac.new(
485
- secret_key.encode(),
486
- payload_b64.encode(),
487
- hashlib.sha256,
488
- ).hexdigest()
489
-
490
- return f"{payload_b64}.{signature}"
491
-
492
- def verify_session_token(
493
- self,
494
- token: str,
495
- secret_key: str,
496
- ) -> dict[str, Any] | None:
497
- """Verify and decode a session token.
498
-
499
- Returns:
500
- Decoded payload or None if invalid
501
- """
502
- parts = token.split(".")
503
- if len(parts) != 2:
504
- return None
505
-
506
- payload_b64, signature = parts
507
-
508
- expected_sig = hmac.new(
509
- secret_key.encode(),
510
- payload_b64.encode(),
511
- hashlib.sha256,
512
- ).hexdigest()
513
-
514
- if not hmac.compare_digest(signature, expected_sig):
515
- logger.warning("saml.session_token_invalid_signature")
516
- return None
517
-
518
- try:
519
- padding = 4 - len(payload_b64) % 4
520
- if padding != 4:
521
- payload_b64 += "=" * padding
522
- payload_json = base64.urlsafe_b64decode(payload_b64).decode()
523
- import json
524
-
525
- payload = json.loads(payload_json)
526
- except (ValueError, json.JSONDecodeError) as e:
527
- logger.warning("saml.session_token_decode_error: %s", e)
528
- return None
529
-
530
- if payload.get("exp", 0) < time.time():
531
- logger.debug("saml.session_token_expired")
532
- return None
533
-
534
- return dict(payload)
535
-
536
-
537
- def create_mock_saml_response(
538
- assertion_id: str,
539
- issuer: str,
540
- subject: str,
541
- audience: str,
542
- attributes: dict[str, list[str]] | None = None,
543
- not_before: datetime | None = None,
544
- not_on_or_after: datetime | None = None,
545
- ) -> str:
546
- """Create a mock SAML response for testing."""
547
- now = datetime.now(UTC)
548
- not_before = not_before or now - timedelta(minutes=5)
549
- not_on_or_after = not_on_or_after or now + timedelta(hours=1)
550
- response_id = f"_resp_{secrets.token_hex(16)}"
551
-
552
- attrs_xml = ""
553
- if attributes:
554
- attrs_xml = "<saml:AttributeStatement>"
555
- for name, values in attributes.items():
556
- attrs_xml += f'<saml:Attribute Name="{name}">'
557
- for v in values:
558
- attrs_xml += f"<saml:AttributeValue>{v}</saml:AttributeValue>"
559
- attrs_xml += "</saml:Attribute>"
560
- attrs_xml += "</saml:AttributeStatement>"
561
-
562
- nameid_fmt = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
563
- response = f"""<?xml version="1.0" encoding="UTF-8"?>
564
- <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
565
- xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
566
- ID="{response_id}"
567
- Version="2.0"
568
- IssueInstant="{now.strftime("%Y-%m-%dT%H:%M:%SZ")}">
569
- <saml:Issuer>{issuer}</saml:Issuer>
570
- <samlp:Status>
571
- <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
572
- </samlp:Status>
573
- <saml:Assertion ID="{assertion_id}" Version="2.0"
574
- IssueInstant="{now.strftime("%Y-%m-%dT%H:%M:%SZ")}">
575
- <saml:Issuer>{issuer}</saml:Issuer>
576
- <saml:Subject>
577
- <saml:NameID Format="{nameid_fmt}">{subject}</saml:NameID>
578
- </saml:Subject>
579
- <saml:Conditions NotBefore="{not_before.strftime("%Y-%m-%dT%H:%M:%SZ")}"
580
- NotOnOrAfter="{not_on_or_after.strftime("%Y-%m-%dT%H:%M:%SZ")}">
581
- <saml:AudienceRestriction>
582
- <saml:Audience>{audience}</saml:Audience>
583
- </saml:AudienceRestriction>
584
- </saml:Conditions>
585
- <saml:AuthnStatement AuthnInstant="{now.strftime("%Y-%m-%dT%H:%M:%SZ")}"
586
- SessionIndex="_session_{secrets.token_hex(8)}">
587
- <saml:AuthnContext>
588
- <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
589
- </saml:AuthnContext>
590
- </saml:AuthnStatement>
591
- {attrs_xml}
592
- </saml:Assertion>
593
- </samlp:Response>"""
594
-
595
- return base64.b64encode(response.encode()).decode()
portal/ops/__init__.py DELETED
@@ -1,53 +0,0 @@
1
- """Production operations module for Kekkai Portal.
2
-
3
- Provides:
4
- - Backup/restore functionality for DB and media
5
- - Upgrade management with version pinning and rollback
6
- - Centralized logging and monitoring
7
- - Secret rotation utilities
8
-
9
- ASVS 5.0 Requirements:
10
- - V16.4.3: Send logs to separate system
11
- - V16.4.2: Log protection
12
- - V13.1.4: Secret rotation schedule
13
- - V12.1.2: Strong cipher suites
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- from .backup import BackupConfig, BackupJob, BackupResult, create_backup_job
19
- from .log_shipper import LogShipper, LogShipperConfig, ShipperType
20
- from .monitoring import (
21
- AlertRule,
22
- AlertSeverity,
23
- MonitoringConfig,
24
- MonitoringService,
25
- create_monitoring_service,
26
- )
27
- from .restore import RestoreConfig, RestoreJob, RestoreResult, create_restore_job
28
- from .secrets import RotationSchedule, SecretRotation
29
- from .upgrade import UpgradeManager, UpgradeResult, VersionManifest
30
-
31
- __all__ = [
32
- "BackupConfig",
33
- "BackupJob",
34
- "BackupResult",
35
- "create_backup_job",
36
- "RestoreConfig",
37
- "RestoreJob",
38
- "RestoreResult",
39
- "create_restore_job",
40
- "AlertRule",
41
- "AlertSeverity",
42
- "MonitoringConfig",
43
- "MonitoringService",
44
- "create_monitoring_service",
45
- "LogShipper",
46
- "LogShipperConfig",
47
- "ShipperType",
48
- "SecretRotation",
49
- "RotationSchedule",
50
- "UpgradeManager",
51
- "VersionManifest",
52
- "UpgradeResult",
53
- ]