serverless, aws, saas, IoT

Bygg en serverlös ansluten grill som SaaS - Del 3 - Hyresgäster

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

Questo file e stato tradotto automaticamente dall'IA, potrebbero verificarsi errori

Den tiden har kommit för del 3 i serien om att skapa en Serverlös Ansluten Grill som SaaS. I denna tredje post kommer vi att titta på hyresgästskapande, autentisering och auktorisering. Vi kommer att skapa en ny hyresgästtjänst och koppla ihop den med användartjänsten från del två.

Om du inte redan har tittat på den, här är del ett och del två

Hyresgäster i en SaaS

I en Software as a Service (SaaS)-lösning är en hyresgäst den organisation eller individ som abonnerar på och använder tjänsten.

En av de mest avgörande delarna i en multi-hyresgäst SaaS-lösning är hyresgästdataisolering, vilket säkerställer att varje hyresgästs data förblir separat och säker från andra inom samma miljö. Detta är särskilt viktigt för att upprätthålla dataintegritet, uppfylla regler och skydda mot dataintrång.

Tillvägagångssätt för hyresgästdataisolering i AWS

För att isolera hyresgästdata finns det flera olika tillvägagångssätt vi kan använda.

Dedikerad databas / databas per hyresgäst

Varje hyresgäst har en separat databasinstans eller databas, som S3. Detta ger den starkaste isoleringen eftersom varje hyresgästs data är helt åtskilda på databas- / databasnivå. I detta tillvägagångssätt kan vi också använda separata KMS-nycklar för varje hyresgäst, vilket säkerställer att data är säkrad även på krypteringsnivå.

Några av utmaningarna med detta tillvägagångssätt skulle vara högre kostnader på grund av flera databasinstanser. Skalning kan bli komplex när antalet hyresgäster växer.

Tabell per hyresgäst

Varje hyresgäst har en separat tabell i databasen. För en databas som S3 kan varje hyresgäst ha ett unikt prefix som data lagras under. För att använda separata KMS-nycklar måste data krypteras på klientnivå och inte på lagringsnivå, vilket kan ge ännu mer isolering.

Detta tillvägagångssätt ger en bra kompromiss mellan kostnad och isolering. och lättare att genomdriva dataisolering på tabellnivå.

Några av utmaningarna med detta tillvägagångssätt är att det fortfarande kräver noggrann hantering av tabellnamn och schemautveckling. Prestanda kan påverkas.

Radnivåsäkerhet (RLS)

Data för alla hyresgäster lagras i en enda tabell, men med åtkomstkontroll på radnivå för att säkerställa att varje hyresgäst endast kan åtkomst sin egen data. Denna åtkomstkontroll kan vara på lagringsnivå med inbyggd RLS eller på klientnivå med auktoriseringskontroller med tjänster som Amazon Verified Permissions. För en databas som S3 kan varje hyresgäst fortfarande ha ett unikt prefix som data lagras under.

Några av utmaningarna med detta tillvägagångssätt är att det är mer komplext att genomdriva korrekt; eventuella buggar i logik kan exponera data. Prestanda kan påverkas av komplexa frågefiltreringar.

Vilket tillvägagångssätt bör jag använda?

Det tillvägagångssätt du använder beror på ditt användningsfallet, den nivå av efterlevnad du behöver och kundkraven.

I denna serie om grilldata kommer jag att ta detta tillvägagångssätt och använda radnivåsäkerhet.

Arkitekturöversikt

I denna arkitektur fortsätter vi att bygga på den händeldrivna uppställningen som introducerades i del 2. När en användare registrerar sig, skapas en händelse som skickas från användartjänsten till en EventBridge-händelsebuss, detta kommer nu att anropa hyresgästtjänsten. Hyresgästtjänsten har två primära DynamoDB-tabeller, en som lagrar hyresgästinformationen, ID, namn, etc och en som kartlägger användarnas åtkomst till en hyresgäst. Tjänsten börjar med att skapa ett hyresgäst-ID, lagrar det och sedan kartlägger användaren till denna nya hyresgäst.

Bild som visar översikt över hyresgästskapande

Vår hyresgästtjänst kan anropas via två separata API:er. En för vår webbapplikation som kommer att hämta och uppdatera hyresgästinformationen, och en admin-API som inte bara kommer att användas av SaaS-administratörer utan också för kommunikation mellan tjänster. Vi kommer att introducera den första rundan av API-auktorisering i denna post, IAM för maskin-till-maskin och Oauth och JWT-tokenvalidering för webbapplikations-API:et. Vi kommer att gå djupare in på både API:erna och auktorisering senare i denna post.

Bild som visar översikt över hyresgästskapande

Hyresgästskapande

Först skapar vi de resurser som behövs för hyresgästskapande. Här skapar vi två nya DynamoDB-tabeller. I en tabell lagrar vi hyresgästerna och information om dem som hyresgästnamn etc. I den andra tabellen kommer vi att lagra en mappning mellan hyresgäster och användare för åtkomst. Vi måste också kunna fråga vilka hyresgäster en användare har åtkomst till, för detta ställer vi också in ett index för tabellen.

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

När en användare skapas måste vi skapa den hyresgäst som denna användare äger. Användartjänsten kommer att skicka en händelse på applikationens händelsebuss när en användare skapas, så här fortsätter vi att bygga på ett händeldrivet saga-mönster och skapa hyresgäst.

Genom att använda StepFunctions blir det enkelt att skriva till DynamoDB och skicka en händelse för att nästa del ska ta över.

Bild som visar hyresgästskapande stegfunktioner

Efter att hyresgästern skapats och lagrats måste vi mappa den första admin-användaren till den, i princip användaren som äger hyresgästen. Denna StepFunction anropas av händelsen att hyresgästen skapades.

Bild som visar mappning av hyresgästanvändare stegfunktioner

Sistaste steget i denna StepFunction publicerar också till EventBridge för framtida användning.

Låt oss lägga till dessa i StepFunctions till vår mall.


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

Hyresgästtjänsten har två separata API:er. Först av allt en API som används av webbapplikationen för att ladda, visa och uppdatera information om hyresgästen. Denna API kommer att vara central i kommande delar när vi utökar hur användare får åtkomst till hyresgäster. Den andra API:en är en hanteringsadmin-API som kan användas för speciella tjänste- och systemintegrationer.

För applikations-API:et kommer vi att använda de token som utfärdas av Cognito för att auktorisera användarna, och för hanterings-API:et kommer vi att använda maskin-till-maskin-auktorisering med AWS Iam, åtminstone för nu.

Båda API:erna kommer att backas av Lambda-funktioner för implementering av affärslogiken.

För att börja skapar vi applikations-API:et. Användare kommer att anropa API:et med den token de fick från Cognito. API Gateway har tre Lambda-integrationer som kommer att hämta rätt data, och vi har en Lambda Authorizer för att validera token.

Bild som visar hyresgästapplikations-API

Låt oss börja med att lägga till API-definitionen i mallen, vi kommer att använda AWS::Serverless::Api-resurser och dessa MÅSTE definieras i samma mall som Lambda-funktionerna för att enkelt skapa integrationen.


  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

Nästa skapar vi Lambda-funktionerna som kommer att backa vår applikations-API.

  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

Nästa kan vi gå över till hanterings-API:et, som kommer att använda AWS Iam för att auktorisera anropen och har en Lambda-baserad integration för att hämta alla hyresgäster för en användare.

Bild som visar hyresgästhanterings-API

  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 överallt

Låt mig börja med att citera den enda och enda Eric Johnson. Om CORS hade ett ansikte skulle jag slå till i näsan det sammanfattar i princip mina känslor om CORS.

Först och främst måste vi sätta CORS på vår API, genom att ange AllowMethods, AllowHeaders, AllowOrigin på vår API-resurs, detta kommer att lägga till OPTIONS vilket tillåter preflight-hämtning från webbläsaren.

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: "'*'"

Om du sätter en authorizer på API-resursen och du inte explicit sätt AddDefaultAuthorizerToCorsPreflight: false kommer auktorisering att läggas till OPTIONS vilket i de flesta fall kommer att göra att preflight misslyckas och genererar ett cors-fel i webbläsaren.

Men, eftersom vi använder en proxy Lambda-integration är det inte tillräckligt att sätta cors på API:et. Lambda-funktionen är ansvarig för att sätta och returnera cors-huvudena, så vi måste lägga till dem i returen i våra funktioner också.

return {
    "statusCode": 200,
    "body": json.dumps(response["Items"]),
    "headers": {
        "Access-Control-Allow-Origin": "*",  # Tillåt alla ursprung
        "Access-Control-Allow-Headers": "Content-Type,Authorization",
        "Access-Control-Allow-Methods": "GET,POST,OPTIONS,PUT,DELETE",  # Tillåtna metoder
    },
}

Användarauktorisering

I denna del börjar vi långsamt introducera API:er och för det behöver vi naturligtvis någon form av användarauktorisering. Vi kommer att använda Lambda Authorizer på den offentliga webbapplikations-API:et. I den första versionen och ganska primitiv funktionalitet kommer vi att lita på att User Pool berikar JWT-token med ett hyresgäst-ID.

Cognito Pre Token Generation

För att lägga till anpassade anspråk och berika JWT-token kommer vi att ansluta oss till User Pool-autentiseringsflödet och använda Pre Token Generation-hook. Just nu använder jag version 1 av denna hook som kommer att lägga till anspråken till ID-token. Det var inte så länge sedan som möjligheten att anpassa åtkomsttoken läggs till. I senare delar kommer vi att utöka detta och gå över till version 2 av händelsen som kommer att anpassa både ID och åtkomsttoken.

Under processen kommer vi att anropa hyresgäst-API:et och hämta hyresgäst-ID för användaren och lägga till detta i token.

Bild som visar pre token generation

När du använder version 1-händelsen är det fortfarande endast möjligt att lägga till anpassade strängvärden, det är inte möjligt att lägga till arrayer, vilket jag känner är en liten nackdel.

För maskin-till-maskin-auktorisering kommer vi att lita på AWS IAM, vilket innebär att vi måste signera våra förfrågningar med 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

Anpassade Lambda Authorizers

För att auktorisera anropen och säkerställa att användaren som gör anropet för att hämta hyresgästdata faktiskt har åtkomst till den hyresgästen, använder vi en anpassad Lambda Authorizer på vår API. Vi kommer att lita på att User Pool lägger till hyresgäst-ID i JWT-token under autentiseringsprocessen. Vi kommer därför att dekoda JWT-token och validera signaturen av token, och läsa ut hyresgäst-ID från token. Hyresgäst-ID:et i token kommer sedan att kontrolleras mot den hyresgäst som användaren försöker åtkomst. Det är lite primitivt men det fungerar för vårt nuvarande användningsfallet.

I senare delar av denna serie kommer vi att skapa en central auktoriseringstjänst som kommer att validera och auktorisera anrop i alla delar av systemet.

import os
import json
import jwt
from jwt import PyJWKClient


def handler(event, context):
  token = event["headers"].get("authorization", "")

    if not token:
        raise Exception("Unauthorized")

    token = token.replace("Bearer ", "")
    try:
        path_tenant_id = event["pathParameters"]["tenantId"]

        jwks_url = os.environ["JWKS_URL"]
        jwks_client = PyJWKClient(jwks_url)
        signing_key = jwks_client.get_signing_key_from_jwt(token)

        # Decode the JWT and validate the signature
        decoded_token = jwt.decode(
            token,
            signing_key.key,
            algorithms=["RS256"],
            audience=os.environ["AUDIENCE"],
        )

        token_tenant_id = decoded_token.get("tenant")

        if token_tenant_id == path_tenant_id:
            return generate_policy(
                decoded_token["sub"], "Allow", event["methodArn"], decoded_token
            )

    except Exception as e:
        print(f"Authorization error: {str(e)}")
        raise Exception("Unauthorized")

    # Generate a default policy that deny access
    return generate_policy(
        decoded_token["sub"], "Deny", event["methodArn"], decoded_token
    )

def generate_policy(principal_id, effect, resource, context):
    auth_response = {
        "principalId": principal_id,
        "policyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {"Action": "execute-api:Invoke", "Effect": effect, "Resource": resource}
            ],
        },
        "context": context,
    }
    return auth_response

Dashboard

Vi utökar dashboarden med en ytterligare sida som visar hyresgästinformationen, och som har möjligheten att ange ett nytt namn på hyresgästen.

Bild som visar pre token generation

Få koden

Den fullständiga uppställningen med all kod är tillgänglig på Serverless Handbook

Avslutande ord

Detta var en post där jag går igenom del tre i serien om ansluten grill som Saas och diskuterar hyresgäster och hyresgästskapande. Om du tycker om quiz-delen gå och kolla in kvist.ai

Kolla in Min serverless-handbok för några av koncepten som nämns i denna post.

Glöm inte att följa mig på LinkedIn och X för mer innehåll, och läs resten av mina bloggar

Som Werner säger! Nu gå och bygg!

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