sfdctrust 0.1.0__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.
- sfdctrust/__init__.py +54 -0
- sfdctrust/_async_client.py +132 -0
- sfdctrust/_async_pat_refresh.py +151 -0
- sfdctrust/_async_transport.py +129 -0
- sfdctrust/_auth.py +96 -0
- sfdctrust/_client.py +126 -0
- sfdctrust/_constants.py +14 -0
- sfdctrust/_exceptions.py +131 -0
- sfdctrust/_pat_refresh.py +239 -0
- sfdctrust/_pat_store.py +99 -0
- sfdctrust/_transport.py +326 -0
- sfdctrust/_version.py +84 -0
- sfdctrust/integrations/__init__.py +8 -0
- sfdctrust/models/__init__.py +8 -0
- sfdctrust/models/_wire.py +39 -0
- sfdctrust/models/events.py +228 -0
- sfdctrust/models/status.py +33 -0
- sfdctrust/models/tenants.py +74 -0
- sfdctrust/py.typed +0 -0
- sfdctrust/resources/__init__.py +8 -0
- sfdctrust/resources/_async_events.py +154 -0
- sfdctrust/resources/_async_status.py +29 -0
- sfdctrust/resources/_async_tenants.py +94 -0
- sfdctrust/resources/_requests.py +134 -0
- sfdctrust/resources/events.py +228 -0
- sfdctrust/resources/status.py +39 -0
- sfdctrust/resources/tenants.py +115 -0
- sfdctrust-0.1.0.dist-info/METADATA +208 -0
- sfdctrust-0.1.0.dist-info/RECORD +31 -0
- sfdctrust-0.1.0.dist-info/WHEEL +4 -0
- sfdctrust-0.1.0.dist-info/licenses/LICENSE +21 -0
sfdctrust/_constants.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: sfdctrust._constants
|
|
4
|
+
:Synopsis: Package identity and My Trust Center API defaults
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6)
|
|
7
|
+
:Modified Date: 01 Nov 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
PACKAGE_NAME: Final[str] = 'sfdctrust'
|
|
13
|
+
DEFAULT_BASE_URL: Final[str] = 'https://api.salesforce.com/platform/mytrust/v1'
|
|
14
|
+
DEFAULT_LOCALE: Final[str] = 'en-US'
|
sfdctrust/_exceptions.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: sfdctrust._exceptions
|
|
4
|
+
:Synopsis: Exception classes that are utilized throughout the package
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6.1 Sol)
|
|
7
|
+
:Modified Date: 04 Nov 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
'TrustCenterError',
|
|
16
|
+
'ConfigurationError',
|
|
17
|
+
'NetworkError',
|
|
18
|
+
'RequestTimeoutError',
|
|
19
|
+
'APIStatusError',
|
|
20
|
+
'AuthenticationError',
|
|
21
|
+
'RateLimitError',
|
|
22
|
+
'NotFoundError',
|
|
23
|
+
'MalformedResponseError',
|
|
24
|
+
'PaginationError',
|
|
25
|
+
'PATRefreshPendingError',
|
|
26
|
+
'PATRefreshAmbiguousError',
|
|
27
|
+
'PATPersistenceError',
|
|
28
|
+
'PATProbeResult',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TrustCenterError(Exception):
|
|
33
|
+
"""Base class for sfdctrust exceptions."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConfigurationError(TrustCenterError):
|
|
37
|
+
"""Invalid SDK configuration; messages must be SDK-authored and secret-free."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NetworkError(TrustCenterError):
|
|
41
|
+
"""A request could not complete because of a network failure.
|
|
42
|
+
|
|
43
|
+
Construct with a safe SDK-authored message, never an underlying exception's
|
|
44
|
+
text, request, URL, headers, or response body.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RequestTimeoutError(NetworkError):
|
|
49
|
+
"""A request exceeded its configured timeout."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class APIStatusError(TrustCenterError):
|
|
53
|
+
"""An API request returned an unsuccessful HTTP status.
|
|
54
|
+
|
|
55
|
+
Only the status is retained. Raw requests, responses, and server messages
|
|
56
|
+
are deliberately excluded. Request IDs are deferred until a documented,
|
|
57
|
+
safe source is verified; no header name is assumed.
|
|
58
|
+
|
|
59
|
+
:param status_code: An HTTP error status from 400 through 599.
|
|
60
|
+
:raises ValueError: If the status is not an integer HTTP error status.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, status_code: int) -> None:
|
|
64
|
+
if type(status_code) is not int or not 400 <= status_code <= 599:
|
|
65
|
+
raise ValueError('status_code must be an integer from 400 through 599')
|
|
66
|
+
self.status_code = status_code
|
|
67
|
+
super().__init__(f'My Trust Center API returned HTTP {status_code}.')
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AuthenticationError(APIStatusError):
|
|
71
|
+
"""HTTP 401: the PAT is missing, invalid, revoked, or expired.
|
|
72
|
+
|
|
73
|
+
This error does not identify which condition occurred and never triggers
|
|
74
|
+
PAT refresh or credential replacement.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
super().__init__(401)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RateLimitError(APIStatusError):
|
|
82
|
+
"""HTTP 429, without assuming JSON content or retry-related headers."""
|
|
83
|
+
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
super().__init__(429)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class NotFoundError(APIStatusError):
|
|
89
|
+
"""HTTP 404: the resource does not exist or is not authorized."""
|
|
90
|
+
|
|
91
|
+
def __init__(self) -> None:
|
|
92
|
+
super().__init__(404)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class MalformedResponseError(TrustCenterError):
|
|
96
|
+
"""A successful response could not be decoded or validated.
|
|
97
|
+
|
|
98
|
+
Construct with a safe SDK-authored message, without response content or
|
|
99
|
+
validation errors that might echo sensitive input.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PaginationError(TrustCenterError):
|
|
104
|
+
"""A traversal requires another page but reached its limit or repeated a cursor."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PATProbeResult(StrEnum):
|
|
108
|
+
"""Result of the single read-only check with the old PAT."""
|
|
109
|
+
|
|
110
|
+
OLD_PAT_ACCEPTED = 'old_pat_accepted'
|
|
111
|
+
OLD_PAT_REJECTED = 'old_pat_rejected'
|
|
112
|
+
INCONCLUSIVE = 'inconclusive'
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PATRefreshPendingError(TrustCenterError):
|
|
116
|
+
"""A pending or competing rotation prevents the requested operation."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PATRefreshAmbiguousError(TrustCenterError):
|
|
120
|
+
"""Salesforce may have rotated the PAT without returning a usable replacement.
|
|
121
|
+
|
|
122
|
+
:param probe: Sanitized result of the old PAT check.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, probe: PATProbeResult) -> None:
|
|
126
|
+
self.probe = PATProbeResult(probe)
|
|
127
|
+
super().__init__(f'PAT refresh outcome is ambiguous. Old PAT probe: {self.probe.value}.')
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class PATPersistenceError(TrustCenterError):
|
|
131
|
+
"""A required durable PAT store transition could not be confirmed."""
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: sfdctrust._pat_refresh
|
|
4
|
+
:Synopsis: Store coordination and recovery for explicit PAT rotation
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6.1 Sol)
|
|
7
|
+
:Modified Date: 04 Nov 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from threading import Lock
|
|
16
|
+
from uuid import UUID, uuid4
|
|
17
|
+
|
|
18
|
+
from ._auth import PATAuth, _validate_pat
|
|
19
|
+
from ._exceptions import (
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
ConfigurationError,
|
|
22
|
+
PATPersistenceError,
|
|
23
|
+
PATProbeResult,
|
|
24
|
+
PATRefreshAmbiguousError,
|
|
25
|
+
PATRefreshPendingError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
)
|
|
28
|
+
from ._pat_store import PATStore, PATStoreRecord
|
|
29
|
+
from ._transport import _Transport
|
|
30
|
+
|
|
31
|
+
_METADATA_FIELDS = frozenset({'secret', 'tokenId', 'label', 'createdAt', 'expiresAt'})
|
|
32
|
+
_DATE_TIME = re.compile(
|
|
33
|
+
r'[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:[Zz]|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class PATRefreshResult:
|
|
39
|
+
"""Outcome of a durably saved replacement, with no server-supplied values.
|
|
40
|
+
|
|
41
|
+
:param metadata_valid: Whether the response metadata matched the documented schema.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
metadata_valid: bool
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _replacement_secret(payload: dict[str, object], old_pat: str) -> str | None:
|
|
48
|
+
secret = payload.get('secret')
|
|
49
|
+
if not isinstance(secret, str) or secret == old_pat:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
_validate_pat(secret)
|
|
53
|
+
except ConfigurationError:
|
|
54
|
+
return None
|
|
55
|
+
return secret
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_metadata(payload: dict[str, object]) -> bool:
|
|
59
|
+
if payload.keys() != _METADATA_FIELDS or not isinstance(payload['label'], str):
|
|
60
|
+
return False
|
|
61
|
+
token_id, created, expires = (payload[key] for key in ('tokenId', 'createdAt', 'expiresAt'))
|
|
62
|
+
if not isinstance(token_id, str) or not isinstance(created, str) or not isinstance(expires, str):
|
|
63
|
+
return False
|
|
64
|
+
if not (_DATE_TIME.fullmatch(created) and _DATE_TIME.fullmatch(expires)):
|
|
65
|
+
return False
|
|
66
|
+
try:
|
|
67
|
+
if str(UUID(token_id)) != token_id.lower():
|
|
68
|
+
return False
|
|
69
|
+
datetime.fromisoformat(created.upper())
|
|
70
|
+
datetime.fromisoformat(expires.upper())
|
|
71
|
+
except ValueError:
|
|
72
|
+
return False
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class _UnconfirmedWrite:
|
|
78
|
+
revision: UUID
|
|
79
|
+
attempt: UUID
|
|
80
|
+
replacement: PATStoreRecord
|
|
81
|
+
result: PATRefreshResult
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _require_ready(record: PATStoreRecord) -> None:
|
|
85
|
+
if record.pending_attempt is not None:
|
|
86
|
+
raise PATRefreshPendingError('The PAT store has an unresolved rotation.')
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _validate_refresh_days(days: int | None) -> None:
|
|
90
|
+
if days is not None and (type(days) is not int or not 1 <= days <= 30):
|
|
91
|
+
raise ConfigurationError('expires_in_days must be an integer from 1 through 30 or None.')
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _replacement_write(current: PATStoreRecord, attempt: UUID, payload: dict[str, object] | None) -> _UnconfirmedWrite | None:
|
|
95
|
+
if payload is None:
|
|
96
|
+
return None
|
|
97
|
+
secret = _replacement_secret(payload, current.get_pat())
|
|
98
|
+
if secret is None:
|
|
99
|
+
return None
|
|
100
|
+
replacement = PATStoreRecord(uuid4(), secret)
|
|
101
|
+
result = PATRefreshResult(metadata_valid=_validate_metadata(payload))
|
|
102
|
+
return _UnconfirmedWrite(current.revision, attempt, replacement, result)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _probe_error(status: int | None) -> PATRefreshAmbiguousError:
|
|
106
|
+
probe = {200: PATProbeResult.OLD_PAT_ACCEPTED, 401: PATProbeResult.OLD_PAT_REJECTED}
|
|
107
|
+
return PATRefreshAmbiguousError(probe.get(status, PATProbeResult.INCONCLUSIVE))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _matches_replacement(current: PATStoreRecord, write: _UnconfirmedWrite) -> bool:
|
|
111
|
+
return (
|
|
112
|
+
current.revision == write.replacement.revision
|
|
113
|
+
and current.pending_attempt is None
|
|
114
|
+
and current.get_pat() == write.replacement.get_pat()
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _StoredPAT:
|
|
119
|
+
def __init__(self, store: PATStore) -> None:
|
|
120
|
+
try:
|
|
121
|
+
suitable = isinstance(store, PATStore) and callable(store.load) and callable(store.compare_and_swap)
|
|
122
|
+
except Exception:
|
|
123
|
+
suitable = False
|
|
124
|
+
if not suitable:
|
|
125
|
+
raise ConfigurationError('A versioned PAT store adapter is required.')
|
|
126
|
+
self._store = store
|
|
127
|
+
self._operation = Lock()
|
|
128
|
+
self._unconfirmed: _UnconfirmedWrite | None = None
|
|
129
|
+
record = self._load()
|
|
130
|
+
_require_ready(record)
|
|
131
|
+
self._activate(record)
|
|
132
|
+
|
|
133
|
+
def _load(self) -> PATStoreRecord:
|
|
134
|
+
try:
|
|
135
|
+
record = self._store.load()
|
|
136
|
+
except Exception:
|
|
137
|
+
record = None
|
|
138
|
+
if type(record) is PATStoreRecord:
|
|
139
|
+
return record
|
|
140
|
+
raise ConfigurationError('The PAT store could not supply a valid seeded record.')
|
|
141
|
+
|
|
142
|
+
def _swap(
|
|
143
|
+
self,
|
|
144
|
+
revision: UUID,
|
|
145
|
+
attempt: UUID | None,
|
|
146
|
+
replacement: PATStoreRecord,
|
|
147
|
+
failure: str,
|
|
148
|
+
) -> bool:
|
|
149
|
+
try:
|
|
150
|
+
changed = self._store.compare_and_swap(expected_revision=revision, expected_attempt=attempt, replacement=replacement)
|
|
151
|
+
except Exception:
|
|
152
|
+
changed = None
|
|
153
|
+
if type(changed) is bool:
|
|
154
|
+
return changed
|
|
155
|
+
raise PATPersistenceError(failure)
|
|
156
|
+
|
|
157
|
+
def _activate(self, record: PATStoreRecord) -> None:
|
|
158
|
+
self._loaded = (record.revision, PATAuth(record.get_pat()))
|
|
159
|
+
|
|
160
|
+
def read_auth(self) -> PATAuth:
|
|
161
|
+
if self._unconfirmed is not None:
|
|
162
|
+
raise PATPersistenceError('Retry persistence before using this client.')
|
|
163
|
+
revision, auth = self._loaded
|
|
164
|
+
record = self._load()
|
|
165
|
+
_require_ready(record)
|
|
166
|
+
if record.revision != revision:
|
|
167
|
+
raise ConfigurationError('The stored PAT changed. Recreate the store-backed client to reload it.')
|
|
168
|
+
return auth
|
|
169
|
+
|
|
170
|
+
def refresh(self, transport: _Transport, days: int | None) -> PATRefreshResult:
|
|
171
|
+
if not self._operation.acquire(blocking=False):
|
|
172
|
+
raise PATRefreshPendingError('A PAT operation is already in progress on this client.')
|
|
173
|
+
try:
|
|
174
|
+
if self._unconfirmed is not None:
|
|
175
|
+
raise PATPersistenceError('Retry persistence before refreshing again.')
|
|
176
|
+
return self._refresh(transport, days)
|
|
177
|
+
finally:
|
|
178
|
+
self._operation.release()
|
|
179
|
+
|
|
180
|
+
def _refresh(self, transport: _Transport, days: int | None) -> PATRefreshResult:
|
|
181
|
+
current = self._load()
|
|
182
|
+
_require_ready(current)
|
|
183
|
+
old_pat = current.get_pat()
|
|
184
|
+
auth = PATAuth(old_pat)
|
|
185
|
+
attempt = uuid4()
|
|
186
|
+
pending = PATStoreRecord(current.revision, old_pat, attempt)
|
|
187
|
+
if not self._swap(current.revision, None, pending, 'Could not confirm the pending marker. No refresh was sent.'):
|
|
188
|
+
raise PATRefreshPendingError('The PAT store changed before refresh. No refresh was sent.')
|
|
189
|
+
|
|
190
|
+
status, payload = transport.refresh_once(auth, days)
|
|
191
|
+
if status in (401, 429):
|
|
192
|
+
failure = f'Refresh returned HTTP {status}, but the pending marker could not be cleared.'
|
|
193
|
+
if not self._swap(current.revision, attempt, current, failure):
|
|
194
|
+
raise PATPersistenceError(failure)
|
|
195
|
+
if status == 401:
|
|
196
|
+
raise AuthenticationError()
|
|
197
|
+
raise RateLimitError()
|
|
198
|
+
|
|
199
|
+
write = _replacement_write(current, attempt, payload)
|
|
200
|
+
if write is None:
|
|
201
|
+
raise _probe_error(transport.probe_once(auth))
|
|
202
|
+
|
|
203
|
+
self._unconfirmed = write
|
|
204
|
+
return self._commit(write)
|
|
205
|
+
|
|
206
|
+
def _commit(self, write: _UnconfirmedWrite) -> PATRefreshResult:
|
|
207
|
+
failure = 'Could not confirm durable storage of the replacement. Retry persistence on this client.'
|
|
208
|
+
if not self._swap(write.revision, write.attempt, write.replacement, failure):
|
|
209
|
+
raise PATPersistenceError(failure)
|
|
210
|
+
return self._finish(write)
|
|
211
|
+
|
|
212
|
+
def _finish(self, write: _UnconfirmedWrite) -> PATRefreshResult:
|
|
213
|
+
self._activate(write.replacement)
|
|
214
|
+
self._unconfirmed = None
|
|
215
|
+
return write.result
|
|
216
|
+
|
|
217
|
+
def retry_persistence(self) -> PATRefreshResult:
|
|
218
|
+
if not self._operation.acquire(blocking=False):
|
|
219
|
+
raise PATRefreshPendingError('A PAT operation is already in progress on this client.')
|
|
220
|
+
try:
|
|
221
|
+
write = self._unconfirmed
|
|
222
|
+
if write is None:
|
|
223
|
+
raise ConfigurationError('This client has no replacement awaiting persistence.')
|
|
224
|
+
return self._reconcile(write)
|
|
225
|
+
finally:
|
|
226
|
+
self._operation.release()
|
|
227
|
+
|
|
228
|
+
def _reconcile(self, write: _UnconfirmedWrite) -> PATRefreshResult:
|
|
229
|
+
try:
|
|
230
|
+
current = self._load()
|
|
231
|
+
except ConfigurationError:
|
|
232
|
+
current = None
|
|
233
|
+
if current is None:
|
|
234
|
+
raise PATPersistenceError('Could not read the PAT store to confirm the replacement.')
|
|
235
|
+
if current.revision == write.revision and current.pending_attempt == write.attempt:
|
|
236
|
+
return self._commit(write)
|
|
237
|
+
if _matches_replacement(current, write):
|
|
238
|
+
return self._finish(write)
|
|
239
|
+
raise PATPersistenceError('The store changed while persistence was unresolved. Reconcile the store before use.')
|
sfdctrust/_pat_store.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: sfdctrust._pat_store
|
|
4
|
+
:Synopsis: Versioned credential store contract for explicit PAT rotation
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6.1 Sol)
|
|
7
|
+
:Modified Date: 04 Nov 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import InitVar, dataclass, field
|
|
13
|
+
from typing import NoReturn, Protocol, runtime_checkable
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
from pydantic import SecretStr
|
|
17
|
+
|
|
18
|
+
from ._auth import _validate_pat
|
|
19
|
+
from ._exceptions import ConfigurationError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
23
|
+
class PATStoreRecord:
|
|
24
|
+
"""A trusted adapter's snapshot of one PAT lineage.
|
|
25
|
+
|
|
26
|
+
:param revision: Unique version of the credential, stable across pending marker changes.
|
|
27
|
+
:param pat: The PAT without the Bearer prefix.
|
|
28
|
+
:param pending_attempt: Unique identifier of the unresolved rotation, if any.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
revision: UUID
|
|
32
|
+
pat: InitVar[str]
|
|
33
|
+
pending_attempt: UUID | None = None
|
|
34
|
+
_secret: SecretStr = field(init=False, repr=False)
|
|
35
|
+
|
|
36
|
+
def __post_init__(self, pat: str) -> None:
|
|
37
|
+
if type(self.revision) is not UUID or (self.pending_attempt is not None and type(self.pending_attempt) is not UUID):
|
|
38
|
+
raise ConfigurationError('PAT store revisions and attempt identifiers must be UUIDs.')
|
|
39
|
+
_validate_pat(pat)
|
|
40
|
+
object.__setattr__(self, '_secret', SecretStr(pat))
|
|
41
|
+
|
|
42
|
+
def get_pat(self) -> str:
|
|
43
|
+
"""Return the secret for trusted store adapter I/O only."""
|
|
44
|
+
return self._secret.get_secret_value()
|
|
45
|
+
|
|
46
|
+
def __getstate__(self) -> NoReturn:
|
|
47
|
+
raise TypeError('PAT store records cannot be serialized.')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@runtime_checkable
|
|
51
|
+
class PATStore(Protocol):
|
|
52
|
+
"""Durable, atomic store for one PAT lineage shared by all rotators.
|
|
53
|
+
|
|
54
|
+
Implementations must provide strongly consistent reads and durable
|
|
55
|
+
compare-and-swap across processes. A pending attempt survives loss of a
|
|
56
|
+
process or lease and is cleared only by a coordinated state transition.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def load(self) -> PATStoreRecord | None:
|
|
60
|
+
"""Return the current record, or None for an unseeded lineage."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def compare_and_swap(self, *, expected_revision: UUID, expected_attempt: UUID | None, replacement: PATStoreRecord) -> bool:
|
|
64
|
+
"""Conditionally and durably replace the complete record.
|
|
65
|
+
|
|
66
|
+
:param expected_revision: Credential version that must still be current.
|
|
67
|
+
:param expected_attempt: Pending marker that must still match, including None.
|
|
68
|
+
:param replacement: Full next record, with the credential and marker written atomically.
|
|
69
|
+
:returns: True only after durable commit. False if the comparison failed
|
|
70
|
+
and no write occurred. Raise if the outcome cannot be confirmed.
|
|
71
|
+
"""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@runtime_checkable
|
|
76
|
+
class AsyncPATStore(Protocol):
|
|
77
|
+
"""Asynchronous durable, atomic store for one PAT lineage.
|
|
78
|
+
|
|
79
|
+
Provide the same strongly consistent reads and durable cross-process
|
|
80
|
+
compare-and-swap as ``PATStore``. Propagate cancellation. A cancelled
|
|
81
|
+
write can have committed, and pending state must survive process loss.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
async def load(self) -> PATStoreRecord | None:
|
|
85
|
+
"""Return the latest committed record, or None for an unseeded lineage."""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
async def compare_and_swap(
|
|
89
|
+
self, *, expected_revision: UUID, expected_attempt: UUID | None, replacement: PATStoreRecord
|
|
90
|
+
) -> bool:
|
|
91
|
+
"""Conditionally and durably replace the complete record.
|
|
92
|
+
|
|
93
|
+
:param expected_revision: Credential version that must still be current.
|
|
94
|
+
:param expected_attempt: Pending marker that must still match, including None.
|
|
95
|
+
:param replacement: Full next record, with the credential and marker written atomically.
|
|
96
|
+
:returns: True only after durable commit. False if the comparison failed
|
|
97
|
+
and no write occurred. Raise if the outcome cannot be confirmed.
|
|
98
|
+
"""
|
|
99
|
+
...
|