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

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.

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.

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

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.

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

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: LambdaUserRequestAuthorizerA 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.

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

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 eventAutorizadores 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_responseTablero
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.

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!