wip
This commit is contained in:
@@ -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.utilities.typing import LambdaContext
|
||||
|
||||
from middlewares import AuthorizerMiddleware
|
||||
from middlewares import AuthorizerMiddleware, TenantMiddleware
|
||||
from routes import courses, enrollments, lookup, me, orders, users, webhooks
|
||||
|
||||
DEBUG = os.getenv('LOG_LEVEL') == 'DEBUG'
|
||||
@@ -19,7 +19,7 @@ DEBUG = os.getenv('LOG_LEVEL') == 'DEBUG'
|
||||
tracer = Tracer()
|
||||
logger = Logger(__name__)
|
||||
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(enrollments.router, prefix='/enrollments')
|
||||
app.include_router(orders.router, prefix='/orders')
|
||||
|
||||
@@ -24,6 +24,7 @@ Example
|
||||
"""
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
import boto3
|
||||
from aws_lambda_powertools import Logger, Tracer
|
||||
@@ -53,31 +54,46 @@ collect = DynamoDBCollection(user_layer)
|
||||
@tracer.capture_lambda_handler
|
||||
@logger.inject_lambda_context
|
||||
@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', ''))
|
||||
|
||||
if not bearer:
|
||||
return APIGatewayAuthorizerResponseV2(authorize=False).asdict()
|
||||
|
||||
kwargs = asdict(_authorizer(bearer))
|
||||
return APIGatewayAuthorizerResponseV2(**kwargs).asdict()
|
||||
attrs = _authorizer(bearer).asdict()
|
||||
return APIGatewayAuthorizerResponseV2(**attrs).asdict()
|
||||
|
||||
|
||||
class TokenType(str, Enum):
|
||||
API_KEY = 'API_KEY'
|
||||
USER_TOKEN = 'USER_TOKEN'
|
||||
class AuthFlowType(str, Enum):
|
||||
USER_AUTH = 'USER_AUTH'
|
||||
API_AUTH = 'API_AUTH'
|
||||
|
||||
|
||||
@dataclass
|
||||
class BearerToken:
|
||||
auth_type: TokenType
|
||||
auth_flow_type: AuthFlowType
|
||||
token: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Authorizer:
|
||||
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:
|
||||
@@ -95,22 +111,13 @@ def _authorizer(bearer: BearerToken) -> Authorizer:
|
||||
An Authorizer object with the appropriate authorization status and context.
|
||||
"""
|
||||
try:
|
||||
match bearer.auth_type:
|
||||
case TokenType.USER_TOKEN:
|
||||
user = get_user(bearer.token, idp_client=idp_client)
|
||||
if bearer.auth_flow_type == AuthFlowType.USER_AUTH:
|
||||
user = get_user(bearer.token, idp_client)
|
||||
return Authorizer(True, {'user': user})
|
||||
case TokenType.API_KEY:
|
||||
apikey = collect.get_item(KeyPair('apikey', bearer.token))
|
||||
return Authorizer(
|
||||
True,
|
||||
pick(
|
||||
(
|
||||
'user',
|
||||
'tenant',
|
||||
),
|
||||
apikey,
|
||||
),
|
||||
)
|
||||
|
||||
apikey = _get_apikey(bearer.token)
|
||||
context = pick(('tenant', 'user'), apikey)
|
||||
return Authorizer(True, context, AuthFlowType.API_AUTH)
|
||||
except Exception:
|
||||
return Authorizer()
|
||||
|
||||
@@ -118,14 +125,14 @@ def _authorizer(bearer: BearerToken) -> Authorizer:
|
||||
def _parse_bearer_token(s: str) -> BearerToken | None:
|
||||
"""Parses and identifies a bearer token as either an API key or a user token."""
|
||||
try:
|
||||
_, bearer_token = s.split(' ')
|
||||
_, token = s.split(' ')
|
||||
|
||||
if bearer_token.startswith(APIKEY_PREFIX):
|
||||
if token.startswith(APIKEY_PREFIX):
|
||||
return BearerToken(
|
||||
TokenType.API_KEY,
|
||||
bearer_token.removeprefix(APIKEY_PREFIX),
|
||||
AuthFlowType.API_AUTH,
|
||||
token.removeprefix(APIKEY_PREFIX),
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
return BearerToken(TokenType.USER_TOKEN, bearer_token)
|
||||
return BearerToken(AuthFlowType.USER_AUTH, token)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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."""
|
||||
try:
|
||||
user = idp_client.get_user(AccessToken=access_token)
|
||||
|
||||
@@ -12,15 +12,21 @@ from aws_lambda_powertools.shared.functions import (
|
||||
from layercake.dateutils import now, ttl
|
||||
from layercake.dynamodb import ComposeKey, DynamoDBCollection, KeyPair
|
||||
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
|
||||
|
||||
|
||||
class AuthenticatedUser(BaseModel):
|
||||
id: str = Field(alias='custom:user_id')
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: str
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class CognitoUser(User):
|
||||
id: str = Field(alias='custom:user_id')
|
||||
email_verified: bool
|
||||
sub: UUID4
|
||||
|
||||
@@ -33,12 +39,31 @@ class AuthorizerMiddleware(BaseMiddlewareHandler):
|
||||
) -> Response:
|
||||
# Gets the Lambda authorizer associated with the current API Gateway event.
|
||||
# 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:
|
||||
user = authorizer['user']
|
||||
app.append_context(authenticated_user=AuthenticatedUser(**user))
|
||||
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
|
||||
auth_flow_type = context.get('auth_flow_type')
|
||||
return next_middleware(app)
|
||||
|
||||
|
||||
@@ -78,11 +103,10 @@ class AuditLogMiddleware(BaseMiddlewareHandler):
|
||||
app: APIGatewayHttpResolver,
|
||||
next_middleware: NextMiddleware,
|
||||
) -> 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)
|
||||
user = app.context.get('authenticated_user')
|
||||
request_ctx = app.current_event.request_context
|
||||
ip_addr = request_ctx.http.source_ip
|
||||
|
||||
# Successful request
|
||||
if 200 <= response.status_code < 300 and user:
|
||||
@@ -98,7 +122,7 @@ class AuditLogMiddleware(BaseMiddlewareHandler):
|
||||
else None
|
||||
)
|
||||
|
||||
collect.put_item(
|
||||
self.collect.put_item(
|
||||
key=KeyPair(
|
||||
pk=ComposeKey(user.id, prefix='logs'),
|
||||
sk=now_.isoformat(),
|
||||
|
||||
@@ -8,7 +8,7 @@ from layercake.dynamodb import (
|
||||
|
||||
import konviva
|
||||
from boto3clients import dynamodb_client
|
||||
from middlewares import AuthenticatedUser
|
||||
from middlewares import User
|
||||
from settings import USER_TABLE
|
||||
|
||||
router = Router()
|
||||
@@ -21,7 +21,7 @@ LIMIT = 25
|
||||
|
||||
@router.get('/', include_in_schema=False)
|
||||
def me():
|
||||
user: AuthenticatedUser = router.context['authenticated_user']
|
||||
user: User = router.context['user']
|
||||
acls = collect.get_items(
|
||||
KeyPair(user.id, PrefixKey('acls')),
|
||||
limit=LIMIT,
|
||||
@@ -39,7 +39,7 @@ def me():
|
||||
|
||||
@router.get('/konviva', include_in_schema=False)
|
||||
def konviva_():
|
||||
user: AuthenticatedUser = router.context['authenticated_user']
|
||||
user: User = router.context['user']
|
||||
token = konviva.token(user.email)
|
||||
|
||||
return {'redirect_uri': konviva.redirect_uri(token)}
|
||||
|
||||
@@ -59,6 +59,7 @@ class HttpApiProxy:
|
||||
'custom:user_id': '5OxmMjL-ujoR5IMGegQz',
|
||||
'sub': 'c4f30dbd-083e-4b84-aa50-c31afe9b9c01',
|
||||
},
|
||||
'auth_flow_type': 'USER_AUTH',
|
||||
},
|
||||
'jwt': {
|
||||
'claims': {'claim1': 'value1', 'claim2': 'value2'},
|
||||
|
||||
@@ -26,7 +26,8 @@ def test_bearer_jwt(lambda_context: LambdaContext):
|
||||
'sub': '58efed8d-d276-41a8-8502-4ab8b5a6415e',
|
||||
'name': 'pytest',
|
||||
'custom:user_id': '5OxmMjL-ujoR5IMGegQz',
|
||||
}
|
||||
},
|
||||
'auth_flow_type': 'USER_AUTH',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -55,10 +56,11 @@ def test_bearer_apikey(
|
||||
'name': 'Sérgio R Siqueira',
|
||||
'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(
|
||||
{
|
||||
'headers': {
|
||||
@@ -75,11 +77,11 @@ def test_parse_bearer_token_api_key():
|
||||
)
|
||||
|
||||
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():
|
||||
bearer = _parse_bearer_token('Bearer d977f5a2-0302-4dd2-87c7-57414264d27a')
|
||||
|
||||
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
2
http-api/uv.lock
generated
@@ -444,7 +444,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "layercake"
|
||||
version = "0.1.9"
|
||||
version = "0.1.11"
|
||||
source = { directory = "../layercake" }
|
||||
dependencies = [
|
||||
{ name = "aws-lambda-powertools", extra = ["all"] },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "layercake"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
@@ -21,7 +21,6 @@ dependencies = [
|
||||
"pytz>=2025.1",
|
||||
"shortuuid>=1.0.13",
|
||||
"requests>=2.32.3",
|
||||
"dataclasses-json>=0.6.7",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
51
layercake/uv.lock
generated
51
layercake/uv.lock
generated
@@ -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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "dnspython"
|
||||
version = "2.7.0"
|
||||
@@ -478,12 +465,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "layercake"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aws-lambda-powertools", extra = ["all"] },
|
||||
{ name = "boto3" },
|
||||
{ name = "dataclasses-json" },
|
||||
{ name = "elasticsearch" },
|
||||
{ name = "elasticsearch-dsl" },
|
||||
{ name = "ftfy" },
|
||||
@@ -510,7 +496,6 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "aws-lambda-powertools", extras = ["all"], specifier = ">=3.8.0" },
|
||||
{ name = "boto3", specifier = ">=1.37.16" },
|
||||
{ name = "dataclasses-json", specifier = ">=0.6.7" },
|
||||
{ name = "elasticsearch", specifier = ">=8.17.2" },
|
||||
{ name = "elasticsearch-dsl", specifier = ">=8.17.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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "mergedeep"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "orjson"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "urllib3"
|
||||
version = "2.3.0"
|
||||
|
||||
Reference in New Issue
Block a user