openbox-sdk-python 1.2.0__py3-none-any.whl → 1.3.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.
- openbox_core/__init__.py +1 -1
- openbox_core/bootstrap.py +296 -0
- openbox_core/client.py +1021 -40
- openbox_core/config.py +309 -6
- openbox_core/errors.py +64 -3
- openbox_core/identity_okta.py +302 -0
- openbox_core/identity_transitions.py +134 -0
- openbox_core/identity_types.py +74 -0
- openbox_core/instrumentation/db.py +20 -3
- openbox_core/instrumentation/http.py +17 -0
- openbox_core/jwk_thumbprint.py +78 -0
- openbox_core/runtime.py +6 -1
- openbox_core/workload_identity.py +383 -0
- {openbox_sdk_python-1.2.0.dist-info → openbox_sdk_python-1.3.0.dist-info}/METADATA +76 -7
- {openbox_sdk_python-1.2.0.dist-info → openbox_sdk_python-1.3.0.dist-info}/RECORD +17 -10
- {openbox_sdk_python-1.2.0.dist-info → openbox_sdk_python-1.3.0.dist-info}/WHEEL +1 -1
- openbox_sdk_python-1.3.0.dist-info/licenses/LICENSE +21 -0
openbox_core/__init__.py
CHANGED
|
@@ -37,7 +37,7 @@ from .errors import (
|
|
|
37
37
|
# governance; eagerly it can deadlock package init as a circular import, and
|
|
38
38
|
# lazily it can recurse unboundedly when a per-request header builder resolves
|
|
39
39
|
# the version. Keep in sync with pyproject.toml on release.
|
|
40
|
-
__version__ = "1.
|
|
40
|
+
__version__ = "1.3.0"
|
|
41
41
|
|
|
42
42
|
__all__ = [
|
|
43
43
|
"__version__",
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Identity bootstrap: fetch the non-secret metadata needed to construct a v2
|
|
2
|
+
assertion from ``GET /api/v2/auth/bootstrap``, and prove the local private key
|
|
3
|
+
belongs to the credential Core actually selected.
|
|
4
|
+
|
|
5
|
+
Why this exists: signing a v2 assertion requires seven values (agent id,
|
|
6
|
+
organization id, deployment id, audience, external Okta agent id, credential
|
|
7
|
+
``kid``, algorithm) that Core already owns. Requiring an operator to copy them
|
|
8
|
+
into the runtime invites drift — a stale ``kid`` after rotation, an audience
|
|
9
|
+
pointing at the wrong deployment, an agent id copied from the wrong agent. Core is
|
|
10
|
+
the authority that validates them, so Core supplies them.
|
|
11
|
+
|
|
12
|
+
Fetching them does NOT make them trusted. Core independently re-derives and
|
|
13
|
+
re-compares every value when the signed assertion arrives; bootstrap only removes
|
|
14
|
+
the copying step.
|
|
15
|
+
|
|
16
|
+
Import safety: ``httpx`` and ``cryptography`` are imported lazily by the callers
|
|
17
|
+
this module delegates to, so importing this module stays cheap.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .errors import OpenBoxConfigError
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AUTH_BOOTSTRAP_PATH_V2",
|
|
30
|
+
"SUPPORTED_BOOTSTRAP_VERSION",
|
|
31
|
+
"PRIVATE_KEY_MISMATCH_MESSAGE",
|
|
32
|
+
"IdentityBootstrapAuthority",
|
|
33
|
+
"IdentityBootstrapDocument",
|
|
34
|
+
"IdentityBootstrapOkta",
|
|
35
|
+
"parse_bootstrap_document",
|
|
36
|
+
"assert_private_key_matches_document",
|
|
37
|
+
"bootstrap_guidance_for",
|
|
38
|
+
"raise_for_bootstrap_status",
|
|
39
|
+
"parse_bootstrap_response",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
AUTH_BOOTSTRAP_PATH_V2 = "/api/v2/auth/bootstrap"
|
|
43
|
+
|
|
44
|
+
# The only bootstrap wire version this SDK understands. An unknown version fails
|
|
45
|
+
# closed with upgrade guidance rather than being interpreted optimistically.
|
|
46
|
+
SUPPORTED_BOOTSTRAP_VERSION = 1
|
|
47
|
+
|
|
48
|
+
# The fatal key-mismatch message. Actionable on purpose: this is the one bootstrap
|
|
49
|
+
# failure an operator can only fix by changing which key the runtime holds, or
|
|
50
|
+
# which credential the agent has selected. Kept byte-identical to the TypeScript
|
|
51
|
+
# SDK's message so operators see one wording across languages.
|
|
52
|
+
PRIVATE_KEY_MISMATCH_MESSAGE = (
|
|
53
|
+
"The configured private key does not match the selected Okta credential for "
|
|
54
|
+
"this OpenBox agent. Export the private key associated with the selected "
|
|
55
|
+
"credential, or rotate the agent credential."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class IdentityBootstrapAuthority:
|
|
61
|
+
"""Provider-neutral, non-secret active-authority metadata from Core."""
|
|
62
|
+
|
|
63
|
+
assignment_id: str
|
|
64
|
+
provider_generation_id: str
|
|
65
|
+
generation_number: int
|
|
66
|
+
activation_version: str
|
|
67
|
+
identity_id: str
|
|
68
|
+
credential_id: str
|
|
69
|
+
projection_version: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class IdentityBootstrapOkta:
|
|
74
|
+
"""The okta_ai_agent half of the bootstrap document."""
|
|
75
|
+
|
|
76
|
+
external_agent_id: str
|
|
77
|
+
credential_kid: str
|
|
78
|
+
algorithm: str
|
|
79
|
+
public_jwk_thumbprint: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class IdentityBootstrapDocument:
|
|
84
|
+
"""A validated bootstrap document. Contains no secret material."""
|
|
85
|
+
|
|
86
|
+
bootstrap_version: int
|
|
87
|
+
identity_method: str
|
|
88
|
+
openbox_agent_id: str
|
|
89
|
+
organization_id: str
|
|
90
|
+
deployment_id: str
|
|
91
|
+
assertion_audience: str
|
|
92
|
+
authority: IdentityBootstrapAuthority
|
|
93
|
+
okta: IdentityBootstrapOkta
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _require_string(source: dict, key: str, path: str) -> str:
|
|
97
|
+
value = source.get(key)
|
|
98
|
+
if not isinstance(value, str) or not value:
|
|
99
|
+
raise OpenBoxConfigError(
|
|
100
|
+
f"Identity bootstrap response is invalid: {path!r} must be a non-empty string."
|
|
101
|
+
)
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_authority(raw: Any) -> IdentityBootstrapAuthority:
|
|
106
|
+
if not isinstance(raw, dict):
|
|
107
|
+
raise OpenBoxConfigError(
|
|
108
|
+
"Identity bootstrap response is invalid: 'authority' must be an object."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
generation_number = raw.get("generation_number")
|
|
112
|
+
if (
|
|
113
|
+
not isinstance(generation_number, int)
|
|
114
|
+
or isinstance(generation_number, bool)
|
|
115
|
+
or generation_number < 1
|
|
116
|
+
):
|
|
117
|
+
raise OpenBoxConfigError(
|
|
118
|
+
"Identity bootstrap response is invalid: "
|
|
119
|
+
"'authority.generation_number' must be a positive integer."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return IdentityBootstrapAuthority(
|
|
123
|
+
assignment_id=_require_string(raw, "assignment_id", "authority.assignment_id"),
|
|
124
|
+
provider_generation_id=_require_string(
|
|
125
|
+
raw, "provider_generation_id", "authority.provider_generation_id"
|
|
126
|
+
),
|
|
127
|
+
generation_number=generation_number,
|
|
128
|
+
activation_version=_require_string(
|
|
129
|
+
raw, "activation_version", "authority.activation_version"
|
|
130
|
+
),
|
|
131
|
+
identity_id=_require_string(raw, "identity_id", "authority.identity_id"),
|
|
132
|
+
credential_id=_require_string(raw, "credential_id", "authority.credential_id"),
|
|
133
|
+
projection_version=_require_string(
|
|
134
|
+
raw, "projection_version", "authority.projection_version"
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def parse_bootstrap_document(raw: Any) -> IdentityBootstrapDocument:
|
|
140
|
+
"""Parse and strictly validate a bootstrap response body.
|
|
141
|
+
|
|
142
|
+
Every check fails closed. A response that is merely *plausible* is not good
|
|
143
|
+
enough: the values here determine what this runtime signs, and a silently
|
|
144
|
+
accepted wrong value produces assertions Core will reject with no local
|
|
145
|
+
explanation.
|
|
146
|
+
"""
|
|
147
|
+
if not isinstance(raw, dict):
|
|
148
|
+
raise OpenBoxConfigError("Identity bootstrap response is invalid: expected a JSON object.")
|
|
149
|
+
|
|
150
|
+
version = raw.get("bootstrap_version")
|
|
151
|
+
if version != SUPPORTED_BOOTSTRAP_VERSION:
|
|
152
|
+
raise OpenBoxConfigError(
|
|
153
|
+
f"Unsupported identity bootstrap version {version!r}; this SDK supports "
|
|
154
|
+
f"version {SUPPORTED_BOOTSTRAP_VERSION}. Upgrade the OpenBox SDK to match "
|
|
155
|
+
"your Core deployment."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
identity_method = _require_string(raw, "identity_method", "identity_method")
|
|
159
|
+
if identity_method != "okta_ai_agent":
|
|
160
|
+
raise OpenBoxConfigError(
|
|
161
|
+
f"This OpenBox agent's identity method is {identity_method!r}, not "
|
|
162
|
+
"'okta_ai_agent'. Configure the matching identity for this agent, or "
|
|
163
|
+
"select an Okta credential for it in OpenBox."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
okta_raw = raw.get("okta")
|
|
167
|
+
if not isinstance(okta_raw, dict):
|
|
168
|
+
raise OpenBoxConfigError(
|
|
169
|
+
"Identity bootstrap response is invalid: 'okta' must be an object."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
algorithm = _require_string(okta_raw, "algorithm", "okta.algorithm")
|
|
173
|
+
if algorithm.upper() != "RS256":
|
|
174
|
+
raise OpenBoxConfigError(
|
|
175
|
+
f"Unsupported Okta credential algorithm {algorithm!r}; only 'RS256' is allowlisted."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return IdentityBootstrapDocument(
|
|
179
|
+
bootstrap_version=SUPPORTED_BOOTSTRAP_VERSION,
|
|
180
|
+
identity_method=identity_method,
|
|
181
|
+
openbox_agent_id=_require_string(raw, "openbox_agent_id", "openbox_agent_id"),
|
|
182
|
+
organization_id=_require_string(raw, "organization_id", "organization_id"),
|
|
183
|
+
deployment_id=_require_string(raw, "deployment_id", "deployment_id"),
|
|
184
|
+
assertion_audience=_require_string(raw, "assertion_audience", "assertion_audience"),
|
|
185
|
+
authority=_parse_authority(raw.get("authority")),
|
|
186
|
+
okta=IdentityBootstrapOkta(
|
|
187
|
+
external_agent_id=_require_string(
|
|
188
|
+
okta_raw, "external_agent_id", "okta.external_agent_id"
|
|
189
|
+
),
|
|
190
|
+
credential_kid=_require_string(okta_raw, "credential_kid", "okta.credential_kid"),
|
|
191
|
+
algorithm="RS256",
|
|
192
|
+
public_jwk_thumbprint=_require_string(
|
|
193
|
+
okta_raw, "public_jwk_thumbprint", "okta.public_jwk_thumbprint"
|
|
194
|
+
),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def assert_private_key_matches_document(
|
|
200
|
+
private_key_pem: str, document: IdentityBootstrapDocument
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Verify the local private key corresponds to the selected public credential.
|
|
203
|
+
|
|
204
|
+
Runs BEFORE any governed request. Sending one after a mismatch could only
|
|
205
|
+
produce a signature Core rejects, with a far less diagnosable error.
|
|
206
|
+
"""
|
|
207
|
+
from .identity_okta import load_rsa_pkcs8_private_key
|
|
208
|
+
from .jwk_thumbprint import jwk_thumbprint_sha256, thumbprints_match
|
|
209
|
+
|
|
210
|
+
local = jwk_thumbprint_sha256(load_rsa_pkcs8_private_key(private_key_pem))
|
|
211
|
+
if not thumbprints_match(local, document.okta.public_jwk_thumbprint):
|
|
212
|
+
raise OpenBoxConfigError(PRIVATE_KEY_MISMATCH_MESSAGE)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _reason_code_of(body: bytes | None) -> str | None:
|
|
216
|
+
"""Machine reason code from Core's error body, mirroring the client's helper."""
|
|
217
|
+
if not body:
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
parsed = json.loads(body.decode("utf-8", errors="replace"))
|
|
221
|
+
except Exception:
|
|
222
|
+
return None
|
|
223
|
+
if not isinstance(parsed, dict):
|
|
224
|
+
return None
|
|
225
|
+
# `is not None`, not `or`: an empty-string reason_code must not fall through
|
|
226
|
+
# to `reason`, matching the TypeScript SDK's `??`.
|
|
227
|
+
code = parsed.get("reason_code")
|
|
228
|
+
if code is None:
|
|
229
|
+
code = parsed.get("reason")
|
|
230
|
+
return code if isinstance(code, str) else None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def bootstrap_guidance_for(code: str | None, status: int) -> str:
|
|
234
|
+
"""Operator-facing guidance per Core reason code.
|
|
235
|
+
|
|
236
|
+
Codes come from Core's stable bootstrap set; an unrecognized code falls back
|
|
237
|
+
to a generic hint rather than being treated as success.
|
|
238
|
+
"""
|
|
239
|
+
guidance = {
|
|
240
|
+
"invalid_api_key": "the OPENBOX_API_KEY is absent, invalid, or revoked.",
|
|
241
|
+
"agent_inactive": "this agent is not active in OpenBox.",
|
|
242
|
+
"identity_method_mismatch": (
|
|
243
|
+
"this agent is not configured for Okta identity verification."
|
|
244
|
+
),
|
|
245
|
+
"selected_credential_missing": (
|
|
246
|
+
"select or register an Okta credential for this agent in OpenBox."
|
|
247
|
+
),
|
|
248
|
+
"selected_credential_inactive": (
|
|
249
|
+
"the agent's selected Okta credential or its provider link is not active."
|
|
250
|
+
),
|
|
251
|
+
"credential_algorithm_unsupported": (
|
|
252
|
+
"the selected Okta credential uses an algorithm this contract does not allow."
|
|
253
|
+
),
|
|
254
|
+
"provider_metadata_stale": (
|
|
255
|
+
"OpenBox has not recently synchronized this credential from Okta; retry shortly."
|
|
256
|
+
),
|
|
257
|
+
"identity_configuration_invalid": (
|
|
258
|
+
"the OpenBox Core deployment's identity configuration is incomplete."
|
|
259
|
+
),
|
|
260
|
+
"verifier_unavailable": (
|
|
261
|
+
"OpenBox Core is temporarily unable to resolve identity metadata; retry shortly."
|
|
262
|
+
),
|
|
263
|
+
}
|
|
264
|
+
if code in guidance:
|
|
265
|
+
return guidance[code]
|
|
266
|
+
if status >= 500:
|
|
267
|
+
return "OpenBox Core reported a server-side problem; retry shortly."
|
|
268
|
+
return "check the API key and this agent's identity configuration in OpenBox."
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def raise_for_bootstrap_status(status: int, body: bytes | None) -> None:
|
|
272
|
+
"""Raise for a non-2xx bootstrap response; return normally on success."""
|
|
273
|
+
if status == 404:
|
|
274
|
+
raise OpenBoxConfigError(
|
|
275
|
+
"This Core version does not support Okta identity bootstrap. Upgrade Core "
|
|
276
|
+
"or provide the complete legacy Okta identity configuration."
|
|
277
|
+
)
|
|
278
|
+
if status < 200 or status >= 300:
|
|
279
|
+
code = _reason_code_of(body)
|
|
280
|
+
suffix = f" ({code})" if code else ""
|
|
281
|
+
raise OpenBoxConfigError(
|
|
282
|
+
f"Identity bootstrap failed with HTTP {status}{suffix}: "
|
|
283
|
+
f"{bootstrap_guidance_for(code, status)}"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def parse_bootstrap_response(status: int, body: bytes | None) -> IdentityBootstrapDocument:
|
|
288
|
+
"""Validate an HTTP status then parse the body into a document."""
|
|
289
|
+
raise_for_bootstrap_status(status, body)
|
|
290
|
+
try:
|
|
291
|
+
parsed = json.loads((body or b"").decode("utf-8"))
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
raise OpenBoxConfigError(
|
|
294
|
+
"Identity bootstrap response is invalid: body is not valid JSON."
|
|
295
|
+
) from exc
|
|
296
|
+
return parse_bootstrap_document(parsed)
|