Skip to content
Merged
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
6 changes: 4 additions & 2 deletions .gitallowed
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@

token: \$\{\{ secrets.GITHUB_TOKEN \}\}
"token": response.get\("SessionToken"\)
"token": response\["SessionToken"\]
token=credentials\["token"\]

:123456789012:
\"123456789012\"
pytokens
.*(GITHUB|SONAR)_TOKEN: \$\{\{ secrets.(GITHUB|SONAR)_TOKEN \}\}
.*asttokens = ">=2.1.0"
2 changes: 1 addition & 1 deletion .github/workflows/merge-develop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:

- name: setup java
if: github.actor != 'dependabot[bot]' && (success() || failure())
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: "corretto"
java-version: "17"
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
tox:
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]

runs-on: ubuntu-latest
if: github.repository == 'NHSDigital/nhs-aws-helpers'
Expand Down Expand Up @@ -145,7 +145,7 @@ jobs:

- name: setup java
if: success() || failure()
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: "corretto"
java-version: "17"
Expand Down
41 changes: 31 additions & 10 deletions nhs_aws_helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,12 @@
from mypy_boto3_dynamodb.client import DynamoDBClient
from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table
from mypy_boto3_dynamodb.type_defs import KeysAndAttributesTypeDef
from mypy_boto3_ecs.client import ECSClient
from mypy_boto3_events import EventBridgeClient
from mypy_boto3_firehose import FirehoseClient
from mypy_boto3_glue import GlueClient
from mypy_boto3_healthlake.client import HealthLakeClient
from mypy_boto3_iam import IAMClient
from mypy_boto3_kms.client import KMSClient
from mypy_boto3_lambda.client import LambdaClient
from mypy_boto3_logs.client import CloudWatchLogsClient
Expand All @@ -72,6 +75,8 @@
from mypy_boto3_sqs.client import SQSClient
from mypy_boto3_ssm.client import SSMClient
from mypy_boto3_stepfunctions import SFNClient
from mypy_boto3_sts.client import STSClient
from mypy_boto3_sts.type_defs import AssumeRoleRequestTypeDef

from nhs_aws_helpers.common import run_in_executor
from nhs_aws_helpers.s3_object_writer import S3ObjectWriter
Expand Down Expand Up @@ -191,6 +196,10 @@ def backup_client(session: Optional[Session] = None, config: Optional[Config] =
return _aws("backup", "client", session, config) # type: ignore[no-any-return]


def iam_client(session: Optional[Session] = None, config: Optional[Config] = None) -> IAMClient:
return _aws("iam", "client", session, config) # type: ignore[no-any-return]


def register_retry_handler(
client_or_resource: Union[S3ServiceResource, S3Client],
on_error: Optional[Callable] = None,
Expand Down Expand Up @@ -349,6 +358,18 @@ def events_client(session: Optional[Session] = None, config: Optional[Config] =
return _aws("events", "client", session, config) # type: ignore[no-any-return]


def healthlake_client(session: Optional[Session] = None, config: Optional[Config] = None) -> HealthLakeClient:
return _aws("healthlake", "client", session, config) # type: ignore[no-any-return]


def sts_client(session: Optional[Session] = None, config: Optional[Config] = None) -> STSClient:
return _aws("sts", "client", session, config) # type: ignore[no-any-return]


def ecs_client(session: Optional[Session] = None, config: Optional[Config] = None) -> ECSClient:
return _aws("ecs", "client", session, config) # type: ignore[no-any-return]


def s3_bucket(bucket: str, session: Optional[Session] = None, config: Optional[Config] = None) -> Bucket:
return s3_resource(session=session, config=config).Bucket(bucket)

Expand Down Expand Up @@ -1041,19 +1062,19 @@ def assumed_credentials(

sts_client = boto3.client("sts", region_name=region, endpoint_url=endpoint_url)

params = {
"RoleArn": f"arn:aws:iam::{account_id}:role/{role}",
"RoleSessionName": role_session_name,
"DurationSeconds": duration_seconds,
}
params = AssumeRoleRequestTypeDef(
RoleArn=f"arn:aws:iam::{account_id}:role/{role}",
RoleSessionName=role_session_name,
DurationSeconds=duration_seconds,
)

response = sts_client.assume_role(**params).get("Credentials")
response = sts_client.assume_role(**params)["Credentials"]

credentials = {
"access_key": response.get("AccessKeyId"),
"secret_key": response.get("SecretAccessKey"),
"token": response.get("SessionToken"),
"expiry_time": response.get("Expiration").isoformat(),
"access_key": response["AccessKeyId"],
"secret_key": response["SecretAccessKey"],
"token": response["SessionToken"],
"expiry_time": response["Expiration"].isoformat(),
}
return credentials

Expand Down
599 changes: 338 additions & 261 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ repository = "https://github.com/NHSDigital/nhs-aws-helpers"
# core dependencies
python = ">=3.9,<4.0"
boto3 = "^1.38.14"
boto3-stubs = {extras = ["s3", "ssm", "secretsmanager", "dynamodb", "stepfunctions", "sqs", "lambda", "logs", "ses", "sns", "events", "kms", "firehose", "athena", "glue", "ce", "cloudwatch", "backup"], version = "^1.38.6"}
boto3-stubs = {extras = ["s3", "ssm", "secretsmanager", "dynamodb", "stepfunctions", "sqs", "lambda", "logs", "ses", "sns", "events", "kms", "firehose", "athena", "glue", "ce", "cloudwatch", "backup", "healthlake", "sts", "ecs", "iam"], version = "^1.38.6"}
botocore-stubs = "^1.38.46"


Expand Down
2 changes: 1 addition & 1 deletion scripts/hooks/commit-msg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ message_file="${1}"

commit_message="$(tr '[:upper:]' '[:lower:]' < "${message_file}")"

if [[ "${commit_message}" =~ ^(((mesh|spinecore)?\-[0-9]+\:?\ )|(merge\ branch)).* ]]; then
if [[ "${commit_message}" =~ ^(((mesh|spinecore|pdm)?\-[0-9]+\:?\ )|(merge\ branch)).* ]]; then
exit 0
else
echo ""
Expand Down
44 changes: 39 additions & 5 deletions tests/aws_tests.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import logging
import os
from typing import Any, cast
from typing import Any, Literal, TypedDict, cast
from uuid import uuid4

import pytest
Expand All @@ -10,7 +11,9 @@
from pytest_httpserver import HTTPServer

from nhs_aws_helpers import (
assumed_credentials,
dynamodb_retry_backoff,
iam_client,
post_create_client,
register_config_default,
register_retry_handler,
Expand Down Expand Up @@ -247,14 +250,20 @@ def test_s3_retries(
key = f"{expected_folder}/filename.txt"
httpserver.expect_request(f"/{temp_s3_bucket.name}/{key}").respond_with_json({}, status=503)

# Type-identical spec to match botocore's internal typing of _RetryDict
class BotocoreRetries(TypedDict, total=False):
total_max_attempts: int
max_attempts: int
mode: Literal["legacy", "standard", "adaptive"]

config = Config(
connect_timeout=float(os.environ.get("BOTO_CONNECT_TIMEOUT", "1")),
read_timeout=float(os.environ.get("BOTO_READ_TIMEOUT", "1")),
max_pool_connections=int(os.environ.get("BOTO_MAX_POOL_CONNECTIONS", "10")),
retries={
"mode": os.environ.get("BOTO_RETRIES_MODE", "standard"), # type: ignore[typeddict-item]
"total_max_attempts": int(os.environ.get("BOTO_RETRIES_TOTAL_MAX_ATTEMPTS", "2")),
},
retries=BotocoreRetries(
mode=os.environ.get("BOTO_RETRIES_MODE", "standard"), # type: ignore[typeddict-item]
total_max_attempts=int(os.environ.get("BOTO_RETRIES_TOTAL_MAX_ATTEMPTS", "2")),
),
)

def _post_create_aws(boto_module: str, _: str, client):
Expand Down Expand Up @@ -359,3 +368,28 @@ def test_s3_upload_multipart_from_copy_missing_part_data(temp_s3_bucket: Bucket)
target_object.get()

assert client_error.value.response["Error"]["Code"] == "NoSuchKey"


def test_assumed_credentials():
iam = iam_client()
user_name = uuid4().hex
role_name = uuid4().hex

iam.create_user(UserName=user_name)

role_arn = f"arn:aws:iam::123456789012:role/{role_name}"

policy_document = {
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "sts:AssumeRole", "Resource": role_arn}],
}

iam.put_user_policy(UserName=user_name, PolicyName="AllowAssumeRole", PolicyDocument=json.dumps(policy_document))

creds = assumed_credentials(
account_id="123456789012", role=role_name, sts_endpoint_url=os.environ["AWS_ENDPOINT_URL"]
)
assert creds["access_key"]
assert creds["secret_key"]
assert creds["token"]
assert creds["expiry_time"]
21 changes: 21 additions & 0 deletions tests/client_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from nhs_aws_helpers import ecs_client, healthlake_client, iam_client, sts_client


def test_sts_client():
client = sts_client()
assert client.meta.service_model.service_id == "STS"


def test_healthlake_client():
client = healthlake_client()
assert client.meta.service_model.service_id == "HealthLake"


def test_ecs_client():
client = ecs_client()
assert client.meta.service_model.service_id == "ECS"


def test_iam_client():
client = iam_client()
assert client.meta.service_model.service_id == "IAM"
6 changes: 5 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@

@pytest.fixture(scope="session", autouse=True)
def autouse():
with temp_env_vars(AWS_ENDPOINT_URL="http://localhost:4566"):
with temp_env_vars(
AWS_ENDPOINT_URL="http://localhost:4566",
AWS_ACCESS_KEY_ID="dummy_access_key",
AWS_SECRET_ACCESS_KEY="dummy_secret_key",
):
yield
Loading