This commit is contained in:
2025-08-15 00:06:05 -03:00
parent 6e0e5f788d
commit a53f37393a
27 changed files with 617 additions and 338 deletions

View File

@@ -60,12 +60,14 @@ app.include_router(enrollments.cancel, prefix='/enrollments')
app.include_router(enrollments.deduplication_window, prefix='/enrollments')
app.include_router(orders.router, prefix='/orders')
app.include_router(users.router, prefix='/users')
app.include_router(users.add, prefix='/users')
app.include_router(users.logs, prefix='/users')
app.include_router(users.emails, prefix='/users')
app.include_router(users.orgs, prefix='/users')
app.include_router(billing.router, prefix='/billing')
app.include_router(orgs.policies, prefix='/orgs')
app.include_router(orgs.address, prefix='/orgs')
app.include_router(orgs.admins, prefix='/orgs')
app.include_router(orgs.custom_pricing, prefix='/orgs')
app.include_router(webhooks.router, prefix='/webhooks')
app.include_router(settings.router, prefix='/settings')

View File

@@ -1,5 +1,6 @@
from .address import router as address
from .admins import router as admins
from .custom_pricing import router as custom_pricing
from .policies import router as policies
__all__ = ['policies', 'address', 'custom_pricing']
__all__ = ['policies', 'address', 'custom_pricing', 'admins']

View File

@@ -0,0 +1,78 @@
from http import HTTPStatus
from typing import Annotated
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.openapi.params import Body
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from layercake.extra_types import NameStr
from pydantic import BaseModel, EmailStr
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
from config import USER_TABLE
router = Router()
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
@router.get('/<id>/admins', compress=True)
def admins(id: str):
return user_layer.collection.query(
KeyPair(id, 'admins'),
limit=100,
)
class Admin(BaseModel):
id: str
name: NameStr
email: EmailStr
@router.post('/<id>/admins', compress=True)
def grant(id: str, admin: Admin):
now_ = now()
with user_layer.transact_writer() as transact:
transact.condition(
key=KeyPair(f'orgmembers#{id}', admin.id),
cond_expr='attribute_exists(sk)',
)
# Grant admin privileges
transact.put(
item={
'id': admin.id,
'sk': f'acls#{id}',
'roles': ['ADMIN'],
'created_ad': now_,
},
cond_expr='attribute_not_exists(sk)',
)
# Add user to admin list
transact.put(
item={
'id': id,
'sk': f'admins#{admin.id}',
'name': admin.name,
'email': admin.email,
'created_ad': now_,
},
cond_expr='attribute_not_exists(sk)',
)
return JSONResponse(status_code=HTTPStatus.CREATED)
@router.delete('/<id>/admins', compress=True)
def revoke(
id: str,
user_id: Annotated[str, Body(embed=True)],
):
with user_layer.transact_writer() as transact:
# Revoke admin privileges
transact.delete(key=KeyPair(user_id, f'acls#{id}'))
# Remove user from admin list
transact.delete(key=KeyPair(id, f'admins#{user_id}'))
return JSONResponse(status_code=HTTPStatus.OK)

View File

@@ -24,13 +24,11 @@ course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
@router.get('/<id>/custompricing', compress=True)
def get_custom_pricing(id: str):
result = course_layer.collection.query(
return course_layer.collection.query(
PartitionKey(f'CUSTOM_PRICING#ORG#{id}'),
limit=100,
)
return result
class CustomPricing(BaseModel):
course_id: UUID4
@@ -38,7 +36,7 @@ class CustomPricing(BaseModel):
@router.post('/<id>/custompricing', compress=True)
def post_custom_pricing(id: str, custom_princing: CustomPricing):
def add_custom_pricing(id: str, custom_princing: CustomPricing):
now_ = now()
with course_layer.transact_writer() as transact:
@@ -61,18 +59,18 @@ def post_custom_pricing(id: str, custom_princing: CustomPricing):
return JSONResponse(status_code=HTTPStatus.CREATED)
class DeleteCustomPricing(BaseModel):
class RemoveCustomPricing(BaseModel):
course_id: UUID4
@router.delete('/<id>/custompricing', compress=True)
def delete_custom_pricing(id: str, custom_princing: DeleteCustomPricing):
pair = KeyPair(
def remove_custom_pricing(id: str, custom_princing: RemoveCustomPricing):
key = KeyPair(
f'CUSTOM_PRICING#ORG#{id}',
f'COURSE#{custom_princing.course_id}',
)
if course_layer.delete_item(pair):
if course_layer.delete_item(key):
return JSONResponse(status_code=HTTPStatus.OK)

View File

@@ -23,14 +23,14 @@ from api_gateway import JSONResponse
from boto3clients import dynamodb_client, idp_client
from config import MEILISEARCH_API_KEY, MEILISEARCH_HOST, USER_POOOL_ID, USER_TABLE
from middlewares import AuditLogMiddleware, Tenant, TenantMiddleware
from models import User
from rules.user import update_user
from .add import router as add
from .emails import router as emails
from .logs import router as logs
from .orgs import router as orgs
__all__ = ['logs', 'emails', 'orgs']
__all__ = ['add', 'logs', 'emails', 'orgs']
class BadRequestError(MissingError, PowertoolsBadRequestError):
@@ -82,17 +82,6 @@ def get_users():
)
@router.post(
'/',
compress=True,
tags=['User'],
summary='Create user',
middlewares=[AuditLogMiddleware('USER_ADD', user_collect)],
)
def post_user(payload: User):
return JSONResponse(status_code=HTTPStatus.CREATED)
class UserData(BaseModel):
name: NameStr
cpf: CpfStr
@@ -129,7 +118,7 @@ def put_user(id: str, payload: UserData):
@router.get('/<id>', compress=True, tags=['User'], summary='Get user')
def get_user(id: str):
return user_collect.get_items(
TransactKey(id) + SortKey('0') + SortKey('rate_limit#user_update')
TransactKey(id) + SortKey('0') + SortKey('RATE_LIMIT#USER_UPDATE')
)

View File

@@ -0,0 +1,163 @@
from http import HTTPStatus
from uuid import uuid4
from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError,
NotFoundError,
)
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
from layercake.extra_types import CnpjStr, CpfStr, NameStr
from pydantic import (
UUID4,
BaseModel,
ConfigDict,
EmailStr,
Field,
)
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
from config import USER_TABLE
router = Router()
logger = Logger(__name__)
layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
class Org(BaseModel):
id: str
name: str
cnpj: CnpjStr
class User(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: UUID4 = Field(default_factory=uuid4)
name: NameStr
email: EmailStr
email_verified: bool = False
cpf: CpfStr
org: Org = Field(..., exclude=True)
class UserNotFoundError(NotFoundError):
def __init__(self, *_):
super().__init__('User not found')
class CPFConflictError(BadRequestError):
def __init__(self, *_):
super().__init__('CPF already exists')
class EmailConflictError(BadRequestError):
def __init__(self, *_):
super().__init__('Email already exists')
@router.post('/', compress=True)
def add(user: User):
now_ = now()
user_id = user.id
org = user.org
try:
with layer.transact_writer() as transact:
transact.put(
item={
'id': 'cpf',
'sk': user.cpf,
'user_id': user.id,
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
exc_cls=CPFConflictError,
)
transact.put(
item={
'id': 'email',
'sk': user.email,
'user_id': user.id,
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
exc_cls=EmailConflictError,
)
transact.put(
item={
'sk': '0',
'tenant_id': {org.id},
# Post-migration: uncomment the following line
# 'createDate': now_,
'createDate': now_,
}
| user.model_dump()
)
transact.put(
item={
'id': user.id,
'sk': f'orgs#{user.org.id}',
'name': org.name,
'cnpj': org.cnpj,
'created_at': now_,
}
)
transact.put(
item={
'id': f'orgmembers#{org.id}',
'sk': user.id,
'created_at': now_,
}
)
except (CPFConflictError, EmailConflictError):
user_id = layer.collection.get_items(
KeyPair(
pk='cpf',
sk=SortKey(user.cpf, path_spec='user_id'),
rename_key='id',
)
+ KeyPair(
pk='email',
sk=SortKey(user.email, path_spec='user_id'),
rename_key='id',
),
flatten_top=False,
).get('id')
if not user_id:
raise UserNotFoundError()
with layer.transact_writer() as transact:
transact.update(
key=KeyPair(user_id, '0'),
update_expr='ADD tenant_id :org_id',
expr_attr_values={
':org_id': {org.id},
},
)
transact.put(
item={
'id': user_id,
'sk': f'orgs#{user.org.id}',
'name': org.name,
'cnpj': org.cnpj,
'created_at': now_,
}
)
transact.put(
item={
'id': f'orgmembers#{org.id}',
'sk': user_id,
'created_at': now_,
}
)
return JSONResponse(
status_code=HTTPStatus.CREATED,
body={'id': user_id},
)

View File

@@ -1,13 +1,11 @@
from http import HTTPStatus
from typing import Annotated
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError as PowertoolsBadRequestError,
)
from aws_lambda_powertools.event_handler.openapi.params import Body
from layercake.dynamodb import (
DynamoDBPersistenceLayer,
KeyPair,
MissingError,
PrefixKey,
)
from pydantic import BaseModel, EmailStr
@@ -15,23 +13,13 @@ from pydantic import BaseModel, EmailStr
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
from config import USER_TABLE
from middlewares import AuditLogMiddleware
from rules.user import add_email, del_email, set_email_as_primary
class BadRequestError(MissingError, PowertoolsBadRequestError): ...
from rules.user import add_email, remove_email, set_email_as_primary
router = Router()
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
@router.get(
'/<id>/emails',
compress=True,
tags=['User'],
summary='Get user emails',
)
@router.get('/<id>/emails', compress=True)
def get_emails(id: str):
start_key = router.current_event.get_query_string_value('start_key', None)
@@ -45,18 +33,12 @@ class Email(BaseModel):
email: EmailStr
@router.post(
'/<id>/emails',
compress=True,
tags=['User'],
summary='Add user email',
middlewares=[AuditLogMiddleware('EMAIL_ADD', user_layer.collection, ('email',))],
)
def post_email(id: str, payload: Email):
add_email(id, payload.email, persistence_layer=user_layer)
@router.post('/<id>/emails', compress=True)
def add_email_(id: str, email: Annotated[str, Body(embed=True)]):
add_email(id, email, persistence_layer=user_layer)
return JSONResponse(
body=payload,
body={'email': email},
status_code=HTTPStatus.CREATED,
)
@@ -67,22 +49,7 @@ class EmailAsPrimary(BaseModel):
email_verified: bool = False
@router.patch(
'/<id>/emails',
compress=True,
tags=['User'],
summary='Add user email as primary',
middlewares=[
AuditLogMiddleware(
'EMAIL_CHANGE',
user_layer.collection,
(
'new_email',
'old_email',
),
)
],
)
@router.patch('/<id>/emails', compress=True)
def patch_email(id: str, payload: EmailAsPrimary):
set_email_as_primary(
id,
@@ -98,15 +65,9 @@ def patch_email(id: str, payload: EmailAsPrimary):
)
@router.delete(
'/<id>/emails',
compress=True,
tags=['User'],
summary='Delete user email',
middlewares=[AuditLogMiddleware('EMAIL_DEL', user_layer.collection, ('email',))],
)
@router.delete('/<id>/emails', compress=True)
def delete_email(id: str, payload: Email):
del_email(
remove_email(
id,
payload.email,
persistence_layer=user_layer,

View File

@@ -4,7 +4,6 @@ from aws_lambda_powertools.event_handler.exceptions import (
)
from layercake.dynamodb import (
ComposeKey,
DynamoDBCollection,
DynamoDBPersistenceLayer,
MissingError,
PartitionKey,
@@ -13,29 +12,19 @@ from layercake.dynamodb import (
from boto3clients import dynamodb_client
from config import USER_TABLE
from .orgs import router as orgs
__all__ = ['orgs']
class BadRequestError(MissingError, PowertoolsBadRequestError): ...
router = Router()
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer, exc_cls=BadRequestError)
@router.get(
'/<id>/logs',
compress=True,
tags=['User'],
summary='Get user logs',
)
@router.get('/<id>/logs', compress=True, tags=['User'])
def get_logs(id: str):
return user_collect.query(
# Post-migration: uncomment to enable PartitionKey with a composite key (id with `logs` prefix).
# PartitionKey(ComposeKey(id, 'logs')),
return user_layer.collection.query(
# Post-migration: uncomment the following line
# PartitionKey(ComposeKey(id, 'LOGS')),
PartitionKey(ComposeKey(id, 'log', delimiter=':')),
start_key=router.current_event.get_query_string_value('start_key', None),
)

View File

@@ -16,8 +16,7 @@ from pydantic import BaseModel
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
from config import USER_TABLE
from middlewares.audit_log_middleware import AuditLogMiddleware
from rules.user import del_org_member
from rules.user import remove_org_member
class BadRequestError(MissingError, PowertoolsBadRequestError): ...
@@ -30,13 +29,13 @@ user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
@router.get(
'/<id>/orgs',
compress=True,
tags=['User'],
summary='Get user orgs',
)
def get_orgs(id: str):
start_key = router.current_event.get_query_string_value('start_key', None)
return user_layer.collection.query(
KeyPair(id, PrefixKey('orgs')),
start_key=router.current_event.get_query_string_value('start_key', None),
start_key=start_key,
)
@@ -46,17 +45,7 @@ class Unassign(BaseModel):
cnpj: CnpjStr
@router.delete(
'/<id>/orgs',
compress=True,
tags=['User'],
summary='Delete user org',
middlewares=[
AuditLogMiddleware(
'UNASSIGN_ORG', user_layer.collection, ('id', 'name', 'cnpj')
)
],
)
@router.delete('/<id>/orgs', compress=True)
def delete_org(id: str, payload: Unassign):
del_org_member(id, org_id=payload.id, persistence_layer=user_layer)
remove_org_member(id, org_id=payload.id, persistence_layer=user_layer)
return JSONResponse(status_code=HTTPStatus.OK, body=payload)

View File

View File

@@ -15,14 +15,29 @@ from layercake.dynamodb import (
User = TypedDict('User', {'id': str, 'name': str, 'cpf': str})
class CPFConflictError(BadRequestError):
def __init__(self, *_):
super().__init__('CPF already exists')
class RateLimitError(BadRequestError):
def __init__(self, *_):
super().__init__('Update limit reached')
class EmailConflictError(BadRequestError):
def __init__(self, *_):
super().__init__('Email already exists')
def update_user(
data: User,
obj: User,
/,
*,
persistence_layer: DynamoDBPersistenceLayer,
) -> bool:
now_ = now()
user = SimpleNamespace(**data)
user = SimpleNamespace(**obj)
# Get the user's CPF, if it exists.
old_cpf = persistence_layer.collection.get_item(
KeyPair(
@@ -46,15 +61,11 @@ def update_user(
cond_expr='attribute_exists(sk)',
)
class RateLimitError(BadRequestError):
def __init__(self, msg: str):
super().__init__('Update limit reached')
# Prevent the user from updating more than once every 24 hours
transact.put(
item={
'id': user.id,
'sk': 'rate_limit#user_update',
'sk': 'RATE_LIMIT#USER_UPDATE',
'created_at': now_,
'ttl': ttl(start_dt=now_ + timedelta(hours=24)),
},
@@ -62,10 +73,6 @@ def update_user(
cond_expr='attribute_not_exists(sk)',
)
class CPFConflictError(BadRequestError):
def __init__(self, msg: str):
super().__init__('CPF already exists')
if user.cpf != old_cpf:
transact.put(
item={
@@ -114,10 +121,6 @@ def add_email(
cond_expr='attribute_not_exists(sk)',
)
class EmailConflictError(BadRequestError):
def __init__(self, msg: str):
super().__init__('Email already exists')
# Prevent duplicate emails
transact.put(
item={
@@ -133,7 +136,7 @@ def add_email(
return True
def del_email(
def remove_email(
id: str,
email: str,
/,
@@ -207,7 +210,7 @@ def set_email_as_primary(
return True
def del_org_member(
def remove_org_member(
id: str,
*,
org_id: str,