add apikey

This commit is contained in:
2025-03-27 01:14:18 -03:00
parent 7021833476
commit 8118dfd403
14 changed files with 114 additions and 69 deletions

View File

@@ -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:

View File

@@ -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)

View File

@@ -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']}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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),
)

View File

@@ -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}}

View File

@@ -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

View File

@@ -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"}}

View File

@@ -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',

2
http-api/uv.lock generated
View File

@@ -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"] },

View File

@@ -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:

View File

@@ -1,6 +1,6 @@
[project]
name = "layercake"
version = "0.1.4"
version = "0.1.5"
description = "Add your description here"
readme = "README.md"
authors = [

View File

@@ -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 == {}