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.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')

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

2
http-api/uv.lock generated
View File

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