serverless, aws, saas, IoT

Construyendo un BBQ conectado serverless como SaaS - Parte 3 - Arrendatarios

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

Este archivo ha sido traducido automaticamente por IA, pueden ocurrir errores

Llegó el momento de la Parte 3 en la serie de creación de un BBQ Conectado Serverless como SaaS. En esta tercera parte veremos la creación de arrendatarios, la autenticación y la autorización. Crearemos un nuevo servicio de arrendatarios y lo conectaremos con el servicio de usuarios de la segunda parte.

Si aún no lo has revisado, aquí están la primera parte y la segunda parte

Arrendatarios en un SaaS

En una solución de Software como Servicio (SaaS), un arrendatario es la organización o individuo que se suscribe y utiliza el servicio.

Una de las partes más cruciales de una solución SaaS multi-arrendatario, es el aislamiento de datos de arrendatarios, asegurando que los datos de cada arrendatario permanezcan separados y seguros de los demás dentro del mismo entorno. Esto es especialmente importante para mantener la privacidad de los datos, cumplir con las regulaciones y protegerse contra violaciones de datos.

Enfoques para el Aislamiento de Datos de Arrendatarios en AWS

Para aislar los datos de arrendatarios, hay varios enfoques diferentes que podemos usar.

Base de datos / almacén de datos dedicado por arrendatario

Cada arrendatario tiene una instancia de base de datos o almacén de datos separado, como S3. Esto proporciona el aislamiento más fuerte ya que los datos de cada arrendatario están completamente segregados a nivel de base de datos / almacén de datos. En este enfoque también podemos usar claves KMS separadas para cada arrendatario, asegurando que los datos estén protegidos incluso a nivel de encriptación.

Algunos de los desafíos de este enfoque serían un mayor costo debido a múltiples instancias de base de datos. El escalado puede volverse complejo a medida que crece el número de arrendatarios.

Tabla por arrendatario

Cada arrendatario tiene una tabla separada en la base de datos. Para un almacén de datos como S3, cada arrendatario puede tener un prefijo único bajo el cual se almacenan los datos. Para usar claves KMS separadas, los datos deben estar encriptados a nivel de cliente y no a nivel de almacenamiento, lo que puede agregar aún más aislamiento.

Este enfoque proporciona un buen compromiso entre costo y aislamiento, y es más fácil de hacer cumplir el aislamiento de datos a nivel de tabla.

Algunos de los desafíos de este enfoque son que aún requiere una gestión cuidadosa de los nombres de las tablas y la evolución del esquema. El rendimiento podría verse afectado.

Seguridad de Nivel de Fila (RLS)

Los datos de todos los arrendatarios se almacenan en una sola tabla, pero con controles de acceso a nivel de fila para asegurar que cada arrendatario solo pueda acceder a sus propios datos. Este control de acceso puede estar a nivel de almacenamiento de datos con RLS incorporado o a nivel de cliente con verificaciones de autorización con servicios como Amazon Verified Permissions. Para un almacén de datos como S3, cada arrendatario aún puede tener un prefijo único bajo el cual se almacenan los datos.

Algunos de los desafíos de este enfoque son que es más complejo de hacer cumplir correctamente; cualquier error en la lógica podría exponer datos. El rendimiento puede verse afectado por el filtrado complejo de consultas.

¿Qué enfoque debería usar?

El enfoque que use depende de su caso de uso, el nivel de cumplimiento que necesita y los requisitos del cliente.

En esta serie de datos de BBQ tomaré el enfoque y usaré la seguridad de nivel de fila.

Visión General de la Arquitectura

En esta arquitectura continuamos construyendo sobre la configuración basada en eventos introducida en la parte 2. Cuando un usuario se registra, se crea, se enviará un evento desde el servicio de usuarios a un evento de EventBridge, esto ahora invocará el servicio de arrendatarios. El servicio de arrendatarios tiene dos tablas de DynamoDB principales, una que almacena la información del arrendatario, ID, nombre, etc. y otra que mapea el acceso de los usuarios a un arrendatario. El servicio comenzará creando un ID de arrendatario, lo almacenará y luego asignará al usuario a este nuevo arrendatario.

Imagen mostrando la visión general de la creación de arrendatarios

Nuestro servicio de arrendatarios puede ser llamado a través de dos APIs separadas. Una para nuestra aplicación web que obtendrá y actualizará la información del arrendatario, y una API de administración que no solo será utilizada por los administradores de SaaS sino también para la comunicación entre servicios. Introduciremos la primera ronda de autorización de API en esta publicación, IAM para máquina-a-máquina y Oauth y validación de tokens JWT para la API de la aplicación web. Profundizaremos más en ambas APIs y autorización más adelante en esta publicación.

Imagen mostrando la visión general del servicio de arrendatarios

Creación de arrendatarios

Primero creemos los recursos de creación de arrendatarios necesarios. Aquí creamos dos nuevas tablas de DynamoDB. En una tabla almacenamos los arrendatarios y la información del mismo, como el nombre del arrendatario, etc. En la segunda tabla almacenaremos un mapeo entre arrendatarios y usuarios para el acceso. También necesitamos consultar qué arrendatarios tiene acceso un usuario, para esto también configuramos un índice para la tabla.

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

Cuando se crea un usuario, necesitamos crear el arrendatario que este usuario posee. El servicio de usuarios enviará un evento en el evento de la aplicación cuando se cree un usuario, por lo que aquí seguimos construyendo sobre un patrón de saga basado en eventos y creamos el arrendatario.

Usar StepFunctions hace que sea fácil escribir en DynamoDB y publicar un evento para que la siguiente parte tome el control.

Imagen mostrando las funciones de estado de creación de arrendatarios

Después de que el arrendatario haya sido creado y almacenado, necesitamos asignar al primer usuario administrador, básicamente el usuario que posee el arrendatario. Esta StepFunction es invocada por el evento de que el arrendatario fue creado.

Imagen mostrando las funciones de estado de asignación de usuarios de arrendatarios

El paso final en esta StepFunction también publica en EventBridge para uso futuro.

Agreguemos estos a StepFunctions a nuestra plantilla.


  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

El servicio de arrendatarios tiene dos APIs separadas. En primer lugar, una API que es utilizada por la aplicación web para cargar, mostrar y actualizar la información sobre el arrendatario. Esta API será central en las próximas partes a medida que extendamos cómo los usuarios obtienen acceso a los arrendatarios. La segunda API es una API de administración que puede ser utilizada para servicios especiales e integración del sistema.

Para la API de la aplicación, usaremos los tokens emitidos por Cognito para autorizar a los usuarios, y para la API de administración usaremos autorización máquina-a-máquina con AWS Iam, al menos por ahora.

Ambas APIs estarán respaldadas por funciones Lambda para la implementación de la lógica de negocio.

Para comenzar, creemos la API de la aplicación. Los usuarios llamarán a la API con el token que obtuvieron de Cognito. API Gateway tiene tres integraciones Lambda que obtendrán los datos correctos, y tenemos un Lambda Authorizer para validar el token.

Imagen mostrando la API de aplicación de arrendatarios

Comencemos agregando la definición de API a la plantilla, usaremos recursos AWS::Serverless::Api y estos DEBEN ser definidos en la misma plantilla que las funciones Lambda para que podamos crear fácilmente la integración.


  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

A continuación, creamos las funciones Lambda que respaldarán nuestra API de aplicación.

  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

A continuación, podemos pasar a la API de administración, que usará AWS Iam para autorizar las llamadas y tendrá una integración basada en Lambda para obtener todos los arrendatarios para un usuario.

Imagen mostrando la API de administración de arrendatarios

  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 por todas partes

Permíteme comenzar citando al único y único Eric Johnson. Si CORS tuviera una cara, le daría un puñetazo en la nariz, eso básicamente resume mis sentimientos sobre CORS.

En primer lugar, necesitamos configurar CORS en nuestra API, especificando AllowMethods, AllowHeaders, AllowOrigin en nuestro recurso de API, esto agregará OPTIONS permitiendo la solicitud previa desde el 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: "'*'"

Si establece un autorizador en el recurso de API y no establece explícitamente AddDefaultAuthorizerToCorsPreflight: false, la autorización se agregará a OPTIONS, lo que en la mayoría de los casos hará que la solicitud previa falle generando un error de cors en el navegador.

Pero, dado que usamos una integración Lambda proxy, configurar CORS en la API no es suficiente. La función Lambda es responsable de establecer y devolver los encabezados de CORS, por lo que también debemos agregarlos a la devolución en nuestras funciones.

return {
    "statusCode": 200,
    "body": json.dumps(response["Items"]),
    "headers": {
        "Access-Control-Allow-Origin": "*",  # Permitir todos los orígenes
        "Access-Control-Allow-Headers": "Content-Type,Authorization",
        "Access-Control-Allow-Methods": "GET,POST,OPTIONS,PUT,DELETE",  # Métodos permitidos
    },
}

Autorización de usuarios

En esta parte comenzamos a introducir lentamente APIs y para eso, por supuesto, necesitamos alguna forma de autorización de usuarios. Usaremos Lambda Authorizer en la API pública de la aplicación web. En la primera versión y funcionalidad bastante primitiva, confiaremos en que el User Pool enriquezca el token JWT con un ID de arrendatario.

Generación previa de tokens de Cognito

Para agregar afirmaciones personalizadas y enriquecer el token JWT, nos conectamos al flujo de autenticación del User Pool y usamos el gancho de generación previa de tokens. En este momento estoy usando la versión 1 de este gancho que agregará las afirmaciones al token de ID. No hace mucho tiempo que se agregó la posibilidad de personalizar el token de acceso. En partes posteriores expandiremos sobre esto y pasaremos a la versión 2 del evento que personalizará tanto el token de ID como el de acceso.

Durante el proceso llamaremos a la API de arrendatarios y obtendremos el ID del arrendatario para el usuario y lo agregaremos al token.

Imagen mostrando la generación previa de tokens

Ahora, al usar el evento de versión 1, todavía solo es posible agregar valores de cadena personalizados, no es posible agregar matrices, lo que siento que es un poco un inconveniente.

Para la autorización máquina-a-máquina confiaremos en AWS IAM, lo que significa que necesitamos firmar nuestras solicitudes 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

Autorizadores Lambda personalizados

Para autorizar las llamadas y asegurarnos de que el usuario que realiza la llamada para obtener datos del arrendatario realmente tenga acceso a ese arrendatario, usamos un autorizador Lambda personalizado en nuestra API. Confiaremos en que el User Pool agregue el ID del arrendatario al token JWT durante el proceso de autenticación. Por lo tanto, decodificaremos el token JWT y validaremos la firma del token, y leeremos el id del arrendatario del token. El ID del arrendatario en el token se comparará entonces con el arrendatario que el usuario intenta acceder. Es un poco primitivo pero funciona para nuestro caso de uso actual.

En partes posteriores de esta serie crearemos un servicio de autorización central que validará y autorizará las llamadas en todas las partes del 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

Tablero

Ampliamos el tablero con una página más que muestra la información del arrendatario y que tiene la posibilidad de establecer un nuevo nombre en el arrendatario.

Imagen mostrando la generación previa de tokens

Obtén el código

La configuración completa con todo el código está disponible en Serverless Handbook

Palabras finales

Esta fue una publicación donde repaso la tercera parte de la serie sobre BBQ conectado como Saas y discuto los arrendatarios y la creación de arrendatarios. Si disfrutas la parte del cuestionario, visita kvist.ai

Visita Mi manual serverless para algunos de los conceptos mencionados en esta publicación.

No olvides seguirme en LinkedIn y X para más contenido, y lee el resto de mis Blogs

Como dice Werner! ¡Ahora ve a construir!

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