serverless, aws, saas, IoT

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

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

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.

Image montrant l'aperçu de la création du 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.

Image montrant l'aperçu du service de locataire

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_REQUEST

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

Image montrant les fonctions d'état de création de locataire

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

Image montrant les fonctions d'état de correspondance des utilisateurs de locataire

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

API

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.

Image montrant l'API d'application de locataire

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

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

Image montrant l'API de gestion de locataire

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

Image montrant la génération de pré-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 event

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

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

Image montrant la génération de pré-jeton

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 !

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