|
"""Classes to get secrets out of SSM.""" |
|
# Standard Library |
|
import os |
|
import pathlib |
|
import typing |
|
|
|
# Third Party Libraries |
|
import boto3 |
|
import pydantic |
|
from mypy_boto3_ssm.client import SSMClient |
|
from pydantic.env_settings import SettingsError, read_env_file |
|
|
|
|
|
SettingsSourceCallable = typing.Callable[['BaseSettings'], typing.Dict[str, typing.Any]] |
|
|
|
|
|
def _get_ssm_client() -> SSMClient: |
|
"""Create the SSM client""" |
|
return boto3.client("ssm") |
|
|
|
|
|
def _is_ssm_arn(val: str) -> bool: |
|
"""Is the value an SSM ARN?""" |
|
if not hasattr(val, "startswith"): |
|
return False |
|
|
|
arn_parts = val.split(":") |
|
if len(arn_parts) < 6: |
|
return False |
|
|
|
return val.startswith("arn:aws:ssm") and arn_parts[5].startswith( |
|
"parameter" |
|
) |
|
|
|
|
|
class SSMAwareEnvSettingsSource: |
|
"""Environment, with SSM ARNs replaced with their plaintext values |
|
|
|
Based on pydantic's EnvSettingsSource, this class will return the |
|
environment from ``os.environ``, but will replace any instances where |
|
the value is an SSM ARN with the unencrypted value from SSM. |
|
|
|
It assumes it is operating inside a Lambda function or similar and so |
|
does not attempt to configure credentials for AWS in any way.""" |
|
|
|
__slots__ = ("env_file", "env_file_encoding") |
|
|
|
def __init__( |
|
self, |
|
env_file: typing.Union[pathlib.Path, str, None], |
|
env_file_encoding: typing.Optional[str], |
|
): |
|
self.env_file: typing.Union[pathlib.Path, str, None] = env_file |
|
self.env_file_encoding: typing.Optional[str] = env_file_encoding |
|
|
|
def __call__( |
|
self, settings: pydantic.BaseSettings |
|
) -> typing.Dict[str, typing.Any]: |
|
""" |
|
Build environment variables suitable for passing to the Model. |
|
""" |
|
d: typing.Dict[str, typing.Optional[str]] = {} |
|
|
|
if settings.__config__.case_sensitive: |
|
env_vars: typing.Mapping[str, typing.Optional[str]] = os.environ |
|
else: |
|
env_vars = {k.lower(): v for k, v in os.environ.items()} |
|
|
|
if self.env_file is not None: |
|
env_path = pathlib.Path(self.env_file).expanduser() |
|
if env_path.is_file(): |
|
env_vars = { |
|
**read_env_file( |
|
env_path, |
|
encoding=self.env_file_encoding, |
|
case_sensitive=settings.__config__.case_sensitive, |
|
), |
|
**env_vars, |
|
} |
|
|
|
# SSM aware section |
|
env_vars = self._replace_arns_with_ssm_vals(env_vars) |
|
|
|
for field in settings.__fields__.values(): |
|
env_val: typing.Optional[str] = None |
|
for env_name in field.field_info.extra["env_names"]: |
|
env_val = env_vars.get(env_name) |
|
if env_val is not None: |
|
break |
|
|
|
if env_val is None: |
|
continue |
|
|
|
if field.is_complex(): |
|
try: |
|
cfg = settings.__config__ |
|
env_val = cfg.json_loads(env_val) # type: ignore |
|
except ValueError as e: |
|
raise SettingsError( |
|
f'error parsing JSON for "{env_name}"' |
|
) from e |
|
d[field.alias] = env_val |
|
return d |
|
|
|
def _replace_arns_with_ssm_vals( |
|
self, env_vars: typing.Dict[str, typing.Any] |
|
) -> typing.Dict[str, typing.Any]: |
|
"""Replace values with SSM value if the original is an SSM ARN.""" |
|
# get the ARNs we'll need |
|
ssm_arns = {v for v in env_vars.values() if _is_ssm_arn(v)} |
|
|
|
# if no SSM vars we can skip looking them up |
|
if not ssm_arns: |
|
return env_vars |
|
|
|
# lookup the ARNs as a dict |
|
client = _get_ssm_client() |
|
resp = client.get_parameters( |
|
Names=list( |
|
arn.split(":")[-1][len("parameter") :] for arn in ssm_arns |
|
), |
|
WithDecryption=True, |
|
) |
|
|
|
plaintexts = { |
|
param["ARN"]: param["Value"] for param in resp["Parameters"] |
|
} |
|
|
|
return { |
|
k: v if v not in plaintexts else plaintexts[v] |
|
for k, v in env_vars.items() |
|
} |
|
|
|
def __repr__(self) -> str: |
|
return ( |
|
f"SSMAwareEnvSettingsSource(env_file={self.env_file!r}, " |
|
f"env_file_encoding={self.env_file_encoding!r})" |
|
) |
Thanks for this! turns out I'm actually using 'secretsmanager' instead of 'ssm' but close enough to the same thing to make it fit with some tweaks! Esp. the moto testing. I had looked at moto in the past and it never really clicked. Super helpful!