diff --git a/.mypy.ini b/.mypy.ini index a1706696b0..91dc65a0ec 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -78,6 +78,7 @@ modules = azul.service.user_service, scripts.pull_request, scripts.claude_mv, + service.test_user_controller, packages = diff --git a/bin/wheels/runtime/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl b/bin/wheels/runtime/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl deleted file mode 100644 index 089d999c85..0000000000 Binary files a/bin/wheels/runtime/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl and /dev/null differ diff --git a/bin/wheels/runtime/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl b/bin/wheels/runtime/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl new file mode 100644 index 0000000000..2a3beee800 Binary files /dev/null and b/bin/wheels/runtime/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl differ diff --git a/bin/wheels/runtime/pyjwt-2.12.1-py3-none-any.whl b/bin/wheels/runtime/pyjwt-2.12.1-py3-none-any.whl new file mode 100644 index 0000000000..63f17ddf7b Binary files /dev/null and b/bin/wheels/runtime/pyjwt-2.12.1-py3-none-any.whl differ diff --git a/bin/wheels/runtime/pyopenssl-26.1.0-py3-none-any.whl b/bin/wheels/runtime/pyopenssl-26.1.0-py3-none-any.whl deleted file mode 100644 index c3845cf6b6..0000000000 Binary files a/bin/wheels/runtime/pyopenssl-26.1.0-py3-none-any.whl and /dev/null differ diff --git a/bin/wheels/runtime/pyopenssl-26.2.0-py3-none-any.whl b/bin/wheels/runtime/pyopenssl-26.2.0-py3-none-any.whl new file mode 100644 index 0000000000..12c8cb7593 Binary files /dev/null and b/bin/wheels/runtime/pyopenssl-26.2.0-py3-none-any.whl differ diff --git a/deployments/prod/environment.py b/deployments/prod/environment.py index 7881aace05..36a625f1df 100644 --- a/deployments/prod/environment.py +++ b/deployments/prod/environment.py @@ -419,7 +419,7 @@ def union(previous_catalog: dict[DatasetName, SourceItem | None], source('bigquery', 'datarepo-bc83ab27', 'hca_prod_a2a2f324cf24409ea859deaee871269c__20220330_dcp2_20220607_dcp17'), source('bigquery', 'datarepo-10a33a05', 'hca_prod_a62dae2ecd694d5cb5f84f7e8abdbafa__20220606_dcp2_20220607_dcp17'), source('bigquery', 'datarepo-e3d0317e', 'hca_prod_a9f5323ace71471c9caf04cc118fd1d7__20220606_dcp2_20220607_dcp17'), - source('bigquery', 'datarepo-cd2ab73f', 'hca_prod_ad04c8e79b7d4cceb8e901e31da10b94__20220118_dcp2_20220607_dcp17'), + source('bigquery', 'datarepo-cd2ab73f', 'hca_prod_ad04c8e79b7d4cceb8e901e31da10b94__20220118_dcp2_20220607_dcp17', no_ma_mirror), # noqa: E501 source('bigquery', 'datarepo-dcd2f9cf', 'hca_prod_aff9c3cd6b844fc2abf2b9c0b3038277__20220330_dcp2_20220607_dcp17'), source('bigquery', 'datarepo-c9b6cc1c', 'hca_prod_b9484e4edc404e389b854cecf5b8c068__20220118_dcp2_20220607_dcp17'), source('bigquery', 'datarepo-49083689', 'hca_prod_bd7104c9a950490e94727d41c6b11c62__20220118_dcp2_20220607_dcp17'), @@ -700,7 +700,7 @@ def union(previous_catalog: dict[DatasetName, SourceItem | None], source('bigquery', 'datarepo-d962a1b4', 'hca_prod_a991ef154d4a4b80a93ec538b4b54127__20220118_dcp2_20230314_dcp25'), source('bigquery', 'datarepo-60416b5f', 'hca_prod_a9f5323ace71471c9caf04cc118fd1d7__20220606_dcp2_20230314_dcp25'), source('bigquery', 'datarepo-f2f57a7c', 'hca_prod_ac289b77fb124a6bad43c0721c698e70__20220906_dcp2_20230314_dcp25'), - source('bigquery', 'datarepo-bbe8303d', 'hca_prod_ad04c8e79b7d4cceb8e901e31da10b94__20220118_dcp2_20230314_dcp25'), + source('bigquery', 'datarepo-bbe8303d', 'hca_prod_ad04c8e79b7d4cceb8e901e31da10b94__20220118_dcp2_20230314_dcp25', no_ma_mirror), # noqa: E501 source('bigquery', 'datarepo-d67d5486', 'hca_prod_ae62bb3155ca4127b0fbb1771a604645__20230313_dcp2_20230314_dcp25'), source('bigquery', 'datarepo-2a19065b', 'hca_prod_aefb919243fc46d7a4c129597f7ef61b__20220330_dcp2_20230314_dcp25'), source('bigquery', 'datarepo-ed809ac3', 'hca_prod_b7259878436c4274bfffca76f4cb7892__20220118_dcp2_20230314_dcp25'), @@ -1077,6 +1077,7 @@ def union(previous_catalog: dict[DatasetName, SourceItem | None], ])) dcp40_sources = union(dcp39_sources, 458, delta([ + # @formatter:off source('bigquery', 'datarepo-7ff6ae27', 'hca_prod_005d611a14d54fbf846e571a1f874f70__20220111_dcp2_20240711_dcp40'), source('bigquery', 'datarepo-083a593d', 'hca_prod_027c51c60719469fa7f5640fe57cbece__20220110_dcp2_20240711_dcp40'), source('bigquery', 'datarepo-6e878a15', 'hca_prod_065e6c13ad6b46a38075c3137eb03068__20220213_dcp2_20240711_dcp40'), @@ -1099,7 +1100,7 @@ def union(previous_catalog: dict[DatasetName, SourceItem | None], source('bigquery', 'datarepo-1a5200cb', 'hca_prod_86fd2521c5014e41841c06d79277bb7c__20240708_dcp2_20240711_dcp40'), source('bigquery', 'datarepo-436c5a47', 'hca_prod_99101928d9b14aafb759e97958ac7403__20220118_dcp2_20240711_dcp40'), source('bigquery', 'datarepo-e10ecf5f', 'hca_prod_a83b7f45bfb14c6a97e98e3370065cc1__20240708_dcp2_20240711_dcp40'), - source('bigquery', 'datarepo-028b06ac', 'hca_prod_ad04c8e79b7d4cceb8e901e31da10b94__20220118_dcp2_20240711_dcp40'), + source('bigquery', 'datarepo-028b06ac', 'hca_prod_ad04c8e79b7d4cceb8e901e31da10b94__20220118_dcp2_20240711_dcp40', no_ma_mirror), # noqa: E501 source('bigquery', 'datarepo-7c60076d', 'hca_prod_ae71be1dddd84feb9bed24c3ddb6e1ad__20220118_dcp2_20240711_dcp40'), source('bigquery', 'datarepo-27cbfba4', 'hca_prod_b963bd4b4bc14404842569d74bc636b8__20220118_dcp2_20240711_dcp40'), source('bigquery', 'datarepo-7345f02d', 'hca_prod_c16a754f5da346ed8c1e6426af2ef625__20220519_dcp2_20240711_dcp40'), @@ -1111,6 +1112,7 @@ def union(previous_catalog: dict[DatasetName, SourceItem | None], source('bigquery', 'datarepo-812cbdeb', 'hca_prod_cddab57b68684be4806f395ed9dd635a__20220118_dcp2_20240711_dcp40'), source('bigquery', 'datarepo-d8cb1e24', 'hca_prod_d3446f0c30f34a12b7c36af877c7bb2d__20220119_dcp2_20240711_dcp40'), source('bigquery', 'datarepo-bde87024', 'hca_prod_dc0b65b0771346f0a3390b03ea786046__20230427_dcp2_20240711_dcp40') + # @formatter:on ])) dcp41_sources = union(dcp40_sources, 462, delta([ @@ -1251,7 +1253,7 @@ def union(previous_catalog: dict[DatasetName, SourceItem | None], source('bigquery', 'datarepo-47e132f5', 'hca_prod_9bef1e81e5d94ece81cbab7449232021__20241104_dcp2_20241107_dcp44'), source('bigquery', 'datarepo-8e2c06ed', 'hca_prod_9dd91b6e7c6249d3a3d474f603deffdb__20240903_dcp2_20241107_dcp44'), source('bigquery', 'datarepo-b45fddfb', 'hca_prod_a4f154f85cc940b5b8d7af90afce8a8f__20230526_dcp2_20241107_dcp44'), - source('bigquery', 'datarepo-18b2a11c', 'hca_prod_ad04c8e79b7d4cceb8e901e31da10b94__20220118_dcp2_20241107_dcp44'), + source('bigquery', 'datarepo-18b2a11c', 'hca_prod_ad04c8e79b7d4cceb8e901e31da10b94__20220118_dcp2_20241107_dcp44', no_ma_mirror), # noqa: E501 source('bigquery', 'datarepo-950c161b', 'hca_prod_ae62bb3155ca4127b0fbb1771a604645__20230313_dcp2_20241107_dcp44'), source('bigquery', 'datarepo-ca6f869a', 'hca_prod_ae71be1dddd84feb9bed24c3ddb6e1ad__20220118_dcp2_20241107_dcp44'), source('bigquery', 'datarepo-168f955e', 'hca_prod_aebc99a33151482a9709da6802617763__20240201_dcp2_20241107_dcp44'), @@ -1784,7 +1786,7 @@ def union(previous_catalog: dict[DatasetName, SourceItem | None], dcp58_sources = union(dcp57_sources, 530, delta([ # @formatter:off source('bigquery', 'datarepo-74756a12', 'hca_prod_984ce0a2682d47a3b80e1354dfe51ca3__20260304_dcp2_20260304_dcp58'), - source('bigquery', 'datarepo-90320986', 'hca_prod_ad04c8e79b7d4cceb8e901e31da10b94__20220118_dcp2_20260304_dcp58'), + source('bigquery', 'datarepo-90320986', 'hca_prod_ad04c8e79b7d4cceb8e901e31da10b94__20220118_dcp2_20260304_dcp58', no_ma_mirror), # noqa: E501 source('bigquery', 'datarepo-04e4ece0', 'hca_prod_c8503de8d02d4bdaad064c851b37fa97__20260304_dcp2_20260304_dcp58', no_ma_mirror), # noqa: E501 # @formatter:on ])) diff --git a/environment b/environment index 7ba01e92a2..9d5cf21d29 100644 --- a/environment +++ b/environment @@ -386,36 +386,26 @@ _hibernate() { # Launch Claude Code in a loop, reloading the environment between invocations. # Each invocation will pick up where the previous one left off. # -# Usage: _claude [session_id] [claude_args...] +# Usage: _claude [session_id] # # To exit the loop, exit Claude and then hit any key within 3 seconds. # _claude() { - local session_id resume + local session_id flag if [ -n "$1" ] ; then session_id="$1" - shift - resume=true + flag=--resume else session_id="$(python -c 'import uuid; print(uuid.uuid4())')" - resume=false + flag=--session-id fi - while true ; do - local flag - if $resume ; then - flag=--resume - else - flag=--session-id - resume=true - fi - if ! claude "$flag" "$session_id" "$@" ; then - return 1 - fi + while claude "$flag" "$session_id" ; do echo >&2 'Reloading environment in 3 seconds. Press any key to exit …' if read -r -t 3 -n 1 ; then break fi _refresh + flag=--resume done } diff --git a/lambdas/service/openapi.json b/lambdas/service/openapi.json index 6fa5d8d30e..d7c17044db 100644 --- a/lambdas/service/openapi.json +++ b/lambdas/service/openapi.json @@ -13948,7 +13948,7 @@ } }, "summary": "Obtain an OAuth 2.0 access token in exchange for an authorization code", - "description": "\nInvoke this endpoint as part of the authorization code flow\nfrom a single-page app.\n\nNote that while this endpoint is part of the authorization\ncode flow, which typically yields a refresh token along with\nthe access token, this endpoint returns only the access\ntoken. Doing so is an aspect of the commonly adopted\nsecurity best practice known as [Backend For Frontend](\nhttps://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#section-6.1).\n\n**When initiating the authorization code flow, be\nsure to request the `openid` scope.**\n", + "description": "\nInvoke this endpoint as part of the authorization code flow\nfrom a single-page app.\n\nNote that while this endpoint is part of the authorization\ncode flow, which typically yields a refresh token along with\nthe access token, this endpoint returns only the access\ntoken. Doing so is an aspect of the commonly adopted\nsecurity best practice known as [Backend For Frontend](\nhttps://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#section-6.1).\n\n**When initiating the authorization code flow, be\nsure to request the `email` and `openid` scopes.**\n", "requestBody": { "description": "\nJSON conforming to the Google Sign-In [CodeResponse](\nhttps://developers.google.com/identity/oauth2/web/reference/js-reference#CodeResponse),\nexcept for the error-related parts. Those should be\nhandled client-side.\n", "required": true, @@ -13963,7 +13963,7 @@ }, "scope": { "type": "string", - "description": "\nA space-delimited list of scopes that are\napproved by the user. Must contain `openid`.\n" + "description": "\nA space-delimited list of scopes that are\napproved by the user. Must contain\n`email` and `openid`.\n" }, "state": { "type": "string", diff --git a/requirements.all.txt b/requirements.all.txt index cc20bb0ec0..8fa062bd56 100644 --- a/requirements.all.txt +++ b/requirements.all.txt @@ -1,7 +1,7 @@ atomicwrites==1.4.1 attrs==26.1.0 aws-requests-auth==0.4.3 -blessed==1.38.0 +blessed==1.39.0 boto3==1.42.97 boto3-stubs-lite==1.42.97 botocore==1.42.97 @@ -13,7 +13,7 @@ charset-normalizer==3.4.7 chevron==0.14.0 click==8.3.3 coverage==7.13.5 -cryptography==47.0.0 +cryptography==48.0.0 docker==7.1.0 editor==1.7.0 et_xmlfile==2.0.0 @@ -52,7 +52,7 @@ jsonschema==4.26.0 jsonschema-path==0.3.4 jsonschema-specifications==2025.9.1 lazy-object-proxy==1.12.0 -librt==0.9.0 +librt==0.10.0 markupsafe==3.0.3 mccabe==0.7.0 more-itertools==11.0.2 @@ -99,7 +99,7 @@ pyflakes==3.4.0 pygithub==2.9.1 pyjwt==2.12.1 pynacl==1.6.2 -pyopenssl==26.1.0 +pyopenssl==26.2.0 pyparsing==3.3.2 python-dateutil==2.9.0.post0 python-dxf==12.1.1 @@ -129,7 +129,7 @@ typing_extensions==4.15.0 uritemplate==4.2.0 urllib3==2.6.3 watchdog==6.0.0 -wcwidth==0.6.0 +wcwidth==0.7.0 werkzeug==3.1.8 wheel==0.46.3 www-authenticate==0.9.2 diff --git a/requirements.dev.trans.txt b/requirements.dev.trans.txt index eed23ffab2..6ad7e933f2 100644 --- a/requirements.dev.trans.txt +++ b/requirements.dev.trans.txt @@ -1,4 +1,4 @@ -blessed==1.38.0 +blessed==1.39.0 botocore-stubs==1.42.41 click==8.3.3 editor==1.7.0 @@ -12,7 +12,7 @@ inquirer==3.4.1 jinja2==3.1.6 jsonschema-path==0.3.4 lazy-object-proxy==1.12.0 -librt==0.9.0 +librt==0.10.0 mccabe==0.7.0 mypy-boto3-apigateway==1.42.68 mypy-boto3-cloudwatch==1.42.95 @@ -38,7 +38,6 @@ pathspec==1.1.1 py-partiql-parser==0.6.3 pycodestyle==2.14.0 pyflakes==3.4.0 -pyjwt==2.12.1 pynacl==1.6.2 pyparsing==3.3.2 readchar==4.2.2 @@ -50,7 +49,7 @@ tqdm==4.67.3 types-awscrt==0.31.3 types-s3transfer==0.16.0 uritemplate==4.2.0 -wcwidth==0.6.0 +wcwidth==0.7.0 www-authenticate==0.9.2 xmltodict==1.0.4 xmod==1.9.0 diff --git a/requirements.trans.txt b/requirements.trans.txt index 7eb737f979..9e5979b273 100644 --- a/requirements.trans.txt +++ b/requirements.trans.txt @@ -1,7 +1,7 @@ certifi==2026.4.22 cffi==2.0.0 charset-normalizer==3.4.7 -cryptography==47.0.0 +cryptography==48.0.0 events==0.5 google-cloud-core==2.5.1 google-crc32c==1.8.0 @@ -19,7 +19,7 @@ protobuf==6.33.6 pyasn1==0.6.3 pyasn1_modules==0.4.2 pycparser==3.0 -pyopenssl==26.1.0 +pyopenssl==26.2.0 python-dateutil==2.9.0.post0 rpds-py==0.30.0 s3transfer==0.16.1 diff --git a/requirements.txt b/requirements.txt index 0b44fcf8c7..261fc454b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ jsonschema==4.26.0 more-itertools==11.0.2 msgpack==1.1.2 # versioned independently from the type stub dependency but when updating one, the other should be updated, too, if posssible opensearch-py==2.8.0 # < 3 to match server +pyjwt==2.12.1 referencing==0.36.2 # < 0.37.0, see https://github.com/DataBiosphere/azul/issues/7832 requests==2.33.1 resumablehash==1.5 # match version with --find-links above diff --git a/src/azul/__init__.py b/src/azul/__init__.py index 2ab9f231c4..7f7b13e5fb 100644 --- a/src/azul/__init__.py +++ b/src/azul/__init__.py @@ -1605,14 +1605,14 @@ def gitlab_data_volume_id(self) -> str | None: def lambda_layer_key(self) -> str: return 'lambda_layers' - @property - def dynamo_object_version_table_name(self) -> str: - return self.qualified_resource_name('object_versions') - @property def dynamo_sources_cache_table_name(self) -> str: return self.qualified_resource_name('sources_cache_by_auth') + @property + def dynamo_users_table_name(self) -> str: + return self.qualified_resource_name('users') + @property def current_sources(self) -> list[str] | None: try: diff --git a/src/azul/service/lambda_iam_policy.py b/src/azul/service/lambda_iam_policy.py index ac0f88fd58..5f883d0f7d 100644 --- a/src/azul/service/lambda_iam_policy.py +++ b/src/azul/service/lambda_iam_policy.py @@ -154,8 +154,8 @@ 'Resource': [ f'arn:aws:dynamodb:{aws.region_name}:{aws.account}:table/{table_name}' for table_name in ( - config.dynamo_object_version_table_name, - config.dynamo_sources_cache_table_name + config.dynamo_sources_cache_table_name, + config.dynamo_users_table_name ) ] }, diff --git a/src/azul/service/user_controller.py b/src/azul/service/user_controller.py index cc2178100d..e8d3c618ef 100644 --- a/src/azul/service/user_controller.py +++ b/src/azul/service/user_controller.py @@ -18,7 +18,9 @@ cached_property, ) from azul.lib.strings import ( + back_quote, format_and_dedent as fd, + join_grammatically, ) from azul.lib.types import ( JSON, @@ -70,8 +72,8 @@ def handlers(self) -> dict[str, Any]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#section-6.1). **When initiating the authorization code flow, be - sure to request the `openid` scope.** - '''), + sure to request the {required_scopes} scopes.** + ''', required_scopes=self._required_scopes), 'requestBody': { 'description': fd(''' JSON conforming to the Google Sign-In [CodeResponse]( @@ -88,8 +90,9 @@ def handlers(self) -> dict[str, Any]: ''')), scope=describe(str, fd(''' A space-delimited list of scopes that are - approved by the user. Must contain `openid`. - ''')), + approved by the user. Must contain + {required_scopes}. + ''', required_scopes=self._required_scopes)), state=optional(describe(str, fd(''' The string value that your application uses to maintain state between your authorization @@ -133,6 +136,12 @@ def authorize(): return locals() + @cached_property + def _required_scopes(self) -> str: + scopes = sorted(self._service.required_scopes) + scopes = list(map(back_quote, scopes)) + return join_grammatically(scopes) + def _authorize(self) -> JSON: try: request: JSON = json_mapping(self.current_request.json_body) diff --git a/src/azul/service/user_service.py b/src/azul/service/user_service.py index 6019979320..05f76bed05 100644 --- a/src/azul/service/user_service.py +++ b/src/azul/service/user_service.py @@ -1,3 +1,13 @@ +import logging +from time import ( + time, +) +from typing import ( + TypedDict, +) + +import jwt + from azul import ( config, ) @@ -8,6 +18,9 @@ R, cached_property, ) +from azul.lib.strings import ( + format_and_dedent as fd, +) from azul.lib.types import ( not_none, ) @@ -17,8 +30,31 @@ TokenForCodeResponse, ) +log = logging.getLogger(__name__) + + +class User(TypedDict): + #: The OAuth 2.0 access token issued by the authorization server + access_token: str + #: The OAuth 2.0 refresh token used to obtain new access tokens + refresh_token: str + #: The user's email address from the ID token + email: str + #: Whether the email address has been verified by the identity provider + email_verified: bool + #: The Unix timestamp after which the refresh token expires + expiration: int + class UserService: + required_scopes = {'openid', 'email'} + + key_attribute = 'identity' + ttl_attribute = 'expiration' + + _table_name = config.dynamo_users_table_name + _key_separator = '#' + _default_expiration = 180 * 24 * 60 * 60 @cached_property def _oauth_client(self) -> OAuth2Client: @@ -34,15 +70,74 @@ def _client_secret(self) -> str: def _client_id(self) -> str: return not_none(config.google_oauth2_client_id) + @property + def _dynamodb(self): + return aws.dynamodb + def authorize(self, authorization: Authorization) -> TokenForCodeResponse: - assert 'openid' in authorization['scope'].split(), R( - 'The authorization server did not return an OpenID Connect ID ' - 'token in the response. Be sure to include the "openid" scope ' - 'when requesting the authorization code.') + scopes = set(authorization['scope'].split()) + assert self.required_scopes.issubset(scopes), R( + 'Be sure to include the required scopes when requesting the ' + 'authorization code:', self.required_scopes) response = self._oauth_client.token_for_code( authorization_code=authorization['code'], client_id=self._client_id, client_secret=self._client_secret ) assert 'id_token' in response, response + self._store_tokens(response) return response + + def get_user(self, iss: str, sub: str) -> User | None: + key = self._key_separator.join([iss, sub]) + response = self._dynamodb.get_item( + TableName=self._table_name, + Key={self.key_attribute: {'S': key}} + ) + item = response.get('Item') + if item is None: + return None + else: + return User( + access_token=item['access_token']['S'], + refresh_token=item['refresh_token']['S'], + email=item['email']['S'], + email_verified=item['email_verified']['BOOL'], + expiration=int(item[self.ttl_attribute]['N']) + ) + + def _store_tokens(self, response: TokenForCodeResponse) -> None: + # Signature verification is unnecessary per OIDC 3.1.3.7 since the + # token was received directly from the token endpoint over TLS. + id_claims = jwt.decode(response['id_token'], + options={'verify_signature': False}) + iss, sub = id_claims['iss'], id_claims['sub'] + assert self._key_separator not in iss, R( + 'Unexpected separator in issuer', iss) + key = self._key_separator.join([iss, sub]) + expiration = response.get('refresh_token_expires_in', + self._default_expiration) + email = id_claims['email'] + email_verified = id_claims['email_verified'] + self._dynamodb.update_item( + TableName=self._table_name, + Key={self.key_attribute: {'S': key}}, + UpdateExpression=fd(''' + SET access_token = :access_token, + refresh_token = :refresh_token, + email = :email, + email_verified = :email_verified, + #expiration = :expiration + '''), + ExpressionAttributeNames={'#expiration': self.ttl_attribute}, + ExpressionAttributeValues={ + ':access_token': {'S': response['access_token']}, + ':refresh_token': {'S': response['refresh_token']}, + ':email': {'S': email}, + ':email_verified': {'BOOL': email_verified}, + ':expiration': {'N': str(self._now() + expiration)}, + } + ) + + def _now(self) -> int: + return int(time()) diff --git a/terraform/dynamo.tf.json.template.py b/terraform/dynamo.tf.json.template.py index 109aa05632..226b9687d2 100644 --- a/terraform/dynamo.tf.json.template.py +++ b/terraform/dynamo.tf.json.template.py @@ -7,6 +7,9 @@ from azul.service.source_service import ( SourceService, ) +from azul.service.user_service import ( + UserService, +) emit_tf( { @@ -27,6 +30,21 @@ "attribute_name": SourceService.ttl_attribute, "enabled": True } + }, + "users": { + "name": config.dynamo_users_table_name, + "billing_mode": "PAY_PER_REQUEST", + "hash_key": UserService.key_attribute, + "attribute": [ + { + "name": UserService.key_attribute, + "type": "S" + } + ], + "ttl": { + "attribute_name": UserService.ttl_attribute, + "enabled": True + } } } } diff --git a/terraform/shared/shared.tf.json.template.py b/terraform/shared/shared.tf.json.template.py index 4857747b66..c412f812a6 100644 --- a/terraform/shared/shared.tf.json.template.py +++ b/terraform/shared/shared.tf.json.template.py @@ -512,37 +512,6 @@ def conformance_pack(name: str) -> str: }) } }, - **( - { - 'aws_cloudwatch_event_rule': { - 'inspector': { - 'name': 'inspector', - 'event_pattern': json.dumps({ - 'source': ['aws.inspector2'], - 'detail-type': ['Inspector2 Finding'], - 'detail.severity': ['CRITICAL', 'HIGH'], - 'detail.status': ['ACTIVE'] - }) - } - }, - 'aws_cloudwatch_event_target': { - 'inspector_to_sns': { - 'rule': '${aws_cloudwatch_event_rule.inspector.name}', - 'arn': '${aws_sns_topic.monitoring.arn}', - 'input_transformer': { - # AWS EventBridge transforms resemble JSON, but are not valid JSON - # https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-transform-target-input.html - 'input_template': json.dumps({ - 'deployment': config.deployment_stage, - 'event': {} - }).replace('{}', '') - } - } - } - } - if config.slack_integration else - {} - ), 'aws_cloudtrail': { 'trail': { 'name': config.qualified_resource_name('trail'), diff --git a/test/service/test_user_controller.py b/test/service/test_user_controller.py new file mode 100644 index 0000000000..7b2f32aec3 --- /dev/null +++ b/test/service/test_user_controller.py @@ -0,0 +1,191 @@ +from collections.abc import ( + Mapping, +) +import json +from unittest.mock import ( + PropertyMock, + patch, +) + +import jwt +from moto import ( + mock_aws, +) +from mypy_boto3_dynamodb.literals import ( + ScalarAttributeTypeType, +) + +from app_test_case import ( + LocalAppTestCase, +) +from azul.http import ( + HasCachedHttpClient, +) +from azul.lib.types import ( + not_none, +) +from azul.logging import ( + configure_test_logging, + get_test_logger, +) +from azul.oauth2 import ( + OAuth2Client, + TokenForCodeResponse, +) +from azul.service.user_service import ( + User, + UserService, +) +from azul_test_case import ( + DCP2TestCase, +) +from dynamodb_test_case import ( + DynamoDBTestCase, +) + +log = get_test_logger(__name__) + + +# noinspection PyPep8Naming +def setUpModule(): + configure_test_logging(log) + + +@mock_aws +class TestUserController(DCP2TestCase, + LocalAppTestCase, + DynamoDBTestCase, + HasCachedHttpClient): + + @classmethod + def app_name(cls) -> str: + return 'service' + + def _dynamodb_table_name(self) -> str: + return UserService._table_name + + def _dynamodb_attributes(self) -> Mapping[str, ScalarAttributeTypeType]: + return {UserService.key_attribute: 'S'} + + def _dynamodb_hash_key(self) -> str: + return UserService.key_attribute + + _mock_iss = 'https://accounts.google.com' + _mock_sub = '105096702580025601450' + _mock_email = 'user@example.com' + _mock_access_token = 'ya29.mock_access_token' + _mock_refresh_token = '1//mock_refresh_token' + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.addClassPatch(patch.object(UserService, '_client_id', + new_callable=PropertyMock, + return_value='mock_client_id')) + cls.addClassPatch(patch.object(UserService, '_client_secret', + new_callable=PropertyMock, + return_value='mock_client_secret')) + + def _mock_token_response(self, + *, + access_token: str | None = None, + refresh_token: str | None = None, + refresh_token_expires_in: int | None = None + ) -> TokenForCodeResponse: + id_token = { + 'iss': self._mock_iss, + 'sub': self._mock_sub, + 'email': self._mock_email, + 'email_verified': True, + } + id_token = jwt.encode(payload=id_token, key='a' * 32, algorithm='HS256') + response: TokenForCodeResponse = { + 'access_token': access_token or self._mock_access_token, + 'refresh_token': refresh_token or self._mock_refresh_token, + 'expires_in': 3600, + 'scope': 'openid email', + 'token_type': 'Bearer', + 'id_token': id_token, + } + if refresh_token_expires_in is not None: + response['refresh_token_expires_in'] = refresh_token_expires_in + return response + + def _authorize(self, *, scope='openid email'): + client = self._http_client + url = str(self.base_url.set(path='/user/authorize')) + body = json.dumps({ + 'code': 'mock_auth_code', + 'scope': scope + }).encode() + return client.request('POST', url, + body=body, + headers={'Content-Type': 'application/json'}) + + def _get_user(self) -> User: + service = self._app.user_controller._service # type: ignore[attr-defined] + return not_none(service.get_user(self._mock_iss, self._mock_sub)) + + @patch.object(OAuth2Client, 'token_for_code') + def test_authorize(self, mock_token_for_code): + mock_token_for_code.return_value = self._mock_token_response() + response = self._authorize() + self.assertEqual(200, response.status) + mock_token_for_code.assert_called_once_with( + authorization_code='mock_auth_code', + client_id='mock_client_id', + client_secret='mock_client_secret' + ) + body = json.loads(response.data) + self.assertEqual(self._mock_access_token, body['access_token']) + self.assertNotIn('refresh_token', body) + self.assertIn('id_token', body) + user = self._get_user() + self.assertEqual(self._mock_access_token, user['access_token']) + self.assertEqual(self._mock_refresh_token, user['refresh_token']) + self.assertEqual(self._mock_email, user['email']) + self.assertTrue(user['email_verified']) + + @patch.object(OAuth2Client, 'token_for_code') + def test_authorize_with_refresh_token_expiration(self, mock_token_for_code): + mock_token_for_code.return_value = self._mock_token_response( + refresh_token_expires_in=86400 + ) + response = self._authorize() + self.assertEqual(200, response.status) + user = self._get_user() + now = UserService()._now() + self.assertAlmostEqual(86400, user['expiration'] - now, delta=5) + + @patch.object(OAuth2Client, 'token_for_code') + def test_authorize_default_expiration(self, mock_token_for_code): + mock_token_for_code.return_value = self._mock_token_response() + response = self._authorize() + self.assertEqual(200, response.status) + user = self._get_user() + now = UserService()._now() + self.assertAlmostEqual(UserService._default_expiration, + user['expiration'] - now, + delta=5) + + @patch.object(OAuth2Client, 'token_for_code') + def test_authorize_updates_existing_user(self, mock_token_for_code): + mock_token_for_code.return_value = self._mock_token_response() + self._authorize() + new_access_token = 'ya29.new_access_token' + new_refresh_token = '1//new_refresh_token' + mock_token_for_code.return_value = self._mock_token_response( + access_token=new_access_token, + refresh_token=new_refresh_token + ) + response = self._authorize() + self.assertEqual(200, response.status) + user = self._get_user() + self.assertEqual(new_access_token, user['access_token']) + self.assertEqual(new_refresh_token, user['refresh_token']) + + def test_authorize_missing_required_scope(self): + for scope in ('email', 'openid', 'profile'): + with self.subTest(scope=scope): + response = self._authorize(scope=scope) + self.assertEqual(400, response.status)