Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 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,40 @@ 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:
error_code = CredentialErrorCode.CREDENTIAL_ACCESS_DENIED
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
53 changes: 52 additions & 1 deletion pyoaev/credential/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,30 @@
from .types import CredentialType
from .attachment import (
CredentialAttachment,
get_credential_attachment,
resolve_inject_credential,
)
from .errors import (
CredentialErrorCode,
CredentialResolutionError,
InvalidResolvedSecretError,
UnsupportedSecretTypeError,
credential_error_code_from_http,
)
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 +36,31 @@ 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",
"build_single_referenced_credential_element",
"ensure_compatible",
"get_credential_attachment",
"materialize",
"parse_resolved_secret",
"resolve_inject_credential",
]
114 changes: 114 additions & 0 deletions pyoaev/credential/attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""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.
"""

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
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
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_inject_credential",
]
152 changes: 152 additions & 0 deletions pyoaev/credential/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Errors raised when an injector resolves a credential reference.

The platform resolution endpoint answers with a closed set of error codes.
``CREDENTIAL_INCOMPATIBLE`` is detected by the injector itself, when the
resolved credential does not satisfy the contract field. Each code carries a fixed, human-readable message meant to be reported as is
in the inject execution trace. Messages only ever mention the credential
reference id, never a secret value, and the raw HTTP request and response
bodies are deliberately never kept on the error.
"""

from enum import Enum
from typing import Optional

from pyoaev.credential.types import CredentialType
from pyoaev.exceptions import OpenAEVError


class CredentialErrorCode(str, Enum):
CREDENTIAL_NOT_FOUND = "CREDENTIAL_NOT_FOUND"
CREDENTIAL_INACTIVE = "CREDENTIAL_INACTIVE"
CREDENTIAL_ACCESS_DENIED = "CREDENTIAL_ACCESS_DENIED"
CREDENTIAL_INCOMPATIBLE = "CREDENTIAL_INCOMPATIBLE"


_PLATFORM_CREDENTIAL_ERROR_CODES = {
CredentialErrorCode.CREDENTIAL_NOT_FOUND,
CredentialErrorCode.CREDENTIAL_INACTIVE,
CredentialErrorCode.CREDENTIAL_ACCESS_DENIED,
}


_CREDENTIAL_ERROR_MESSAGES = {
CredentialErrorCode.CREDENTIAL_NOT_FOUND: (
"The credential configured on this inject ({reference}) no longer exists "
"at the moment of execution. Select an existing credential on the inject, "
"then run it again."
),
CredentialErrorCode.CREDENTIAL_INACTIVE: (
"The credential {reference} is inactive at the moment of execution. "
"Update or replace this credential, then run the inject again."
),
CredentialErrorCode.CREDENTIAL_ACCESS_DENIED: (
"This execution is not entitled to use the credential configured on this "
"inject. Contact your Cloud platform administrator"
),
CredentialErrorCode.CREDENTIAL_INCOMPATIBLE: (
"The credential {reference} is not compatible with this inject. Select a "
"credential of type {type} on the inject, then run it again."
),
}

# Used when the expected credential type is not known where the error is raised
# (for example an unknown secret type returned by the platform).
_CREDENTIAL_INCOMPATIBLE_UNKNOWN_TYPE_MESSAGE = (
"The credential {reference} is not compatible with this inject. Select a "
"credential of the type expected by the inject, then run it again."
)


def credential_error_code_from_http(
response_code: Optional[int], error_message: Optional[str]
) -> CredentialErrorCode:
"""Map a failed resolution call to its credential error code.

The code sent by the platform in the response ``message`` wins. A 404
without a known code (the inject itself does not exist) is reported as
``CREDENTIAL_NOT_FOUND``. Any other failure falls back to
``CREDENTIAL_ACCESS_DENIED`` so that nothing about the credential leaks.
"""
if error_message:
try:
code = CredentialErrorCode(error_message.strip())
except ValueError:
code = None
if code in _PLATFORM_CREDENTIAL_ERROR_CODES:
return code
if response_code == 404:
return CredentialErrorCode.CREDENTIAL_NOT_FOUND
return CredentialErrorCode.CREDENTIAL_ACCESS_DENIED


class CredentialResolutionError(OpenAEVError):
"""A credential reference could not be resolved for the current execution.

``message`` holds the fixed human-readable message of ``code``. The HTTP
response body is never stored, so it cannot surface in ``str()``.
``expected_type`` is the credential type the inject expects, only used by
the ``CREDENTIAL_INCOMPATIBLE`` message.
"""

def __init__(
self,
code: CredentialErrorCode,
reference: Optional[str] = None,
response_code: Optional[int] = None,
expected_type: Optional[CredentialType] = None,
) -> None:
self.code = CredentialErrorCode(code)
self.reference = reference
self.expected_type = (
CredentialType(expected_type) if expected_type is not None else None
)
template = _CREDENTIAL_ERROR_MESSAGES[self.code]
if (
self.code == CredentialErrorCode.CREDENTIAL_INCOMPATIBLE
and self.expected_type is None
):
template = _CREDENTIAL_INCOMPATIBLE_UNKNOWN_TYPE_MESSAGE
message = template.format(
reference=reference or "unknown reference",
type=self.expected_type.value if self.expected_type else None,
)
super().__init__(
error_message=message, response_code=response_code, response_body=None
)

@property
def message(self) -> str:
return self.error_message

def __str__(self) -> str:
return f"{self.code.value}: {self.error_message}"


class UnsupportedSecretTypeError(CredentialResolutionError):
"""The platform resolved a secret type this client does not know."""

def __init__(
self, secret_type: Optional[str] = None, reference: Optional[str] = None
) -> None:
self.secret_type = secret_type
super().__init__(CredentialErrorCode.CREDENTIAL_INCOMPATIBLE, reference)


class InvalidResolvedSecretError(CredentialResolutionError):
"""The resolved secret payload does not match its declared type.

Reported as ``CREDENTIAL_ACCESS_DENIED``, like any unexpected failure, so
that nothing about the payload leaks in the trace.
"""

def __init__(self, reference: Optional[str] = None) -> None:
super().__init__(CredentialErrorCode.CREDENTIAL_ACCESS_DENIED, reference)


__all__ = [
"CredentialErrorCode",
"CredentialResolutionError",
"InvalidResolvedSecretError",
"UnsupportedSecretTypeError",
"credential_error_code_from_http",
]
Loading
Loading