Construction d'un BBQ connecté sans serveur en tant que SaaS - Partie 3 - Locataires

Ce fichier a ete traduit automatiquement par IA, des erreurs peuvent survenir
Le moment est venu pour la partie 3 de la série de création d'un BBQ Connecté Sans Serveur en tant que SaaS. Dans ce troisième article, nous allons examiner la création de locataires, l'authentification et l'autorisation. Nous allons créer un nouveau service de locataire et l'intégrer avec le service utilisateur de la partie deux.
Si vous ne l'avez pas encore consulté, voici la première partie et la deuxième partie
Locataires dans un SaaS
Dans une solution Logiciel en tant que Service (SaaS), un locataire est l'organisation ou l'individu qui souscrit et utilise le service.
L'une des parties les plus cruciales d'une solution SaaS multi-locataire est l'isolation des données des locataires, garantissant que les données de chaque locataire restent séparées et sécurisées des autres au sein du même environnement. Cela est particulièrement important pour maintenir la confidentialité des données, se conformer aux réglementations et se protéger contre les violations de données.
Approches pour l'isolation des données des locataires dans AWS
Pour isoler les données des locataires, il existe plusieurs approches différentes que nous pouvons utiliser.
Base de données / magasin de données dédié par locataire
Chaque locataire dispose d'une instance de base de données ou d'un magasin de données séparée, comme S3. Cela fournit l'isolation la plus forte puisque les données de chaque locataire sont entièrement séparées au niveau de la base de données / du magasin de données. Dans cette approche, nous pouvons également utiliser des clés KMS séparées pour chaque locataire, en nous assurant que les données sont sécurisées même au niveau du chiffrement.
Certains des défis de cette approche seraient un coût plus élevé dû à plusieurs instances de base de données. Le mise à l'échelle peut devenir complexe à mesure que le nombre de locataires augmente.
Table par locataire
Chaque locataire dispose d'une table séparée dans la base de données. Pour un magasin de données comme S3, chaque locataire peut avoir un préfixe unique sous lequel les données sont stockées. Pour utiliser des clés KMS séparées, les données doivent être chiffrées au niveau du client et non au niveau du stockage, ce qui peut ajouter encore plus d'isolation.
Cette approche offre un bon compromis entre coût et isolation, et est plus facile à appliquer pour l'isolation des données au niveau des tables.
Certains des défis de cette approche sont qu'elle nécessite toujours une gestion attentive des noms de tables et de l'évolution du schéma. Les performances pourraient être impactées.
Sécurité au niveau des lignes (RLS)
Les données de tous les locataires sont stockées dans une seule table, mais avec des contrôles d'accès au niveau des lignes pour s'assurer que chaque locataire ne peut accéder qu'à ses propres données. Ce contrôle d'accès peut être au niveau du stockage de données avec le RLS intégré ou au niveau du client avec des vérifications d'autorisation avec des services comme Amazon Verified Permissions. Pour un magasin de données comme S3, chaque locataire peut toujours avoir un préfixe unique sous lequel les données sont stockées.
Certains des défis de cette approche sont qu'elle est plus complexe à appliquer correctement ; tout bogue dans la logique pourrait exposer des données. Les performances peuvent être impactées par le filtrage complexe des requêtes.
Quelle approche devrais-je utiliser ?
L'approche que vous utilisez dépend de votre cas d'utilisation, du niveau de conformité dont vous avez besoin et des exigences des clients.
Dans cette série de données BBQ, je vais adopter l'approche et utiliser la sécurité au niveau des lignes.
Aperçu de l'architecture
Dans cette architecture, nous continuons à construire sur la configuration basée sur les événements introduite dans la partie 2. Lorsqu'un utilisateur s'inscrit, est créé, un événement sera envoyé du service utilisateur sur un bus d'événements EventBridge, ce qui va maintenant déclencher le service de locataire. Le service de locataire a deux tables DynamoDB principales, une qui stocke les informations du locataire, ID, nom, etc. et une qui mappe l'accès des utilisateurs à un locataire. Le service commencera par créer un ID de locataire, le stockera puis mappera l'utilisateur à ce nouveau locataire.

Notre service de locataire peut être appelé via deux API séparées. Une pour notre application web qui récupérera et mettra à jour les informations du locataire, et une API d'administration qui sera non seulement utilisée par les administrateurs SaaS mais aussi pour la communication inter-services. Nous allons introduire la première série d'autorisation d'API dans cet article, IAM pour machine-à-machine et Oauth et validation de jeton JWT pour l'API de l'application web. Nous allons creuser plus profondément dans les deux API et l'autorisation plus loin dans cet article.

Création du locataire
Tout d'abord, créons les ressources de création de locataire nécessaires. Ici, nous créons deux nouvelles tables DynamoDB. Dans une table, nous stockons les locataires et les informations pour eux comme le nom du locataire, etc. Dans la deuxième table, nous stockerons une correspondance entre les locataires et les utilisateurs pour l'accès. Nous devons également interroger quels locataires un utilisateur a accès, pour cela nous configurons également un index pour la table.
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_REQUESTLorsqu'un utilisateur est créé, nous devons créer le locataire que cet utilisateur possède. Le service utilisateur enverra un événement sur le bus d'événements de l'application lorsqu'un utilisateur est créé, donc ici nous continuons à construire sur un modèle de saga basé sur les événements et créons le locataire.
L'utilisation de StepFunctions facilite l'écriture dans DynamoDB et l'envoi d'un événement pour que la partie suivante prenne le relais.

Après que le locataire a été créé et stocké, nous devons mapper le premier utilisateur administrateur à celui-ci, essentiellement l'utilisateur qui possède le locataire. Ce StepFunction est invoqué par l'événement que le locataire a été créé.

La dernière étape de cette StepFunction publie également sur EventBridge pour une utilisation future.
Ajoutons ces éléments à StepFunctions dans notre modèle.
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: trueAPI
Le service de locataire a deux API séparées. Tout d'abord une API qui est utilisée par l'application web pour charger, afficher et mettre à jour les informations sur le locataire. Cette API sera centrale dans les parties à venir alors que nous étendons la façon dont les utilisateurs obtiennent l'accès aux locataires. La deuxième API est une API de gestion d'administration qui peut être utilisée pour une intégration spéciale de service et de système.
Pour l'API d'application, nous utiliserons les jetons émis par Cognito pour autoriser les utilisateurs, et pour l'API de gestion, nous utiliserons l'autorisation machine-à-machine avec AWS Iam, pour l'instant.
Les deux API seront soutenues par des fonctions Lambda pour l'implémentation de la logique métier.
Pour commencer, créons l'API d'application. Les utilisateurs appelleront l'API avec le jeton qu'ils ont obtenu de Cognito. API Gateway a trois intégrations Lambda qui récupéreront les bonnes données, et nous avons un Lambda Authorizer pour valider le jeton.

Commençons par ajouter la définition de l'API au modèle, nous utiliserons des ressources AWS::Serverless::Api et celles-ci DOIVENT être définies dans le même modèle que les fonctions Lambda pour nous permettre de créer facilement l'intégration.
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
Ensuite, nous créons les fonctions Lambda qui soutiendront notre API d'application.
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: LambdaUserRequestAuthorizerEnsuite, nous pouvons passer à l'API de gestion, qui utilisera AWS Iam pour autoriser les appels et aura une intégration basée sur Lambda pour récupérer tous les locataires pour un utilisateur.

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 partout
Permettez-moi de commencer par citer le seul et unique Eric Johnson. Si CORS avait un visage, je lui mettrais un coup de poing dans le nez cela résume essentiellement mon sentiment sur CORS.
Tout d'abord, nous devons configurer CORS sur notre API, en spécifiant AllowMethods, AllowHeaders, AllowOrigin sur notre ressource API, cela ajoutera OPTIONS permettant la prévolée du navigateur.
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 vous définissez un autorisateur sur la ressource API et que vous ne définissez pas explicitement AddDefaultAuthorizerToCorsPreflight: false, l'autorisation sera ajoutée aux OPTIONS ce qui dans la plupart des cas fera échouer la prévolée générant une erreur cors dans le navigateur.
Mais, puisque nous utilisons une intégration Lambda proxy, la configuration de CORS sur l'API n'est pas suffisante. La fonction Lambda est responsable de la définition et du retour des en-têtes CORS, donc nous devons également les ajouter au retour dans nos fonctions.
return {
"statusCode": 200,
"body": json.dumps(response["Items"]),
"headers": {
"Access-Control-Allow-Origin": "*", # Allow all origins
"Access-Control-Allow-Headers": "Content-Type,Authorization",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS,PUT,DELETE", # Allowed methods
},
}Autorisation utilisateur
Dans cette partie, nous commençons lentement à introduire des API et pour cela nous avons bien sûr besoin d'une forme d'autorisation utilisateur. Nous utiliserons Lambda Authorizer sur l'API publique de l'application web. Dans la première version et une fonctionnalité assez primitive, nous compterons sur le User Pool pour enrichir le jeton JWT avec un ID de locataire.
Cognito Pre Token Generation
Pour ajouter des revendications personnalisées et enrichir le jeton JWT, nous nous connectons au flux d'authentification du User Pool et utilisons le hook Pre Token Generation. Pour l'instant, j'utilise la version 1 de ce hook qui ajoutera les revendications au jeton ID. Il n'y a pas si longtemps que la possibilité de personnaliser le jeton d'accès a été ajoutée. Dans les parties suivantes, nous allons étendre cela et passer à la version 2 de l'événement qui personnalisera à la fois le jeton ID et le jeton d'accès.
Pendant le processus, nous appellerons l'API de locataire et récupérerons l'ID de locataire pour l'utilisateur et l'ajouterons au jeton.

Maintenant, lorsque nous utilisons la version 1 de l'événement, il est encore seulement possible d'ajouter des valeurs de chaîne personnalisées, il n'est pas possible d'ajouter des tableaux, ce que je trouve un peu dommage.
Pour l'autorisation machine-à-machine, nous compterons sur AWS IAM, ce qui signifie que nous devons signer nos requêtes utilisant 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 personnalisés
Pour autoriser les appels et nous assurer que l'utilisateur faisant l'appel pour récupérer les données du locataire a réellement accès à ce locataire, nous utilisons un Lambda Authorizer personnalisé sur notre API. Nous compterons sur le User Pool pour ajouter l'ID de locataire au jeton JWT pendant le processus d'authentification. Nous décoderons donc le jeton JWT, validerons la signature du jeton et lirons l'ID de locataire à partir du jeton. L'ID de locataire dans le jeton sera ensuite vérifié contre le locataire que l'utilisateur essaie d'accéder. C'est un peu primitif mais cela fonctionne pour notre cas d'utilisation actuel.
Dans les parties suivantes de cette série, nous créerons un service d'autorisation centralisé qui validera et autorisera les appels dans toutes les parties du système.
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_responseTableau de bord
Nous étendons le tableau de bord avec une page supplémentaire qui montre les informations du locataire, et qui a la possibilité de définir un nouveau nom sur le locataire.

Obtenez le code
La configuration complète avec tout le code est disponible sur Serverless Handbook
Derniers mots
Ceci était un article où je passe en revue la troisième partie de la série sur le BBQ connecté en tant que SaaS et discute des locataires et de la création de locataires. Si vous appréciez la partie quiz, allez vérifier kvist.ai
Consultez Mon manuel sans serveur pour certaines des concepts mentionnés dans cet article.
N'oubliez pas de me suivre sur LinkedIn et X pour plus de contenu, et lisez le reste de mes Blogs
Comme le dit Werner ! Maintenant, allez construire !