This commit is contained in:
2025-03-28 20:37:04 -03:00
parent a1141dcce8
commit dbe7a924e2
10 changed files with 89 additions and 105 deletions

View File

@@ -11,7 +11,7 @@ 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 from middlewares import AuthorizerMiddleware, TenantMiddleware
from routes import courses, enrollments, lookup, me, orders, users, webhooks from routes import courses, enrollments, lookup, me, orders, users, webhooks
DEBUG = os.getenv('LOG_LEVEL') == 'DEBUG' DEBUG = os.getenv('LOG_LEVEL') == 'DEBUG'
@@ -19,7 +19,7 @@ DEBUG = os.getenv('LOG_LEVEL') == 'DEBUG'
tracer = Tracer() tracer = Tracer()
logger = Logger(__name__) logger = Logger(__name__)
app = APIGatewayHttpResolver(enable_validation=True, debug=DEBUG) app = APIGatewayHttpResolver(enable_validation=True, debug=DEBUG)
app.use(middlewares=[AuthorizerMiddleware()]) app.use(middlewares=[AuthorizerMiddleware(), TenantMiddleware()])
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

@@ -24,6 +24,7 @@ Example
""" """
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from typing import Any
import boto3 import boto3
from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools import Logger, Tracer
@@ -53,31 +54,46 @@ collect = DynamoDBCollection(user_layer)
@tracer.capture_lambda_handler @tracer.capture_lambda_handler
@logger.inject_lambda_context @logger.inject_lambda_context
@event_source(data_class=APIGatewayAuthorizerEventV2) @event_source(data_class=APIGatewayAuthorizerEventV2)
def lambda_handler(event: APIGatewayAuthorizerEventV2, context: LambdaContext): def lambda_handler(event: APIGatewayAuthorizerEventV2, context: LambdaContext) -> dict:
bearer = _parse_bearer_token(event.headers.get('authorization', '')) bearer = _parse_bearer_token(event.headers.get('authorization', ''))
if not bearer: if not bearer:
return APIGatewayAuthorizerResponseV2(authorize=False).asdict() return APIGatewayAuthorizerResponseV2(authorize=False).asdict()
kwargs = asdict(_authorizer(bearer)) attrs = _authorizer(bearer).asdict()
return APIGatewayAuthorizerResponseV2(**kwargs).asdict() return APIGatewayAuthorizerResponseV2(**attrs).asdict()
class TokenType(str, Enum): class AuthFlowType(str, Enum):
API_KEY = 'API_KEY' USER_AUTH = 'USER_AUTH'
USER_TOKEN = 'USER_TOKEN' API_AUTH = 'API_AUTH'
@dataclass @dataclass
class BearerToken: class BearerToken:
auth_type: TokenType auth_flow_type: AuthFlowType
token: str token: str
@dataclass @dataclass
class Authorizer: class Authorizer:
authorize: bool = False authorize: bool = False
context: dict | None = None context: dict[str, Any] | None = None
auth_flow_type: AuthFlowType = AuthFlowType.USER_AUTH
def asdict(self) -> dict:
data = asdict(self)
auth_flow_type = data.pop('auth_flow_type')
# If authorization is enabled, add `auth_flow_type` to the context
if self.authorize:
data['context'].update(auth_flow_type=auth_flow_type)
return data
def _get_apikey(token: str) -> dict[str, dict | str]:
return collect.get_item(KeyPair('apikey', token))
def _authorizer(bearer: BearerToken) -> Authorizer: def _authorizer(bearer: BearerToken) -> Authorizer:
@@ -95,22 +111,13 @@ def _authorizer(bearer: BearerToken) -> Authorizer:
An Authorizer object with the appropriate authorization status and context. An Authorizer object with the appropriate authorization status and context.
""" """
try: try:
match bearer.auth_type: if bearer.auth_flow_type == AuthFlowType.USER_AUTH:
case TokenType.USER_TOKEN: user = get_user(bearer.token, idp_client)
user = get_user(bearer.token, idp_client=idp_client)
return Authorizer(True, {'user': user}) return Authorizer(True, {'user': user})
case TokenType.API_KEY:
apikey = collect.get_item(KeyPair('apikey', bearer.token)) apikey = _get_apikey(bearer.token)
return Authorizer( context = pick(('tenant', 'user'), apikey)
True, return Authorizer(True, context, AuthFlowType.API_AUTH)
pick(
(
'user',
'tenant',
),
apikey,
),
)
except Exception: except Exception:
return Authorizer() return Authorizer()
@@ -118,14 +125,14 @@ def _authorizer(bearer: BearerToken) -> Authorizer:
def _parse_bearer_token(s: str) -> BearerToken | None: def _parse_bearer_token(s: str) -> BearerToken | None:
"""Parses and identifies a bearer token as either an API key or a user token.""" """Parses and identifies a bearer token as either an API key or a user token."""
try: try:
_, bearer_token = s.split(' ') _, token = s.split(' ')
if bearer_token.startswith(APIKEY_PREFIX): if token.startswith(APIKEY_PREFIX):
return BearerToken( return BearerToken(
TokenType.API_KEY, AuthFlowType.API_AUTH,
bearer_token.removeprefix(APIKEY_PREFIX), token.removeprefix(APIKEY_PREFIX),
) )
except ValueError: except ValueError:
return None return None
else: else:
return BearerToken(TokenType.USER_TOKEN, bearer_token) return BearerToken(AuthFlowType.USER_AUTH, token)

View File

@@ -1,7 +1,7 @@
class UnauthorizedError(Exception): ... class UnauthorizedError(Exception): ...
def get_user(access_token: str, *, idp_client) -> dict | None: def get_user(access_token: str, /, idp_client) -> dict[str, str]:
"""Gets the user attributes and metadata for a user.""" """Gets the user attributes and metadata for a user."""
try: try:
user = idp_client.get_user(AccessToken=access_token) user = idp_client.get_user(AccessToken=access_token)

View File

@@ -12,15 +12,21 @@ from aws_lambda_powertools.shared.functions import (
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, Field from pydantic import UUID4, BaseModel, EmailStr, Field
from auth import AuthFlowType
LOG_RETENTION_DAYS = 365 * 2 # 2 years LOG_RETENTION_DAYS = 365 * 2 # 2 years
class AuthenticatedUser(BaseModel): class User(BaseModel):
id: str = Field(alias='custom:user_id') id: str
name: str name: str
email: str email: EmailStr
class CognitoUser(User):
id: str = Field(alias='custom:user_id')
email_verified: bool email_verified: bool
sub: UUID4 sub: UUID4
@@ -33,12 +39,31 @@ class AuthorizerMiddleware(BaseMiddlewareHandler):
) -> Response: ) -> Response:
# Gets the Lambda authorizer associated with the current API Gateway event. # Gets the Lambda authorizer associated with the current API Gateway event.
# You can check the file `auth.py` for more details. # You can check the file `auth.py` for more details.
authorizer = app.current_event.request_context.authorizer.get_lambda context = app.current_event.request_context.authorizer.get_lambda
auth_flow_type = context.get('auth_flow_type')
if 'user' in authorizer: if not auth_flow_type:
user = authorizer['user'] return next_middleware(app)
app.append_context(authenticated_user=AuthenticatedUser(**user))
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
auth_flow_type = context.get('auth_flow_type')
return next_middleware(app) return next_middleware(app)
@@ -78,11 +103,10 @@ class AuditLogMiddleware(BaseMiddlewareHandler):
app: APIGatewayHttpResolver, app: APIGatewayHttpResolver,
next_middleware: NextMiddleware, next_middleware: NextMiddleware,
) -> Response: ) -> Response:
collect = self.collect user = app.context.get('user')
req_context = app.current_event.request_context
ip_addr = req_context.http.source_ip
response = next_middleware(app) response = next_middleware(app)
user = app.context.get('authenticated_user')
request_ctx = app.current_event.request_context
ip_addr = request_ctx.http.source_ip
# Successful request # Successful request
if 200 <= response.status_code < 300 and user: if 200 <= response.status_code < 300 and user:
@@ -98,7 +122,7 @@ class AuditLogMiddleware(BaseMiddlewareHandler):
else None else None
) )
collect.put_item( self.collect.put_item(
key=KeyPair( key=KeyPair(
pk=ComposeKey(user.id, prefix='logs'), pk=ComposeKey(user.id, prefix='logs'),
sk=now_.isoformat(), sk=now_.isoformat(),

View File

@@ -8,7 +8,7 @@ from layercake.dynamodb import (
import konviva import konviva
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from middlewares import AuthenticatedUser from middlewares import User
from settings import USER_TABLE from settings import USER_TABLE
router = Router() router = Router()
@@ -21,7 +21,7 @@ LIMIT = 25
@router.get('/', include_in_schema=False) @router.get('/', include_in_schema=False)
def me(): def me():
user: AuthenticatedUser = router.context['authenticated_user'] user: User = router.context['user']
acls = collect.get_items( acls = collect.get_items(
KeyPair(user.id, PrefixKey('acls')), KeyPair(user.id, PrefixKey('acls')),
limit=LIMIT, limit=LIMIT,
@@ -39,7 +39,7 @@ def me():
@router.get('/konviva', include_in_schema=False) @router.get('/konviva', include_in_schema=False)
def konviva_(): def konviva_():
user: AuthenticatedUser = router.context['authenticated_user'] user: User = router.context['user']
token = konviva.token(user.email) token = konviva.token(user.email)
return {'redirect_uri': konviva.redirect_uri(token)} return {'redirect_uri': konviva.redirect_uri(token)}

View File

@@ -59,6 +59,7 @@ class HttpApiProxy:
'custom:user_id': '5OxmMjL-ujoR5IMGegQz', 'custom:user_id': '5OxmMjL-ujoR5IMGegQz',
'sub': 'c4f30dbd-083e-4b84-aa50-c31afe9b9c01', 'sub': 'c4f30dbd-083e-4b84-aa50-c31afe9b9c01',
}, },
'auth_flow_type': 'USER_AUTH',
}, },
'jwt': { 'jwt': {
'claims': {'claim1': 'value1', 'claim2': 'value2'}, 'claims': {'claim1': 'value1', 'claim2': 'value2'},

View File

@@ -26,7 +26,8 @@ def test_bearer_jwt(lambda_context: LambdaContext):
'sub': '58efed8d-d276-41a8-8502-4ab8b5a6415e', 'sub': '58efed8d-d276-41a8-8502-4ab8b5a6415e',
'name': 'pytest', 'name': 'pytest',
'custom:user_id': '5OxmMjL-ujoR5IMGegQz', 'custom:user_id': '5OxmMjL-ujoR5IMGegQz',
} },
'auth_flow_type': 'USER_AUTH',
}, },
} }
@@ -55,10 +56,11 @@ def test_bearer_apikey(
'name': 'Sérgio R Siqueira', 'name': 'Sérgio R Siqueira',
'email': 'sergio@somosbeta.com.br', 'email': 'sergio@somosbeta.com.br',
}, },
'auth_flow_type': 'API_AUTH',
}, },
} }
# # This data was added from seeds # This data was added from seeds
assert app.lambda_handler( assert app.lambda_handler(
{ {
'headers': { 'headers': {
@@ -75,11 +77,11 @@ def test_parse_bearer_token_api_key():
) )
assert bearer.token == '35433970-6857-4062-bb43-f71683b2f68e' # type: ignore assert bearer.token == '35433970-6857-4062-bb43-f71683b2f68e' # type: ignore
assert bearer.auth_type == 'API_KEY' # type: ignore assert bearer.auth_flow_type == 'API_AUTH' # type: ignore
def test_parse_bearer_token_user_token(): def test_parse_bearer_token_user_token():
bearer = _parse_bearer_token('Bearer d977f5a2-0302-4dd2-87c7-57414264d27a') bearer = _parse_bearer_token('Bearer d977f5a2-0302-4dd2-87c7-57414264d27a')
assert bearer.token == 'd977f5a2-0302-4dd2-87c7-57414264d27a' # type: ignore assert bearer.token == 'd977f5a2-0302-4dd2-87c7-57414264d27a' # type: ignore
assert bearer.auth_type == 'USER_TOKEN' # type: ignore assert bearer.auth_flow_type == 'USER_AUTH' # type: ignore

2
http-api/uv.lock generated
View File

@@ -444,7 +444,7 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.1.9" version = "0.1.11"
source = { directory = "../layercake" } source = { directory = "../layercake" }
dependencies = [ dependencies = [
{ name = "aws-lambda-powertools", extra = ["all"] }, { name = "aws-lambda-powertools", extra = ["all"] },

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "layercake" name = "layercake"
version = "0.1.10" version = "0.1.11"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
authors = [ authors = [
@@ -21,7 +21,6 @@ dependencies = [
"pytz>=2025.1", "pytz>=2025.1",
"shortuuid>=1.0.13", "shortuuid>=1.0.13",
"requests>=2.32.3", "requests>=2.32.3",
"dataclasses-json>=0.6.7",
] ]
[dependency-groups] [dependency-groups]

51
layercake/uv.lock generated
View File

@@ -279,19 +279,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 }, { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 },
] ]
[[package]]
name = "dataclasses-json"
version = "0.6.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "marshmallow" },
{ name = "typing-inspect" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 },
]
[[package]] [[package]]
name = "dnspython" name = "dnspython"
version = "2.7.0" version = "2.7.0"
@@ -478,12 +465,11 @@ wheels = [
[[package]] [[package]]
name = "layercake" name = "layercake"
version = "0.1.9" version = "0.1.10"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aws-lambda-powertools", extra = ["all"] }, { name = "aws-lambda-powertools", extra = ["all"] },
{ name = "boto3" }, { name = "boto3" },
{ name = "dataclasses-json" },
{ name = "elasticsearch" }, { name = "elasticsearch" },
{ name = "elasticsearch-dsl" }, { name = "elasticsearch-dsl" },
{ name = "ftfy" }, { name = "ftfy" },
@@ -510,7 +496,6 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.8.0" }, { name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.8.0" },
{ name = "boto3", specifier = ">=1.37.16" }, { name = "boto3", specifier = ">=1.37.16" },
{ name = "dataclasses-json", specifier = ">=0.6.7" },
{ name = "elasticsearch", specifier = ">=8.17.2" }, { name = "elasticsearch", specifier = ">=8.17.2" },
{ name = "elasticsearch-dsl", specifier = ">=8.17.1" }, { name = "elasticsearch-dsl", specifier = ">=8.17.1" },
{ name = "ftfy", specifier = ">=6.3.1" }, { name = "ftfy", specifier = ">=6.3.1" },
@@ -580,18 +565,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
] ]
[[package]]
name = "marshmallow"
version = "3.26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/5e/5e53d26b42ab75491cda89b871dab9e97c840bf12c63ec58a1919710cd06/marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6", size = 221825 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878 },
]
[[package]] [[package]]
name = "mergedeep" name = "mergedeep"
version = "1.3.4" version = "1.3.4"
@@ -689,15 +662,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/d0/ef6e82f7a68c7ac02e1a01815fbe88773f4f9e40728ed35bd1664a5d76f2/mkdocstrings_python-1.16.8-py3-none-any.whl", hash = "sha256:211b7aaf776cd45578ecb531e5ad0d3a35a8be9101a6bfa10de38a69af9d8fd8", size = 124116 }, { url = "https://files.pythonhosted.org/packages/67/d0/ef6e82f7a68c7ac02e1a01815fbe88773f4f9e40728ed35bd1664a5d76f2/mkdocstrings_python-1.16.8-py3-none-any.whl", hash = "sha256:211b7aaf776cd45578ecb531e5ad0d3a35a8be9101a6bfa10de38a69af9d8fd8", size = 124116 },
] ]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 },
]
[[package]] [[package]]
name = "orjson" name = "orjson"
version = "3.10.15" version = "3.10.15"
@@ -1079,19 +1043,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
] ]
[[package]]
name = "typing-inspect"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 },
]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.3.0" version = "2.3.0"