From 8118dfd403f7b689b9ce5759c0f3ac7805692696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Thu, 27 Mar 2025 01:14:18 -0300 Subject: [PATCH] add apikey --- http-api/auth.py | 38 ++++++++++----- http-api/cli.py | 73 ++++++++++++++--------------- http-api/cognito.py | 5 +- http-api/routes/courses/__init__.py | 4 +- http-api/routes/me/__init__.py | 4 +- http-api/routes/users/__init__.py | 6 +-- http-api/seeds/test-users.jsonl | 1 + http-api/template.yaml | 2 +- http-api/tests/seeds.jsonl | 1 + http-api/tests/test_auth.py | 29 +++++++++++- http-api/uv.lock | 2 +- layercake/layercake/dynamodb.py | 14 +++--- layercake/pyproject.toml | 2 +- layercake/tests/test_dynamodb.py | 2 +- 14 files changed, 114 insertions(+), 69 deletions(-) diff --git a/http-api/auth.py b/http-api/auth.py index a64c616..6b89823 100644 --- a/http-api/auth.py +++ b/http-api/auth.py @@ -23,7 +23,7 @@ Example """ -from dataclasses import dataclass +from dataclasses import asdict, dataclass import boto3 from aws_lambda_powertools import Logger, Tracer @@ -34,14 +34,19 @@ from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event i ) from aws_lambda_powertools.utilities.typing import LambdaContext from botocore.endpoint_provider import Enum +from layercake.dynamodb import DynamoDBCollection, DynamoDBPersistenceLayer, KeyPair +from boto3clients import dynamodb_client from cognito import get_user +from settings import USER_TABLE APIKEY_PREFIX = 'sk-' tracer = Tracer() logger = Logger(__name__) idp_client = boto3.client('cognito-idp') +user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) +collect = DynamoDBCollection(user_layer) @tracer.capture_lambda_handler @@ -53,16 +58,8 @@ def lambda_handler(event: APIGatewayAuthorizerEventV2, context: LambdaContext): if not bearer: return APIGatewayAuthorizerResponseV2(authorize=False).asdict() - if bearer.auth_type == TokenType.USER_TOKEN: - user = get_user(bearer.token, idp_client=idp_client) - - if user: - return APIGatewayAuthorizerResponseV2( - authorize=True, - context=dict(user=user), - ).asdict() - - return APIGatewayAuthorizerResponseV2(authorize=False).asdict() + kwargs = asdict(_authorizer(bearer)) + return APIGatewayAuthorizerResponseV2(**kwargs).asdict() class TokenType(str, Enum): @@ -76,6 +73,25 @@ class BearerToken: token: str +@dataclass +class Authorizer: + authorize: bool = False + context: dict | None = None + + +def _authorizer(bearer: BearerToken) -> Authorizer: + try: + match bearer.auth_type: + case TokenType.USER_TOKEN: + user = get_user(bearer.token, idp_client=idp_client) + return Authorizer(True, {'user': user}) + case TokenType.API_KEY: + apikey = collect.get_item(KeyPair('apikey', bearer.token)) + return Authorizer(True, {'tenant': apikey['tenant']}) + except Exception: + return 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: diff --git a/http-api/cli.py b/http-api/cli.py index 9bd1bb3..f643ffb 100644 --- a/http-api/cli.py +++ b/http-api/cli.py @@ -8,7 +8,7 @@ from tqdm import tqdm from boto3clients import dynamodb_client elastic_client = Elasticsearch('http://127.0.0.1:9200') -files = ( +jsonl_files = ( 'test-orders.jsonl', 'test-users.jsonl', 'test-enrollments.jsonl', @@ -16,7 +16,7 @@ files = ( ) -def put_item(item: dict, table_name: str, *, dynamodb_client) -> bool: +def put_item(item: dict, table_name: str, /, dynamodb_client) -> bool: try: dynamodb_client.put_item( TableName=table_name, @@ -28,7 +28,7 @@ def put_item(item: dict, table_name: str, *, dynamodb_client) -> bool: return True -def scan_table(table_name: str, *, dynamodb_client, **kwargs) -> Generator: +def scan_table(table_name: str, /, dynamodb_client, **kwargs) -> Generator: try: r = dynamodb_client.scan(TableName=table_name, **kwargs) except Exception: @@ -45,6 +45,31 @@ def scan_table(table_name: str, *, dynamodb_client, **kwargs) -> Generator: ) +class Elastic: + def __init__(self, client: Elasticsearch) -> None: + self.client = client + + def index_item( + self, + id: str, + index: str, + doc: dict, + ): + return self.client.index( + index=index, + id=id, + document=_serialize_python_type(doc), + ) + + def delete_index(self, index: str) -> bool: + try: + self.client.indices.delete(index=index) + except Exception: + return False + else: + return True + + def _serialize_python_type(value: Any) -> Any: if isinstance(value, dict): return {k: _serialize_python_type(v) for k, v in value.items()} @@ -58,53 +83,27 @@ def _serialize_python_type(value: Any) -> Any: return value -def index_item( - id: str, - index: str, - doc: dict, - *, - elastic_client: Elasticsearch, -): - return elastic_client.index( - index=index, - id=id, - document=_serialize_python_type(doc), - ) - - -def delete_index(index: str, *, elastic_client: Elasticsearch) -> bool: - try: - elastic_client.indices.delete(index=index) - except Exception: - return False - else: - return True - - if __name__ == '__main__': - for file in tqdm(files, desc='Processing files'): + elastic = Elastic(elastic_client) + + for file in tqdm(jsonl_files, desc='Processing files'): with jsonl.readlines(f'seeds/{file}') as lines: table_name = file.removesuffix('.jsonl') for line in tqdm(lines, desc=f'Processing lines in {file}'): - put_item(line, table_name, dynamodb_client=dynamodb_client) + put_item(line, table_name, dynamodb_client) - for file in tqdm(files, desc='Scanning tables'): + for file in tqdm(jsonl_files, desc='Scanning tables'): table_name = file.removesuffix('.jsonl') - delete_index(table_name, elastic_client=elastic_client) + elastic.delete_index(table_name) for record in tqdm( scan_table( table_name, - dynamodb_client=dynamodb_client, + dynamodb_client, FilterExpression='sk = :sk', ExpressionAttributeValues={':sk': {'S': '0'}}, ), desc=f'Indexing {table_name}', ): - index_item( - id=record['id'], - index=table_name, - doc=record, - elastic_client=elastic_client, - ) + elastic.index_item(id=record['id'], index=table_name, doc=record) diff --git a/http-api/cognito.py b/http-api/cognito.py index b1c3120..485bf6d 100644 --- a/http-api/cognito.py +++ b/http-api/cognito.py @@ -1,8 +1,11 @@ +class UserNotFound(Exception): ... + + def get_user(access_token: str, *, idp_client) -> dict | None: """Gets the user attributes and metadata for a user.""" try: user = idp_client.get_user(AccessToken=access_token) except idp_client.exceptions.ClientError: - return None + raise UserNotFound() else: return {attr['Name']: attr['Value'] for attr in user['UserAttributes']} diff --git a/http-api/routes/courses/__init__.py b/http-api/routes/courses/__init__.py index 6188337..192bec3 100644 --- a/http-api/routes/courses/__init__.py +++ b/http-api/routes/courses/__init__.py @@ -43,11 +43,9 @@ class CoursePayload(BaseModel): @router.post('/', compress=True, tags=['Course']) def post_course(payload: CoursePayload): - org = Org(id='*', name='EDUSEG') - create_course( course=payload.course, - org=org, + org=Org(id='*', name='default'), persistence_layer=course_layer, ) diff --git a/http-api/routes/me/__init__.py b/http-api/routes/me/__init__.py index 4998e9a..0dad297 100644 --- a/http-api/routes/me/__init__.py +++ b/http-api/routes/me/__init__.py @@ -23,11 +23,11 @@ LIMIT = 25 def me(): user: AuthenticatedUser = router.context['user'] acls = collect.get_items( - KeyPair(user.id, PrefixKey('acls#')), + KeyPair(user.id, PrefixKey('acls')), limit=LIMIT, ) workspaces = collect.get_items( - KeyPair(user.id, PrefixKey('orgs#')), + KeyPair(user.id, PrefixKey('orgs')), limit=LIMIT, ) diff --git a/http-api/routes/users/__init__.py b/http-api/routes/users/__init__.py index 8e0ae56..1147b8e 100644 --- a/http-api/routes/users/__init__.py +++ b/http-api/routes/users/__init__.py @@ -32,7 +32,7 @@ class BadRequestError(MissingError, PowertoolsBadRequestError): ... router = Router() user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) -collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError) +collect = DynamoDBCollection(user_layer, exc_cls=BadRequestError) elastic_client = Elasticsearch(**ELASTIC_CONN) @@ -98,7 +98,7 @@ def get_idp(id: str): ) def get_emails(id: str): return collect.get_items( - KeyPair(id, PrefixKey('emails#')), + KeyPair(id, PrefixKey('emails')), start_key=router.current_event.get_query_string_value('start_key', None), ) @@ -126,6 +126,6 @@ def get_logs(id: str): ) def get_orgs(id: str): return collect.get_items( - KeyPair(id, PrefixKey('orgs#')), + KeyPair(id, PrefixKey('orgs')), start_key=router.current_event.get_query_string_value('start_key', None), ) diff --git a/http-api/seeds/test-users.jsonl b/http-api/seeds/test-users.jsonl index eb8d187..a61e966 100644 --- a/http-api/seeds/test-users.jsonl +++ b/http-api/seeds/test-users.jsonl @@ -1,3 +1,4 @@ +{"id": {"S": "apikey"}, "sk": {"S": "32504457-f133-4c00-936b-6aa712ca9f40"}} {"updateDate": {"S": "2024-02-08T16:42:33.776409-03:00"}, "createDate": {"S": "2019-03-25T00:00:00-03:00"}, "email_verified": {"BOOL": true}, "cognito:sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"}, "cpf": {"S": "07879819908"}, "sk": {"S": "0"}, "email": {"S": "sergio@somosbeta.com.br"}, "id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}, "lastLogin": {"S": "2024-02-08T20:53:45.818126-03:00"}, "orgs": {"L": [{"S": "cJtK9SsnJhKPyxESe7g3DG"}, {"S": "edp8njvgQuzNkLx2ySNfAD"}, {"S": "8TVSi5oACLxTiT8ycKPmaQ"}]}} {"sk": {"S": "acl#admin"}, "id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "create_date": {"S": "2022-06-13T15:00:24.309410-03:00"}} {"emailVerified": {"BOOL": true}, "updateDate": {"S": "2024-02-08T16:42:33.776409-03:00"}, "createDate": {"S": "2024-01-19T22:53:43.135080-03:00"}, "deliverability": {"S": "DELIVERABLE"}, "sk": {"S": "emails#osergiosiqueira@gmail.com"}, "id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "primaryEmail": {"BOOL": false}, "emailDeliverable": {"BOOL": true}} diff --git a/http-api/template.yaml b/http-api/template.yaml index 617e17c..b0deb98 100644 --- a/http-api/template.yaml +++ b/http-api/template.yaml @@ -23,7 +23,7 @@ Globals: Architectures: - x86_64 Layers: - - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:20 + - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:21 Environment: Variables: TZ: America/Sao_Paulo diff --git a/http-api/tests/seeds.jsonl b/http-api/tests/seeds.jsonl index 9db4070..34038e9 100644 --- a/http-api/tests/seeds.jsonl +++ b/http-api/tests/seeds.jsonl @@ -1,3 +1,4 @@ +{"id": {"S": "apikey"}, "sk": {"S": "32504457-f133-4c00-936b-6aa712ca9f40"}, "tenant": {"M": {"id": {"S": "*"}, "name": {"S": "default"}}}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "0"}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_verified": {"BOOL": true}, "cognito:sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"}, "cpf": {"S": "07879819908"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}, "last_login": {"S": "2024-02-08T20:53:45.818126-03:00"}, "tenant:org_id": {"L": [{"S": "cJtK9SsnJhKPyxESe7g3DG"}]}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "cognito"}, "create_date": {"S": "2025-03-03T17:12:26.443507-03:00"}, "sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"}} {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "emails#sergio@somosbeta.com.br"}, "email_verified": {"BOOL": true}, "update_date": {"S": "2024-02-08T16:42:33.776409-03:00"}, "create_date": {"S": "2019-03-25T00:00:00-03:00"}, "email_primary": {"BOOL": true}, "mx_record_exists": {"BOOL": true}, "update_date": {"S": "2023-11-09T12:13:04.308986-03:00"}} diff --git a/http-api/tests/test_auth.py b/http-api/tests/test_auth.py index 808aee7..1fa81d4 100644 --- a/http-api/tests/test_auth.py +++ b/http-api/tests/test_auth.py @@ -1,10 +1,12 @@ +from layercake.dynamodb import DynamoDBCollection, DynamoDBPersistenceLayer + import auth as app from auth import _parse_bearer_token from .conftest import LambdaContext -def test_bearer_jwt(lambda_context: LambdaContext, dynamodb_seeds): +def test_bearer_jwt(lambda_context: LambdaContext): # You should mock the Cognito user to pass the test app.get_user = lambda *args, **kwargs: { 'sub': '58efed8d-d276-41a8-8502-4ab8b5a6415e', @@ -29,6 +31,31 @@ def test_bearer_jwt(lambda_context: LambdaContext, dynamodb_seeds): } +def test_bearer_apikey( + dynamodb_seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + lambda_context: LambdaContext, +): + app.collect = DynamoDBCollection(dynamodb_persistence_layer) + + event = { + 'headers': { + 'authorization': 'Bearer sk-32504457-f133-4c00-936b-6aa712ca9f40', + } + } + # This data was added from seeds + assert app.lambda_handler(event, lambda_context) == { + 'isAuthorized': True, + 'context': {'tenant': {'name': 'default', 'id': '*'}}, + } + + # This data was added from seeds + assert app.lambda_handler( + {'headers': {'authorization': 'Bearer sk-abc'}}, + lambda_context, + ) == {'isAuthorized': False} + + def test_parse_bearer_token_api_key(): bearer = _parse_bearer_token( 'Bearer sk-35433970-6857-4062-bb43-f71683b2f68e', diff --git a/http-api/uv.lock b/http-api/uv.lock index 5175699..256102c 100644 --- a/http-api/uv.lock +++ b/http-api/uv.lock @@ -444,7 +444,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.1.4" +version = "0.1.5" source = { directory = "../layercake" } dependencies = [ { name = "aws-lambda-powertools", extra = ["all"] }, diff --git a/layercake/layercake/dynamodb.py b/layercake/layercake/dynamodb.py index f38ee0d..ab13bee 100644 --- a/layercake/layercake/dynamodb.py +++ b/layercake/layercake/dynamodb.py @@ -572,30 +572,30 @@ class DynamoDBCollection: def __init__( self, persistence_layer: DynamoDBPersistenceLayer, - exception_cls: Type[ValueError] = MissingError, + exc_cls: Type[ValueError] = MissingError, tz: str = TZ, ) -> None: - if not issubclass(exception_cls, ValueError): + if not issubclass(exc_cls, ValueError): raise TypeError( - f'exception_cls must be a subclass of ValueError, got {exception_cls}' + f'exception_cls must be a subclass of ValueError, got {exc_cls}' ) self.persistence_layer = persistence_layer - self.exception_cls = exception_cls + self.exc_cls = exc_cls self.tz = tz def get_item( self, key: KeyPair, path_spec: str | None = None, - raise_if_missing: bool = True, + raise_on_error: bool = True, default: Any = None, delimiter: str = '#', ) -> Any: - exc_cls = self.exception_cls + exc_cls = self.exc_cls data = self.persistence_layer.get_item(key) - if raise_if_missing and not data: + if raise_on_error and not data: raise exc_cls(f'Item with {key} not found.') if path_spec and data: diff --git a/layercake/pyproject.toml b/layercake/pyproject.toml index b5bf237..fad6cd9 100644 --- a/layercake/pyproject.toml +++ b/layercake/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "layercake" -version = "0.1.4" +version = "0.1.5" description = "Add your description here" readme = "README.md" authors = [ diff --git a/layercake/tests/test_dynamodb.py b/layercake/tests/test_dynamodb.py index 246d264..0bca0d5 100644 --- a/layercake/tests/test_dynamodb.py +++ b/layercake/tests/test_dynamodb.py @@ -98,7 +98,7 @@ def test_collection_get_item( pk='5OxmMjL-ujoR5IMGegQz', sk='tenant', ), - raise_if_missing=False, + raise_on_error=False, default={}, ) assert data_notfound == {}