Skip to content

Commit 072ab48

Browse files
committed
feat(credential): split error summary and guidance, map platform failures to unavailable (#7882)
1 parent af95d81 commit 072ab48

9 files changed

Lines changed: 256 additions & 45 deletions

File tree

‎pyoaev/apis/inject.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ def resolve_attachment_secret(
6262
e.response_code, e.error_message
6363
)
6464
except requests.RequestException:
65-
error_code = CredentialErrorCode.CREDENTIAL_ACCESS_DENIED
65+
# Connection error or timeout: the platform never answered.
66+
error_code = CredentialErrorCode.CREDENTIAL_RESOLUTION_UNAVAILABLE
6667
else:
6768
if not isinstance(result, dict):
6869
error_code = CredentialErrorCode.CREDENTIAL_ACCESS_DENIED

‎pyoaev/credential/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .attachment import (
22
CredentialAttachment,
33
get_credential_attachment,
4+
resolve_credential_attachment,
45
resolve_inject_credential,
56
)
67
from .errors import (
@@ -9,6 +10,8 @@
910
InvalidResolvedSecretError,
1011
UnsupportedSecretTypeError,
1112
credential_error_code_from_http,
13+
credential_error_guidance,
14+
credential_error_summary,
1215
)
1316
from .materializer import CredentialCleanupError, MaterializedCredential, materialize
1417
from .resolved import (
@@ -57,10 +60,13 @@ def build_single_referenced_credential_element(provider_name: str):
5760
"UnsupportedSecretTypeError",
5861
"UsernamePasswordSecret",
5962
"credential_error_code_from_http",
63+
"credential_error_guidance",
64+
"credential_error_summary",
6065
"build_single_referenced_credential_element",
6166
"ensure_compatible",
6267
"get_credential_attachment",
6368
"materialize",
6469
"parse_resolved_secret",
70+
"resolve_credential_attachment",
6571
"resolve_inject_credential",
6672
]

‎pyoaev/credential/attachment.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
``attachments`` is ``null`` when the inject has no reference: the injector then
99
keeps its legacy credential fields and no resolution request is sent.
1010
11-
``resolve_inject_credential`` is the single entry point injectors call.
11+
``resolve_inject_credential`` is the single entry point injectors call. An
12+
injector that reads the attachment while validating its input and resolves it
13+
later calls ``get_credential_attachment`` then ``resolve_credential_attachment``.
1214
"""
1315

1416
from dataclasses import dataclass, field
@@ -100,6 +102,23 @@ def resolve_inject_credential(
100102
attachment = get_credential_attachment(data)
101103
if attachment is None:
102104
return None
105+
return resolve_credential_attachment(api, inject_id, attachment, expected_type)
106+
107+
108+
def resolve_credential_attachment(
109+
api: "OpenAEV",
110+
inject_id: str,
111+
attachment: CredentialAttachment,
112+
expected_type: Union[CredentialType, str],
113+
) -> ResolvedSecret:
114+
"""Resolve an attachment already read with ``get_credential_attachment``.
115+
116+
Same contract as ``resolve_inject_credential``, for injectors that read
117+
the attachment and resolve it at two different stages.
118+
119+
Raises:
120+
CredentialResolutionError: with the code to report in the trace.
121+
"""
103122
payload = api.inject.resolve_attachment_secret(
104123
inject_id, attachment.reference, attachment.authorisation_code
105124
)
@@ -110,5 +129,6 @@ def resolve_inject_credential(
110129
__all__ = [
111130
"CredentialAttachment",
112131
"get_credential_attachment",
132+
"resolve_credential_attachment",
113133
"resolve_inject_credential",
114134
]

‎pyoaev/credential/errors.py‎

Lines changed: 80 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22
33
The platform resolution endpoint answers with a closed set of error codes.
44
``CREDENTIAL_INCOMPATIBLE`` is detected by the injector itself, when the
5-
resolved credential does not satisfy the contract field. Each code carries a fixed, human-readable message meant to be reported as is
6-
in the inject execution trace. Messages only ever mention the credential
7-
reference id, never a secret value, and the raw HTTP request and response
8-
bodies are deliberately never kept on the error.
5+
resolved credential does not satisfy the contract field, and
6+
``CREDENTIAL_RESOLUTION_UNAVAILABLE`` when the platform could not be reached or
7+
failed to answer. Each code carries a fixed, human-readable message meant to be
8+
reported as is in the inject execution trace. Messages only ever mention the
9+
credential reference id, never a secret value, and the raw HTTP request and
10+
response bodies are deliberately never kept on the error.
11+
12+
A message is made of a summary (what happened) followed by a guidance (what to
13+
do). Both parts are exposed through ``credential_error_summary`` and
14+
``credential_error_guidance`` so injectors that report them separately reuse
15+
the exact same texts.
916
"""
1017

1118
from enum import Enum
@@ -20,6 +27,7 @@ class CredentialErrorCode(str, Enum):
2027
CREDENTIAL_INACTIVE = "CREDENTIAL_INACTIVE"
2128
CREDENTIAL_ACCESS_DENIED = "CREDENTIAL_ACCESS_DENIED"
2229
CREDENTIAL_INCOMPATIBLE = "CREDENTIAL_INCOMPATIBLE"
30+
CREDENTIAL_RESOLUTION_UNAVAILABLE = "CREDENTIAL_RESOLUTION_UNAVAILABLE"
2331

2432

2533
_PLATFORM_CREDENTIAL_ERROR_CODES = {
@@ -28,43 +36,84 @@ class CredentialErrorCode(str, Enum):
2836
CredentialErrorCode.CREDENTIAL_ACCESS_DENIED,
2937
}
3038

39+
UNKNOWN_REFERENCE = "unknown reference"
3140

32-
_CREDENTIAL_ERROR_MESSAGES = {
41+
_CREDENTIAL_ERROR_SUMMARIES = {
3342
CredentialErrorCode.CREDENTIAL_NOT_FOUND: (
3443
"The credential configured on this inject ({reference}) no longer exists "
35-
"at the moment of execution. Select an existing credential on the inject, "
36-
"then run it again."
44+
"at the moment of execution."
3745
),
3846
CredentialErrorCode.CREDENTIAL_INACTIVE: (
39-
"The credential {reference} is inactive at the moment of execution. "
40-
"Update or replace this credential, then run the inject again."
47+
"The credential {reference} is inactive at the moment of execution."
4148
),
4249
CredentialErrorCode.CREDENTIAL_ACCESS_DENIED: (
4350
"This execution is not entitled to use the credential configured on this "
44-
"inject. Contact your Cloud platform administrator"
51+
"inject."
4552
),
4653
CredentialErrorCode.CREDENTIAL_INCOMPATIBLE: (
47-
"The credential {reference} is not compatible with this inject. Select a "
48-
"credential of type {type} on the inject, then run it again."
54+
"The credential {reference} is not compatible with this inject."
55+
),
56+
CredentialErrorCode.CREDENTIAL_RESOLUTION_UNAVAILABLE: (
57+
"The credential {reference} could not be resolved: the OpenAEV platform "
58+
"was unavailable at the moment of execution."
59+
),
60+
}
61+
62+
_CREDENTIAL_ERROR_GUIDANCES = {
63+
CredentialErrorCode.CREDENTIAL_NOT_FOUND: (
64+
"Select an existing credential on the inject, then run it again."
65+
),
66+
CredentialErrorCode.CREDENTIAL_INACTIVE: (
67+
"Update or replace this credential, then run the inject again."
68+
),
69+
CredentialErrorCode.CREDENTIAL_ACCESS_DENIED: (
70+
"Contact your Cloud platform administrator"
71+
),
72+
CredentialErrorCode.CREDENTIAL_INCOMPATIBLE: (
73+
"Select a credential of type {type} on the inject, then run it again."
74+
),
75+
CredentialErrorCode.CREDENTIAL_RESOLUTION_UNAVAILABLE: (
76+
"Check that the injector can reach the platform, then run the inject again."
4977
),
5078
}
5179

5280
# Used when the expected credential type is not known where the error is raised
5381
# (for example an unknown secret type returned by the platform).
54-
_CREDENTIAL_INCOMPATIBLE_UNKNOWN_TYPE_MESSAGE = (
55-
"The credential {reference} is not compatible with this inject. Select a "
56-
"credential of the type expected by the inject, then run it again."
82+
_CREDENTIAL_INCOMPATIBLE_UNKNOWN_TYPE_GUIDANCE = (
83+
"Select a credential of the type expected by the inject, then run it again."
5784
)
5885

5986

87+
def credential_error_summary(
88+
code: CredentialErrorCode, reference: Optional[str] = None
89+
) -> str:
90+
"""Return what happened, mentioning only the reference id."""
91+
return _CREDENTIAL_ERROR_SUMMARIES[CredentialErrorCode(code)].format(
92+
reference=reference or UNKNOWN_REFERENCE
93+
)
94+
95+
96+
def credential_error_guidance(
97+
code: CredentialErrorCode, expected_type: Optional[CredentialType] = None
98+
) -> str:
99+
"""Return what the operator should do about ``code``."""
100+
code = CredentialErrorCode(code)
101+
if code == CredentialErrorCode.CREDENTIAL_INCOMPATIBLE and expected_type is None:
102+
return _CREDENTIAL_INCOMPATIBLE_UNKNOWN_TYPE_GUIDANCE
103+
return _CREDENTIAL_ERROR_GUIDANCES[code].format(
104+
type=CredentialType(expected_type).value if expected_type else None
105+
)
106+
107+
60108
def credential_error_code_from_http(
61109
response_code: Optional[int], error_message: Optional[str]
62110
) -> CredentialErrorCode:
63111
"""Map a failed resolution call to its credential error code.
64112
65113
The code sent by the platform in the response ``message`` wins. A 404
66114
without a known code (the inject itself does not exist) is reported as
67-
``CREDENTIAL_NOT_FOUND``. Any other failure falls back to
115+
``CREDENTIAL_NOT_FOUND``, and a server error as
116+
``CREDENTIAL_RESOLUTION_UNAVAILABLE``. Any other failure falls back to
68117
``CREDENTIAL_ACCESS_DENIED`` so that nothing about the credential leaks.
69118
"""
70119
if error_message:
@@ -76,16 +125,19 @@ def credential_error_code_from_http(
76125
return code
77126
if response_code == 404:
78127
return CredentialErrorCode.CREDENTIAL_NOT_FOUND
128+
if response_code is not None and response_code >= 500:
129+
return CredentialErrorCode.CREDENTIAL_RESOLUTION_UNAVAILABLE
79130
return CredentialErrorCode.CREDENTIAL_ACCESS_DENIED
80131

81132

82133
class CredentialResolutionError(OpenAEVError):
83134
"""A credential reference could not be resolved for the current execution.
84135
85-
``message`` holds the fixed human-readable message of ``code``. The HTTP
86-
response body is never stored, so it cannot surface in ``str()``.
87-
``expected_type`` is the credential type the inject expects, only used by
88-
the ``CREDENTIAL_INCOMPATIBLE`` message.
136+
``message`` holds the fixed human-readable message of ``code``, which is
137+
``summary`` followed by ``guidance``. The HTTP response body is never
138+
stored, so it cannot surface in ``str()``. ``expected_type`` is the
139+
credential type the inject expects, only used by the
140+
``CREDENTIAL_INCOMPATIBLE`` message.
89141
"""
90142

91143
def __init__(
@@ -100,18 +152,12 @@ def __init__(
100152
self.expected_type = (
101153
CredentialType(expected_type) if expected_type is not None else None
102154
)
103-
template = _CREDENTIAL_ERROR_MESSAGES[self.code]
104-
if (
105-
self.code == CredentialErrorCode.CREDENTIAL_INCOMPATIBLE
106-
and self.expected_type is None
107-
):
108-
template = _CREDENTIAL_INCOMPATIBLE_UNKNOWN_TYPE_MESSAGE
109-
message = template.format(
110-
reference=reference or "unknown reference",
111-
type=self.expected_type.value if self.expected_type else None,
112-
)
155+
self.summary = credential_error_summary(self.code, reference)
156+
self.guidance = credential_error_guidance(self.code, self.expected_type)
113157
super().__init__(
114-
error_message=message, response_code=response_code, response_body=None
158+
error_message=f"{self.summary} {self.guidance}",
159+
response_code=response_code,
160+
response_body=None,
115161
)
116162

117163
@property
@@ -144,9 +190,12 @@ def __init__(self, reference: Optional[str] = None) -> None:
144190

145191

146192
__all__ = [
193+
"UNKNOWN_REFERENCE",
147194
"CredentialErrorCode",
148195
"CredentialResolutionError",
149196
"InvalidResolvedSecretError",
150197
"UnsupportedSecretTypeError",
151198
"credential_error_code_from_http",
199+
"credential_error_guidance",
200+
"credential_error_summary",
152201
]

‎pyoaev/credential/materializer.py‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from typing import Callable, Dict, Iterator, List, Optional
1717

1818
from pyoaev.credential.errors import (
19+
CredentialErrorCode,
20+
CredentialResolutionError,
1921
InvalidResolvedSecretError,
2022
UnsupportedSecretTypeError,
2123
)
@@ -42,6 +44,10 @@
4244
}
4345

4446

47+
class _UnsupportedAzureEnvironmentError(Exception):
48+
"""The Azure cloud of the credential has no known authority host."""
49+
50+
4551
class CredentialCleanupError(RuntimeError):
4652
"""Temporary credential files could not be removed."""
4753

@@ -167,7 +173,9 @@ def _azure_authority_host(environment: str) -> str:
167173
try:
168174
return AZURE_AUTHORITY_HOSTS[environment]
169175
except KeyError:
170-
raise InvalidResolvedSecretError() from None
176+
# A valid platform value (e.g. the retired AzureGermanCloud) the
177+
# injectors cannot target: the credential is incompatible, not invalid.
178+
raise _UnsupportedAzureEnvironmentError() from None
171179

172180

173181
def _azure_service_principal(
@@ -245,27 +253,45 @@ def _gcp_oauth2(secret: GcpOAuth2Secret, workspace: _Workspace) -> Dict[str, str
245253

246254
@contextmanager
247255
def materialize(
248-
resolved: ResolvedSecret, temporary_root: Optional[Path] = None
256+
resolved: ResolvedSecret,
257+
temporary_root: Optional[Path] = None,
258+
*,
259+
reference: Optional[str] = None,
249260
) -> Iterator[MaterializedCredential]:
250261
"""Materialize ``resolved`` for the duration of the ``with`` block.
251262
252263
Args:
253264
resolved: the secret returned by ``resolve_inject_credential``.
254265
temporary_root: parent of the private directory, the system temporary
255266
directory when omitted.
267+
reference: the credential reference id, only used in error messages.
256268
257269
Raises:
258270
UnsupportedSecretTypeError: the secret type is not a cloud credential
259271
(``USERNAME_PASSWORD``, ``HASH``).
272+
CredentialResolutionError: ``CREDENTIAL_INCOMPATIBLE`` when the Azure
273+
cloud of the credential cannot be targeted.
260274
InvalidResolvedSecretError: a value cannot be materialized safely.
261275
CredentialCleanupError: the temporary files could not be removed.
262276
"""
263277
materializer = _MATERIALIZERS.get(resolved.secret_type)
264278
if materializer is None:
265-
raise UnsupportedSecretTypeError(resolved.secret_type.value)
279+
raise UnsupportedSecretTypeError(resolved.secret_type.value, reference)
266280
workspace = _Workspace(temporary_root)
281+
error: Optional[CredentialResolutionError] = None
267282
try:
268-
env = materializer(resolved, workspace)
283+
# Raised outside of the except blocks so that no original error, which
284+
# may quote a secret value, is chained to the reported one.
285+
try:
286+
env = materializer(resolved, workspace)
287+
except _UnsupportedAzureEnvironmentError:
288+
error = CredentialResolutionError(
289+
CredentialErrorCode.CREDENTIAL_INCOMPATIBLE, reference
290+
)
291+
except InvalidResolvedSecretError:
292+
error = InvalidResolvedSecretError(reference)
293+
if error is not None:
294+
raise error
269295
yield MaterializedCredential(
270296
secret_type=resolved.secret_type, env=env, files=list(workspace.files)
271297
)

‎test/apis/inject/test_inject.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,14 +132,23 @@ def test_unknown_failures_fall_back_to_access_denied(self):
132132
for response in (
133133
build_response(401, {"message": "Unauthorized"}, "Unauthorized"),
134134
build_response(400, {"message": "attachment_id: must not be blank"}),
135-
build_response(500, {"message": "boom"}, "Internal Server Error"),
136-
build_response(502, None, "Bad Gateway"),
137135
):
138136
with self.subTest(status_code=response.status_code):
139137
self._assert_resolution_error(
140138
response, CredentialErrorCode.CREDENTIAL_ACCESS_DENIED
141139
)
142140

141+
def test_server_errors_are_resolution_unavailable(self):
142+
for response in (
143+
build_response(500, {"message": "boom"}, "Internal Server Error"),
144+
build_response(502, None, "Bad Gateway"),
145+
):
146+
with self.subTest(status_code=response.status_code):
147+
error = self._assert_resolution_error(
148+
response, CredentialErrorCode.CREDENTIAL_RESOLUTION_UNAVAILABLE
149+
)
150+
self.assertNotIn("boom", error.message)
151+
143152
def test_non_json_success_is_access_denied(self):
144153
response = requests.Response()
145154
response.status_code = 200
@@ -150,7 +159,7 @@ def test_non_json_success_is_access_denied(self):
150159
response, CredentialErrorCode.CREDENTIAL_ACCESS_DENIED
151160
)
152161

153-
def test_network_error_is_access_denied(self):
162+
def test_network_error_is_resolution_unavailable(self):
154163
api_client = OpenAEV("url", "token")
155164
with mock.patch(
156165
"requests.Session.request",
@@ -162,7 +171,8 @@ def test_network_error_is_access_denied(self):
162171
)
163172

164173
self.assertEqual(
165-
context.exception.code, CredentialErrorCode.CREDENTIAL_ACCESS_DENIED
174+
context.exception.code,
175+
CredentialErrorCode.CREDENTIAL_RESOLUTION_UNAVAILABLE,
166176
)
167177

168178
def test_error_never_exposes_response_body(self):

0 commit comments

Comments
 (0)