add middleware
This commit is contained in:
@@ -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')
|
||||
|
||||
11
http-api/middlewares/__init__.py
Normal file
11
http-api/middlewares/__init__.py
Normal 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',
|
||||
]
|
||||
@@ -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,
|
||||
48
http-api/middlewares/authorizer_middleware.py
Normal file
48
http-api/middlewares/authorizer_middleware.py
Normal 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)
|
||||
75
http-api/middlewares/tenant_middelware.py
Normal file
75
http-api/middlewares/tenant_middelware.py
Normal 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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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']],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -143,7 +143,7 @@ def dynamodb_seeds(dynamodb_client):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(monkeypatch):
|
||||
def mock_app():
|
||||
import app
|
||||
|
||||
return app
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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
2
http-api/uv.lock
generated
@@ -467,7 +467,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "layercake"
|
||||
version = "0.1.13"
|
||||
version = "0.1.14"
|
||||
source = { directory = "../layercake" }
|
||||
dependencies = [
|
||||
{ name = "arnparse" },
|
||||
|
||||
Reference in New Issue
Block a user