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.
@@ -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'
@@ -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.')
@@ -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
+ ...