This commit is contained in:
2025-04-01 19:15:10 -03:00
parent dbe7a924e2
commit ef4bfc07f3
16 changed files with 197 additions and 44 deletions

1
http-api/.gitignore vendored
View File

@@ -2,3 +2,4 @@
env.json
dynamodb_volume/
elastic_volume/
meili_data/

View File

@@ -4,6 +4,7 @@ from typing import Any
from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.event_handler.api_gateway import (
APIGatewayHttpResolver,
CORSConfig,
Response,
content_types,
)
@@ -12,20 +13,26 @@ from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.utilities.typing import LambdaContext
from middlewares import AuthorizerMiddleware, TenantMiddleware
from routes import courses, enrollments, lookup, me, orders, users, webhooks
from routes import courses, enrollments, lookup, orders, settings, users, webhooks
DEBUG = os.getenv('LOG_LEVEL') == 'DEBUG'
DEBUG = 'AWS_SAM_LOCAL' in os.environ
tracer = Tracer()
logger = Logger(__name__)
app = APIGatewayHttpResolver(enable_validation=True, debug=DEBUG)
cors = CORSConfig(
allow_origin='*',
allow_headers=['Content-Type', 'X-Requested-With', 'Authorization', 'X-Tenant'],
max_age=600,
allow_credentials=False,
)
app = APIGatewayHttpResolver(enable_validation=True, cors=cors, debug=DEBUG)
app.use(middlewares=[AuthorizerMiddleware(), TenantMiddleware()])
app.include_router(courses.router, prefix='/courses')
app.include_router(enrollments.router, prefix='/enrollments')
app.include_router(orders.router, prefix='/orders')
app.include_router(users.router, prefix='/users')
app.include_router(webhooks.router, prefix='/webhooks')
app.include_router(me.router, prefix='/me')
app.include_router(settings.router, prefix='/settings')
app.include_router(lookup.router, prefix='/lookup')

View File

@@ -48,19 +48,21 @@ tracer = Tracer()
logger = Logger(__name__)
idp_client = boto3.client('cognito-idp')
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
collect = DynamoDBCollection(user_layer)
user_collect = DynamoDBCollection(user_layer)
@tracer.capture_lambda_handler
@logger.inject_lambda_context
@event_source(data_class=APIGatewayAuthorizerEventV2)
def lambda_handler(event: APIGatewayAuthorizerEventV2, context: LambdaContext) -> dict:
"""Authenticates a user using a bearer token (for user or API).
Only handles authentication; any additional logic (e.g., tenant) is performed afterward."""
bearer = _parse_bearer_token(event.headers.get('authorization', ''))
if not bearer:
return APIGatewayAuthorizerResponseV2(authorize=False).asdict()
attrs = _authorizer(bearer).asdict()
attrs = _authorizer(bearer, user_collect).asdict()
return APIGatewayAuthorizerResponseV2(**attrs).asdict()
@@ -76,7 +78,7 @@ class BearerToken:
@dataclass
class Authorizer:
class AuthorizerResponseV2:
authorize: bool = False
context: dict[str, Any] | None = None
auth_flow_type: AuthFlowType = AuthFlowType.USER_AUTH
@@ -92,11 +94,15 @@ class Authorizer:
return data
def _get_apikey(token: str) -> dict[str, dict | str]:
def _get_apikey(token: str, /, collect: DynamoDBCollection) -> dict[str, dict | str]:
return collect.get_item(KeyPair('apikey', token))
def _authorizer(bearer: BearerToken) -> Authorizer:
def _authorizer(
bearer: BearerToken,
/,
collect: DynamoDBCollection,
) -> AuthorizerResponseV2:
"""
Build an Authorizer object based on the bearer token's auth type.
@@ -113,13 +119,13 @@ def _authorizer(bearer: BearerToken) -> Authorizer:
try:
if bearer.auth_flow_type == AuthFlowType.USER_AUTH:
user = get_user(bearer.token, idp_client)
return Authorizer(True, {'user': user})
return AuthorizerResponseV2(True, {'user': user})
apikey = _get_apikey(bearer.token)
apikey = _get_apikey(bearer.token, collect)
context = pick(('tenant', 'user'), apikey)
return Authorizer(True, context, AuthFlowType.API_AUTH)
return AuthorizerResponseV2(True, context, AuthFlowType.API_AUTH)
except Exception:
return Authorizer()
return AuthorizerResponseV2()
def _parse_bearer_token(s: str) -> BearerToken | None:

View File

@@ -9,6 +9,15 @@ services:
working_dir: /home/dynamodblocal
command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
meilisearch:
container_name: meilisearch
image: getmeili/meilisearch:v1.13
volumes:
- ./meili_data:/meili_data
restart: unless-stopped
ports:
- 7700:7700
elastic:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.3
container_name: elastic

View File

@@ -34,6 +34,7 @@ def token(username: str) -> KonvivaToken:
r = requests.get(url.geturl(), headers=headers)
r.raise_for_status()
# Because Konviva does not return the proper HTTP status code
if err := glom(r.json(), 'errors.0', default=None):
raise KonvivaError(err)

View File

@@ -63,7 +63,15 @@ class TenantMiddleware(BaseMiddlewareHandler):
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)
@@ -108,7 +116,8 @@ class AuditLogMiddleware(BaseMiddlewareHandler):
ip_addr = req_context.http.source_ip
response = next_middleware(app)
# Successful request
# Successful response
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status#successful_responses
if 200 <= response.status_code < 300 and user:
now_ = now()
data = (
@@ -124,7 +133,8 @@ class AuditLogMiddleware(BaseMiddlewareHandler):
self.collect.put_item(
key=KeyPair(
pk=ComposeKey(user.id, prefix='logs'),
# Post-migration: remove `delimiter` from ComposeKey.
pk=ComposeKey(user.id, prefix='logs', delimiter=':'),
sk=now_.isoformat(),
),
action=self.action,

View File

@@ -13,27 +13,28 @@ from settings import USER_TABLE
router = Router()
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
collect = DynamoDBCollection(user_layer)
user_collect = DynamoDBCollection(user_layer)
LIMIT = 25
@router.get('/', include_in_schema=False)
def me():
def settings():
user: User = router.context['user']
acls = collect.get_items(
acls = user_collect.get_items(
KeyPair(user.id, PrefixKey('acls')),
limit=LIMIT,
)
workspaces = collect.get_items(
tenants = user_collect.get_items(
KeyPair(user.id, PrefixKey('orgs')),
limit=LIMIT,
)
return {
'acls': acls['items'],
'workspaces': workspaces['items'],
# Note: ensure compatibility with search on React's tenant menu
'tenants': [x | {'id': x['sk']} for x in tenants['items']],
}

View File

@@ -33,7 +33,7 @@ class BadRequestError(MissingError, PowertoolsBadRequestError): ...
router = Router()
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError)
user_collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError)
elastic_client = Elasticsearch(**ELASTIC_CONN)
@@ -61,7 +61,7 @@ def get_users():
compress=True,
tags=['User'],
summary='Create user',
middlewares=[AuditLogMiddleware('USER_ADD', collect)],
middlewares=[AuditLogMiddleware('USER_ADD', user_collect)],
)
def post_user(payload: User):
return Response(status_code=HTTPStatus.CREATED)
@@ -84,7 +84,7 @@ def patch_reset(id: str, payload: NewPasswordPayload):
summary='Get user',
)
def get_user(id: str):
return collect.get_item(KeyPair(id, '0'))
return user_collect.get_item(KeyPair(id, '0'))
@router.get('/<id>/idp', compress=True, include_in_schema=False)
@@ -99,7 +99,7 @@ def get_idp(id: str):
summary='Get user emails',
)
def get_emails(id: str):
return collect.get_items(
return user_collect.get_items(
KeyPair(id, PrefixKey('emails')),
start_key=router.current_event.get_query_string_value('start_key', None),
)
@@ -112,7 +112,7 @@ def get_emails(id: str):
summary='Get user logs',
)
def get_logs(id: str):
return collect.get_items(
return user_collect.get_items(
# Post-migration: uncomment to enable PartitionKey with a composite key (id with `logs` prefix).
# PartitionKey(ComposeKey(id, prefix='logs')),
PartitionKey(ComposeKey(id, prefix='log', delimiter=':')),
@@ -127,7 +127,7 @@ def get_logs(id: str):
summary='Get user orgs',
)
def get_orgs(id: str):
return collect.get_items(
return user_collect.get_items(
KeyPair(id, PrefixKey('orgs')),
start_key=router.current_event.get_query_string_value('start_key', None),
)

View File

@@ -23,7 +23,7 @@ Globals:
Architectures:
- x86_64
Layers:
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:26
- !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:28
Environment:
Variables:
TZ: America/Sao_Paulo
@@ -39,6 +39,8 @@ Globals:
ELASTIC_AUTH_PASS: "{{resolve:ssm:/betaeducacao/elastic/auth_pass/str}}"
KONVIVA_API_URL: https://saladeaula.digital
KONVIVA_SECRET_KEY: "{{resolve:ssm:/betaeducacao/konviva/secret_key/str}}"
MEILISEARCH_HOST: https://meili.vps.eduseg.com.br
MEILISEARCH_API_KEY: "{{resolve:ssm:/saladeaula/meili_api_key}}"
Resources:
HttpLog:
@@ -53,6 +55,8 @@ Resources:
AllowOrigins: ["*"]
AllowMethods: [GET, POST, PUT, DELETE, PATCH, OPTIONS]
AllowHeaders: [Content-Type, X-Requested-With, Authorization, X-Tenant]
AllowCredentials: false
MaxAge: 600
Auth:
DefaultAuthorizer: LambdaRequestAuthorizer
Authorizers:
@@ -63,6 +67,7 @@ Resources:
EnableSimpleResponses: true
Identity:
Headers: [Authorization]
ReauthorizeEvery: 300
HttpApiFunction:
Type: AWS::Serverless::Function
@@ -76,6 +81,14 @@ Resources:
- DynamoDBCrudPolicy:
TableName: !Ref CourseTable
Events:
Preflight:
Type: HttpApi
Properties:
Path: /{proxy+}
Method: OPTIONS
ApiId: !Ref HttpApi
Auth:
Authorizer: NONE
AnyRequest:
Type: HttpApi
Properties:

View File

@@ -34,6 +34,7 @@ class HttpApiProxy:
body: dict = {},
*,
headers: dict = {},
auth_flow_type: str = 'USER_AUTH',
**kwargs,
) -> dict:
return {
@@ -59,7 +60,7 @@ class HttpApiProxy:
'custom:user_id': '5OxmMjL-ujoR5IMGegQz',
'sub': 'c4f30dbd-083e-4b84-aa50-c31afe9b9c01',
},
'auth_flow_type': 'USER_AUTH',
'auth_flow_type': auth_flow_type,
},
'jwt': {
'claims': {'claim1': 'value1', 'claim2': 'value2'},

View File

@@ -4,7 +4,7 @@ from http import HTTPMethod, HTTPStatus
from ..conftest import HttpApiProxy, LambdaContext
def test_me(
def test_settings(
mock_app,
dynamodb_seeds,
http_api_proxy: HttpApiProxy,
@@ -12,10 +12,7 @@ def test_me(
):
# This data was added from seeds
r = mock_app.lambda_handler(
http_api_proxy(
raw_path='/me',
method=HTTPMethod.GET,
),
http_api_proxy(raw_path='/settings', method=HTTPMethod.GET),
lambda_context,
)
@@ -37,7 +34,7 @@ def test_me(
'roles': ['ADMIN'],
},
],
'workspaces': [
'tenants': [
{
'sk': 'cJtK9SsnJhKPyxESe7g3DG',
'name': 'Beta Educação',

View File

@@ -5,8 +5,6 @@ from .conftest import LambdaContext
def test_bearer_jwt(lambda_context: LambdaContext):
import auth as app
# You should mock the Cognito user to pass the test
app.get_user = lambda *args, **kwargs: {
'sub': '58efed8d-d276-41a8-8502-4ab8b5a6415e',
@@ -32,11 +30,7 @@ def test_bearer_jwt(lambda_context: LambdaContext):
}
def test_bearer_apikey(
monkeypatch,
dynamodb_seeds,
lambda_context: LambdaContext,
):
def test_bearer_apikey(dynamodb_seeds, lambda_context: LambdaContext):
event = {
'headers': {
'authorization': 'Bearer sk-MzI1MDQ0NTctZjEzMy00YzAwLTkzNmItNmFhNzEyY2E5ZjQw',

View File

@@ -0,0 +1,31 @@
from http import HTTPMethod
from aws_lambda_powertools.event_handler.api_gateway import APIGatewayHttpResolver
from middlewares import TenantMiddleware
from .conftest import HttpApiProxy, LambdaContext
def test_eval(
dynamodb_seeds,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
):
app = APIGatewayHttpResolver()
app.use(middlewares=[TenantMiddleware()])
@app.get('/')
def index():
return {}
result = app(
http_api_proxy(
raw_path='/',
method=HTTPMethod.GET,
headers={'Tenant': 'cJtK9SsnJhKPyxESe7g3DG'},
),
lambda_context,
)
assert result['statusCode'] == 200

42
http-api/uv.lock generated
View File

@@ -10,6 +10,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
[[package]]
name = "arnparse"
version = "0.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/42/949284e998282b167e273872fa9c39b06d41a6055163c30aa2daaeee76a0/arnparse-0.0.2.tar.gz", hash = "sha256:cb87f17200d07121108a9085d4a09cc69a55582647776b9a917b0b1f279db8f8", size = 2677 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/6f/630bedeb32964e99661990811a66389201b62c047b35c17e332dad9be2a3/arnparse-0.0.2-py2.py3-none-any.whl", hash = "sha256:b0906734e4b8f19e39b1e32944c6cd6274b6da90c066a83882ac7a11d27553e0", size = 2904 },
]
[[package]]
name = "attrs"
version = "25.3.0"
@@ -107,6 +116,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/5b/f96cf58c37704b907ac2f9cc94e45ba0a2aa3b2062421aa8b8614f1d78de/botocore-1.37.20-py3-none-any.whl", hash = "sha256:c34f4f25fda7c4f726adf5a948590bd6bd7892c05278d31e344b5908e7b43301", size = 13432464 },
]
[[package]]
name = "camel-converter"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/3d/dd783586dc0c4aee5b6b88489666fdb2c0c344ea0aa8a5c10746cc423707/camel_converter-4.0.1.tar.gz", hash = "sha256:401414549ae4ac4073e38cdc4aa6d464dc534fc40aa06ff787bf0960b0c86535", size = 38915 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/e5/806359514cc8305f047bd6d39d63890298c0596f7328b534059724bd1a9e/camel_converter-4.0.1-py3-none-any.whl", hash = "sha256:0cba7ca1354a29ca2191983deecc9dcf28889f606c28d6ed18ac7d4586b163ac", size = 6243 },
]
[package.optional-dependencies]
pydantic = [
{ name = "pydantic" },
]
[[package]]
name = "certifi"
version = "2025.1.31"
@@ -444,15 +467,17 @@ wheels = [
[[package]]
name = "layercake"
version = "0.1.11"
version = "0.1.13"
source = { directory = "../layercake" }
dependencies = [
{ name = "arnparse" },
{ name = "aws-lambda-powertools", extra = ["all"] },
{ name = "boto3" },
{ name = "elasticsearch" },
{ name = "elasticsearch-dsl" },
{ name = "ftfy" },
{ name = "glom" },
{ name = "meilisearch" },
{ name = "orjson" },
{ name = "pycpfcnpj" },
{ name = "pydantic", extra = ["email"] },
@@ -464,12 +489,14 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "arnparse", specifier = ">=0.0.2" },
{ name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.8.0" },
{ name = "boto3", specifier = ">=1.37.16" },
{ name = "elasticsearch", specifier = ">=8.17.2" },
{ name = "elasticsearch-dsl", specifier = ">=8.17.1" },
{ name = "ftfy", specifier = ">=6.3.1" },
{ name = "glom", specifier = ">=24.11.0" },
{ name = "meilisearch", specifier = ">=0.34.0" },
{ name = "orjson", specifier = ">=3.10.15" },
{ name = "pycpfcnpj", specifier = ">=1.8" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.10.6" },
@@ -488,6 +515,19 @@ dev = [
{ name = "ruff", specifier = ">=0.11.1" },
]
[[package]]
name = "meilisearch"
version = "0.34.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "camel-converter", extra = ["pydantic"] },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/42/b6a62f355057521c0d9df44a402205e3037299fdcb9cee4dfa22eebd22f0/meilisearch-0.34.0.tar.gz", hash = "sha256:6244af23fa118f5a127ebf3f1297ea8d1d73324bf189b13d61cc201e18cd9e90", size = 23623 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/2f/264c07a3f488260ea36c78cbc201b76e6baf9ef92e0c7f78657a6a5e5f22/meilisearch-0.34.0-py3-none-any.whl", hash = "sha256:fae8ad2a15d12c27fa0a1fff2ae2e4e3e2e22b869950408d63c87e2c095a9f61", size = 24373 },
]
[[package]]
name = "orjson"
version = "3.10.16"