From 8cd755f0ae668786952dce4a8cfc24edf73b9e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Wed, 2 Apr 2025 13:43:10 -0300 Subject: [PATCH] add middleware --- http-api/app.py | 12 +-- http-api/middlewares/__init__.py | 11 +++ .../audit_log_middleware.py} | 75 +++---------------- http-api/middlewares/authorizer_middleware.py | 48 ++++++++++++ http-api/middlewares/tenant_middelware.py | 75 +++++++++++++++++++ http-api/routes/courses/__init__.py | 11 ++- http-api/routes/settings/__init__.py | 4 +- http-api/template.yaml | 2 +- http-api/tests/conftest.py | 2 +- http-api/tests/routes/test_courses.py | 9 ++- http-api/tests/routes/test_settings.py | 4 +- http-api/tests/seeds.jsonl | 1 + http-api/tests/test_middelwares.py | 50 ++++++++++--- http-api/uv.lock | 2 +- 14 files changed, 213 insertions(+), 93 deletions(-) create mode 100644 http-api/middlewares/__init__.py rename http-api/{middlewares.py => middlewares/audit_log_middleware.py} (57%) create mode 100644 http-api/middlewares/authorizer_middleware.py create mode 100644 http-api/middlewares/tenant_middelware.py diff --git a/http-api/app.py b/http-api/app.py index 2be5868..9798a1c 100644 --- a/http-api/app.py +++ b/http-api/app.py @@ -12,11 +12,9 @@ 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, TenantMiddleware +from middlewares import AuthorizerMiddleware from routes import courses, enrollments, lookup, orders, settings, users, webhooks -DEBUG = 'AWS_SAM_LOCAL' in os.environ - tracer = Tracer() logger = Logger(__name__) cors = CORSConfig( @@ -25,8 +23,12 @@ cors = CORSConfig( max_age=600, allow_credentials=False, ) -app = APIGatewayHttpResolver(enable_validation=True, cors=cors, debug=DEBUG) -app.use(middlewares=[AuthorizerMiddleware(), TenantMiddleware()]) +app = APIGatewayHttpResolver( + enable_validation=True, + cors=cors, + debug='AWS_SAM_LOCAL' in os.environ, +) +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') diff --git a/http-api/middlewares/__init__.py b/http-api/middlewares/__init__.py new file mode 100644 index 0000000..c7d342e --- /dev/null +++ b/http-api/middlewares/__init__.py @@ -0,0 +1,11 @@ +from .audit_log_middleware import AuditLogMiddleware +from .authorizer_middleware import AuthorizerMiddleware, User +from .tenant_middelware import Tenant, TenantMiddleware + +__all__ = [ + 'AuthorizerMiddleware', + 'AuditLogMiddleware', + 'TenantMiddleware', + 'User', + 'Tenant', +] diff --git a/http-api/middlewares.py b/http-api/middlewares/audit_log_middleware.py similarity index 57% rename from http-api/middlewares.py rename to http-api/middlewares/audit_log_middleware.py index ffb28ef..24bc7c4 100644 --- a/http-api/middlewares.py +++ b/http-api/middlewares/audit_log_middleware.py @@ -10,69 +10,17 @@ from aws_lambda_powertools.shared.functions import ( extract_event_from_common_models, ) from layercake.dateutils import now, ttl -from layercake.dynamodb import ComposeKey, DynamoDBCollection, KeyPair +from layercake.dynamodb import ( + ComposeKey, + DynamoDBCollection, + KeyPair, +) from layercake.funcs import pick -from pydantic import UUID4, BaseModel, EmailStr, Field -from auth import AuthFlowType +from .authorizer_middleware import User -LOG_RETENTION_DAYS = 365 * 2 # 2 years - - -class User(BaseModel): - id: str - name: str - email: EmailStr - - -class CognitoUser(User): - id: str = Field(alias='custom:user_id') - email_verified: bool - sub: UUID4 - - -class AuthorizerMiddleware(BaseMiddlewareHandler): - def handler( - self, - app: APIGatewayHttpResolver, - next_middleware: NextMiddleware, - ) -> Response: - # Gets the Lambda authorizer associated with the current API Gateway event. - # You can check the file `auth.py` for more details. - context = app.current_event.request_context.authorizer.get_lambda - auth_flow_type = context.get('auth_flow_type') - - 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 - tenant = app.current_event.headers.get('x-tenant') - auth_flow_type = context.get('auth_flow_type') - - match auth_flow_type, tenant: - case AuthFlowType.API_AUTH, None: - app.append_context(tenant=context['tenant']) - case AuthFlowType.USER_AUTH, str(): - print(tenant) - - return next_middleware(app) +YEAR_DAYS = 365 +LOG_RETENTION_DAYS = YEAR_DAYS * 2 class AuditLogMiddleware(BaseMiddlewareHandler): @@ -111,7 +59,7 @@ class AuditLogMiddleware(BaseMiddlewareHandler): app: APIGatewayHttpResolver, next_middleware: NextMiddleware, ) -> Response: - user = app.context.get('user') + user: User | None = app.context.get('user') req_context = app.current_event.request_context ip_addr = req_context.http.source_ip response = next_middleware(app) @@ -133,8 +81,9 @@ class AuditLogMiddleware(BaseMiddlewareHandler): self.collect.put_item( key=KeyPair( - # Post-migration: remove `delimiter` from ComposeKey. - pk=ComposeKey(user.id, prefix='logs', delimiter=':'), + # Post-migration: remove `delimiter` and update prefix from `log` to `logs` + # in ComposeKey. + pk=ComposeKey(user.id, prefix='log', delimiter=':'), sk=now_.isoformat(), ), action=self.action, diff --git a/http-api/middlewares/authorizer_middleware.py b/http-api/middlewares/authorizer_middleware.py new file mode 100644 index 0000000..9082e51 --- /dev/null +++ b/http-api/middlewares/authorizer_middleware.py @@ -0,0 +1,48 @@ +from aws_lambda_powertools.event_handler.api_gateway import ( + APIGatewayHttpResolver, + Response, +) +from aws_lambda_powertools.event_handler.middlewares import ( + BaseMiddlewareHandler, + NextMiddleware, +) +from pydantic import UUID4, BaseModel, EmailStr, Field + +from auth import AuthFlowType + + +class User(BaseModel): + id: str + name: str + email: EmailStr + + +class CognitoUser(User): + id: str = Field(alias='custom:user_id') + email_verified: bool + sub: UUID4 + + +class AuthorizerMiddleware(BaseMiddlewareHandler): + def handler( + self, + app: APIGatewayHttpResolver, + next_middleware: NextMiddleware, + ) -> Response: + # Gets the Lambda authorizer associated with the current API Gateway event. + # You can check the file `auth.py` for more details. + context = app.current_event.request_context.authorizer.get_lambda + auth_flow_type = context.get('auth_flow_type') + + 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) diff --git a/http-api/middlewares/tenant_middelware.py b/http-api/middlewares/tenant_middelware.py new file mode 100644 index 0000000..fa8341d --- /dev/null +++ b/http-api/middlewares/tenant_middelware.py @@ -0,0 +1,75 @@ +from http import HTTPStatus + +from aws_lambda_powertools.event_handler.api_gateway import ( + APIGatewayHttpResolver, + Response, +) +from aws_lambda_powertools.event_handler.exceptions import BadRequestError, ServiceError +from aws_lambda_powertools.event_handler.middlewares import ( + BaseMiddlewareHandler, + NextMiddleware, +) +from layercake.dynamodb import ComposeKey, DynamoDBCollection, KeyPair +from pydantic import UUID4, BaseModel + +from auth import AuthFlowType + +from .authorizer_middleware import User + + +class Tenant(BaseModel): + id: UUID4 | str + name: str + + +class TenantMiddleware(BaseMiddlewareHandler): + def __init__(self, collect: DynamoDBCollection) -> None: + self.collect = collect + + 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') + + if auth_flow_type == AuthFlowType.API_AUTH: + app.append_context(tenant=Tenant(**context['tenant'])) + + if auth_flow_type == AuthFlowType.USER_AUTH: + app.append_context( + tenant=_tenant( + app.current_event.headers.get('x-tenant'), + app.context.get('user'), # type: ignore + collect=self.collect, + ) + ) + + return next_middleware(app) + + +class ForbiddenError(ServiceError): + def __init__(self, msg: str): + super().__init__(HTTPStatus.FORBIDDEN, msg) + + +def _tenant( + tenant_id: str | None, + user: User, + /, + collect: DynamoDBCollection, +) -> Tenant: + if not tenant_id: + raise BadRequestError('Missing tenant') + + collect.get_item( + KeyPair(user.id, ComposeKey(tenant_id, prefix='acls')), + exception_cls=ForbiddenError, + ) + + if tenant_id == '*': + return Tenant(id=tenant_id, name='default') + + obj = collect.get_item(KeyPair(tenant_id, '0')) + return Tenant.parse_obj(obj) diff --git a/http-api/routes/courses/__init__.py b/http-api/routes/courses/__init__.py index 53aaf08..4131f28 100644 --- a/http-api/routes/courses/__init__.py +++ b/http-api/routes/courses/__init__.py @@ -9,7 +9,7 @@ from layercake.dynamodb import DynamoDBCollection, DynamoDBPersistenceLayer import elastic from boto3clients import dynamodb_client from course import create_course -from middlewares import AuditLogMiddleware +from middlewares import AuditLogMiddleware, Tenant, TenantMiddleware from models import Course, Org from settings import COURSE_TABLE, ELASTIC_CONN, USER_TABLE @@ -17,7 +17,7 @@ router = Router() elastic_client = Elasticsearch(**ELASTIC_CONN) course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client) user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) -collect = DynamoDBCollection(user_layer) +user_collect = DynamoDBCollection(user_layer) @router.get( @@ -44,13 +44,16 @@ def get_courses(): compress=True, tags=['Course'], middlewares=[ - AuditLogMiddleware('COURSE_ADD', collect, ('id', 'name')), + TenantMiddleware(user_collect), + AuditLogMiddleware('COURSE_ADD', user_collect, ('id', 'name')), ], ) def post_course(payload: Course): + tenant: Tenant = router.context['tenant'] + create_course( course=payload, - org=Org(id='*', name='default'), + org=Org(id=tenant.id, name=tenant.name), persistence_layer=course_layer, ) diff --git a/http-api/routes/settings/__init__.py b/http-api/routes/settings/__init__.py index 6044822..3ec95b3 100644 --- a/http-api/routes/settings/__init__.py +++ b/http-api/routes/settings/__init__.py @@ -33,8 +33,8 @@ def settings(): return { 'acls': acls['items'], - # Note: ensure compatibility with search on React's tenant menu - 'tenants': [x | {'id': x['sk']} for x in tenants['items']], + # Note: Ensure compatibility with search on React's tenant menu + 'tenants': [x | {'id': x['sk'], 'sk': '0'} for x in tenants['items']], } diff --git a/http-api/template.yaml b/http-api/template.yaml index a506ce2..f31f02c 100644 --- a/http-api/template.yaml +++ b/http-api/template.yaml @@ -23,7 +23,7 @@ Globals: Architectures: - x86_64 Layers: - - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:28 + - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:30 Environment: Variables: TZ: America/Sao_Paulo diff --git a/http-api/tests/conftest.py b/http-api/tests/conftest.py index d4651d3..38f1b8a 100644 --- a/http-api/tests/conftest.py +++ b/http-api/tests/conftest.py @@ -143,7 +143,7 @@ def dynamodb_seeds(dynamodb_client): @pytest.fixture -def mock_app(monkeypatch): +def mock_app(): import app return app diff --git a/http-api/tests/routes/test_courses.py b/http-api/tests/routes/test_courses.py index 4c8af93..48e8b24 100644 --- a/http-api/tests/routes/test_courses.py +++ b/http-api/tests/routes/test_courses.py @@ -5,9 +5,12 @@ from layercake.dynamodb import ComposeKey, DynamoDBCollection, PartitionKey from ..conftest import HttpApiProxy, LambdaContext +YEAR_DAYS = 365 + def test_post_course( mock_app, + dynamodb_seeds, dynamodb_persistence_layer, http_api_proxy: HttpApiProxy, lambda_context: LambdaContext, @@ -19,9 +22,9 @@ def test_post_course( headers={'X-Tenant': '*'}, body={ 'name': 'pytest', - 'access_period': 365, + 'access_period': YEAR_DAYS, 'cert': { - 'exp_interval': 730, # 2 years + 'exp_interval': YEAR_DAYS * 2, }, }, ), @@ -35,6 +38,6 @@ def test_post_course( collect = DynamoDBCollection(dynamodb_persistence_layer) logs = collect.get_items( - PartitionKey(ComposeKey('5OxmMjL-ujoR5IMGegQz', prefix='logs')) + PartitionKey(ComposeKey('5OxmMjL-ujoR5IMGegQz', prefix='log', delimiter=':')) ) print(logs) diff --git a/http-api/tests/routes/test_settings.py b/http-api/tests/routes/test_settings.py index 8497ee7..17b2aea 100644 --- a/http-api/tests/routes/test_settings.py +++ b/http-api/tests/routes/test_settings.py @@ -36,9 +36,9 @@ def test_settings( ], 'tenants': [ { - 'sk': 'cJtK9SsnJhKPyxESe7g3DG', + 'sk': '0', 'name': 'Beta Educação', - 'id': '5OxmMjL-ujoR5IMGegQz', + 'id': 'cJtK9SsnJhKPyxESe7g3DG', 'cnpj': '15608435000190', 'create_date': '2025-03-13T16:36:50.073156-03:00', } diff --git a/http-api/tests/seeds.jsonl b/http-api/tests/seeds.jsonl index f12724c..2282654 100644 --- a/http-api/tests/seeds.jsonl +++ b/http-api/tests/seeds.jsonl @@ -7,3 +7,4 @@ {"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": "log:5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2024-02-08T16:42:33.776409-03:00"}, "action": {"S": "OPEN_EMAIL"}} {"id": {"S": "log:5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2019-03-25T00:00:00-03:00"}, "action": {"S": "CLICK_EMAIL"}} +{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "0"}, "name": {"S": "EDUSEG"}, "cnpj": {"S": "15608435000190"}} diff --git a/http-api/tests/test_middelwares.py b/http-api/tests/test_middelwares.py index c51bc83..f4d1480 100644 --- a/http-api/tests/test_middelwares.py +++ b/http-api/tests/test_middelwares.py @@ -1,31 +1,59 @@ from http import HTTPMethod +import pytest from aws_lambda_powertools.event_handler.api_gateway import APIGatewayHttpResolver +from layercake.dynamodb import DynamoDBCollection, DynamoDBPersistenceLayer -from middlewares import TenantMiddleware +from middlewares import AuthorizerMiddleware, TenantMiddleware from .conftest import HttpApiProxy, LambdaContext -def test_eval( +@pytest.fixture +def mock_app(dynamodb_persistence_layer: DynamoDBPersistenceLayer): + collect = DynamoDBCollection(dynamodb_persistence_layer) + app = APIGatewayHttpResolver() + app.use(middlewares=[AuthorizerMiddleware(), TenantMiddleware(collect)]) + + @app.get('/') + def index(): + return app.context.get('tenant') + + return app + + +def test_tenant_user_auth( + mock_app, dynamodb_seeds, http_api_proxy: HttpApiProxy, lambda_context: LambdaContext, ): - app = APIGatewayHttpResolver() - app.use(middlewares=[TenantMiddleware()]) - - @app.get('/') - def index(): - return {} - - result = app( + # This data was added from seeds + result = mock_app( http_api_proxy( raw_path='/', method=HTTPMethod.GET, - headers={'Tenant': 'cJtK9SsnJhKPyxESe7g3DG'}, + headers={'X-Tenant': 'cJtK9SsnJhKPyxESe7g3DG'}, ), lambda_context, ) + assert result['body'] == '{"id":"cJtK9SsnJhKPyxESe7g3DG","name":"EDUSEG"}' assert result['statusCode'] == 200 + + +def test_tenant_forbidden( + mock_app, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + result = mock_app( + http_api_proxy( + raw_path='/', + method=HTTPMethod.GET, + headers={'X-Tenant': 'abc'}, + ), + lambda_context, + ) + + assert result['statusCode'] == 403 diff --git a/http-api/uv.lock b/http-api/uv.lock index 5216adc..195a5be 100644 --- a/http-api/uv.lock +++ b/http-api/uv.lock @@ -467,7 +467,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.1.13" +version = "0.1.14" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" },