We’ve already touched on data isolation and row-level security in a multi-tenant SaaS environment. Now, it’s time to look at how to handle authorization, the process of controlling access to data and functionality based on who the user is and what they’re allowed to do.
In this post, we’ll explore the architecture of a centralized Policy Decision Point (PDP) with distributed Policy Enforcement Points (PEPs) to ensure consistent and secure authorization across your SaaS platform. We’ll also break down the key differences between Authentication (AuthN) and Authorization (AuthZ).
If you have not already checked it out, here is part one, part two, and part three
Authentication (AuthN) vs. Authorization (AuthZ)
Before we get into building the architecture we need to establish a common understanding. It’s crucial to distinguish between Authentication and Authorization, two terms that often get mixed up, and that I have had to explain on so many occasions, but serve very different purposes.
Authentication (AuthN)
Authentication is all about verifying identity. It answers the question, Who are you?
When a user logs into our SaaS application, authentication ensures that they are who they claim to be. This could involve something as simple as a username and password or more complex multi-factor authentication (MFA).
We are using Amazon Cognito User Pools for our authentication in this series.
Authorization (AuthZ)
Once a user’s identity is authenticated, authorization kicks in. This process answers the question, What can you do?
Authorization determines what resources, data, and actions a user is permitted to access based on their roles and permissions.
In a multi-tenant environment, getting authorization right is important. We need to ensure that users can only access the data and functionality relevant to their tenant and role. This is where a robust, scalable authorization system comes into play.
Centralized PDP and Distributed PEPs: The Authorization Backbone
Now that we’ve clarified the difference between AuthN and AuthZ, let’s focus on building a authorization system using a centralized PDP and distributed PEPs.
Centralized PDP
A Policy Decision Point (PDP) is where all your authorization decisions are made. It’s the brain of the authorization system, evaluating each request against predefined policies or roles and determining whether to allow or deny the request.
There are some clear benefits with a centralized PDP:
Consistency: Every request is evaluated against the same set of policies, ensuring consistent decision-making across our systems.
Management and Compliance: With all policies managed in one place, updating and audits are made easy. Logging decisions for audit purpose is important for some regulatory compliance.
However, some drawback would be increase latency. Routing every authorization request through a single PDP can slow things down, especially in a distributed system.
Using Amazon Verified Permissions (AVP) a centralized PDP is required.
Distributed PEPs
Policy Enforcement Points (PEPs) are where the authorization decisions made by the PDP are enforced. These points are distributed across our system. Our Lambda based Authorizer is an example of a PEP. Front door of our microservices can then also act as PEP and call our PDP.
We have some benefits running our PEPs in a distributed way.
Reduced Latency: By placing PEPs close to where decisions need to be enforced, we can reduce the latency, with an caching strategy this can be reduced even more.
Scalability: As your system grows, distributed PEPs ensure that no single point becomes a bottleneck, specially with caching enabled.
Architecture Overview
In our new authorization architecture we will introduce a new authorization service, that will be our central PDP. The logic for authorization will be moved from our previous Lambda Authorizer to the PDP, instead our Lambda Authorizer will now call the PDP that will return access decision.
Create Authorization service (PDP)
The first thing we need to do is to create our Authorization Service (PDP), as shown this will be an API Gateway and a Lambda function. One interesting approach that we could use is to remove the API Gateway and just use Lambda Function URLs. However, for some easier extensibility later I decided to use an API Gateway.
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
UserPoolStackName:
Type: String
Description: The name of the Stack with the Cognito User Pool
Globals:
Function:
Timeout: 30
MemorySize: 2048
Runtime: python3.12
Resources:
LambdaPDPFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: Lambda/AuthZ
Handler: authz.handler
Environment:
Variables:
JWKS_URL:
Fn::ImportValue: !Sub ${UserPoolStackName}:jwks-url
AUDIENCE:
Fn::ImportValue: !Sub ${UserPoolStackName}:app-audience
Events:
AuthZ:
Type: Api
Properties:
Path: /authz
Method: post
RestApiId: !Ref PDPApi
Auth:
AuthorizationType: AWS_IAM
PDPApi:
Type: AWS::Serverless::Api
Properties:
Name: !Sub ${ApplicationName}-pdp-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
Outputs:
PDPApiID:
Value: !Ref PDPApi
Description: The ID of the PDP API
Export:
Name: !Sub ${AWS::StackName}:pdp-api-id
Our API will use AWS IAM for machine-2-machine authorization, just as our admin API that we created in our previous part.
Next we move our AuthZ logic from our Lambda Authorizer to our new PDP implementation. We will handle both authorization for user and tenant access and introduce two new resources tenant
and tenantUser
.
import os
import json
import jwt
from jwt import PyJWKClient
def handler(event, context):
data = json.loads(event["body"])
jwt_token = data["jwt_token"]
try:
jwks_url = os.environ["JWKS_URL"]
jwks_client = PyJWKClient(jwks_url)
signing_key = jwks_client.get_signing_key_from_jwt(jwt_token)
decoded_token = jwt.decode(
jwt_token,
signing_key.key,
algorithms=["RS256"],
audience=os.environ["AUDIENCE"],
)
# Check resource and make an AuthZ decision
if data["resource"] == "tenant":
tenant_id = data["tenant_id"]
token_tenant_id = decoded_token.get("tenant")
if token_tenant_id == tenant_id:
response_body = generate_access(
"Allow", data["action"], data["resource"]
)
return {
"statusCode": 200,
"body": json.dumps(response_body),
"headers": {"Content-Type": "application/json"},
}
elif data["resource"] == "tenantUser":
user_id = data["user_id"]
token_user_id = decoded_token.get("cognito:username")
if token_user_id == user_id:
response_body = generate_access(
"Allow", data["action"], data["resource"]
)
return {
"statusCode": 200,
"body": json.dumps(response_body),
"headers": {"Content-Type": "application/json"},
}
except Exception as e:
print(f"Authorization error: {str(e)}")
# Generate a default response that deny access
response_body = generate_access("Deny", data["action"], data["resource"])
return {
"statusCode": 403,
"body": json.dumps(response_body),
"headers": {"Content-Type": "application/json"},
}
def generate_access(effect, action, resource):
auth_response = {
"effect": effect,
"action": action,
"resource": resource,
}
return auth_response
Next we of course need to update the Lambda Authorizer to call our PDP for authorization.
api_endpoint = os.environ.get("PDP_AUTHZ_API_ENDPOINT")
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"]
session = boto3.Session()
credentials = session.get_credentials().get_frozen_credentials()
region = os.environ["AWS_REGION"]
auth = AWS4Auth(
credentials.access_key,
credentials.secret_key,
region,
"execute-api",
session_token=credentials.token,
)
data = {
"jwt_token": token,
"resource": "tenant",
"action": "read",
"resource_path": event["path"],
"tenant_id": event["pathParameters"]["tenantId"],
}
headers = {"Content-type": "application/json"}
response = requests.post(
api_endpoint + "/authz", data=json.dumps(data), headers=headers, auth=auth
)
......
except Exception as e:
print(f"Authorization error: {str(e)}")
Summary
With those changes we have moved our authorization to a centralized PDP with a distributed PEP setup. This create a good foundation for us to keep evolving our AuthZ. In later posts we will introduce a RBAC (Role Based Access Control) and ABAC (Attribute Based Access Control), we will also move to use Amazon Verified permissions.
Get the code
The complete setup with all the code is available on Serverless Handbook
Final Words
In this post, we looked at the architecture of a centralized authorization system using a PDP and distributed PEPs. We’ve also highlighted the differences between Authentication (AuthN) and Authorization (AuthZ).
In the coming posts we will start looking at device onboarding and data, stay tuned!
Check out My serverless Handbook for the code to the solution built in this series of posts.
Don't forget to follow me on LinkedIn and X for more content, and read rest of my Blogs
As Werner says! Now Go Build!
Top comments (0)