serverless, aws, saas, IoT

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

2024-08-23
This post cover image
aws cloud serverless bbq iot saas

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.

Imagem mostrando a visão geral da criação de 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.

Imagem mostrando a visão geral da criação de locatário

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_REQUEST

Quando 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.

Imagem mostrando funções de estado de criação de locatário

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.

Imagem mostrando funções de estado de mapeamento de usuário de locatário

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: EXPRESS
Comment: 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: true
Comment: 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: true

APIs

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.

Imagem mostrando API de aplicação de locatário

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: LambdaUserRequestAuthorizer

Em 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.

Imagem mostrando API de gerenciamento de locatá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_IAM

Cors 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.

Imagem mostrando pré-geração de 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 event

Lambda 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_response

Dashboard

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.

Imagem mostrando pré-geração de token

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!

If this saved you an afternoon, you can buy me a coffee.