diff --git a/http-api/.env b/http-api/.env index a9cf1f1..1150530 100644 --- a/http-api/.env +++ b/http-api/.env @@ -1,6 +1,6 @@ # If UV does not load this file, try running `export UV_ENV_FILE=.env` # See more details at https://docs.astral.sh/uv/configuration/files/#env -ELASTIC_HOSTS=http://host.docker.internal:9200 +ELASTIC_HOSTS=http://localhost:9200 DYNAMODB_PARTITION_KEY=id DYNAMODB_SORT_KEY=sk diff --git a/http-api/app.py b/http-api/app.py index 82e7131..ed8d014 100644 --- a/http-api/app.py +++ b/http-api/app.py @@ -8,18 +8,20 @@ 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 CorrelationIdMiddleware -from routes import courses, enrollments, orders, users, webhooks +from middlewares import AuthorizerMiddleware +from routes import courses, enrollments, lookup, orders, settings, users, webhooks tracer = Tracer() logger = Logger(__name__) app = APIGatewayHttpResolver(enable_validation=True) -app.use(middlewares=[CorrelationIdMiddleware('workspace')]) -app.include_router(users.router, prefix='/users') +app.use(middlewares=[AuthorizerMiddleware()]) +app.include_router(courses.router, prefix='/courses') app.include_router(enrollments.router, prefix='/enrollments') app.include_router(orders.router, prefix='/orders') -app.include_router(courses.router, prefix='/courses') +app.include_router(users.router, prefix='/users') app.include_router(webhooks.router, prefix='/webhooks') +app.include_router(settings.router, prefix='/settings') +app.include_router(lookup.router, prefix='/lookup') @app.exception_handler(ServiceError) diff --git a/http-api/auth.py b/http-api/auth.py index 60c0f27..a410117 100644 --- a/http-api/auth.py +++ b/http-api/auth.py @@ -1,3 +1,28 @@ +""" +Example +------- + + Resources: + HttpApi: + Type: AWS::Serverless::HttpApi + Properties: + Auth: + DefaultAuthorizer: LambdaRequestAuthorizer + Authorizers: + LambdaRequestAuthorizer: + FunctionArn: !GetAtt Authorizer.Arn + AuthorizerPayloadFormatVersion: "2.0" + EnableFunctionDefaultPermissions: true + EnableSimpleResponses: true + Identity: + Headers: [Authorization] + Authorizer: + Type: AWS::Serverless::Function + Properties: + Handler: auth.lambda_handler + +""" + from dataclasses import dataclass import boto3 diff --git a/http-api/middlewares.py b/http-api/middlewares.py index 594898f..95b9558 100644 --- a/http-api/middlewares.py +++ b/http-api/middlewares.py @@ -6,24 +6,29 @@ from aws_lambda_powertools.event_handler.middlewares import ( BaseMiddlewareHandler, NextMiddleware, ) +from pydantic import UUID4, BaseModel, Field -class CorrelationIdMiddleware(BaseMiddlewareHandler): - def __init__(self, header: str): - super().__init__() - self.header = header - +class AuthorizerMiddleware(BaseMiddlewareHandler): def handler( - self, app: APIGatewayHttpResolver, next_middleware: NextMiddleware + self, + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, ) -> Response: - # BEFORE logic - request_id = app.current_event.request_context.request_id - correlation_id = app.current_event.headers.get(self.header, request_id) + # 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 - # Call next middleware or route handler ('/todos') - response = next_middleware(app) + if 'user' in authorizer: + user = authorizer['user'] + app.append_context(user=AuthenticatedUser(**user)) - # AFTER logic - response.headers[self.header] = correlation_id + return next_middleware(app) - return response + +class AuthenticatedUser(BaseModel): + id: str = Field(alias='custom:user_id') + name: str + email: str + email_verified: bool + sub: UUID4 diff --git a/http-api/routes/lookup/__init__.py b/http-api/routes/lookup/__init__.py new file mode 100644 index 0000000..2fe5d22 --- /dev/null +++ b/http-api/routes/lookup/__init__.py @@ -0,0 +1,32 @@ +from http import HTTPStatus + +from aws_lambda_powertools.event_handler import Response, content_types +from aws_lambda_powertools.event_handler.api_gateway import Router +from elasticsearch import Elasticsearch +from elasticsearch_dsl import Search +from layercake.funcs import pick + +from settings import ELASTIC_CONN, USER_TABLE + +router = Router() +elastic_client = Elasticsearch(**ELASTIC_CONN) + + +@router.get('/', include_in_schema=False) +def lookup(username: str): + s = Search(using=elastic_client, index=USER_TABLE).query( + 'bool', + should=[ + {'term': {'email.keyword': username}}, + {'term': {'cpf.keyword': username}}, + ], + minimum_should_match=1, + ) + + for hit in s.execute(): + return pick(('id', 'name', 'email', 'cognito:sub'), hit.to_dict()) + + return Response( + content_type=content_types.APPLICATION_JSON, + status_code=HTTPStatus.NOT_FOUND, + ) diff --git a/http-api/routes/settings/__init__.py b/http-api/routes/settings/__init__.py new file mode 100644 index 0000000..b189515 --- /dev/null +++ b/http-api/routes/settings/__init__.py @@ -0,0 +1,11 @@ +from aws_lambda_powertools.event_handler.api_gateway import Router + +router = Router() + + +@router.get('/') +def settings(): + user = router.context['user'] + print(user.email_verified) + + return {} diff --git a/http-api/settings.py b/http-api/settings.py index 3eae5e4..58c0d0f 100644 --- a/http-api/settings.py +++ b/http-api/settings.py @@ -5,13 +5,21 @@ ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore COURSE_TABLE: str = os.getenv('COURSE_TABLE') # type: ignore -ELASTIC_CLOUD_ID = os.getenv('ELASTIC_CLOUD_ID') -ELASTIC_AUTH_PASS = os.getenv('ELASTIC_AUTH_PASS') -if {'AWS_SAM_LOCAL', 'ELASTIC_HOSTS'}.intersection(os.environ): - ELASTIC_CONN = {'hosts': 'http://host.docker.internal:9200'} -else: - ELASTIC_CONN = { - 'cloud_id': ELASTIC_CLOUD_ID, - 'basic_auth': ('elastic', ELASTIC_AUTH_PASS), - } +match (os.getenv('AWS_SAM_LOCAL'), os.getenv('ELASTIC_HOSTS')): + case (str() as AWS_SAM_LOCAL, _) if AWS_SAM_LOCAL: + ELASTIC_CONN = { + 'hosts': 'http://host.docker.internal:9200', + } + case (_, str() as ELASTIC_HOSTS) if ELASTIC_HOSTS: + ELASTIC_CONN = { + 'hosts': ELASTIC_HOSTS, + } + case _: + ELASTIC_CLOUD_ID = os.getenv('ELASTIC_CLOUD_ID') + ELASTIC_AUTH_PASS = os.getenv('ELASTIC_AUTH_PASS') + + ELASTIC_CONN = { + 'cloud_id': ELASTIC_CLOUD_ID, + 'basic_auth': ('elastic', ELASTIC_AUTH_PASS), + } diff --git a/http-api/template.yaml b/http-api/template.yaml index a87f405..431f477 100644 --- a/http-api/template.yaml +++ b/http-api/template.yaml @@ -60,8 +60,7 @@ Resources: EnableFunctionDefaultPermissions: true EnableSimpleResponses: true Identity: - Headers: - - Authorization + Headers: [Authorization] HttpApiFunction: Type: AWS::Serverless::Function @@ -81,10 +80,10 @@ Resources: Path: /{proxy+} Method: ANY ApiId: !Ref HttpApi - Swagger: + Lookup: Type: HttpApi Properties: - Path: /_swagger + Path: /lookup/{username} Method: GET ApiId: !Ref HttpApi Auth: diff --git a/http-api/tests/conftest.py b/http-api/tests/conftest.py index 9deffc0..abeaafd 100644 --- a/http-api/tests/conftest.py +++ b/http-api/tests/conftest.py @@ -49,8 +49,13 @@ class HttpApiProxy: 'apiId': 'api-id', 'authorizer': { 'lambda': { - 'user': {}, - 'tenant': '*', + 'user': { + 'name': 'Sérgio R Siqueira', + 'email': 'sergio@somosbeta.com.br', + 'email_verified': 'true', + 'custom:user_id': '5OxmMjL-ujoR5IMGegQz', + 'sub': 'c4f30dbd-083e-4b84-aa50-c31afe9b9c01', + }, }, 'jwt': { 'claims': {'claim1': 'value1', 'claim2': 'value2'}, diff --git a/http-api/tests/routes/test_lookup.py b/http-api/tests/routes/test_lookup.py new file mode 100644 index 0000000..6c8140f --- /dev/null +++ b/http-api/tests/routes/test_lookup.py @@ -0,0 +1,26 @@ +import json +from http import HTTPMethod, HTTPStatus + +from layercake.dynamodb import DynamoDBPersistenceLayer + +from ..conftest import HttpApiProxy, LambdaContext + + +def test_lookup( + mock_app, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + mock_app.courses.course_layer = dynamodb_persistence_layer + + r = mock_app.lambda_handler( + http_api_proxy( + raw_path='/lookup/sergio@somosbeta.com.br', + method=HTTPMethod.GET, + ), + lambda_context, + ) + + assert 'id' in json.loads(r['body']) + assert r['statusCode'] == HTTPStatus.OK diff --git a/http-api/tests/routes/test_settings.py b/http-api/tests/routes/test_settings.py new file mode 100644 index 0000000..525da75 --- /dev/null +++ b/http-api/tests/routes/test_settings.py @@ -0,0 +1,24 @@ +from http import HTTPMethod, HTTPStatus + +from layercake.dynamodb import DynamoDBPersistenceLayer + +from ..conftest import HttpApiProxy, LambdaContext + + +def test_settings( + mock_app, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = mock_app.lambda_handler( + http_api_proxy( + raw_path='/settings', + method=HTTPMethod.GET, + ), + lambda_context, + ) + print(r) + + # assert 'id' in json.loads(r['body']) + assert r['statusCode'] == HTTPStatus.OK diff --git a/http-api/tests/seeds.jsonl b/http-api/tests/seeds.jsonl index d742d4a..fb171d8 100644 --- a/http-api/tests/seeds.jsonl +++ b/http-api/tests/seeds.jsonl @@ -1,5 +1,8 @@ -{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "0"}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_verified": {"BOOL": true}, "cognito:sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"}, "cpf": {"S": "07879819908"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}, "last_login": {"S": "2024-02-08T20:53:45.818126-03:00"}, "tenant:org_id": {"L": [{"S": "cJtK9SsnJhKPyxESe7g3DG"}, {"S": "edp8njvgQuzNkLx2ySNfAD"}, {"S": "8TVSi5oACLxTiT8ycKPmaQ"}]}} +{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "0"}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_verified": {"BOOL": true}, "cognito:sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"}, "cpf": {"S": "07879819908"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}, "last_login": {"S": "2024-02-08T20:53:45.818126-03:00"}, "tenant:org_id": {"L": [{"S": "cJtK9SsnJhKPyxESe7g3DG"}]}} +{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "cognito"}, "create_date": {"S": "2025-03-03T17:12:26.443507-03:00"}, "sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "emails#sergio@somosbeta.com.br"}, "email_verified": {"BOOL": true}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_primary": {"BOOL": true}, "mx_record_exists": {"BOOL": true}, "update_date": {"S": "2023-11-09T12:13:04.308986-03:00"}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "acls#*"}, "create_date": {"S": "2022-06-13T15:00:24.309410-03:00"}, "roles": {"L": [{"S": "ADMIN"}]}} +{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "acls#cJtK9SsnJhKPyxESe7g3DG"}, "create_date": {"S": "2025-03-14T10:06:34.628078-03:00"}, "roles": {"L": [{"S": "ADMIN"}]}} +{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "orgs#cJtK9SsnJhKPyxESe7g3DG"}, "cnpj": {"S": "15608435000190"}, "create_date": {"S": "2025-03-13T16:36:50.073156-03:00"}, "name": {"S": "Beta Educação"}} {"id": {"S": "logs#5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2024-02-08T16:42:33.776409-03:00"}, "action": {"S": "OPEN_EMAIL"}} {"id": {"S": "logs#5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2019-03-25T00:00:00-03:00"}, "action": {"S": "CLICK_EMAIL"}}