Skip to content
47 changes: 46 additions & 1 deletion pyoaev/apis/inject.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from typing import Any, Dict
from typing import Any, Dict, Optional

import requests

from pyoaev import exceptions as exc
from pyoaev.base import RESTManager, RESTObject
from pyoaev.credential.errors import (
CredentialErrorCode,
CredentialResolutionError,
credential_error_code_from_http,
)


class Inject(RESTObject):
Expand All @@ -27,3 +34,41 @@ def execution_reception(
path = f"{self.path}/execution/reception/{inject_id}"
result = self.openaev.http_post(path, post_data=data, **kwargs)
return result

def resolve_attachment_secret(
self, inject_id: str, attachment_id: str, authorisation: str, **kwargs: Any
) -> Dict[str, Any]:
"""Resolve the secret of a credential attached to an inject.

Must be called while the inject is being executed, with the
authorisation code received with the job. The tenant prefix is added
by the client when a tenant id is configured.

Raises:
CredentialResolutionError: on any failure. The request and response
bodies are never logged nor kept on the error.
"""
path = f"{self.path}/{inject_id}/attachment/secret"
data = {"attachment_id": attachment_id, "authorisation": authorisation}
error_code: Optional[CredentialErrorCode] = None
response_code: Optional[int] = None
# The error is raised outside of the except blocks so the original
# exception, which holds the response body, is not chained to it.
try:
result = self.openaev.http_post(path, post_data=data, **kwargs)
except exc.OpenAEVError as e:
response_code = e.response_code
error_code = credential_error_code_from_http(
e.response_code, e.error_message
)
except requests.RequestException:
# Connection error or timeout: the platform never answered.
error_code = CredentialErrorCode.CREDENTIAL_RESOLUTION_UNAVAILABLE
else:
if not isinstance(result, dict):
error_code = CredentialErrorCode.CREDENTIAL_ACCESS_DENIED
if error_code is not None:
raise CredentialResolutionError(
error_code, reference=attachment_id, response_code=response_code
)
return result
59 changes: 58 additions & 1 deletion pyoaev/credential/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,33 @@
from .types import CredentialType
from .attachment import (
CredentialAttachment,
get_credential_attachment,
resolve_credential_attachment,
resolve_inject_credential,
)
from .errors import (
CredentialErrorCode,
CredentialResolutionError,
InvalidResolvedSecretError,
UnsupportedSecretTypeError,
credential_error_code_from_http,
credential_error_guidance,
credential_error_summary,
)
from .materializer import CredentialCleanupError, MaterializedCredential, materialize
from .resolved import (
AwsAccessKeySecret,
AwsAssumeRoleSecret,
AzureManagedIdentitySecret,
AzureServicePrincipalSecret,
GcpOAuth2Secret,
GcpServiceAccountSecret,
HashSecret,
ResolvedSecret,
UsernamePasswordSecret,
ensure_compatible,
parse_resolved_secret,
)
from .types import AwsSourceIdentityType, CredentialType, HashAlgorithm, SecretType


def build_single_referenced_credential_element(provider_name: str):
Expand All @@ -10,6 +39,34 @@ def build_single_referenced_credential_element(provider_name: str):


__all__ = [
"AwsAccessKeySecret",
"AwsAssumeRoleSecret",
"AwsSourceIdentityType",
"AzureManagedIdentitySecret",
"AzureServicePrincipalSecret",
"CredentialAttachment",
"CredentialCleanupError",
"CredentialErrorCode",
"CredentialResolutionError",
"CredentialType",
"GcpOAuth2Secret",
"GcpServiceAccountSecret",
"HashAlgorithm",
"HashSecret",
"InvalidResolvedSecretError",
"MaterializedCredential",
"ResolvedSecret",
"SecretType",
"UnsupportedSecretTypeError",
"UsernamePasswordSecret",
"credential_error_code_from_http",
"credential_error_guidance",
"credential_error_summary",
"build_single_referenced_credential_element",
"ensure_compatible",
"get_credential_attachment",
"materialize",
"parse_resolved_secret",
"resolve_credential_attachment",
"resolve_inject_credential",
]
134 changes: 134 additions & 0 deletions pyoaev/credential/attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Read the credential attachment of an inject job and resolve it.

The platform adds an ``attachments`` object to the job message when the inject
carries a credential reference::

{"credential_references": ["<reference id>"], "authorisation_code": "<code>"}

``attachments`` is ``null`` when the inject has no reference: the injector then
keeps its legacy credential fields and no resolution request is sent.

``resolve_inject_credential`` is the single entry point injectors call. An
injector that reads the attachment while validating its input and resolves it
later calls ``get_credential_attachment`` then ``resolve_credential_attachment``.
"""

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, Optional, Union

from pyoaev.credential.errors import CredentialErrorCode, CredentialResolutionError
from pyoaev.credential.resolved import (
ResolvedSecret,
ensure_compatible,
parse_resolved_secret,
)
from pyoaev.credential.types import CredentialType

if TYPE_CHECKING:
from pyoaev.client import OpenAEV


@dataclass(frozen=True)
class CredentialAttachment:
reference: str
# Only valid for the current job, but still a bearer token: keep it out of logs.
authorisation_code: str = field(repr=False)


def _is_filled_string(value: Any) -> bool:
return isinstance(value, str) and value.strip() != ""


def get_credential_attachment(data: Dict[str, Any]) -> Optional[CredentialAttachment]:
"""Return the credential attachment of a job message, if any.

Only the first credential reference is used.

Returns:
``None`` when ``attachments`` is missing, ``null`` or has no reference,
meaning the injector must use its legacy credential fields.

Raises:
CredentialResolutionError: ``CREDENTIAL_ACCESS_DENIED`` when a reference
is present without an authorisation code, or when the attachment
is malformed.
"""
attachments = data.get("attachments")
if attachments is None:
return None
if not isinstance(attachments, dict):
raise CredentialResolutionError(CredentialErrorCode.CREDENTIAL_ACCESS_DENIED)

references = attachments.get("credential_references")
if not references:
return None
Comment on lines +63 to +64
if not isinstance(references, list) or not _is_filled_string(references[0]):
raise CredentialResolutionError(CredentialErrorCode.CREDENTIAL_ACCESS_DENIED)
reference = references[0]

authorisation_code = attachments.get("authorisation_code")
if not _is_filled_string(authorisation_code):
raise CredentialResolutionError(
CredentialErrorCode.CREDENTIAL_ACCESS_DENIED, reference
)
return CredentialAttachment(reference, authorisation_code)


def resolve_inject_credential(
api: "OpenAEV",
inject_id: str,
data: Dict[str, Any],
expected_type: Union[CredentialType, str],
) -> Optional[ResolvedSecret]:
"""Resolve the credential attached to an inject job, just in time.

Must be called after ``execution_reception`` and before
``execution_callback``, while the inject is in progress.

Args:
api: the OpenAEV client of the injector.
inject_id: the id of the inject being executed.
data: the job message received by the injector.
expected_type: the credential type declared on the contract field,
or the provider name it was declared from (see ``ensure_compatible``).

Returns:
``None`` when the job carries no credential reference (legacy path),
otherwise the resolved secret, checked against ``expected_type``.

Raises:
CredentialResolutionError: with the code to report in the trace.
"""
attachment = get_credential_attachment(data)
if attachment is None:
return None
return resolve_credential_attachment(api, inject_id, attachment, expected_type)


def resolve_credential_attachment(
api: "OpenAEV",
inject_id: str,
attachment: CredentialAttachment,
expected_type: Union[CredentialType, str],
) -> ResolvedSecret:
"""Resolve an attachment already read with ``get_credential_attachment``.

Same contract as ``resolve_inject_credential``, for injectors that read
the attachment and resolve it at two different stages.

Raises:
CredentialResolutionError: with the code to report in the trace.
"""
payload = api.inject.resolve_attachment_secret(
inject_id, attachment.reference, attachment.authorisation_code
)
resolved = parse_resolved_secret(payload, reference=attachment.reference)
return ensure_compatible(resolved, expected_type, reference=attachment.reference)


__all__ = [
"CredentialAttachment",
"get_credential_attachment",
"resolve_credential_attachment",
"resolve_inject_credential",
]
Loading
Loading