add middleware

This commit is contained in:
2025-04-02 13:43:10 -03:00
parent 3a799fbbd1
commit 8cd755f0ae
14 changed files with 213 additions and 93 deletions

View File

@@ -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.logging import correlation_paths
from aws_lambda_powertools.utilities.typing import LambdaContext 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 from routes import courses, enrollments, lookup, orders, settings, users, webhooks
DEBUG = 'AWS_SAM_LOCAL' in os.environ
tracer = Tracer() tracer = Tracer()
logger = Logger(__name__) logger = Logger(__name__)
cors = CORSConfig( cors = CORSConfig(
@@ -25,8 +23,12 @@ cors = CORSConfig(
max_age=600, max_age=600,
allow_credentials=False, allow_credentials=False,
) )
app = APIGatewayHttpResolver(enable_validation=True, cors=cors, debug=DEBUG) app = APIGatewayHttpResolver(
app.use(middlewares=[AuthorizerMiddleware(), TenantMiddleware()]) 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(courses.router, prefix='/courses')
app.include_router(enrollments.router, prefix='/enrollments') app.include_router(enrollments.router, prefix='/enrollments')
app.include_router(orders.router, prefix='/orders') app.include_router(orders.router, prefix='/orders')

View File

@@ -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',
]

View File

@@ -10,69 +10,17 @@ from aws_lambda_powertools.shared.functions import (
extract_event_from_common_models, extract_event_from_common_models,
) )
from layercake.dateutils import now, ttl 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 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 YEAR_DAYS = 365
LOG_RETENTION_DAYS = YEAR_DAYS * 2
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)
class AuditLogMiddleware(BaseMiddlewareHandler): class AuditLogMiddleware(BaseMiddlewareHandler):
@@ -111,7 +59,7 @@ class AuditLogMiddleware(BaseMiddlewareHandler):
app: APIGatewayHttpResolver, app: APIGatewayHttpResolver,
next_middleware: NextMiddleware, next_middleware: NextMiddleware,
) -> Response: ) -> Response:
user = app.context.get('user') user: User | None = app.context.get('user')
req_context = app.current_event.request_context req_context = app.current_event.request_context
ip_addr = req_context.http.source_ip ip_addr = req_context.http.source_ip
response = next_middleware(app) response = next_middleware(app)
@@ -133,8 +81,9 @@ class AuditLogMiddleware(BaseMiddlewareHandler):
self.collect.put_item( self.collect.put_item(
key=KeyPair( key=KeyPair(
# Post-migration: remove `delimiter` from ComposeKey. # Post-migration: remove `delimiter` and update prefix from `log` to `logs`
pk=ComposeKey(user.id, prefix='logs', delimiter=':'), # in ComposeKey.
pk=ComposeKey(user.id, prefix='log', delimiter=':'),
sk=now_.isoformat(), sk=now_.isoformat(),
), ),
action=self.action, action=self.action,

View File

@@ -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)

View File

@@ -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)

View File

@@ -9,7 +9,7 @@ from layercake.dynamodb import DynamoDBCollection, DynamoDBPersistenceLayer
import elastic import elastic
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from course import create_course from course import create_course
from middlewares import AuditLogMiddleware from middlewares import AuditLogMiddleware, Tenant, TenantMiddleware
from models import Course, Org from models import Course, Org
from settings import COURSE_TABLE, ELASTIC_CONN, USER_TABLE from settings import COURSE_TABLE, ELASTIC_CONN, USER_TABLE
@@ -17,7 +17,7 @@ router = Router()
elastic_client = Elasticsearch(**ELASTIC_CONN) elastic_client = Elasticsearch(**ELASTIC_CONN)
course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client) course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
collect = DynamoDBCollection(user_layer) user_collect = DynamoDBCollection(user_layer)
@router.get( @router.get(
@@ -44,13 +44,16 @@ def get_courses():
compress=True, compress=True,
tags=['Course'], tags=['Course'],
middlewares=[ middlewares=[
AuditLogMiddleware('COURSE_ADD', collect, ('id', 'name')), TenantMiddleware(user_collect),
AuditLogMiddleware('COURSE_ADD', user_collect, ('id', 'name')),
], ],
) )
def post_course(payload: Course): def post_course(payload: Course):
tenant: Tenant = router.context['tenant']
create_course( create_course(
course=payload, course=payload,
org=Org(id='*', name='default'), org=Org(id=tenant.id, name=tenant.name),
persistence_layer=course_layer, persistence_layer=course_layer,
) )

View File

@@ -33,8 +33,8 @@ def settings():
return { return {
'acls': acls['items'], 'acls': acls['items'],
# Note: ensure compatibility with search on React's tenant menu # Note: Ensure compatibility with search on React's tenant menu
'tenants': [x | {'id': x['sk']} for x in tenants['items']], 'tenants': [x | {'id': x['sk'], 'sk': '0'} for x in tenants['items']],
} }

View File

@@ -23,7 +23,7 @@ Globals:
Architectures: Architectures:
- x86_64 - x86_64
Layers: Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:28 - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:30
Environment: Environment:
Variables: Variables:
TZ: America/Sao_Paulo TZ: America/Sao_Paulo

View File

@@ -143,7 +143,7 @@ def dynamodb_seeds(dynamodb_client):
@pytest.fixture @pytest.fixture
def mock_app(monkeypatch): def mock_app():
import app import app
return app return app

View File

@@ -5,9 +5,12 @@ from layercake.dynamodb import ComposeKey, DynamoDBCollection, PartitionKey
from ..conftest import HttpApiProxy, LambdaContext from ..conftest import HttpApiProxy, LambdaContext
YEAR_DAYS = 365
def test_post_course( def test_post_course(
mock_app, mock_app,
dynamodb_seeds,
dynamodb_persistence_layer, dynamodb_persistence_layer,
http_api_proxy: HttpApiProxy, http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext, lambda_context: LambdaContext,
@@ -19,9 +22,9 @@ def test_post_course(
headers={'X-Tenant': '*'}, headers={'X-Tenant': '*'},
body={ body={
'name': 'pytest', 'name': 'pytest',
'access_period': 365, 'access_period': YEAR_DAYS,
'cert': { 'cert': {
'exp_interval': 730, # 2 years 'exp_interval': YEAR_DAYS * 2,
}, },
}, },
), ),
@@ -35,6 +38,6 @@ def test_post_course(
collect = DynamoDBCollection(dynamodb_persistence_layer) collect = DynamoDBCollection(dynamodb_persistence_layer)
logs = collect.get_items( logs = collect.get_items(
PartitionKey(ComposeKey('5OxmMjL-ujoR5IMGegQz', prefix='logs')) PartitionKey(ComposeKey('5OxmMjL-ujoR5IMGegQz', prefix='log', delimiter=':'))
) )
print(logs) print(logs)

View File

@@ -36,9 +36,9 @@ def test_settings(
], ],
'tenants': [ 'tenants': [
{ {
'sk': 'cJtK9SsnJhKPyxESe7g3DG', 'sk': '0',
'name': 'Beta Educação', 'name': 'Beta Educação',
'id': '5OxmMjL-ujoR5IMGegQz', 'id': 'cJtK9SsnJhKPyxESe7g3DG',
'cnpj': '15608435000190', 'cnpj': '15608435000190',
'create_date': '2025-03-13T16:36:50.073156-03:00', 'create_date': '2025-03-13T16:36:50.073156-03:00',
} }

View File

@@ -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": "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": "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": "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"}}

View File

@@ -1,31 +1,59 @@
from http import HTTPMethod from http import HTTPMethod
import pytest
from aws_lambda_powertools.event_handler.api_gateway import APIGatewayHttpResolver 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 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, dynamodb_seeds,
http_api_proxy: HttpApiProxy, http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
app = APIGatewayHttpResolver() # This data was added from seeds
app.use(middlewares=[TenantMiddleware()]) result = mock_app(
@app.get('/')
def index():
return {}
result = app(
http_api_proxy( http_api_proxy(
raw_path='/', raw_path='/',
method=HTTPMethod.GET, method=HTTPMethod.GET,
headers={'Tenant': 'cJtK9SsnJhKPyxESe7g3DG'}, headers={'X-Tenant': 'cJtK9SsnJhKPyxESe7g3DG'},
), ),
lambda_context, lambda_context,
) )
assert result['body'] == '{"id":"cJtK9SsnJhKPyxESe7g3DG","name":"EDUSEG"}'
assert result['statusCode'] == 200 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

2
http-api/uv.lock generated
View File

@@ -467,7 +467,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.1.13" version = "0.1.14"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "arnparse" }, { name = "arnparse" },