This commit is contained in:
2025-05-16 14:29:14 -03:00
parent 17131380ac
commit cc9bd08daa
49 changed files with 177 additions and 54 deletions

View File

@@ -0,0 +1,20 @@
from typing import Generic, Mapping
from aws_lambda_powertools.event_handler import content_types
from aws_lambda_powertools.event_handler.api_gateway import Response, ResponseT
class JSONResponse(Response, Generic[ResponseT]):
def __init__(
self,
status_code: int,
body: ResponseT | None = None,
headers: Mapping[str, str | list[str]] | None = None,
):
super().__init__(
status_code,
content_types.APPLICATION_JSON,
body,
headers,
compress=True,
)

61
http-api/app/app.py Normal file
View File

@@ -0,0 +1,61 @@
import os
from typing import Any
from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.event_handler.api_gateway import (
APIGatewayHttpResolver,
CORSConfig,
Response,
content_types,
)
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 AuthenticationMiddleware
from routes import courses, enrollments, lookup, orders, orgs, settings, users, webhooks
tracer = Tracer()
logger = Logger(__name__)
cors = CORSConfig(
allow_origin='*',
allow_headers=['Content-Type', 'X-Requested-With', 'Authorization', 'X-Tenant'],
max_age=600,
allow_credentials=False,
)
app = APIGatewayHttpResolver(
enable_validation=True,
cors=cors,
debug='AWS_SAM_LOCAL' in os.environ,
)
app.use(middlewares=[AuthenticationMiddleware()])
app.include_router(courses.router, prefix='/courses')
app.include_router(enrollments.router, prefix='/enrollments')
app.include_router(enrollments.vacancies, prefix='/enrollments')
app.include_router(orders.router, prefix='/orders')
app.include_router(users.router, 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(orgs.policies, prefix='/orgs')
app.include_router(webhooks.router, prefix='/webhooks')
app.include_router(settings.router, prefix='/settings')
app.include_router(lookup.router, prefix='/lookup')
@app.exception_handler(ServiceError)
def exc_error(exc: ServiceError):
return Response(
body={
'msg': exc.msg,
'err': type(exc).__name__,
},
content_type=content_types.APPLICATION_JSON,
status_code=exc.status_code,
)
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP)
@tracer.capture_lambda_handler
def lambda_handler(event: dict[str, Any], context: LambdaContext) -> dict[str, Any]:
return app.resolve(event, context)

143
http-api/app/auth.py Normal file
View File

@@ -0,0 +1,143 @@
"""
Example
-------
Resources:
HttpApi:
Type: AWS::Serverless::HttpApi
Properties:
Auth:
DefaultAuthorizer: LambdaRequestAuthorizer
Authorizers:
LambdaRequestAuthorizer:
FunctionArn: !GetAtt Authorizer.Arn
AuthorizerPayloadFormatVersion: "2.0"
EnableFunctionDefaultPermissions: true
EnableSimpleResponses: true
Identity:
Headers: [Authorization]
Authorizer:
Type: AWS::Serverless::Function
Properties:
Handler: auth.lambda_handler
"""
from dataclasses import asdict, dataclass
from typing import Any
from aws_lambda_powertools import Logger, Tracer
from aws_lambda_powertools.utilities.data_classes import event_source
from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import (
APIGatewayAuthorizerEventV2,
APIGatewayAuthorizerResponseV2,
)
from aws_lambda_powertools.utilities.typing import LambdaContext
import boto3
from botocore.endpoint_provider import Enum
from layercake.dynamodb import DynamoDBCollection, DynamoDBPersistenceLayer, KeyPair
from layercake.funcs import pick
from boto3clients import dynamodb_client
from cognito import get_user
from conf import USER_TABLE
APIKEY_PREFIX = 'sk-'
tracer = Tracer()
logger = Logger(__name__)
idp_client = boto3.client('cognito-idp')
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer)
@tracer.capture_lambda_handler
@logger.inject_lambda_context
@event_source(data_class=APIGatewayAuthorizerEventV2)
def lambda_handler(event: APIGatewayAuthorizerEventV2, context: LambdaContext) -> dict:
"""Authenticates a user using a bearer token (for user or API).
Only handles authentication; any additional logic (e.g., tenant) is performed afterward."""
bearer = _parse_bearer_token(event.headers.get('authorization', ''))
if not bearer:
return APIGatewayAuthorizerResponseV2(authorize=False).asdict()
attrs = _authorizer(bearer, user_collect).asdict()
return APIGatewayAuthorizerResponseV2(**attrs).asdict()
class AuthFlowType(str, Enum):
USER_AUTH = 'USER_AUTH'
API_AUTH = 'API_AUTH'
@dataclass
class BearerToken:
auth_flow_type: AuthFlowType
token: str
@dataclass
class AuthorizerResponseV2:
authorize: bool = False
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, /, collect: DynamoDBCollection) -> dict[str, dict | str]:
return collect.get_item(KeyPair('apikey', token))
def _authorizer(
bearer: BearerToken,
/,
collect: DynamoDBCollection,
) -> AuthorizerResponseV2:
"""Build an Authorizer object based on the bearer token's auth type.
Parameters
----------
bearer : BearerToken
The bearer token containing authentication information.
Returns
-------
Authorizer
An Authorizer object with the appropriate authorization status and context.
"""
try:
if bearer.auth_flow_type == AuthFlowType.USER_AUTH:
user = get_user(bearer.token, idp_client)
return AuthorizerResponseV2(True, {'user': user})
apikey = _get_apikey(bearer.token, collect)
context = pick(('tenant', 'user'), apikey)
return AuthorizerResponseV2(True, context, AuthFlowType.API_AUTH)
except Exception:
return AuthorizerResponseV2()
def _parse_bearer_token(s: str) -> BearerToken | None:
"""Parses and identifies a bearer token as either an API key or a user token."""
try:
_, token = s.split(' ')
if token.startswith(APIKEY_PREFIX):
return BearerToken(
AuthFlowType.API_AUTH,
token.removeprefix(APIKEY_PREFIX),
)
except ValueError:
return None
else:
return BearerToken(AuthFlowType.USER_AUTH, token)

View File

@@ -0,0 +1,16 @@
import os
import boto3
DYNAMODB_ENDPOINT_URL: str | None = None
# Only when running `sam local start-api`
if 'AWS_SAM_LOCAL' in os.environ:
DYNAMODB_ENDPOINT_URL = 'http://host.docker.internal:8000'
# Only when running `pytest`
if 'PYTEST_VERSION' in os.environ:
DYNAMODB_ENDPOINT_URL = 'http://127.0.0.1:8000'
dynamodb_client = boto3.client('dynamodb', endpoint_url=DYNAMODB_ENDPOINT_URL)
idp_client = boto3.client('cognito-idp')

75
http-api/app/cognito.py Normal file
View File

@@ -0,0 +1,75 @@
from aws_lambda_powertools import Logger
logger = Logger(__name__)
class UnauthorizedError(Exception):
pass
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)
except idp_client.exceptions.ClientError:
raise UnauthorizedError()
else:
return {attr['Name']: attr['Value'] for attr in user['UserAttributes']}
def admin_get_user(
sub: str,
user_pool_id: str,
*,
idp_client,
) -> dict[str, str] | None:
"""Gets the specified user by user name in a user pool as an administrator.
Works on any user.
- https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cognito-idp/client/admin_get_user.html
- https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminGetUser.html
"""
try:
user = idp_client.admin_get_user(Username=sub, UserPoolId=user_pool_id)
except idp_client.exceptions as err:
logger.exception(err)
return None
else:
return user
def admin_set_user_password(
username: str,
password: str,
*,
user_pool_id: str,
permanent: bool = False,
idp_client,
) -> bool:
"""Sets the specified user's password in a user pool as an administrator.
Works on any user.
The password can be temporary or permanent. If it is temporary, the user
status enters the FORCE_CHANGE_PASSWORD state.
When the user next tries to sign in, the InitiateAuth/AdminInitiateAuth
response will contain the NEW_PASSWORD_REQUIRED challenge.
If the user doesn't sign in before it expires, the user won't be able
to sign in, and an administrator must reset their password.
Once the user has set a new password, or the password is permanent,
the user status is set to Confirmed.
"""
try:
idp_client.admin_set_user_password(
UserPoolId=user_pool_id,
Username=username,
Password=password,
Permanent=permanent,
)
except idp_client.exceptions as err:
logger.exception(err)
return False
else:
return True

36
http-api/app/conf.py Normal file
View File

@@ -0,0 +1,36 @@
import os
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore
ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore
COURSE_TABLE: str = os.getenv('COURSE_TABLE') # type: ignore
KONVIVA_API_URL: str = os.getenv('KONVIVA_API_URL') # type: ignore
KONVIVA_SECRET_KEY: str = os.getenv('KONVIVA_SECRET_KEY') # type: ignore
MEILISEARCH_HOST: str = os.getenv('MEILISEARCH_HOST') # type: ignore
MEILISEARCH_API_KEY: str = os.getenv('MEILISEARCH_API_KEY') # type: ignore
match os.getenv('AWS_SAM_LOCAL'), os.getenv('PYTEST_VERSION'):
case str() as SAM_LOCAL, _ if SAM_LOCAL: # Only when running `sam local start-api`
MEILISEARCH_HOST = 'http://host.docker.internal:7700'
ELASTIC_CONN = {
'hosts': 'http://host.docker.internal:9200',
}
case _, str() as PYTEST if PYTEST: # Only when running `pytest`
MEILISEARCH_HOST = 'http://127.0.0.1:7700'
ELASTIC_CONN = {
'hosts': 'http://127.0.0.1:9200',
}
case _:
MEILISEARCH_HOST: str = os.getenv('MEILISEARCH_HOST') # type: ignore
ELASTIC_CLOUD_ID = os.getenv('ELASTIC_CLOUD_ID')
ELASTIC_AUTH_PASS = os.getenv('ELASTIC_AUTH_PASS')
ELASTIC_CONN = {
'cloud_id': ELASTIC_CLOUD_ID,
'basic_auth': ('elastic', ELASTIC_AUTH_PASS),
}
USER_POOOL_ID = 'sa-east-1_s6YmVSfXj'

46
http-api/app/elastic.py Normal file
View File

@@ -0,0 +1,46 @@
import math
from typing import TypedDict
from elasticsearch import Elasticsearch
from elasticsearch_dsl import Search
MAX_PAGE_SIZE = 100
class PaginatedResult(TypedDict):
total_items: int
total_pages: int
items: list[dict]
def search(
index: str,
*,
query: dict,
page_size: int = 25,
elastic_client: Elasticsearch,
) -> PaginatedResult:
if page_size > MAX_PAGE_SIZE:
page_size = MAX_PAGE_SIZE
s = Search(
using=elastic_client,
index=index,
)
s.update_from_dict(query)
s.extra(size=page_size)
try:
r = s.execute()
except Exception:
return {
'total_items': 0,
'total_pages': 0,
'items': [],
}
else:
return {
'total_items': r.hits.total.value, # type: ignore
'total_pages': math.ceil(r.hits.total.value / page_size), # type: ignore
'items': [hit.to_dict() for hit in r],
}

50
http-api/app/konviva.py Normal file
View File

@@ -0,0 +1,50 @@
from dataclasses import asdict, dataclass
from urllib.parse import quote as urlquote
from urllib.parse import urlencode, urlparse
from aws_lambda_powertools.event_handler.exceptions import BadRequestError
from glom import glom
import requests
from conf import KONVIVA_API_URL, KONVIVA_SECRET_KEY
class KonvivaError(BadRequestError):
pass
@dataclass
class KonvivaToken:
login: str
token: str
nonce: str
def token(username: str) -> KonvivaToken:
url = urlparse(KONVIVA_API_URL)._replace(
path='/action/api/usuarios/token',
query=f'login={urlquote(username)}',
)
headers = {
'Authorization': f'KONVIVA {KONVIVA_SECRET_KEY}',
'Content-Type': 'application/json',
}
r = requests.get(url.geturl(), headers=headers)
r.raise_for_status()
# Because Konviva does not return the proper HTTP status code
if err := glom(r.json(), 'errors.0', default=None):
raise KonvivaError(err)
return KonvivaToken(**r.json())
def redirect_uri(token: KonvivaToken) -> str:
url = urlparse(KONVIVA_API_URL)._replace(
path='/action/acessoExterno',
query=urlencode(asdict(token)),
)
return url.geturl()

View File

@@ -0,0 +1,11 @@
from .audit_log_middleware import AuditLogMiddleware
from .authentication_middleware import AuthenticationMiddleware, User
from .tenant_middelware import Tenant, TenantMiddleware
__all__ = [
'AuthenticationMiddleware',
'AuditLogMiddleware',
'TenantMiddleware',
'User',
'Tenant',
]

View File

@@ -0,0 +1,97 @@
from aws_lambda_powertools.event_handler.api_gateway import (
APIGatewayHttpResolver,
Response,
)
from aws_lambda_powertools.event_handler.middlewares import (
BaseMiddlewareHandler,
NextMiddleware,
)
from aws_lambda_powertools.shared.functions import (
extract_event_from_common_models,
)
from layercake.dateutils import now, ttl
from layercake.dynamodb import (
ComposeKey,
DynamoDBCollection,
KeyPair,
)
from layercake.funcs import pick
from .authentication_middleware import User
YEAR_DAYS = 365
LOG_RETENTION_DAYS = YEAR_DAYS * 2
class AuditLogMiddleware(BaseMiddlewareHandler):
"""This middleware logs audit details for successful requests, storing user,
action, and IP info with a specified retention period.
Parameters
----------
action: str
The identifier for the audit log action.
collect: DynamoDBCollection
The collection instance used to persist the audit log data.
audit_attrs: tuple of str, optional
A tuple of attribute names to extract from the response body for logging.
These represent the specific fields to include in the audit log.
retention_days: int or None, optional
The number of days the log is retained on the server.
If None, no time-to-live (TTL) will be applied.
"""
def __init__(
self,
action: str,
/,
collect: DynamoDBCollection,
audit_attrs: tuple[str, ...] = (),
retention_days: int | None = LOG_RETENTION_DAYS,
) -> None:
self.action = action
self.collect = collect
self.audit_attrs = audit_attrs
self.retention_days = retention_days
def handler(
self,
app: APIGatewayHttpResolver,
next_middleware: NextMiddleware,
) -> Response:
user: User | None = app.context.get('user')
req_context = app.current_event.request_context
ip_addr = req_context.http.source_ip
response = next_middleware(app)
# Successful response
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status#successful_responses
if 200 <= response.status_code < 300 and user:
now_ = now()
author = pick(('id', 'name'), dict(user))
data = (
pick(self.audit_attrs, extract_event_from_common_models(response.body))
if response.is_json()
else None
)
retention_days = (
ttl(start_dt=now_, days=self.retention_days)
if self.retention_days
else None
)
self.collect.put_item(
key=KeyPair(
# Post-migration: remove `delimiter` and update prefix from `log` to `logs`
# in ComposeKey.
pk=ComposeKey(user.id, prefix='log', delimiter=':'),
sk=now_.isoformat(),
),
action=self.action,
data=data,
ip=ip_addr,
author=author,
ttl=retention_days,
)
return response

View File

@@ -0,0 +1,49 @@
from auth import AuthFlowType
from aws_lambda_powertools.event_handler.api_gateway import (
APIGatewayHttpResolver,
Response,
)
from aws_lambda_powertools.event_handler.middlewares import (
BaseMiddlewareHandler,
NextMiddleware,
)
from pydantic import UUID4, BaseModel, EmailStr, Field
class User(BaseModel):
id: str
name: str
email: EmailStr
class CognitoUser(User):
id: str = Field(alias='custom:user_id')
email_verified: bool
sub: UUID4
class AuthenticationMiddleware(BaseMiddlewareHandler):
"""This middleware extracts user authentication details from the Lambda authorizer context
and makes them available in the application context."""
def handler(
self,
app: APIGatewayHttpResolver,
next_middleware: NextMiddleware,
) -> Response:
# Gets the Lambda authorizer associated with the current API Gateway event.
# You can check the file `auth.py` for more details.
context = app.current_event.request_context.authorizer.get_lambda
auth_flow_type = context.get('auth_flow_type')
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)

View File

@@ -0,0 +1,123 @@
from http import HTTPStatus
from auth import AuthFlowType
from aws_lambda_powertools.event_handler.api_gateway import (
APIGatewayHttpResolver,
Response,
)
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError,
NotFoundError,
ServiceError,
)
from aws_lambda_powertools.event_handler.middlewares import (
BaseMiddlewareHandler,
NextMiddleware,
)
from layercake.dynamodb import ComposeKey, DynamoDBCollection, KeyPair
from pydantic import UUID4, BaseModel
from .authentication_middleware import User
class Tenant(BaseModel):
id: UUID4 | str
name: str
class TenantMiddleware(BaseMiddlewareHandler):
"""Middleware that associates a Tenant instance with the request context based on the authentication flow.
For API authentication (`AuthFlowType.API_AUTH`), it assigns tenant information directly from the authorizer context.
For user authentication (`AuthFlowType.USER_AUTH`), it gets the Tenant ID from the specified request header.
Parameters
----------
collect : DynamoDBCollection
The DynamoDB collection used to validate user access and retrieve tenant information.
header : str, optional
The request header name containing the tenant ID. Defaults to `'X-Tenant'`.
"""
def __init__(
self,
collect: DynamoDBCollection,
/,
header: str = 'X-Tenant',
) -> None:
self.collect = collect
self.header = header
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')
if auth_flow_type == AuthFlowType.API_AUTH:
app.append_context(tenant=Tenant(**context['tenant']))
if auth_flow_type == AuthFlowType.USER_AUTH:
app.append_context(
tenant=_tenant(
app.current_event.headers.get(self.header),
app.context.get('user'), # type: ignore
collect=self.collect,
)
)
return next_middleware(app)
class ForbiddenError(ServiceError):
def __init__(self, *args, **kwargs):
super().__init__(HTTPStatus.FORBIDDEN, 'Deny')
def _tenant(
tenant_id: str | None,
user: User,
/,
collect: DynamoDBCollection,
) -> Tenant:
"""Get a Tenant instance based on the provided tenant_id and user's access permissions.
Parameters
----------
tenant_id : str | None
The identifier of the tenant. Must not be None or empty.
user : User
The user attempting to access the tenant.
collect : DynamoDBCollection
The DynamoDB collection used to retrieve tenant information.
Returns
-------
Tenant
The Tenant instance corresponding to the provided tenant_id.
Raises
------
BadRequestError
If tenant_id is not provided.
ForbiddenError
If the user lacks the necessary ACL permissions for the specified tenant_id.
NotFoundError
If tenant not found.
"""
if not tenant_id:
raise BadRequestError('Missing tenant')
# Ensure user has ACL
collect.get_item(
KeyPair(user.id, ComposeKey(tenant_id, prefix='acls')),
exception_cls=ForbiddenError,
)
# For root tenant, provide the default Tenant
if tenant_id == '*':
return Tenant(id=tenant_id, name='default')
obj = collect.get_item(KeyPair(tenant_id, '0'), exception_cls=NotFoundError)
return Tenant.parse_obj(obj)

39
http-api/app/models.py Normal file
View File

@@ -0,0 +1,39 @@
from typing import Annotated
from uuid import uuid4
from layercake.extra_types import CnpjStr, CpfStr, NameStr
from pydantic import (
UUID4,
BaseModel,
ConfigDict,
EmailStr,
Field,
StringConstraints,
)
class Org(BaseModel):
id: UUID4 | str = Field(default_factory=uuid4)
name: Annotated[str, StringConstraints(strip_whitespace=True)]
cnpj: CnpjStr | None = None
class User(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: UUID4 | str = Field(default_factory=uuid4)
name: NameStr
email: EmailStr
email_verified: bool = False
cpf: CpfStr | None = None
class Cert(BaseModel):
exp_interval: int
class Course(BaseModel):
id: UUID4 = Field(default_factory=uuid4)
name: str
cert: Cert | None = None
access_period: int = 90 # 3 months

View File

@@ -0,0 +1,97 @@
from http import HTTPStatus
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import NotFoundError
from layercake.dynamodb import DynamoDBCollection, DynamoDBPersistenceLayer, KeyPair
from meilisearch import Client as Meilisearch
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
from conf import (
COURSE_TABLE,
MEILISEARCH_API_KEY,
MEILISEARCH_HOST,
USER_TABLE,
)
from middlewares import AuditLogMiddleware, Tenant, TenantMiddleware
from models import Course, Org
from rules.course import create_course, update_course
router = Router()
meili_client = Meilisearch(MEILISEARCH_HOST, MEILISEARCH_API_KEY)
course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer)
course_collect = DynamoDBCollection(course_layer)
@router.get(
'/',
compress=True,
tags=['Course'],
summary='Get courses',
)
def get_courses():
event = router.current_event
query = event.get_query_string_value('query', '')
sort = event.get_query_string_value('sort', 'create_date:desc')
page = int(event.get_query_string_value('page', '1'))
hits_per_page = int(event.get_query_string_value('hitsPerPage', '25'))
return meili_client.index(COURSE_TABLE).search(
query,
{
'sort': [sort],
'locales': ['pt'],
'page': page,
'hitsPerPage': hits_per_page,
},
)
@router.post(
'/',
compress=True,
tags=['Course'],
middlewares=[
TenantMiddleware(user_collect),
AuditLogMiddleware('COURSE_ADD', user_collect, ('id', 'name')),
],
)
def post_course(payload: Course):
tenant: Tenant = router.context['tenant']
create_course(
payload,
Org(id=tenant.id, name=tenant.name),
persistence_layer=course_layer,
)
return JSONResponse(
body=payload,
status_code=HTTPStatus.CREATED,
)
@router.get('/<id>', compress=True, tags=['Course'])
def get_course(id: str):
return course_collect.get_item(
KeyPair(id, '0'),
exception_cls=NotFoundError,
)
@router.put(
'/<id>',
compress=True,
tags=['Course'],
middlewares=[
TenantMiddleware(user_collect),
AuditLogMiddleware('COURSE_UPDATE', user_collect, ('id', 'name')),
],
)
def put_course(id: str, payload: Course):
update_course(id, payload, persistence_layer=course_layer)
return JSONResponse(
body=payload,
status_code=HTTPStatus.OK,
)

View File

@@ -0,0 +1,99 @@
import json
from aws_lambda_powertools.event_handler.api_gateway import Router
from elasticsearch import Elasticsearch
from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
KeyPair,
SortKey,
TransactKey,
)
from pydantic import UUID4, BaseModel
from boto3clients import dynamodb_client
from conf import ELASTIC_CONN, ENROLLMENT_TABLE, USER_TABLE
import elastic
from middlewares.audit_log_middleware import AuditLogMiddleware
from middlewares.authentication_middleware import User
from rules.enrollment import set_status_as_canceled
from .vacancies import router as vacancies
__all__ = ['vacancies']
router = Router()
elastic_client = Elasticsearch(**ELASTIC_CONN)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
enrollment_collect = DynamoDBCollection(enrollment_layer)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer)
@router.get('/', compress=True, tags=['Enrollment'])
def get_enrollments():
event = router.current_event
query = event.get_query_string_value('query', '{}')
page_size = event.get_query_string_value('page_size', '25')
return elastic.search( # type: ignore
index=ENROLLMENT_TABLE,
page_size=int(page_size),
query=json.loads(query),
elastic_client=elastic_client,
)
@router.get('/<id>', compress=True, tags=['Enrollment'])
def get_enrollment(id: str):
return enrollment_collect.get_items(
TransactKey(id)
+ SortKey('0')
+ SortKey('started_date')
+ SortKey('finished_date')
+ SortKey('failed_date')
+ SortKey('canceled_date')
+ SortKey('archived_date')
+ SortKey('cancel_policy')
+ SortKey('parent_vacancy', path_spec='vacancy')
+ SortKey('lock', path_spec='hash')
+ SortKey('author')
+ SortKey('tenant')
+ SortKey('cert')
)
class Cancel(BaseModel):
id: UUID4 | str
lock_hash: str
course: dict = {}
vacancy: dict = {}
@router.patch(
'/<id>/cancel',
compress=True,
tags=['Enrollment'],
middlewares=[
AuditLogMiddleware('ENROLLMENT_CANCEL', user_collect, ('id', 'course'))
],
)
def cancel(id: str, payload: Cancel):
user: User = router.context['user']
set_status_as_canceled(
id,
lock_hash=payload.lock_hash,
author=user.model_dump(), # type: ignore
course=payload.course, # type: ignore
vacancy_key=KeyPair.parse_obj(payload.vacancy),
persistence_layer=enrollment_layer,
)
return payload
@router.post('/', compress=True, tags=['Enrollment'])
def enroll():
return {}

View File

@@ -0,0 +1,37 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
from layercake.dynamodb import (
ComposeKey,
DynamoDBCollection,
DynamoDBPersistenceLayer,
PartitionKey,
)
from boto3clients import dynamodb_client
from conf import (
ENROLLMENT_TABLE,
USER_TABLE,
)
from middlewares import Tenant, TenantMiddleware
router = Router()
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer)
enrollment_collect = DynamoDBCollection(enrollment_layer)
@router.get(
'/vacancies',
compress=True,
tags=['Enrollment'],
middlewares=[
TenantMiddleware(user_collect),
],
)
def get_vacancies():
tenant: Tenant = router.context['tenant']
return enrollment_collect.query(
PartitionKey(ComposeKey(str(tenant.id), prefix='vacancies'))
)

View File

@@ -0,0 +1,32 @@
from http import HTTPStatus
from aws_lambda_powertools.event_handler import Response, content_types
from aws_lambda_powertools.event_handler.api_gateway import Router
from elasticsearch import Elasticsearch
from elasticsearch_dsl import Search
from layercake.funcs import pick
from conf import ELASTIC_CONN, USER_TABLE
router = Router()
elastic_client = Elasticsearch(**ELASTIC_CONN)
@router.get('/<username>', include_in_schema=False)
def lookup(username: str):
s = Search(using=elastic_client, index=USER_TABLE).query(
'bool',
should=[
{'term': {'email.keyword': username}},
{'term': {'cpf.keyword': username}},
],
minimum_should_match=1,
)
for hit in s.execute():
return pick(('id', 'name', 'email', 'cognito:sub'), hit.to_dict())
return Response(
content_type=content_types.APPLICATION_JSON,
status_code=HTTPStatus.NOT_FOUND,
)

View File

@@ -0,0 +1,45 @@
import json
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError,
)
from elasticsearch import Elasticsearch
from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
KeyPair,
)
from boto3clients import dynamodb_client
from conf import ELASTIC_CONN, ORDER_TABLE
import elastic
router = Router()
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
order_collect = DynamoDBCollection(order_layer, exception_cls=BadRequestError)
elastic_client = Elasticsearch(**ELASTIC_CONN)
@router.get('/', compress=True, tags=['Order'])
def get_orders():
event = router.current_event
query = event.get_query_string_value('query', '{}')
page_size = event.get_query_string_value('page_size', '25')
return elastic.search( # type: ignore
index=ORDER_TABLE,
page_size=int(page_size),
query=json.loads(query),
elastic_client=elastic_client,
)
@router.get(
'/<id>',
compress=True,
tags=['Order'],
summary='Get order',
)
def get_order(id: str):
return order_collect.get_item(KeyPair(id, '0'))

View File

@@ -0,0 +1,3 @@
from .policies import router as policies
__all__ = ['policies']

View File

@@ -0,0 +1,69 @@
from http import HTTPStatus
from typing import Literal
from aws_lambda_powertools.event_handler import Response, content_types
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError,
)
from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
SortKey,
TransactKey,
)
from pydantic.main import BaseModel
from boto3clients import dynamodb_client
from conf import USER_TABLE
from rules.org import update_policies
router = Router()
org_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
org_collect = DynamoDBCollection(org_layer, exception_cls=BadRequestError)
@router.get(
'/<id>/policies',
compress=True,
tags=['Organization'],
summary='Get organization policies',
)
def get_policies(id: str):
return org_collect.get_items(
TransactKey(id) + SortKey('billing_policy') + SortKey('payment_policy'),
flatten_top=False,
)
class BillingPolicy(BaseModel):
billing_day: int
payment_method: Literal['PIX', 'BANK_SLIP', 'MANUAL']
class PaymentPolicy(BaseModel):
due_days: int
class Policies(BaseModel):
billing_policy: BillingPolicy | None = None
payment_policy: PaymentPolicy | None = None
@router.put('/<id>/policies', compress=True, tags=['Organization'])
def put_policies(id: str, payload: Policies):
payment_policy = payload.payment_policy
billing_policy = payload.billing_policy
update_policies(
id,
payment_policy=payment_policy.model_dump() if payment_policy else {},
billing_policy=billing_policy.model_dump() if billing_policy else {},
persistence_layer=org_layer,
)
return Response(
body=payload,
content_type=content_types.APPLICATION_JSON,
status_code=HTTPStatus.OK,
)

View File

@@ -0,0 +1,46 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
KeyPair,
PrefixKey,
)
from boto3clients import dynamodb_client
from conf import USER_TABLE
import konviva
from middlewares import User
router = Router()
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer)
LIMIT = 25
@router.get('/', include_in_schema=False)
def settings():
user: User = router.context['user']
acls = user_collect.query(
KeyPair(user.id, PrefixKey('acls')),
limit=LIMIT,
)
tenants = user_collect.query(
KeyPair(user.id, PrefixKey('orgs')),
limit=LIMIT,
)
return {
'acls': acls['items'],
# Note: Ensure compatibility with search on React's tenant menu
'tenants': [x | {'id': x['sk'], 'sk': '0'} for x in tenants['items']],
}
@router.get('/konviva', include_in_schema=False)
def konviva_():
user: User = router.context['user']
token = konviva.token(user.email)
return {'redirect_uri': konviva.redirect_uri(token)}

View File

@@ -0,0 +1,147 @@
from http import HTTPStatus
import json
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 elasticsearch import Elasticsearch
from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
MissingError,
SortKey,
TransactKey,
)
from layercake.extra_types import CpfStr, NameStr
from pydantic import UUID4, BaseModel, StringConstraints
from api_gateway import JSONResponse
from boto3clients import dynamodb_client, idp_client
import cognito
from conf import ELASTIC_CONN, USER_POOOL_ID, USER_TABLE
import elastic
from middlewares import AuditLogMiddleware
from models import User
from rules.user import update_user
from .emails import router as emails
from .logs import router as logs
from .orgs import router as orgs
__all__ = ['logs', 'emails', 'orgs']
class BadRequestError(MissingError, PowertoolsBadRequestError):
pass
router = Router()
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError)
elastic_client = Elasticsearch(**ELASTIC_CONN)
@router.get('/', compress=True, tags=['User'], summary='Get users')
def get_users():
event = router.current_event
query = event.get_query_string_value('query', '{}')
page_size = event.get_query_string_value('page_size', '25')
return elastic.search(
index=USER_TABLE,
page_size=int(page_size),
query=json.loads(query),
elastic_client=elastic_client,
)
@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
@router.put(
'/<id>',
compress=True,
tags=['User'],
summary='Update user',
middlewares=[
AuditLogMiddleware('USER_UPDATE', user_collect, ('id', 'name', 'new_cpf'))
],
)
def put_user(id: str, payload: UserData):
update_user(
{
'id': id,
'name': payload.name,
'cpf': payload.cpf,
},
persistence_layer=user_layer,
)
return JSONResponse(
body={
'id': id,
'name': payload.name,
'new_cpf': payload.cpf,
},
status_code=HTTPStatus.OK,
)
@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('last_profile_edit')
)
class Password(BaseModel):
cognito_sub: UUID4
new_password: Annotated[str, StringConstraints(min_length=6)]
@router.post(
'/<id>/password',
compress=True,
tags=['User'],
include_in_schema=False,
middlewares=[
AuditLogMiddleware('PASSWORD_RESET', user_collect, ('id', 'cognito_sub'))
],
)
def password(id: str, payload: Password):
cognito.admin_set_user_password(
username=str(payload.cognito_sub),
password=payload.new_password,
user_pool_id=USER_POOOL_ID,
idp_client=idp_client,
)
return JSONResponse(
body={
'id': id,
'cognito_sub': payload.cognito_sub,
},
status_code=HTTPStatus.OK,
)
@router.get('/<sub>/idp', compress=True, include_in_schema=False)
def get_idp(sub: str):
return cognito.admin_get_user(
sub=sub,
user_pool_id=USER_POOOL_ID,
idp_client=idp_client,
)

View File

@@ -0,0 +1,105 @@
from http import HTTPStatus
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError as PowertoolsBadRequestError,
)
from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
KeyPair,
MissingError,
PrefixKey,
)
from pydantic import BaseModel, EmailStr
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
from conf import USER_TABLE
from middlewares import AuditLogMiddleware
from rules.user import add_email, del_email, set_email_as_primary
class BadRequestError(MissingError, PowertoolsBadRequestError): ...
router = Router()
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError)
@router.get(
'/<id>/emails',
compress=True,
tags=['User'],
summary='Get user emails',
)
def get_emails(id: str):
return user_collect.query(
KeyPair(id, PrefixKey('emails')),
start_key=router.current_event.get_query_string_value('start_key', None),
)
class Email(BaseModel):
email: EmailStr
@router.post(
'/<id>/emails',
compress=True,
tags=['User'],
summary='Add user email',
middlewares=[AuditLogMiddleware('EMAIL_ADD', user_collect, ('email',))],
)
def post_email(id: str, payload: Email):
add_email(id, payload.email, persistence_layer=user_layer)
return JSONResponse(
body=payload,
status_code=HTTPStatus.CREATED,
)
class EmailAsPrimary(BaseModel):
new_email: EmailStr
old_email: EmailStr
email_verified: bool = False
@router.patch(
'/<id>/emails',
compress=True,
tags=['User'],
summary='Add user email as primary',
middlewares=[
AuditLogMiddleware(
'EMAIL_CHANGE',
user_collect,
(
'new_email',
'old_email',
),
)
],
)
def patch_email(id: str, payload: EmailAsPrimary):
set_email_as_primary(
id,
payload.new_email,
payload.old_email,
email_verified=payload.email_verified,
persistence_layer=user_layer,
)
return JSONResponse(body=payload, status_code=HTTPStatus.OK)
@router.delete(
'/<id>/emails',
compress=True,
tags=['User'],
summary='Delete user email',
middlewares=[AuditLogMiddleware('EMAIL_DEL', user_collect, ('email',))],
)
def delete_email(id: str, payload: Email):
del_email(id, payload.email, persistence_layer=user_layer)
return payload

View File

@@ -0,0 +1,41 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError as PowertoolsBadRequestError,
)
from layercake.dynamodb import (
ComposeKey,
DynamoDBCollection,
DynamoDBPersistenceLayer,
MissingError,
PartitionKey,
)
from boto3clients import dynamodb_client
from conf 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, exception_cls=BadRequestError)
@router.get(
'/<id>/logs',
compress=True,
tags=['User'],
summary='Get user logs',
)
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')),
PartitionKey(ComposeKey(id, 'log', delimiter=':')),
start_key=router.current_event.get_query_string_value('start_key', None),
)

View File

@@ -0,0 +1,62 @@
from http import HTTPStatus
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError as PowertoolsBadRequestError,
)
from layercake.dynamodb import (
DynamoDBCollection,
DynamoDBPersistenceLayer,
KeyPair,
MissingError,
PrefixKey,
)
from layercake.extra_types import CnpjStr
from pydantic import BaseModel
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
from conf import USER_TABLE
from middlewares.audit_log_middleware import AuditLogMiddleware
from rules.user import del_org_member
class BadRequestError(MissingError, PowertoolsBadRequestError): ...
router = Router()
user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
user_collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError)
@router.get(
'/<id>/orgs',
compress=True,
tags=['User'],
summary='Get user orgs',
)
def get_orgs(id: str):
return user_collect.query(
KeyPair(id, PrefixKey('orgs')),
start_key=router.current_event.get_query_string_value('start_key', None),
)
class Unassign(BaseModel):
id: str
name: str
cnpj: CnpjStr
@router.delete(
'/<id>/orgs',
compress=True,
tags=['User'],
summary='Delete user org',
middlewares=[
AuditLogMiddleware('UNASSIGN_ORG', user_collect, ('id', 'name', 'cnpj'))
],
)
def delete_org(id: str, payload: Unassign):
del_org_member(id, org_id=payload.id, persistence_layer=user_layer)
return JSONResponse(status_code=HTTPStatus.OK, body=payload)

View File

@@ -0,0 +1,8 @@
from aws_lambda_powertools.event_handler.api_gateway import Router
router = Router()
@router.get('/', include_in_schema=False)
def get_webhooks():
return []

View File

View File

@@ -0,0 +1,57 @@
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, TransactItems
from models import Course, Org
def create_course(
course: Course,
org: Org,
/,
persistence_layer: DynamoDBPersistenceLayer,
):
now_ = now()
transact = TransactItems(persistence_layer.table_name)
transact.put(
item={
'sk': '0',
'tenant__org_id': {org.id},
'create_date': now_,
**course.model_dump(),
}
)
transact.put(
item={
'id': course.id,
'sk': 'tenant',
'org_id': org.id,
'name': org.name,
'create_date': now_,
}
)
return persistence_layer.transact_write_items(transact)
def update_course(
id: str,
course: Course,
/,
persistence_layer: DynamoDBPersistenceLayer,
):
now_ = now()
transact = TransactItems(persistence_layer.table_name)
transact.update(
key=KeyPair(id, '0'),
update_expr='SET #name = :name, access_period = :access_period, cert = :cert, update_date = :update_date',
expr_attr_names={
'#name': 'name',
},
expr_attr_values={
':name': course.name,
':cert': course.cert.model_dump() if course.cert else None,
':access_period': course.access_period,
':update_date': now_,
},
cond_expr='attribute_exists(sk)',
)
return persistence_layer.transact_write_items(transact)

View File

@@ -0,0 +1,102 @@
from typing import TypedDict
from uuid import uuid4
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, TransactItems
from conf import ORDER_TABLE
class Author(TypedDict):
id: str
name: str
class Course(TypedDict):
id: str
name: str
time_in_days: int
def set_status_as_canceled(
id: str,
*,
lock_hash: str,
author: Author,
course: Course | None = None,
vacancy_key: KeyPair | None = None,
persistence_layer: DynamoDBPersistenceLayer,
):
"""Cancel the enrollment if there's a `cancel_policy`
and put its vacancy back if `vacancy_key` is provided."""
now_ = now()
transact = TransactItems(persistence_layer.table_name)
transact.update(
key=KeyPair(id, '0'),
update_expr='SET #status = :canceled, update_date = :update',
expr_attr_names={
'#status': 'status',
},
expr_attr_values={
':canceled': 'CANCELED',
':update': now_,
},
)
transact.put(
item={
'id': id,
'sk': 'canceled_date',
'author': author,
'create_date': now_,
},
)
transact.delete(
key=KeyPair(id, 'cancel_policy'),
cond_expr='attribute_exists(sk)',
)
# Remove schedules lifecycle events, referencies and locks
transact.delete(key=KeyPair(id, 'schedules#archive_it'))
transact.delete(key=KeyPair(id, 'schedules#no_activity'))
transact.delete(key=KeyPair(id, 'schedules#access_period_ends'))
transact.delete(key=KeyPair(id, 'schedules#does_not_access'))
transact.delete(key=KeyPair(id, 'parent_vacancy'))
transact.delete(key=KeyPair(id, 'lock'))
transact.delete(key=KeyPair('lock', lock_hash))
if vacancy_key and course:
vacancy_pk, vacancy_sk = vacancy_key.values()
org_id = vacancy_pk.removeprefix('vacancies#')
order_id, enrollment_id = vacancy_sk.split('#')
transact.condition(
key=KeyPair(order_id, '0'),
cond_expr='attribute_exists(id)',
table_name=ORDER_TABLE,
)
# Put the vacancy back and assign a new ID
transact.put(
item={
'id': f'vacancies#{org_id}',
'sk': f'{order_id}#{uuid4()}',
'course': course,
'create_date': now_,
},
cond_expr='attribute_not_exists(sk)',
)
# Set the status of `generated_items` to `ROLLBACK` to know
# which vacancy is available for reuse
transact.update(
key=KeyPair(order_id, f'generated_items#{enrollment_id}'),
update_expr='SET #status = :status, update_date = :update',
expr_attr_names={
'#status': 'status',
},
expr_attr_values={
':status': 'ROLLBACK',
':update': now_,
},
cond_expr='attribute_exists(sk)',
table_name=ORDER_TABLE,
)
return persistence_layer.transact_write_items(transact)

40
http-api/app/rules/org.py Normal file
View File

@@ -0,0 +1,40 @@
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, TransactItems
def update_policies(
id: str,
/,
payment_policy: dict = {},
billing_policy: dict = {},
*,
persistence_layer: DynamoDBPersistenceLayer,
):
now_ = now()
transact = TransactItems(persistence_layer.table_name)
if payment_policy:
transact.put(
item={
'id': id,
'sk': 'payment_policy',
'create_date': now_,
}
| payment_policy
)
else:
transact.delete(key=KeyPair(id, 'payment_policy'))
if billing_policy:
transact.put(
item={
'id': id,
'sk': 'billing_policy',
'create_date': now_,
}
| billing_policy
)
else:
transact.delete(key=KeyPair(id, 'billing_policy'))
return persistence_layer.transact_write_items(transact)

227
http-api/app/rules/user.py Normal file
View File

@@ -0,0 +1,227 @@
from types import SimpleNamespace
from typing import TypedDict
from aws_lambda_powertools.event_handler.exceptions import (
BadRequestError,
)
from botocore.exceptions import ClientError
from botocore.tokens import timedelta
from layercake.dateutils import now, ttl
from layercake.dynamodb import (
ComposeKey,
DynamoDBPersistenceLayer,
KeyPair,
TransactItems,
)
class CPFConflictError(BadRequestError):
pass
User = TypedDict('User', {'id': str, 'name': str, 'cpf': str})
def update_user(
userdata: User,
/,
*,
persistence_layer: DynamoDBPersistenceLayer,
) -> bool:
now_ = now()
ttl_ = now_ + timedelta(hours=24)
user = SimpleNamespace(**userdata)
# Get the user's CPF, if it exists.
old_cpf = persistence_layer.get_item(KeyPair(user.id, '0')).get('cpf', None)
transact = TransactItems(persistence_layer.table_name)
transact.update(
key=KeyPair(user.id, '0'),
update_expr='SET #name = :name, cpf = :cpf, update_date = :update_date',
expr_attr_names={
'#name': 'name',
},
expr_attr_values={
':name': user.name,
':cpf': user.cpf,
':update_date': now_,
},
cond_expr='attribute_exists(sk)',
)
# Prevent the user from updating more than once every 24 hours
transact.put(
item={
'id': user.id,
'sk': 'last_profile_edit',
'create_date': now_,
'ttl': ttl(start_dt=ttl_),
'ttl_date': ttl_,
},
cond_expr='attribute_not_exists(sk)',
)
if user.cpf != old_cpf:
transact.put(
item={
'id': 'cpf',
'sk': user.cpf,
'user_id': user.id,
'create_date': now_,
},
cond_expr='attribute_not_exists(sk)',
)
# Ensures that the old CPF is discarded
if old_cpf:
transact.delete(key=KeyPair('cpf', old_cpf))
try:
persistence_layer.transact_write_items(transact)
except ClientError:
raise CPFConflictError('CPF is already in use.')
else:
return True
def add_email(
id: str,
email: str,
/,
*,
persistence_layer: DynamoDBPersistenceLayer,
):
now_ = now()
transact = TransactItems(persistence_layer.table_name)
transact.update(
key=KeyPair(id, '0'),
update_expr='ADD emails :email',
expr_attr_values={
':email': {email},
},
)
transact.put(
item={
'id': id,
'sk': f'emails#{email}',
'email_primary': False,
'email_verified': False,
'create_date': now_,
},
cond_expr='attribute_not_exists(sk)',
)
transact.put(
item={
'id': 'email',
'sk': email,
'user_id': id,
'create_date': now_,
},
cond_expr='attribute_not_exists(sk)',
)
try:
return persistence_layer.transact_write_items(transact)
except ClientError:
raise BadRequestError('Email already exists.')
def del_email(
id: str,
email: str,
/,
*,
persistence_layer: DynamoDBPersistenceLayer,
) -> bool:
"""Delete any email except the primary email."""
transact = TransactItems(persistence_layer.table_name)
transact.delete(
key=KeyPair('email', email),
)
transact.delete(
key=KeyPair(id, ComposeKey(email, prefix='emails')),
cond_expr='email_primary <> :primary',
expr_attr_values={':primary': True},
)
transact.update(
key=KeyPair(id, '0'),
update_expr='DELETE emails :email',
expr_attr_values={
':email': {email},
},
)
try:
return persistence_layer.transact_write_items(transact)
except ClientError:
raise BadRequestError('Cannot remove the primary email.')
def set_email_as_primary(
id: str,
new_email: str,
old_email: str,
/,
*,
email_verified: bool = False,
persistence_layer: DynamoDBPersistenceLayer,
):
now_ = now()
expr = 'SET email_primary = :email_primary, update_date = :update_date'
transact = TransactItems(persistence_layer.table_name)
# Set the old email as non-primary
transact.update(
key=KeyPair(id, ComposeKey(old_email, 'emails')),
update_expr=expr,
expr_attr_values={
':email_primary': False,
':update_date': now_,
},
)
# Set the new email as primary
transact.update(
key=KeyPair(id, ComposeKey(new_email, 'emails')),
update_expr=expr,
expr_attr_values={
':email_primary': True,
':update_date': now_,
},
)
transact.update(
key=KeyPair(id, '0'),
update_expr=(
'SET email = :email, email_verified = :email_verified, '
'update_date = :update_date'
),
expr_attr_values={
':email': new_email,
':email_verified': email_verified,
':update_date': now_,
},
)
return persistence_layer.transact_write_items(transact)
def del_org_member(
id: str,
*,
org_id: str,
persistence_layer: DynamoDBPersistenceLayer,
) -> bool:
transact = TransactItems(persistence_layer.table_name)
# Remove the user's relationship with the organization and their privileges
transact.delete(key=KeyPair(id, f'acls#{org_id}'))
transact.delete(key=KeyPair(id, f'orgs#{org_id}'))
transact.update(
key=KeyPair(id, '0'),
update_expr='DELETE #tenant :org_id',
expr_attr_names={'#tenant': 'tenant__org_id'},
expr_attr_values={':org_id': {org_id}},
)
# Remove the user from the organization's admins and members list
transact.delete(key=KeyPair(org_id, f'admins#{id}'))
transact.delete(key=KeyPair(f'orgmembers#{org_id}', id))
return persistence_layer.transact_write_items(transact)