Construindo um BBQ conectado serverless como SaaS - Parte 3 - Locatários

Este arquivo foi traduzido automaticamente por IA, erros podem ocorrer
Chegou a hora da Parte 3 na série de criação de um BBQ Conectado Serverless como SaaS. Nesta terceira postagem, vamos examinar a criação de locatários, autenticação e autorização. Vamos criar um novo serviço de locatário e integrá-lo com o serviço de usuário da parte dois.
Se você ainda não conferiu, aqui estão parte um e parte dois
Locatários em um SaaS
Em uma solução Software as a Service (SaaS), um locatário é a organização ou indivíduo que assina e usa o serviço.
Uma das partes mais cruciais de uma solução SaaS multi-locatário é o isolamento de dados dos locatários, garantindo que os dados de cada locatário permaneçam separados e seguros dos outros dentro do mesmo ambiente. Isso é especialmente importante para manter a privacidade dos dados, cumprir regulamentos e proteger contra violações de dados.
Abordagens para Isolamento de Dados de Locatários na AWS
Para isolar os dados dos locatários, existem várias abordagens diferentes que podemos usar.
Banco de dados dedicado / armazenamento de dados por locatário
Cada locatário tem uma instância de banco de dados separada ou armazenamento de dados, como o S3. Isso fornece o isolamento mais forte, pois os dados de cada locatário são totalmente segregados no nível do banco de dados / armazenamento de dados. Nesta abordagem, também podemos usar chaves KMS separadas para cada locatário, garantindo que os dados estejam protegidos mesmo no nível de criptografia.
Alguns dos desafios desta abordagem seriam custos mais altos devido a múltiplas instâncias de banco de dados. O dimensionamento pode se tornar complexo à medida que o número de locatários cresce.
Tabela por locatário
Cada locatário tem uma tabela separada no banco de dados. Para um armazenamento de dados como o S3, cada locatário pode ter um prefixo único sob o qual os dados são armazenados. Para usar chaves KMS separadas, os dados precisam ser criptografados no nível do cliente e não no nível do armazenamento, o que pode adicionar ainda mais isolamento.
Esta abordagem oferece um bom compromisso entre custo e isolamento, e é mais fácil impor o isolamento de dados no nível da tabela.
Alguns dos desafios desta abordagem são que ainda requer gerenciamento cuidadoso de nomes de tabelas e evolução do esquema. O desempenho pode ser impactado.
Segurança de Nível de Linha (RLS)
Os dados de todos os locatários são armazenados em uma única tabela, mas com controles de acesso ao nível da linha para garantir que cada locatário possa acessar apenas seus próprios dados. Esse controle de acesso pode ser no nível do armazenamento de dados com RLS embutido ou no nível do cliente com verificações de autorização com serviços como o Amazon Verified Permissions. Para um armazenamento de dados como o S3, cada locatário ainda pode ter um prefixo único sob o qual os dados são armazenados.
Alguns dos desafios desta abordagem são que é mais complexo impor corretamente; qualquer bug na lógica pode expor dados. O desempenho pode ser impactado pelo filtragem complexa de consultas.
Qual abordagem devo usar?
A abordagem que você usa depende do seu caso de uso, nível de conformidade que você precisa e requisitos do cliente.
Nesta série de dados de BBQ, vou adotar a abordagem e usar a segurança de nível de linha.
Visão Geral da Arquitetura
Nesta arquitetura, continuamos construindo sobre a configuração orientada a eventos introduzida na parte 2. Quando um usuário se cadastra, é criado, um evento será enviado do serviço de usuário para um barramento de eventos EventBridge, que agora invocará o serviço de locatário. O serviço de locatário tem duas tabelas DynamoDB primárias, uma que armazena as informações do locatário, ID, nome, etc e outra que mapeia o acesso dos usuários para um locatário. O serviço começará criando um ID de locatário, armazenando-o e então mapeando o usuário para este novo locatário.

Nosso serviço de locatário pode ser chamado através de duas APIs separadas. Uma para nossa aplicação web que buscará e atualizará as informações do locatário, e uma API de administração que será usada não apenas por administradores de SaaS, mas também para comunicação entre serviços. Introduziremos a primeira rodada de autorização de API nesta postagem, IAM para máquina-2-máquina e Oauth e validação de token JWT para a API da aplicação web. Vamos aprofundar ambas as APIs e autorização mais adiante nesta postagem.

Criação de Locatário
Primeiro, vamos criar os recursos de criação de locatário necessários. Aqui criamos duas novas tabelas DynamoDB. Em uma tabela, armazenamos os locatários e informações para ele, como nome do locatário, etc. Na segunda tabela, armazenaremos um mapeamento entre locatários e usuários para acesso. Também precisamos consultar quais locatários um usuário tem acesso, para isso também configuramos um índice para a tabela.
AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Connected BBQ Application Tenant Service
Parameters:
ApplicationName:
Type: String
Description: Name of owning application
Default: bbq-iot
CommonStackName:
Type: String
Description: The name of the common stack that contains the EventBridge Bus and more
TenantTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: !Sub ${ApplicationName}-tenants
AttributeDefinitions:
- AttributeName: tenantid
AttributeType: S
KeySchema:
- AttributeName: tenantid
KeyType: HASH
BillingMode: PAY_PER_REQUEST
TenantUserTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: !Sub ${ApplicationName}-tenant-users
AttributeDefinitions:
- AttributeName: tenantid
AttributeType: S
- AttributeName: userid
AttributeType: S
KeySchema:
- AttributeName: tenantid
KeyType: HASH
- AttributeName: userid
KeyType: RANGE
GlobalSecondaryIndexes:
- IndexName: user-index
KeySchema:
- AttributeName: userid
KeyType: HASH
- AttributeName: tenantid
KeyType: RANGE
Projection:
ProjectionType: ALL
BillingMode: PAY_PER_REQUESTQuando um usuário é criado, precisamos criar o locatário que esse usuário possui. O serviço de usuário enviará um evento no barramento de eventos da aplicação quando um usuário for criado, então aqui continuamos construindo um padrão de saga orientado a eventos e criamos o locatário.
Usar StepFunctions torna fácil escrever no DynamoDB e enviar um evento para que a próxima parte assuma.

Após o locatário ser criado e armazenado, precisamos mapear o primeiro usuário administrador para ele, basicamente o usuário que possui o locatário. Este StepFunction é invocado pelo evento de que o locatário foi criado.

O passo final neste StepFunction também publica no EventBridge para uso futuro.
Vamos adicionar estes ao StepFunctions ao nosso modelo.
TenantCreateExpress:
Type: AWS::Serverless::StateMachine
Properties:
DefinitionUri: create-tenant-statemachine/statemachine.asl.yaml
Tracing:
Enabled: true
Logging:
Destinations:
- CloudWatchLogsLogGroup:
LogGroupArn: !GetAtt TenantCreateStateMachineLogGroup.Arn
IncludeExecutionData: true
Level: ALL
DefinitionSubstitutions:
EventBridgeBusName:
Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
TenantTable: !Ref TenantTable
ApplicationName: !Ref ApplicationName
Policies:
- Statement:
- Effect: Allow
Action:
- logs:*
Resource: "*"
- EventBridgePutEventsPolicy:
EventBusName:
Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
- DynamoDBCrudPolicy:
TableName: !Ref TenantTable
Events:
CreateTenantEvent:
Type: EventBridgeRule
Properties:
EventBusName:
Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
Pattern:
source:
- !Sub ${ApplicationName}.user
detail-type:
- created
Type: EXPRESS
TenantAddFirstAdminExpress:
Type: AWS::Serverless::StateMachine
Properties:
DefinitionUri: add-tenant-first-admin-statemachine/statemachine.asl.yaml
Tracing:
Enabled: true
Logging:
Destinations:
- CloudWatchLogsLogGroup:
LogGroupArn: !GetAtt TenantAddFirstAdminStateMachineLogGroup.Arn
IncludeExecutionData: true
Level: ALL
DefinitionSubstitutions:
EventBridgeBusName:
Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
TenantUserTable: !Ref TenantUserTable
ApplicationName: !Ref ApplicationName
Policies:
- Statement:
- Effect: Allow
Action:
- logs:*
Resource: "*"
- EventBridgePutEventsPolicy:
EventBusName:
Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
- DynamoDBCrudPolicy:
TableName: !Ref TenantUserTable
Events:
CreateTenantEvent:
Type: EventBridgeRule
Properties:
EventBusName:
Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
Pattern:
source:
- !Sub ${ApplicationName}.tenant
detail-type:
- created
Type: EXPRESSComment: Tenant service - Create Tenant On User Created
StartAt: Debug
States:
Debug:
Type: Pass
Next: Genetate Tenant ID
Genetate Tenant ID:
Type: Pass
Parameters:
tenantid.$: States.UUID()
ResultPath: $.TenantID
Next: Create Tenant
Create Tenant:
Type: Task
Resource: arn:aws:states:::dynamodb:putItem
Parameters:
TableName: ${TenantTable}
Item:
tenantid:
S.$: $.TenantID.tenantid
ResultPath: null
Next: Prepare Event
Prepare Event:
Type: Pass
Parameters:
tenantId.$: $.TenantID.tenantid
email.$: $.detail.email
userName.$: $.detail.userName
name.$: $.detail.name
ResultPath: $.TenantData
Next: Post Event
Post Event:
Type: Task
Resource: arn:aws:states:::events:putEvents
Parameters:
Entries:
- Source: ${ApplicationName}.tenant
DetailType: created
Detail.$: $.TenantData
EventBusName: ${EventBridgeBusName}
End: trueComment: Tenant Service - Create Tenant Admin User Mapping
StartAt: Debug
States:
Debug:
Type: Pass
Next: Create Tenant User Mapping
Create Tenant User Mapping:
Type: Task
Resource: arn:aws:states:::dynamodb:putItem
Parameters:
TableName: ${TenantUserTable}
Item:
tenantid:
S.$: $.detail.tenantId
userid:
S.$: $.detail.userName
name:
S.$: $.detail.name
email:
S.$: $.detail.email
role: admin
ResultPath: null
Next: Prepare Event
Prepare Event:
Type: Pass
Parameters:
tenantId.$: $.detail.tenantId
email.$: $.detail.email
userName.$: $.detail.userName
name.$: $.detail.name
role: admin
ResultPath: $.TenantUser
Next: Post Event
Post Event:
Type: Task
Resource: arn:aws:states:::events:putEvents
Parameters:
Entries:
- Source: ${ApplicationName}.tenant
DetailType: adminUserAdded
Detail.$: $.TenantUser
EventBusName: ${EventBridgeBusName}
End: trueAPIs
O serviço de locatário tem duas APIs separadas. Em primeiro lugar, uma API que é usada pela aplicação web para carregar, exibir e atualizar informações sobre o locatário. Esta API será central nas próximas partes à medida que expandirmos como os usuários obtêm acesso aos locatários. A segunda API é uma API de administração de gerenciamento que pode ser usada para integração especial de serviço e sistema.
Para a API da aplicação, usaremos os tokens emitidos pelo Cognito para autorizar os usuários, e para a API de gerenciamento, usaremos autorização máquina-2-máquina com AWS Iam, pelo menos por enquanto.
Ambas as APIs serão suportadas por funções Lambda para implementação da lógica de negócios.
Para começar, vamos criar a API da aplicação. Os usuários chamarão a API com o token que obtiveram do Cognito. O API Gateway tem três integrações Lambda que buscarão os dados corretos, e temos um Lambda Authorizer para validar o token.

Vamos começar adicionando a definição de API ao modelo, usaremos recursos AWS::Serverless::Api e estes DEVEM ser definidos no mesmo modelo que as funções Lambda para facilitar a criação da integração.
TenantAPi:
Type: AWS::Serverless::Api
Properties:
Name: !Sub ${ApplicationName}-tenant-api
StageName: prod
EndpointConfiguration: REGIONAL
Cors:
AllowMethods: "'GET,PUT,POST,DELETE,OPTIONS'"
AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'"
AllowOrigin: "'*'"
Auth:
AddDefaultAuthorizerToCorsPreflight: false
Authorizers:
LambdaRequestAuthorizer:
FunctionArn: !GetAtt LambdaApiAuthorizer.Arn
FunctionPayloadType: REQUEST
Identity:
Headers:
- Authorization
LambdaUserRequestAuthorizer:
FunctionArn: !GetAtt LambdaApiUserAuthorizer.Arn
FunctionPayloadType: REQUEST
Identity:
Headers:
- Authorization
DefaultAuthorizer: LambdaRequestAuthorizer
Em seguida, criamos as funções Lambda que apoiarão nossa API de aplicação.
LambdaTenantInfoGet:
Type: AWS::Serverless::Function
Properties:
CodeUri: Lambda/Api/TenantInfoGet
Handler: get.handler
Policies:
- DynamoDBReadPolicy:
TableName: !Ref TenantTable
Environment:
Variables:
DYNAMODB_TABLE_NAME: !Ref TenantTable
Events:
GetTenantInfoApi:
Type: Api
Properties:
Path: /tenant/{tenantId}
Method: get
RestApiId: !Ref TenantAPi
LambdaTenantInfoPut:
Type: AWS::Serverless::Function
Properties:
CodeUri: Lambda/Api/TenantInfoPut
Handler: put.handler
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref TenantTable
Environment:
Variables:
DYNAMODB_TABLE_NAME: !Ref TenantTable
Events:
PutTenantInfoApi:
Type: Api
Properties:
Path: /tenant/{tenantId}
Method: put
RestApiId: !Ref TenantAPi
LambdaGetTenants:
Type: AWS::Serverless::Function
Properties:
CodeUri: Lambda/Api/TenantsList
Handler: get.handler
Policies:
- DynamoDBReadPolicy:
TableName: !Ref TenantUserTable
Environment:
Variables:
DYNAMODB_TABLE_NAME: !Ref TenantUserTable
DYNAMODB_INDEX_NAME: user-index
Events:
GetTenantsForUserApi:
Type: Api
Properties:
Path: /tenants/{userId}
Method: get
RestApiId: !Ref TenantAPi
Auth:
Authorizer: LambdaUserRequestAuthorizerEm seguida, podemos mudar para a API de gerenciamento, que usará AWS Iam para autorizar as chamadas e terá uma integração baseada em Lambda para buscar todos os locatários para um usuário.

AdminTenantAPi:
Type: AWS::Serverless::Api
Properties:
Name: !Sub ${ApplicationName}-tenant-admin-api
StageName: prod
EndpointConfiguration: REGIONAL
Cors:
AllowMethods: "'GET,PUT,POST,DELETE,OPTIONS'"
AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'"
AllowOrigin: "'*'"
Auth:
DefaultAuthorizer: AWS_IAM
LambdaGetTenantsForUser:
Type: AWS::Serverless::Function
Properties:
CodeUri: Lambda/Internal/GetTenantForUser
Handler: handler.handler
Policies:
- DynamoDBReadPolicy:
TableName: !Ref TenantUserTable
Environment:
Variables:
DYNAMODB_TABLE_NAME: !Ref TenantUserTable
DYNAMODB_INDEX_NAME: user-index
Events:
GetTenantsForUserApi:
Type: Api
Properties:
Path: /tenants/{userId}
Method: get
RestApiId: !Ref AdminTenantAPi
Auth:
AuthorizationType: AWS_IAMCors cors cors em todos os lugares
Vou começar citando o único e inigualável Eric Johnson. Se CORS tivesse um rosto, eu o socaria no nariz isso basicamente resume meus sentimentos sobre CORS.
Em primeiro lugar, precisamos configurar CORS em nossa API, especificando AllowMethods, AllowHeaders, AllowOrigin em nosso recurso de API, isso adicionará OPTIONS permitindo a pré-busca do navegador.
TenantAPi:
Type: AWS::Serverless::Api
Properties:
.....
Cors:
AllowMethods: "'GET,PUT,POST,DELETE,OPTIONS'"
AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'"
AllowOrigin: "'*'"Se você definir um autorizador no recurso de API e não definir explicitamente AddDefaultAuthorizerToCorsPreflight: false, a autorização será adicionada ao OPTIONS, o que na maioria dos casos fará com que a pré-busca falhe, gerando um erro de cors no navegador.
Mas, como usamos uma integração Lambda proxy, definir cors na API não é suficiente. A função Lambda é responsável por definir e retornar os cabeçalhos cors, portanto, precisamos adicioná-los ao retorno em nossas funções também.
return {
"statusCode": 200,
"body": json.dumps(response["Items"]),
"headers": {
"Access-Control-Allow-Origin": "*", # Permitir todos os origens
"Access-Control-Allow-Headers": "Content-Type,Authorization",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS,PUT,DELETE", # Métodos permitidos
},
}Autorização de usuário
Nesta parte, lentamente começamos a introduzir APIs e para isso, é claro, precisamos de alguma forma de autorização de usuário. Usaremos Lambda Authorizer na API pública da aplicação web. Na primeira versão e funcionalidade bastante primitiva, confiaríamos no User Pool enriquecendo o token JWT com um ID de locatário.
Cognito Pre Token Generation
Para adicionar declarações personalizadas e enriquecer o token JWT, nós conectamos ao fluxo de autenticação do User Pool e usamos o gancho Pre Token Generation. No momento, estou usando a versão 1 deste gancho que adicionará as declarações ao Token de ID. Não faz muito tempo que a possibilidade de personalizar o token de acesso foi adicionada. Nas partes posteriores, expandiremos isso e mudaremos para a versão 2 do evento que personalizará tanto o ID quanto o token de acesso.
Durante o processo, chamaremos a API de locatário e buscaremos o ID do locatário para o usuário e adicionaremos isso ao token.

Agora, ao usar o evento de versão 1, ainda é possível apenas adicionar valores de string personalizados, não é possível adicionar arrays, o que eu acho um pouco limitante.
Para autorização máquina-2-máquina, confiaremos no AWS IAM, o que significa que precisamos assinar nossas solicitações usando sigv4.
import boto3
import os
import json
import requests
from requests_aws4auth import AWS4Auth
api_endpoint = os.environ.get("TENANT_API_ENDPOINT")
def handler(event, context):
user_id = event["request"]["userAttributes"]["sub"]
try:
# Get AWS credentials
session = boto3.Session()
credentials = session.get_credentials().get_frozen_credentials()
# Set up AWS4Auth using the credentials
region = os.environ["AWS_REGION"]
auth = AWS4Auth(
credentials.access_key,
credentials.secret_key,
region,
"execute-api",
session_token=credentials.token,
)
response = requests.get(api_endpoint + "/tenants/" + user_id, auth=auth)
if response.status_code == 200:
tenants = response.json()["tenants"]
event["response"]["claimsOverrideDetails"] = {
"claimsToAddOrOverride": {"tenant": tenants[0]}
}
return event
else:
print(f"Error fetching tenant: {response.status_code}")
except requests.RequestException as e:
# Handle any exceptions that occur during the API call
print(f"Error making API request: {str(e)}")
return eventLambda Authorizers personalizados
Para autorizar as chamadas e garantir que o usuário fazendo a chamada para buscar dados do locatário, realmente tenha acesso a esse locatário, usamos um Lambda Authorizer personalizado em nossa API. Confiaremos no User Pool adicionando o ID do locatário ao token JWT durante o processo de autenticação. Portanto, decodificaremos o token JWT e validaremos a assinatura do token, e leremos o ID do locatário do token. O ID do locatário no token será então verificado contra o locatário que o usuário está tentando acessar. É um pouco primitivo, mas funciona para nosso caso de uso atual.
Nas partes posteriores desta série, criaremos um serviço de autorização centralizado que validará e autorizará chamadas em todas as partes do sistema.
import os
import json
import jwt
from jwt import PyJWKClient
def handler(event, context):
token = event["headers"].get("authorization", "")
if not token:
raise Exception("Unauthorized")
token = token.replace("Bearer ", "")
try:
path_tenant_id = event["pathParameters"]["tenantId"]
jwks_url = os.environ["JWKS_URL"]
jwks_client = PyJWKClient(jwks_url)
signing_key = jwks_client.get_signing_key_from_jwt(token)
# Decode the JWT and validate the signature
decoded_token = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=os.environ["AUDIENCE"],
)
token_tenant_id = decoded_token.get("tenant")
if token_tenant_id == path_tenant_id:
return generate_policy(
decoded_token["sub"], "Allow", event["methodArn"], decoded_token
)
except Exception as e:
print(f"Authorization error: {str(e)}")
raise Exception("Unauthorized")
# Generate a default policy that deny access
return generate_policy(
decoded_token["sub"], "Deny", event["methodArn"], decoded_token
)
def generate_policy(principal_id, effect, resource, context):
auth_response = {
"principalId": principal_id,
"policyDocument": {
"Version": "2012-10-17",
"Statement": [
{"Action": "execute-api:Invoke", "Effect": effect, "Resource": resource}
],
},
"context": context,
}
return auth_responseDashboard
Expandimos o dashboard com mais uma página que mostra as informações do locatário e que tem a possibilidade de definir um novo nome no locatário.

Obtenha o código
A configuração completa com todo o código está disponível no Serverless Handbook
Palavras Finais
Este foi um post onde eu percorro a terceira parte da série sobre BBQ conectado como Saas e discuto locatários e criação de locatários. Se você gosta da parte do quiz, vá e confira kvist.ai
Confira meu Serverless Handbook para alguns dos conceitos mencionados neste post.
Não se esqueça de me seguir no LinkedIn e X para mais conteúdo, e leia o restante dos meus Blogs
Como Werner diz! Agora vá construir!