diff --git a/http-api/app.py b/http-api/app.py index 6a7341a..edf8ba0 100644 --- a/http-api/app.py +++ b/http-api/app.py @@ -11,7 +11,7 @@ from aws_lambda_powertools.event_handler.exceptions import ServiceError from aws_lambda_powertools.logging import correlation_paths from aws_lambda_powertools.utilities.typing import LambdaContext -from middlewares import AuthorizerMiddleware +from middlewares import AuthorizerMiddleware, TenantMiddleware from routes import courses, enrollments, lookup, me, orders, users, webhooks DEBUG = os.getenv('LOG_LEVEL') == 'DEBUG' @@ -19,7 +19,7 @@ DEBUG = os.getenv('LOG_LEVEL') == 'DEBUG' tracer = Tracer() logger = Logger(__name__) app = APIGatewayHttpResolver(enable_validation=True, debug=DEBUG) -app.use(middlewares=[AuthorizerMiddleware()]) +app.use(middlewares=[AuthorizerMiddleware(), TenantMiddleware()]) app.include_router(courses.router, prefix='/courses') app.include_router(enrollments.router, prefix='/enrollments') app.include_router(orders.router, prefix='/orders') diff --git a/http-api/auth.py b/http-api/auth.py index 22ab6f7..4a6c79f 100644 --- a/http-api/auth.py +++ b/http-api/auth.py @@ -24,6 +24,7 @@ Example """ from dataclasses import asdict, dataclass +from typing import Any import boto3 from aws_lambda_powertools import Logger, Tracer @@ -53,31 +54,46 @@ collect = DynamoDBCollection(user_layer) @tracer.capture_lambda_handler @logger.inject_lambda_context @event_source(data_class=APIGatewayAuthorizerEventV2) -def lambda_handler(event: APIGatewayAuthorizerEventV2, context: LambdaContext): +def lambda_handler(event: APIGatewayAuthorizerEventV2, context: LambdaContext) -> dict: bearer = _parse_bearer_token(event.headers.get('authorization', '')) if not bearer: return APIGatewayAuthorizerResponseV2(authorize=False).asdict() - kwargs = asdict(_authorizer(bearer)) - return APIGatewayAuthorizerResponseV2(**kwargs).asdict() + attrs = _authorizer(bearer).asdict() + return APIGatewayAuthorizerResponseV2(**attrs).asdict() -class TokenType(str, Enum): - API_KEY = 'API_KEY' - USER_TOKEN = 'USER_TOKEN' +class AuthFlowType(str, Enum): + USER_AUTH = 'USER_AUTH' + API_AUTH = 'API_AUTH' @dataclass class BearerToken: - auth_type: TokenType + auth_flow_type: AuthFlowType token: str @dataclass class Authorizer: authorize: bool = False - context: dict | None = None + context: dict[str, Any] | None = None + auth_flow_type: AuthFlowType = AuthFlowType.USER_AUTH + + def asdict(self) -> dict: + data = asdict(self) + auth_flow_type = data.pop('auth_flow_type') + + # If authorization is enabled, add `auth_flow_type` to the context + if self.authorize: + data['context'].update(auth_flow_type=auth_flow_type) + + return data + + +def _get_apikey(token: str) -> dict[str, dict | str]: + return collect.get_item(KeyPair('apikey', token)) def _authorizer(bearer: BearerToken) -> Authorizer: @@ -95,22 +111,13 @@ def _authorizer(bearer: BearerToken) -> Authorizer: An Authorizer object with the appropriate authorization status and context. """ try: - match bearer.auth_type: - case TokenType.USER_TOKEN: - user = get_user(bearer.token, idp_client=idp_client) - return Authorizer(True, {'user': user}) - case TokenType.API_KEY: - apikey = collect.get_item(KeyPair('apikey', bearer.token)) - return Authorizer( - True, - pick( - ( - 'user', - 'tenant', - ), - apikey, - ), - ) + if bearer.auth_flow_type == AuthFlowType.USER_AUTH: + user = get_user(bearer.token, idp_client) + return Authorizer(True, {'user': user}) + + apikey = _get_apikey(bearer.token) + context = pick(('tenant', 'user'), apikey) + return Authorizer(True, context, AuthFlowType.API_AUTH) except Exception: return Authorizer() @@ -118,14 +125,14 @@ def _authorizer(bearer: BearerToken) -> Authorizer: def _parse_bearer_token(s: str) -> BearerToken | None: """Parses and identifies a bearer token as either an API key or a user token.""" try: - _, bearer_token = s.split(' ') + _, token = s.split(' ') - if bearer_token.startswith(APIKEY_PREFIX): + if token.startswith(APIKEY_PREFIX): return BearerToken( - TokenType.API_KEY, - bearer_token.removeprefix(APIKEY_PREFIX), + AuthFlowType.API_AUTH, + token.removeprefix(APIKEY_PREFIX), ) except ValueError: return None else: - return BearerToken(TokenType.USER_TOKEN, bearer_token) + return BearerToken(AuthFlowType.USER_AUTH, token) diff --git a/http-api/cognito.py b/http-api/cognito.py index 839dfba..b88a7d8 100644 --- a/http-api/cognito.py +++ b/http-api/cognito.py @@ -1,7 +1,7 @@ class UnauthorizedError(Exception): ... -def get_user(access_token: str, *, idp_client) -> dict | None: +def get_user(access_token: str, /, idp_client) -> dict[str, str]: """Gets the user attributes and metadata for a user.""" try: user = idp_client.get_user(AccessToken=access_token) diff --git a/http-api/middlewares.py b/http-api/middlewares.py index 1a0d720..f69782b 100644 --- a/http-api/middlewares.py +++ b/http-api/middlewares.py @@ -12,15 +12,21 @@ from aws_lambda_powertools.shared.functions import ( from layercake.dateutils import now, ttl from layercake.dynamodb import ComposeKey, DynamoDBCollection, KeyPair from layercake.funcs import pick -from pydantic import UUID4, BaseModel, Field +from pydantic import UUID4, BaseModel, EmailStr, Field + +from auth import AuthFlowType LOG_RETENTION_DAYS = 365 * 2 # 2 years -class AuthenticatedUser(BaseModel): - id: str = Field(alias='custom:user_id') +class User(BaseModel): + id: str name: str - email: str + email: EmailStr + + +class CognitoUser(User): + id: str = Field(alias='custom:user_id') email_verified: bool sub: UUID4 @@ -33,12 +39,31 @@ class AuthorizerMiddleware(BaseMiddlewareHandler): ) -> Response: # Gets the Lambda authorizer associated with the current API Gateway event. # You can check the file `auth.py` for more details. - authorizer = app.current_event.request_context.authorizer.get_lambda + context = app.current_event.request_context.authorizer.get_lambda + auth_flow_type = context.get('auth_flow_type') - if 'user' in authorizer: - user = authorizer['user'] - app.append_context(authenticated_user=AuthenticatedUser(**user)) + if not auth_flow_type: + return next_middleware(app) + cls = { + AuthFlowType.USER_AUTH: CognitoUser, + AuthFlowType.API_AUTH: User, + }.get(auth_flow_type) + + if cls: + app.append_context(user=cls(**context['user'])) + + return next_middleware(app) + + +class TenantMiddleware(BaseMiddlewareHandler): + def handler( + self, + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + ) -> Response: + context = app.current_event.request_context.authorizer.get_lambda + auth_flow_type = context.get('auth_flow_type') return next_middleware(app) @@ -78,11 +103,10 @@ class AuditLogMiddleware(BaseMiddlewareHandler): app: APIGatewayHttpResolver, next_middleware: NextMiddleware, ) -> Response: - collect = self.collect + user = app.context.get('user') + req_context = app.current_event.request_context + ip_addr = req_context.http.source_ip response = next_middleware(app) - user = app.context.get('authenticated_user') - request_ctx = app.current_event.request_context - ip_addr = request_ctx.http.source_ip # Successful request if 200 <= response.status_code < 300 and user: @@ -98,7 +122,7 @@ class AuditLogMiddleware(BaseMiddlewareHandler): else None ) - collect.put_item( + self.collect.put_item( key=KeyPair( pk=ComposeKey(user.id, prefix='logs'), sk=now_.isoformat(), diff --git a/http-api/routes/me/__init__.py b/http-api/routes/me/__init__.py index 05ee9cf..856d825 100644 --- a/http-api/routes/me/__init__.py +++ b/http-api/routes/me/__init__.py @@ -8,7 +8,7 @@ from layercake.dynamodb import ( import konviva from boto3clients import dynamodb_client -from middlewares import AuthenticatedUser +from middlewares import User from settings import USER_TABLE router = Router() @@ -21,7 +21,7 @@ LIMIT = 25 @router.get('/', include_in_schema=False) def me(): - user: AuthenticatedUser = router.context['authenticated_user'] + user: User = router.context['user'] acls = collect.get_items( KeyPair(user.id, PrefixKey('acls')), limit=LIMIT, @@ -39,7 +39,7 @@ def me(): @router.get('/konviva', include_in_schema=False) def konviva_(): - user: AuthenticatedUser = router.context['authenticated_user'] + user: User = router.context['user'] token = konviva.token(user.email) return {'redirect_uri': konviva.redirect_uri(token)} diff --git a/http-api/tests/conftest.py b/http-api/tests/conftest.py index 09ece6f..f169c84 100644 --- a/http-api/tests/conftest.py +++ b/http-api/tests/conftest.py @@ -59,6 +59,7 @@ class HttpApiProxy: 'custom:user_id': '5OxmMjL-ujoR5IMGegQz', 'sub': 'c4f30dbd-083e-4b84-aa50-c31afe9b9c01', }, + 'auth_flow_type': 'USER_AUTH', }, 'jwt': { 'claims': {'claim1': 'value1', 'claim2': 'value2'}, diff --git a/http-api/tests/test_auth.py b/http-api/tests/test_auth.py index 851761d..ed427b8 100644 --- a/http-api/tests/test_auth.py +++ b/http-api/tests/test_auth.py @@ -26,7 +26,8 @@ def test_bearer_jwt(lambda_context: LambdaContext): 'sub': '58efed8d-d276-41a8-8502-4ab8b5a6415e', 'name': 'pytest', 'custom:user_id': '5OxmMjL-ujoR5IMGegQz', - } + }, + 'auth_flow_type': 'USER_AUTH', }, } @@ -55,10 +56,11 @@ def test_bearer_apikey( 'name': 'Sérgio R Siqueira', 'email': 'sergio@somosbeta.com.br', }, + 'auth_flow_type': 'API_AUTH', }, } - # # This data was added from seeds + # This data was added from seeds assert app.lambda_handler( { 'headers': { @@ -75,11 +77,11 @@ def test_parse_bearer_token_api_key(): ) assert bearer.token == '35433970-6857-4062-bb43-f71683b2f68e' # type: ignore - assert bearer.auth_type == 'API_KEY' # type: ignore + assert bearer.auth_flow_type == 'API_AUTH' # type: ignore def test_parse_bearer_token_user_token(): bearer = _parse_bearer_token('Bearer d977f5a2-0302-4dd2-87c7-57414264d27a') assert bearer.token == 'd977f5a2-0302-4dd2-87c7-57414264d27a' # type: ignore - assert bearer.auth_type == 'USER_TOKEN' # type: ignore + assert bearer.auth_flow_type == 'USER_AUTH' # type: ignore diff --git a/http-api/uv.lock b/http-api/uv.lock index 852df5b..f3c1045 100644 --- a/http-api/uv.lock +++ b/http-api/uv.lock @@ -444,7 +444,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.1.9" +version = "0.1.11" source = { directory = "../layercake" } dependencies = [ { name = "aws-lambda-powertools", extra = ["all"] }, diff --git a/layercake/pyproject.toml b/layercake/pyproject.toml index d206af8..a92bf8a 100644 --- a/layercake/pyproject.toml +++ b/layercake/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "layercake" -version = "0.1.10" +version = "0.1.11" description = "Add your description here" readme = "README.md" authors = [ @@ -21,7 +21,6 @@ dependencies = [ "pytz>=2025.1", "shortuuid>=1.0.13", "requests>=2.32.3", - "dataclasses-json>=0.6.7", ] [dependency-groups] diff --git a/layercake/uv.lock b/layercake/uv.lock index 15e76f5..add06f4 100644 --- a/layercake/uv.lock +++ b/layercake/uv.lock @@ -279,19 +279,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 }, ] -[[package]] -name = "dataclasses-json" -version = "0.6.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "marshmallow" }, - { name = "typing-inspect" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 }, -] - [[package]] name = "dnspython" version = "2.7.0" @@ -478,12 +465,11 @@ wheels = [ [[package]] name = "layercake" -version = "0.1.9" +version = "0.1.10" source = { editable = "." } dependencies = [ { name = "aws-lambda-powertools", extra = ["all"] }, { name = "boto3" }, - { name = "dataclasses-json" }, { name = "elasticsearch" }, { name = "elasticsearch-dsl" }, { name = "ftfy" }, @@ -510,7 +496,6 @@ dev = [ requires-dist = [ { name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.8.0" }, { name = "boto3", specifier = ">=1.37.16" }, - { name = "dataclasses-json", specifier = ">=0.6.7" }, { name = "elasticsearch", specifier = ">=8.17.2" }, { name = "elasticsearch-dsl", specifier = ">=8.17.1" }, { name = "ftfy", specifier = ">=6.3.1" }, @@ -580,18 +565,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, ] -[[package]] -name = "marshmallow" -version = "3.26.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/5e/5e53d26b42ab75491cda89b871dab9e97c840bf12c63ec58a1919710cd06/marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6", size = 221825 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878 }, -] - [[package]] name = "mergedeep" version = "1.3.4" @@ -689,15 +662,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/d0/ef6e82f7a68c7ac02e1a01815fbe88773f4f9e40728ed35bd1664a5d76f2/mkdocstrings_python-1.16.8-py3-none-any.whl", hash = "sha256:211b7aaf776cd45578ecb531e5ad0d3a35a8be9101a6bfa10de38a69af9d8fd8", size = 124116 }, ] -[[package]] -name = "mypy-extensions" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, -] - [[package]] name = "orjson" version = "3.10.15" @@ -1079,19 +1043,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] -[[package]] -name = "typing-inspect" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 }, -] - [[package]] name = "urllib3" version = "2.3.0"