diff --git a/api.saladeaula.digital/app/app.py b/api.saladeaula.digital/app/app.py index 2e2ce59..428a0ef 100644 --- a/api.saladeaula.digital/app/app.py +++ b/api.saladeaula.digital/app/app.py @@ -40,12 +40,12 @@ app.include_router(enrollments.download_cert, prefix='/enrollments') app.include_router(enrollments.enroll, prefix='/enrollments') app.include_router(users.router, prefix='/users') app.include_router(users.emails, prefix='/users') -app.include_router(users.add, prefix='/users') app.include_router(users.orgs, prefix='/users') app.include_router(orders.router, prefix='/orders') app.include_router(orgs.admins, prefix='/orgs') app.include_router(orgs.custom_pricing, prefix='/orgs') app.include_router(orgs.scheduled, prefix='/orgs') +app.include_router(orgs.users, prefix='/orgs') @app.get('/health') diff --git a/api.saladeaula.digital/app/keep_warm.py b/api.saladeaula.digital/app/keep_warm.py index 8860742..ea9bd38 100644 --- a/api.saladeaula.digital/app/keep_warm.py +++ b/api.saladeaula.digital/app/keep_warm.py @@ -10,7 +10,10 @@ from aws_lambda_powertools.utilities.typing import LambdaContext logger = Logger(__name__) tracer = Tracer() -urls = ['https://bcs7fgb9og.execute-api.sa-east-1.amazonaws.com/health'] +urls = [ + 'https://bcs7fgb9og.execute-api.sa-east-1.amazonaws.com/health', + 'https://id.saladeaula.digital/health', +] @tracer.capture_lambda_handler diff --git a/api.saladeaula.digital/app/routes/orgs/__init__.py b/api.saladeaula.digital/app/routes/orgs/__init__.py index eb8c307..9b9aef2 100644 --- a/api.saladeaula.digital/app/routes/orgs/__init__.py +++ b/api.saladeaula.digital/app/routes/orgs/__init__.py @@ -1,5 +1,6 @@ from .admins import router as admins from .custom_pricing import router as custom_pricing from .enrollments.scheduled import router as scheduled +from .users import router as users -__all__ = ['admins', 'custom_pricing', 'scheduled'] +__all__ = ['admins', 'custom_pricing', 'scheduled', 'users'] diff --git a/api.saladeaula.digital/app/routes/orgs/admins.py b/api.saladeaula.digital/app/routes/orgs/admins.py index f7ec362..0011a1c 100644 --- a/api.saladeaula.digital/app/routes/orgs/admins.py +++ b/api.saladeaula.digital/app/routes/orgs/admins.py @@ -1,6 +1,11 @@ +from http import HTTPStatus +from typing import Annotated + from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.openapi.params import Body from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair +from api_gateway import JSONResponse from boto3clients import dynamodb_client from config import USER_TABLE @@ -15,3 +20,14 @@ def get_admins(org_id: str): KeyPair(org_id, 'admins#'), limit=100, ) + + +@router.delete('//admins') +def revoke(org_id: str, user_id: Annotated[str, Body(embed=True)]): + with dyn.transact_writer() as transact: + transact.delete( + # Post-migration: rename `admins` to `ADMIN` + key=KeyPair(org_id, f'admins#{user_id}'), + ) + + return JSONResponse(status_code=HTTPStatus.NO_CONTENT) diff --git a/api.saladeaula.digital/app/routes/users/add.py b/api.saladeaula.digital/app/routes/orgs/users/__init__.py similarity index 92% rename from api.saladeaula.digital/app/routes/users/add.py rename to api.saladeaula.digital/app/routes/orgs/users/__init__.py index 7b472c2..f5e79fd 100644 --- a/api.saladeaula.digital/app/routes/users/add.py +++ b/api.saladeaula.digital/app/routes/orgs/users/__init__.py @@ -11,7 +11,7 @@ from aws_lambda_powertools.event_handler.openapi.params import Body from layercake.dateutils import now from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey from layercake.extra_types import CnpjStr, CpfStr, NameStr -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, Field from api_gateway import JSONResponse from boto3clients import dynamodb_client @@ -22,7 +22,7 @@ dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) class Org(BaseModel): - id: str + id: str | None = Field(default=None, exclude=True) name: str cnpj: CnpjStr @@ -44,56 +44,25 @@ class UserConflictError(ServiceError): super().__init__(HTTPStatus.CONFLICT, msg) -class UserNotFoundError(NotFoundError): ... +class UserMissingError(NotFoundError): ... class OrgMissingError(NotFoundError): ... -@router.post('/') +@router.post('//users') def add_user( + org_id: str, user: Annotated[User, Body(embed=True)], org: Annotated[Org, Body(embed=True)], ): + org.id = org_id + if _create_user(user, org): return JSONResponse(HTTPStatus.CREATED) - now_ = now() user_id = _get_user_id(user) - - with dyn.transact_writer() as transact: - transact.update( - key=KeyPair(user_id, '0'), - update_expr='ADD org_id :org_id', - expr_attr_values={ - ':org_id': {org.id}, - }, - ) - transact.put( - item={ - 'id': user_id, - # Post-migration: rename `orgs` to `ORG` - 'sk': f'orgs#{org.id}', - 'name': org.name, - 'cnpj': org.cnpj, - 'created_at': now_, - } - ) - transact.put( - item={ - # Post-migration: rename `orgmembers` to `ORGMEMBER` - 'id': f'orgmembers#{org.id}', - 'sk': user_id, - 'created_at': now_, - }, - cond_expr='attribute_not_exists(sk)', - exc_cls=UserConflictError, - ) - transact.condition( - key=KeyPair(org.id, '0'), - cond_expr='attribute_exists(sk)', - exc_cls=OrgMissingError, - ) + _add_member(user_id, org) return JSONResponse(HTTPStatus.NO_CONTENT) @@ -163,7 +132,7 @@ def _create_user(user: User, org: Org) -> bool: } ) transact.condition( - key=KeyPair(org.id, '0'), + key=KeyPair(org.id, '0'), # type: ignore cond_expr='attribute_exists(sk)', exc_cls=OrgMissingError, ) @@ -173,6 +142,44 @@ def _create_user(user: User, org: Org) -> bool: return True +def _add_member(user_id: str, org: Org) -> None: + now_ = now() + + with dyn.transact_writer() as transact: + transact.update( + key=KeyPair(user_id, '0'), + update_expr='ADD org_id :org_id', + expr_attr_values={ + ':org_id': {org.id}, + }, + ) + transact.put( + item={ + 'id': user_id, + # Post-migration: rename `orgs` to `ORG` + 'sk': f'orgs#{org.id}', + 'name': org.name, + 'cnpj': org.cnpj, + 'created_at': now_, + } + ) + transact.put( + item={ + # Post-migration: rename `orgmembers` to `ORGMEMBER` + 'id': f'orgmembers#{org.id}', + 'sk': user_id, + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=UserConflictError, + ) + transact.condition( + key=KeyPair(org.id, '0'), # type: ignore + cond_expr='attribute_exists(sk)', + exc_cls=OrgMissingError, + ) + + def _get_user_id(user: User) -> str: user_id = dyn.collection.get_items( KeyPair( @@ -189,6 +196,6 @@ def _get_user_id(user: User) -> str: ).get('id') if not user_id: - raise UserNotFoundError() + raise UserMissingError() return user_id diff --git a/api.saladeaula.digital/app/routes/users/__init__.py b/api.saladeaula.digital/app/routes/users/__init__.py index 9e92f40..f7919a5 100644 --- a/api.saladeaula.digital/app/routes/users/__init__.py +++ b/api.saladeaula.digital/app/routes/users/__init__.py @@ -7,11 +7,10 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from boto3clients import dynamodb_client from config import USER_TABLE -from .add import router as add from .emails import router as emails from .orgs import router as orgs -__all__ = ['add', 'emails', 'orgs'] +__all__ = ['emails', 'orgs'] router = Router() dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) diff --git a/api.saladeaula.digital/template.yaml b/api.saladeaula.digital/template.yaml index 4a0517f..f82176b 100644 --- a/api.saladeaula.digital/template.yaml +++ b/api.saladeaula.digital/template.yaml @@ -121,7 +121,7 @@ Resources: ScheduleEvent: Type: ScheduleV2 Properties: - ScheduleExpression: 'cron(*/3 5-23 * * ? *)' + ScheduleExpression: 'cron(*/5 5-23 * * ? *)' ScheduleExpressionTimezone: America/Sao_Paulo Outputs: diff --git a/api.saladeaula.digital/tests/routes/orgs/__init__.py b/api.saladeaula.digital/tests/routes/orgs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api.saladeaula.digital/tests/routes/orgs/test_users.py b/api.saladeaula.digital/tests/routes/orgs/test_users.py new file mode 100644 index 0000000..42c11ac --- /dev/null +++ b/api.saladeaula.digital/tests/routes/orgs/test_users.py @@ -0,0 +1,151 @@ +import json +from http import HTTPMethod, HTTPStatus + +from layercake.dynamodb import ( + DynamoDBPersistenceLayer, + PartitionKey, + SortKey, + TransactKey, +) + +from ...conftest import HttpApiProxy, LambdaContext + + +def test_add_user( + app, + seeds, + http_api_proxy: HttpApiProxy, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/orgs/f6000f79-6e5c-49a0-952f-3bda330ef278/users', + method=HTTPMethod.POST, + body={ + 'user': { + 'name': 'Scott Weiland', + 'email': 'scott@stonetemplopilots.com', + 'cpf': '40245650016', + }, + 'org': { + 'name': 'Branco do Brasil', + 'cnpj': '00000000000191', + }, + }, + ), + lambda_context, + ) + assert r['statusCode'] == HTTPStatus.CREATED + r = dynamodb_persistence_layer.collection.query( + PartitionKey('orgmembers#f6000f79-6e5c-49a0-952f-3bda330ef278') + ) + user_id = r['items'][0]['sk'] + user = dynamodb_persistence_layer.collection.get_items( + TransactKey(user_id) + + SortKey('0') + + SortKey('emails#scott@stonetemplopilots.com') + ) + assert user['name'] == 'Scott Weiland' + assert 'email' in user + assert 'email_verified' in user + assert 'created_at' in user + assert 'org_id' in user + assert 'emails#scott@stonetemplopilots.com' in user + + +def test_user_exists( + app, + seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/orgs/2a8963fc-4694-4fe2-953a-316d1b10f1f5/users', + method=HTTPMethod.POST, + body={ + 'user': { + 'name': 'Sérgio R Siqueira', + 'email': 'sergio@somosbeta.com.br', + 'cpf': '07879819908', + }, + 'org': { + 'name': 'pytest', + 'cnpj': '04978826000180', + }, + }, + ), + lambda_context, + ) + assert r['statusCode'] == HTTPStatus.NO_CONTENT + + r = dynamodb_persistence_layer.collection.query( + PartitionKey('orgmembers#2a8963fc-4694-4fe2-953a-316d1b10f1f5') + ) + user_id = r['items'][0]['sk'] + + user = dynamodb_persistence_layer.collection.get_items( + TransactKey(user_id) + + SortKey('0') + + SortKey('emails#sergio@somosbeta.com.br', rename_key='email') + + SortKey('orgs#2a8963fc-4694-4fe2-953a-316d1b10f1f5', rename_key='org') + ) + assert 'mx_record_exists' in user['email'] + assert 'cnpj' in user['org'] + + +def test_user_conflict( + app, + seeds, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/orgs/f6000f79-6e5c-49a0-952f-3bda330ef278/users', + method=HTTPMethod.POST, + body={ + 'user': { + 'name': 'Sérgio R Siqueira', + 'email': 'sergio@somosbeta.com.br', + 'cpf': '07879819908', + }, + 'org': { + 'name': 'Banco do Brasil', + 'cnpj': '00000000000191', + }, + }, + ), + lambda_context, + ) + assert r['statusCode'] == HTTPStatus.CONFLICT + + +def test_org_not_found( + app, + seeds, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/orgs/123/users', + method=HTTPMethod.POST, + body={ + 'user': { + 'name': 'Sérgio R Siqueira', + 'email': 'sergio@somosbeta.com.br', + 'cpf': '07879819908', + }, + 'org': { + 'name': 'Banco do Brasil', + 'cnpj': '00000000000191', + }, + }, + ), + lambda_context, + ) + body = json.loads(r['body']) + assert body['type'] == 'OrgMissingError' diff --git a/api.saladeaula.digital/tests/routes/test_orgs.py b/api.saladeaula.digital/tests/routes/test_orgs.py index 41b8c1b..9a78f23 100644 --- a/api.saladeaula.digital/tests/routes/test_orgs.py +++ b/api.saladeaula.digital/tests/routes/test_orgs.py @@ -1,6 +1,8 @@ import json from http import HTTPMethod, HTTPStatus +from layercake.dynamodb import DynamoDBPersistenceLayer + from ..conftest import HttpApiProxy, LambdaContext @@ -23,6 +25,24 @@ def test_get_admins( assert len(r['items']) == 1 +def test_revoke( + app, + seeds, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/orgs/f6000f79-6e5c-49a0-952f-3bda330ef278/admins', + method=HTTPMethod.DELETE, + body={'user_id': '15bacf02-1535-4bee-9022-19d106fd7518'}, + ), + lambda_context, + ) + assert r['statusCode'] == HTTPStatus.NO_CONTENT + + def test_get_scheduled( app, seeds, diff --git a/api.saladeaula.digital/tests/routes/test_users.py b/api.saladeaula.digital/tests/routes/test_users.py index 70c2553..7c42ab8 100644 --- a/api.saladeaula.digital/tests/routes/test_users.py +++ b/api.saladeaula.digital/tests/routes/test_users.py @@ -1,13 +1,6 @@ import json from http import HTTPMethod, HTTPStatus -from layercake.dynamodb import ( - DynamoDBPersistenceLayer, - PartitionKey, - SortKey, - TransactKey, -) - from ..conftest import HttpApiProxy, LambdaContext @@ -44,147 +37,3 @@ def test_get_orgs( lambda_context, ) assert r['statusCode'] == HTTPStatus.OK - - -def test_add_user( - app, - seeds, - http_api_proxy: HttpApiProxy, - dynamodb_persistence_layer: DynamoDBPersistenceLayer, - lambda_context: LambdaContext, -): - r = app.lambda_handler( - http_api_proxy( - raw_path='/users', - method=HTTPMethod.POST, - body={ - 'user': { - 'name': 'Scott Weiland', - 'email': 'scott@stonetemplopilots.com', - 'cpf': '40245650016', - }, - 'org': { - 'id': 'f6000f79-6e5c-49a0-952f-3bda330ef278', - 'name': 'Branco do Brasil', - 'cnpj': '00000000000191', - }, - }, - ), - lambda_context, - ) - assert r['statusCode'] == HTTPStatus.CREATED - r = dynamodb_persistence_layer.collection.query( - PartitionKey('orgmembers#f6000f79-6e5c-49a0-952f-3bda330ef278') - ) - user_id = r['items'][0]['sk'] - user = dynamodb_persistence_layer.collection.get_items( - TransactKey(user_id) - + SortKey('0') - + SortKey('emails#scott@stonetemplopilots.com') - ) - assert user['name'] == 'Scott Weiland' - assert 'email' in user - assert 'email_verified' in user - assert 'created_at' in user - assert 'org_id' in user - assert 'emails#scott@stonetemplopilots.com' in user - - -def test_user_exists( - app, - seeds, - dynamodb_persistence_layer: DynamoDBPersistenceLayer, - http_api_proxy: HttpApiProxy, - lambda_context: LambdaContext, -): - r = app.lambda_handler( - http_api_proxy( - raw_path='/users', - method=HTTPMethod.POST, - body={ - 'user': { - 'name': 'Sérgio R Siqueira', - 'email': 'sergio@somosbeta.com.br', - 'cpf': '07879819908', - }, - 'org': { - 'id': '2a8963fc-4694-4fe2-953a-316d1b10f1f5', - 'name': 'pytest', - 'cnpj': '04978826000180', - }, - }, - ), - lambda_context, - ) - assert r['statusCode'] == HTTPStatus.NO_CONTENT - - r = dynamodb_persistence_layer.collection.query( - PartitionKey('orgmembers#2a8963fc-4694-4fe2-953a-316d1b10f1f5') - ) - user_id = r['items'][0]['sk'] - - user = dynamodb_persistence_layer.collection.get_items( - TransactKey(user_id) - + SortKey('0') - + SortKey('emails#sergio@somosbeta.com.br', rename_key='email') - + SortKey('orgs#2a8963fc-4694-4fe2-953a-316d1b10f1f5', rename_key='org') - ) - assert 'mx_record_exists' in user['email'] - assert 'cnpj' in user['org'] - - -def test_user_conflict( - app, - seeds, - http_api_proxy: HttpApiProxy, - lambda_context: LambdaContext, -): - r = app.lambda_handler( - http_api_proxy( - raw_path='/users', - method=HTTPMethod.POST, - body={ - 'user': { - 'name': 'Sérgio R Siqueira', - 'email': 'sergio@somosbeta.com.br', - 'cpf': '07879819908', - }, - 'org': { - 'id': 'f6000f79-6e5c-49a0-952f-3bda330ef278', - 'name': 'Banco do Brasil', - 'cnpj': '00000000000191', - }, - }, - ), - lambda_context, - ) - assert r['statusCode'] == HTTPStatus.CONFLICT - - -def test_org_not_found( - app, - seeds, - http_api_proxy: HttpApiProxy, - lambda_context: LambdaContext, -): - r = app.lambda_handler( - http_api_proxy( - raw_path='/users', - method=HTTPMethod.POST, - body={ - 'user': { - 'name': 'Sérgio R Siqueira', - 'email': 'sergio@somosbeta.com.br', - 'cpf': '07879819908', - }, - 'org': { - 'id': '123', - 'name': 'Banco do Brasil', - 'cnpj': '00000000000191', - }, - }, - ), - lambda_context, - ) - body = json.loads(r['body']) - assert body['type'] == 'OrgMissingError' diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.certs._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.certs._index/route.tsx new file mode 100644 index 0000000..8b1dcf0 --- /dev/null +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.certs._index/route.tsx @@ -0,0 +1,7 @@ +export function meta({}) { + return [{ title: 'Certificações' }] +} + +export default function Route() { + return <>index org +} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.add/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.add/route.tsx index c50d827..2618f78 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.add/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.add/route.tsx @@ -61,10 +61,11 @@ export function meta({}: Route.MetaArgs) { return [{ title: 'Adicionar colaborador' }] } -export async function action({ request, context }: Route.ActionArgs) { +export async function action({ params, request, context }: Route.ActionArgs) { + const { orgid } = params const body = await request.json() const r = await req({ - url: `users`, + url: `orgs/${orgid}/users`, headers: new Headers({ 'Content-Type': 'application/json' }), method: HttpMethod.POST, body: JSON.stringify(body), diff --git a/id.saladeaula.digital/app/app.py b/id.saladeaula.digital/app/app.py index 9a0ac10..6da7259 100644 --- a/id.saladeaula.digital/app/app.py +++ b/id.saladeaula.digital/app/app.py @@ -27,6 +27,11 @@ app.include_router(revoke) app.include_router(openid_configuration) +@app.get('/health') +def health(): + return {'status': 'available'} + + @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]: diff --git a/id.saladeaula.digital/template.yaml b/id.saladeaula.digital/template.yaml index 19c991f..4fb57e5 100644 --- a/id.saladeaula.digital/template.yaml +++ b/id.saladeaula.digital/template.yaml @@ -98,6 +98,12 @@ Resources: Path: /userinfo Method: GET ApiId: !Ref HttpApi + Health: + Type: HttpApi + Properties: + Path: /health + Method: GET + ApiId: !Ref HttpApi Outputs: HttpApiUrl: diff --git a/packages/auth/src/middleware/auth.ts b/packages/auth/src/middleware/auth.ts index a03d57e..93d3868 100644 --- a/packages/auth/src/middleware/auth.ts +++ b/packages/auth/src/middleware/auth.ts @@ -17,11 +17,11 @@ export const authMiddleware = async ( const requestId = context.get(requestIdContext) let user = session.get('user') as User | null - session.set('returnTo', new URL(request.url).toString()) - if (!user) { console.log('There is no user logged in') + session.set('returnTo', new URL(request.url).toString()) + return redirect('/login', { headers: new Headers({ 'Set-Cookie': await sessionStorage.commitSession(session) @@ -43,7 +43,10 @@ export const authMiddleware = async ( refreshToken: tokens.refreshToken() } - console.debug(`[${requestId}] Refresh token retrieved`, user) + console.debug( + `[${new Date().toISOString()}] [${requestId}] Refresh token retrieved`, + user + ) // Should replace the user in the session session.set('user', user) }