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 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.2.0"
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)