diff --git a/README.md b/README.md index 4a8adaf..36c7617 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,14 @@ CI (GitHub Actions): mesmo comando em Python 3.12 em todo PR/`push` na `main` ## CDK / Deploy -Stack único em `cdk/app.py` (ADR-001 PR2): **DynamoDB + Lambda + HTTP API** no mesmo stack — sem `from_function_name`, sem Cognito (PR3). +Stack único em `cdk/app.py` (ADR-001): **Cognito + DynamoDB + Lambda + HTTP API** no mesmo stack — sem `from_function_name`. - Tabela: PK `cliente_id` / SK `request_id`, on-demand; `grant_read_write_data` na Lambda. - Env: `TABLE_NAME` (nome da tabela), `PIX_FEE` (default `50.00`). - Handler: `app.lambda_handler` (código em `lambda/`). -- Throttle HTTP API: **100 rps / burst 200** (espelha `aws-api-gateway-cdk`). +- Throttle HTTP API: **100 rps / burst 200**. +- Auth: Cognito JWT nas rotas mutáveis/status; **`GET /health` público**. +- CORS lab: `http://localhost:3000` e `http://127.0.0.1:3000` (sem `*`). Override via CDK context `corsOrigins` (lista de origins). - Rotas: `POST /solicitar-exclusao-cliente`, `GET /status-exclusao/{cliente_id}`, `POST /confirmar-pagamento`, `GET /health`. ```bash @@ -66,4 +68,57 @@ npx aws-cdk@2 synth # requer Node + AWS CDK CLI # npx aws-cdk@2 deploy # só com CAB / conta lab ``` +Outputs úteis após deploy: `ApiUrl`, `UserPoolId`, `UserPoolClientId`, `JwtIssuer`. + **Nota:** o asset Lambda é o source em `lambda/` — empacotar Powertools (layer ou bundle) fica em follow-up; este PR só faz o wiring IaC. + +--- + +## Cognito JWT — obter token e testar (lab) + +Após `cdk deploy` (com CAB), anote `UserPoolId`, `UserPoolClientId` e `ApiUrl`. + +### 1) Criar usuário (lab) + +```bash +aws cognito-idp sign-up \ + --client-id "$CLIENT_ID" \ + --username "lab@example.com" \ + --password 'LabPass123' \ + --user-attributes Name=email,Value=lab@example.com + +# Confirmar (lab — admin; self-sign-up exige confirmação de e-mail ou admin-confirm) +aws cognito-idp admin-confirm-sign-up \ + --user-pool-id "$USER_POOL_ID" \ + --username "lab@example.com" +``` + +### 2) Obter IdToken + +```bash +aws cognito-idp initiate-auth \ + --client-id "$CLIENT_ID" \ + --auth-flow USER_PASSWORD_AUTH \ + --auth-parameters USERNAME=lab@example.com,PASSWORD='LabPass123' +``` + +Use o campo `AuthenticationResult.IdToken` (não o AccessToken) no header. + +### 3) Chamar API + +```bash +# Público +curl -s "$API_URL/health" + +# Protegido +PAYLOAD='{"cliente_id":"c1","motivo":"lab"}' +curl -s -X POST "$API_URL/solicitar-exclusao-cliente" \ + -H "Authorization: Bearer $ID_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" + +curl -s "$API_URL/status-exclusao/c1" \ + -H "Authorization: Bearer $ID_TOKEN" +``` + +Sem Bearer nas rotas protegidas → API Gateway responde **401** antes da Lambda. diff --git a/SECURITY.md b/SECURITY.md index abfa7fb..eb8c54c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,12 +1,43 @@ # Segurança -## Medidas Implementadas -- Idempotência -- Validação de input -- Throttling -- Configuração via env vars - -## Melhorias Planejadas -- Authorizer -- WAF -- Segurança no QR Code Pix real \ No newline at end of file +## O que este stack CDK cria (honesto) + +Recursos em `cdk/app.py` hoje: + +| Controle | Status | +|----------|--------| +| Cognito User Pool + App Client (lab) | **Sim** — email sign-in; `USER_PASSWORD` + `USER_SRP`; sem client secret | +| HTTP API JWT authorizer (Cognito) | **Sim** — nas rotas protegidas | +| `/health` público | **Sim** — sem authorizer (probes / smoke lab) | +| Throttle HTTP API | **Sim** — 100 rps / burst 200 | +| CORS | **Restrito a localhost** (`http://localhost:3000`, `http://127.0.0.1:3000`); override via CDK context `corsOrigins` | +| DynamoDB on-demand + IAM grant mínimo na Lambda | **Sim** | +| Validação / idempotência no handler | **Sim** (código Lambda) | +| WAF | **Não** — fora do CDK; não prometer | +| ACM / domínio custom / mTLS | **Não** — fora do CDK | +| Secrets no repositório | **Não** — User Pool / Client IDs só via CloudFormation outputs | + +### Rotas + +| Rota | Auth | +|------|------| +| `POST /solicitar-exclusao-cliente` | Cognito JWT | +| `GET /status-exclusao/{cliente_id}` | Cognito JWT | +| `POST /confirmar-pagamento` | Cognito JWT | +| `GET /health` | Público | + +Enviar `Authorization: Bearer ` nas rotas protegidas. + +## Limitações de lab (aceitáveis até próximo ADR) + +- User Pool com self sign-up e remoção `DESTROY` — adequado só a conta lab. +- Throttle ≠ proteção de abuso (sem WAF / rate por cliente). +- Pix continua stub; QR não é canal autenticado. +- CORS sem `*`, mas origins de lab são localhost; frontends em outro host precisam de `corsOrigins` no deploy. + +## Melhorias planejadas (não implementadas neste PR) + +- WAF / AWS Shield +- ACM + custom domain +- Alarmes / X-Ray +- Restringir self sign-up / MFA em ambientes não-lab diff --git a/cdk/app.py b/cdk/app.py index 74a4902..9a49b03 100644 --- a/cdk/app.py +++ b/cdk/app.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""CDK app — exclusão Pix (ADR-001 PR2). +"""CDK app — exclusão Pix (ADR-001 PR3). -Single stack: DynamoDB table + Lambda + HTTP API. -No Cognito (PR3). No from_function_name. +Single stack: Cognito User Pool + DynamoDB + Lambda + HTTP API (JWT). +Health is public; mutable/status routes require Cognito JWT. +No WAF/ACM in this stack. """ from aws_cdk import ( App, @@ -11,7 +12,9 @@ RemovalPolicy, Stack, aws_apigatewayv2 as apigwv2, + aws_apigatewayv2_authorizers as apigwv2_authorizers, aws_apigatewayv2_integrations as apigwv2_integrations, + aws_cognito as cognito, aws_dynamodb as dynamodb, aws_lambda as _lambda, ) @@ -35,6 +38,42 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: removal_policy=RemovalPolicy.DESTROY, ) + # Lab Cognito: email sign-in, USER_PASSWORD + USER_SRP (no client secret). + user_pool = cognito.UserPool( + self, + "ExclusaoClienteUserPool", + self_sign_up_enabled=True, + sign_in_aliases=cognito.SignInAliases(email=True), + auto_verify=cognito.AutoVerifiedAttrs(email=True), + password_policy=cognito.PasswordPolicy( + min_length=8, + require_lowercase=True, + require_uppercase=True, + require_digits=True, + require_symbols=False, + ), + removal_policy=RemovalPolicy.DESTROY, + ) + + user_pool_client = user_pool.add_client( + "ExclusaoClienteAppClient", + auth_flows=cognito.AuthFlow( + user_password=True, + user_srp=True, + ), + generate_secret=False, + ) + + jwt_issuer = ( + f"https://cognito-idp.{self.region}.amazonaws.com/" + f"{user_pool.user_pool_id}" + ) + jwt_authorizer = apigwv2_authorizers.HttpJwtAuthorizer( + "CognitoJwtAuthorizer", + jwt_issuer, + jwt_audience=[user_pool_client.user_pool_client_id], + ) + # Handler: lambda/app.py → lambda_handler (CI uses Python 3.12) # Powertools packaging/layer left for follow-up (asset is source only). fn = _lambda.Function( @@ -53,13 +92,25 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: table.grant_read_write_data(fn) + # Lab CORS: no wildcard. Browser callers must use localhost (or override + # via CDK context key corsOrigins = ["https://app.example"]). + cors_origins = self.node.try_get_context("corsOrigins") or [ + "http://localhost:3000", + "http://127.0.0.1:3000", + ] + http_api = apigwv2.HttpApi( self, "ExclusaoClienteHttpApi", description="API Gateway para Exclusão de Cliente com Pix", cors_preflight=apigwv2.CorsPreflightOptions( - allow_origins=["*"], - allow_methods=[apigwv2.CorsHttpMethod.ANY], + allow_origins=cors_origins, + allow_methods=[ + apigwv2.CorsHttpMethod.GET, + apigwv2.CorsHttpMethod.POST, + apigwv2.CorsHttpMethod.OPTIONS, + ], + allow_headers=["Authorization", "Content-Type"], ), create_default_stage=False, ) @@ -78,21 +129,26 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: "LambdaIntegration", fn ) + # Protected (Cognito JWT): mutáveis + status http_api.add_routes( path="/solicitar-exclusao-cliente", methods=[apigwv2.HttpMethod.POST], integration=integration, + authorizer=jwt_authorizer, ) http_api.add_routes( path="/status-exclusao/{cliente_id}", methods=[apigwv2.HttpMethod.GET], integration=integration, + authorizer=jwt_authorizer, ) http_api.add_routes( path="/confirmar-pagamento", methods=[apigwv2.HttpMethod.POST], integration=integration, + authorizer=jwt_authorizer, ) + # Public: health (probes / lab smoke) http_api.add_routes( path="/health", methods=[apigwv2.HttpMethod.GET], @@ -103,6 +159,11 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: CfnOutput(self, "ApiId", value=http_api.api_id) CfnOutput(self, "DynamoTableName", value=table.table_name) CfnOutput(self, "LambdaName", value=fn.function_name) + CfnOutput(self, "UserPoolId", value=user_pool.user_pool_id) + CfnOutput( + self, "UserPoolClientId", value=user_pool_client.user_pool_client_id + ) + CfnOutput(self, "JwtIssuer", value=jwt_issuer) app = App()