fix
This commit is contained in:
20
http-api/app/api_gateway.py
Normal file
20
http-api/app/api_gateway.py
Normal 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
61
http-api/app/app.py
Normal 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
143
http-api/app/auth.py
Normal 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)
|
||||
16
http-api/app/boto3clients.py
Normal file
16
http-api/app/boto3clients.py
Normal 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
75
http-api/app/cognito.py
Normal 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
36
http-api/app/conf.py
Normal 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
46
http-api/app/elastic.py
Normal 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
50
http-api/app/konviva.py
Normal 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()
|
||||
11
http-api/app/middlewares/__init__.py
Normal file
11
http-api/app/middlewares/__init__.py
Normal 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',
|
||||
]
|
||||
97
http-api/app/middlewares/audit_log_middleware.py
Normal file
97
http-api/app/middlewares/audit_log_middleware.py
Normal 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
|
||||
49
http-api/app/middlewares/authentication_middleware.py
Normal file
49
http-api/app/middlewares/authentication_middleware.py
Normal 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)
|
||||
123
http-api/app/middlewares/tenant_middelware.py
Normal file
123
http-api/app/middlewares/tenant_middelware.py
Normal 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
39
http-api/app/models.py
Normal 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
|
||||
97
http-api/app/routes/courses/__init__.py
Normal file
97
http-api/app/routes/courses/__init__.py
Normal 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,
|
||||
)
|
||||
99
http-api/app/routes/enrollments/__init__.py
Normal file
99
http-api/app/routes/enrollments/__init__.py
Normal 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 {}
|
||||
37
http-api/app/routes/enrollments/vacancies.py
Normal file
37
http-api/app/routes/enrollments/vacancies.py
Normal 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'))
|
||||
)
|
||||
32
http-api/app/routes/lookup/__init__.py
Normal file
32
http-api/app/routes/lookup/__init__.py
Normal 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,
|
||||
)
|
||||
45
http-api/app/routes/orders/__init__.py
Normal file
45
http-api/app/routes/orders/__init__.py
Normal 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'))
|
||||
3
http-api/app/routes/orgs/__init__.py
Normal file
3
http-api/app/routes/orgs/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .policies import router as policies
|
||||
|
||||
__all__ = ['policies']
|
||||
69
http-api/app/routes/orgs/policies.py
Normal file
69
http-api/app/routes/orgs/policies.py
Normal 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,
|
||||
)
|
||||
46
http-api/app/routes/settings/__init__.py
Normal file
46
http-api/app/routes/settings/__init__.py
Normal 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)}
|
||||
147
http-api/app/routes/users/__init__.py
Normal file
147
http-api/app/routes/users/__init__.py
Normal 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,
|
||||
)
|
||||
105
http-api/app/routes/users/emails.py
Normal file
105
http-api/app/routes/users/emails.py
Normal 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
|
||||
41
http-api/app/routes/users/logs.py
Normal file
41
http-api/app/routes/users/logs.py
Normal 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),
|
||||
)
|
||||
62
http-api/app/routes/users/orgs.py
Normal file
62
http-api/app/routes/users/orgs.py
Normal 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)
|
||||
8
http-api/app/routes/webhooks/__init__.py
Normal file
8
http-api/app/routes/webhooks/__init__.py
Normal 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 []
|
||||
0
http-api/app/rules/__init__.py
Normal file
0
http-api/app/rules/__init__.py
Normal file
57
http-api/app/rules/course.py
Normal file
57
http-api/app/rules/course.py
Normal 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)
|
||||
102
http-api/app/rules/enrollment.py
Normal file
102
http-api/app/rules/enrollment.py
Normal 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
40
http-api/app/rules/org.py
Normal 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
227
http-api/app/rules/user.py
Normal 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)
|
||||
Reference in New Issue
Block a user