SaaS access control using Amazon Verified Permissions with a per-tenant policy store
Access control is essential for multi-tenant software as a service (SaaS) applications. SaaS developers must manage permissions, fine-grained authorization, and isolation.
<p>In this post, we demonstrate how you can use <a href="https://aws.amazon.com/verified-permissions/" target="_blank" rel="noopener">Amazon Verified Permissions</a> for access control in a multi-tenant document management SaaS application using a per-tenant policy store approach. We also describe how to enforce the tenant boundary.</p>
<p>We usually see the following access control needs in multi-tenant SaaS applications:</p>
<ul>
<li>Application developers need to define policies that apply across all tenants.</li>
<li>Tenant users need to control who can access their resources.</li>
<li>Tenant admins need to manage all resources for a tenant.</li>
</ul>
<p>Additionally, independent software vendors (ISVs) implement <a href="https://docs.aws.amazon.com/whitepapers/latest/saas-architecture-fundamentals/tenant-isolation.html" target="_blank" rel="noopener">tenant isolation</a> to prevent one tenant from accessing the resources of another tenant. Enforcing tenant boundaries is imperative for SaaS businesses and is one of the foundational topics for SaaS providers. </p>
<p>Verified Permissions is a scalable, fine-grained permissions management and authorization service that helps you build and modernize applications without having to implement authorization logic within the code of your application.</p>
<p>Verified Permissions uses the <a href="https://www.cedarpolicy.com/" target="_blank" rel="noopener">Cedar</a> language to define policies. A <a href="https://docs.cedarpolicy.com/overview/terminology.html#policy" target="_blank" rel="noopener">Cedar policy</a> is a statement that declares which principals are explicitly permitted, or explicitly forbidden, to perform an action on a resource. The collection of policies defines the authorization rules for your application. Verified Permissions stores the policies in a <a href="https://docs.aws.amazon.com/verifiedpermissions/latest/userguide/policy-stores.html" target="_blank" rel="noopener">policy store</a>. A policy store is a container for policies and templates. You can learn more about Cedar policies from the <a href="https://aws.amazon.com/blogs/opensource/using-open-source-cedar-to-write-and-enforce-custom-authorization-policies/" target="_blank" rel="noopener">Using Open Source Cedar to Write and Enforce Custom Authorization Policies</a> blog post.</p>
<p>Before Verified Permissions, you had to implement authorization logic within the code of your application. Now, we’ll show you how Verified Permissions helps remove this undifferentiated heavy lifting in an example application.</p>
<h2>Multi-tenant document management SaaS application</h2>
<p>The application allows to add, share, access and manage documents. It requires the following access controls:</p>
<ul>
<li>Application developers who can define policies that apply across all tenants.</li>
<li>Tenant users who can control who can access their documents.</li>
<li>Tenant admins who can manage all documents for a tenant.</li>
</ul>
<p>Let’s start by describing the application architecture and then dive deeper into the design details.</p>
<h2>Application architecture overview</h2>
<p>There are two approaches to multi-tenant design in Verified Permissions: a single shared policy store and a per-tenant policy store. You can learn about the considerations, trade-offs and guidance for these approaches in the Verified Permissions <a href="https://docs.aws.amazon.com/verifiedpermissions/latest/userguide/design-multi-tenancy-considerations.html" target="_blank" rel="noopener">user guide</a>.</p>
<p>For the example document management SaaS application, we decided to use the per-tenant policy store approach for the following reasons:</p>
<ul>
<li>Low-effort tenant policies isolation</li>
<li>The ability to customize templates and schema per tenant</li>
<li>Low-effort tenant off-boarding</li>
<li>Per-tenant policy store resource quotas</li>
</ul>
<p>We decided to accept the following trade-offs:</p>
<ul>
<li>High effort to implement global policies management (because the application use case doesn’t require frequent changes to these policies)</li>
<li>Medium effort to implement the authorization flow (because we decided that in this context, the above reasons outweigh implementing a mapping from tenant ID to policy store ID)</li>
</ul>
<p>Figure 1 shows the document management SaaS application architecture. For simplicity, we omitted the frontend and focused on the backend.</p>
<div id="attachment_33363" class="wp-caption aligncenter">
<img aria-describedby="caption-attachment-33363" src="https://infracom.com.sg/wp-content/uploads/2024/02/img1-3.png" alt="Figure 1: Document management SaaS application architecture" width="780" class="size-full wp-image-33363">
<p id="caption-attachment-33363" class="wp-caption-text">Figure 1: Document management SaaS application architecture</p>
</div>
<ol>
<li>A tenant user signs in to an identity provider such as <a href="https://aws.amazon.com/cognito/" target="_blank" rel="noopener">Amazon Cognito</a>. They get a JSON Web Token (JWT), which they use for API requests. The JWT contains claims such as the <code>user_id</code>, which identifies the tenant user, and the <code>tenant_id</code>, which defines which tenant the user belongs to.</li>
<li>The tenant user makes API requests with the JWT to the application.</li>
<li><a href="https://aws.amazon.com/api-gateway/" target="_blank" rel="noopener">Amazon API Gateway</a> verifies the validity of the JWT with the identity provider.</li>
<li>If the JWT is valid, API Gateway forwards the request to the compute provider, in this case an <a href="https://aws.amazon.com/lambda/" target="_blank" rel="noopener">AWS Lambda</a> function, for it to run the business logic.</li>
<li>The Lambda function assumes an <a href="https://aws.amazon.com/iam/" target="_blank" rel="noopener">AWS Identity and Access Management (IAM)</a> role with an IAM policy that allows access to the <a href="https://aws.amazon.com/dynamodb/" target="_blank" rel="noopener">Amazon DynamoDB</a> table that provides tenant-to-policy-store mapping. The IAM policy scopes down access such that the Lambda function can only access data for the current <code>tenant_id</code>.</li>
<li>The Lambda function looks up the Verified Permissions <code>policy_store_id</code> for the current request. To do this, it extracts the <code>tenant_id</code> from the JWT. The function then retrieves the <code>policy_store_id</code> from the tenant-to-policy-store mapping table.</li>
<li>The Lambda function assumes another IAM role with an IAM policy that allows access to the Verified Permissions policy store, the document metadata table, and the document store. The IAM policy uses <code>tenant_id</code> and <code>policy_store_id</code> to scope down access.</li>
<li>The Lambda function gets or stores documents metadata in a DynamoDB table. The function uses the metadata for Verified Permissions authorization requests.</li>
<li>Using the information from steps 5 and 6, the Lambda function calls Verified Permissions to make an authorization decision or create Cedar policies.</li>
<li>If authorized, the application can then access or store a document.</li>
</ol>
<h2>Application architecture deep dive</h2>
<p>Now that you know the architecture for the use cases, let’s review them in more detail and work backwards from the user experience to the related part of the application architecture. The architecture focuses on permissions management. Accessing and storing the actual document is out of scope.</p>
<h3>Define policies that apply across all tenants</h3>
<p>The application developer must define global policies that include a basic set of access permissions for all tenants. We use Cedar policies to implement these permissions.</p>
<p>Because we’re using a per-tenant policy store approach, the tenant onboarding process should create these policies for each new tenant. Currently, to update policies, the deployment pipeline should apply changes to all policy stores.</p>
<p>The “Add a document” and “Manage all the documents for a tenant” sections that follow include examples of global policies.</p>
<h3>Make sure that a tenant can’t edit the policies of another tenant</h3>
<p>The application uses IAM to isolate the resources of one tenant from another. Because we’re using a per-tenant policy store approach we can use IAM to isolate one tenant policy store from another.</p>
<h4>Architecture</h4>
<div id="attachment_33364" class="wp-caption aligncenter">
<img aria-describedby="caption-attachment-33364" src="https://infracom.com.sg/wp-content/uploads/2024/02/img2-1.png" alt="Figure 2: Tenant isolation" width="600" class="size-full wp-image-33364">
<p id="caption-attachment-33364" class="wp-caption-text">Figure 2: Tenant isolation</p>
</div>
<ol>
<li>A tenant user calls an API endpoint using a valid JWT.</li>
<li>The Lambda function uses <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html" target="_blank" rel="noopener">AWS Security Token Service (AWS STS)</a> to assume an IAM role with an IAM policy that allows access to the tenant-to-policy-store mapping DynamoDB table. The IAM policy only allows access to the table and the entries that belong to the requesting tenant. When the function assumes the role, it uses <code>tenant_id</code> to scope access to the items whose partition key matches the <code>tenant_id</code>. See the <a href="https://aws.amazon.com/blogs/security/how-to-implement-saas-tenant-isolation-with-abac-and-aws-iam/" target="_blank" rel="noopener">How to implement SaaS tenant isolation with ABAC and AWS IAM</a> blog post for examples of such policies.</li>
<li>The Lambda function uses the user’s <code>tenant_id</code> to get the Verified Permissions <code>policy_store_id</code>.</li>
<li>The Lambda function uses the same mechanism as in step 2 to assume a different IAM role using <code>tenant_id</code> and <code>policy_store_id</code> which only allows access to the tenant policy store.</li>
<li>The Lambda function accesses the tenant policy store.</li>
</ol>
<h3>Add a document</h3>
<p>When a user first accesses the application, they don’t own any documents. To add a document, the frontend calls the <code>POST /documents</code> endpoint and supplies a <code>document_name</code> in the request’s body.</p>
<h4>Cedar policy</h4>
<p>We need a global policy that allows every tenant user to add a new document. The tenant onboarding process creates this policy in the tenant’s policy store.</p>
<div class="hide-language">
<pre class="unlimited-height-code"><code class="lang-text">permit (
principal,
action == DocumentsAPI::Action::”addDocument”,
resource
);
This policy allows any principal to add a document. Because we’re using a per-tenant policy store approach, there’s no need to scope the principal to a tenant.</p>
<h4>Architecture</h4>
<div id="attachment_33365" class="wp-caption aligncenter">
<img aria-describedby="caption-attachment-33365" src="https://infracom.com.sg/wp-content/uploads/2024/02/img3-1.png" alt="Figure 3: Adding a document" width="780" class="size-full wp-image-33365" />
<p id="caption-attachment-33365" class="wp-caption-text">Figure 3: Adding a document</p>
</div>
<ol>
<li>A tenant user calls the <code>POST /documents</code> endpoint to add a document.</li>
<li>The Lambda function uses the user’s <code>tenant_id</code> to get the Verified Permissions <code>policy_store_id</code>.</li>
<li>The Lambda function calls the Verified Permissions policy store to check if the tenant user is authorized to add a document.</li>
<li>After successful authorization, the Lambda function adds a new document to the documents metadata database and uploads the document to the documents storage.</li>
</ol>
<p>The database structure is described in the following table:</p>
<table width="100%">
<tbody>
<tr>
<td width="25%">tenant_id (Partition key): String</td>
<td width="25%">document_id (Sort key): String</td>
<td width="25%">document_name: String</td>
<td width="25%">document_owner: String</td>
</tr>
<tr>
<td width="25%"></td>
<td width="25%"></td>
<td width="25%"></td>
<td width="25%"></td>
</tr>
</tbody>
</table>
<ul>
<li><code>tenant_id</code>: The <code>tenant_id</code> from the JWT claims.</li>
<li><code>document_id</code>: A random identifier for the document, created by the application.</li>
<li><code>document_name</code>: The name of the document supplied with the API request.</li>
<li><code>document_owner</code>: The user who created the document. The value is the <code>user_id</code> from the JWT claims.</li>
</ul>
<h3>Share a document with another user of a tenant</h3>
<p>After a tenant user has created one or more documents, they might want to share them with other users of the same tenant. To share a document, the frontend calls the <code>POST /shares</code> endpoint and provides the <code>document_id</code> of the document the user wants to share and the <code>user_id</code> of the receiving user.</p>
<h4>Cedar policy</h4>
<p>We need a global document owner policy that allows the document owner to manage the document, including sharing. The tenant onboarding process creates this policy in the tenant’s policy store.</p>
<div class="hide-language">
<pre class="unlimited-height-code"><code class="lang-text">permit (<br />
principal,
action,
resource
) when {
resource.owner == principal &&
resource.type == “document”
};
The policy allows principals to perform actions on available resources (the document) when the principal is the document owner. This policy allows the shareDocument
action, which we describe next, to share a document.
We also need a share policy that allows the receiving user to access the document. The application creates these policies for each successful share action. We recommend that you use policy templates to define the share policy. Policy templates allow a policy to be defined once and then attached to multiple principals and resources. Policies that use a policy template are called template-linked policies. Updates to the policy template are reflected across the principals and resources that use the template. The tenant onboarding process creates the share policy template in the tenant’s policy store.
We define the share policy template as follows:
The following is an example of a template-linked policy using the share policy template:
The policy includes the user_id
of the receiving user (principal) and the document_id
of the document (resource).
Architecture
- A tenant user calls the
POST /shares
endpoint to share a document. - The Lambda function uses the user’s
tenant_id
to get the Verified Permissionspolicy_store_id
and policy template IDs for each action from the DynamoDB table that stores the tenant to policy store mapping. In this case the function needs to use theshare_policy_template_id
. - The function queries the documents metadata DynamoDB table to retrieve the
document_owner
attribute for the document the user wants to share. - The Lambda function calls Verified Permissions to check if the user is authorized to share the document. The request context uses the
user_id
from the JWT claims as the principal,shareDocument
as the action, and thedocument_id
as the resource. The document entity includes thedocument_owner
attribute, which came from the documents metadata DynamoDB table. - If the user is authorized to share the resource, the function creates a new template-linked share policy in the tenant’s policy store. This policy includes the
user_id
of the receiving user as the principal and thedocument_id
as the resource.
Access a shared document
After a document has been shared, the receiving user wants to access the document. To access the document, the frontend calls the GET /documents
endpoint and provides the document_id
of the document the user wants to access.
Cedar policy
As shown in the previous section, during the sharing process, the application creates a template-linked share policy that allows the receiving user to access the document. Verified Permissions evaluates this policy when the user tries to access the document.
Architecture
- A tenant user calls the
GET /documents
endpoint to access the document. - The Lambda function uses the user’s
tenant_id
to get the Verified Permissionspolicy_store_id
. - The Lambda function calls Verified Permissions to check if the user is authorized to access the document. The request context uses the
user_id
from the JWT claims as the principal,accessDocument
as the action, and thedocument_id
as the resource.
Manage all the documents for a tenant
When a customer signs up for a SaaS application, the application creates the tenant admin user. The tenant admin must have permissions to perform all actions on all documents for the tenant.
Cedar policy
We need a global policy that allows tenant admins to manage all documents. The tenant onboarding process creates this policy in the tenant’s policy store.
This policy allows every member of the group to perform any action on any document.
Architecture
- A tenant admin calls the
POST /documents
endpoint to manage a document. - The Lambda function uses the user’s
tenant_id
to get the Verified Permissionspolicy_store_id
. - The Lambda function calls Verified Permissions to check if the user is authorized to manage the document.
Conclusion
In this blog post, we showed you how Amazon Verified Permissions helps to implement fine-grained authorization decisions in a multi-tenant SaaS application. You saw how to apply the per-tenant policy store approach to the application architecture. See the Verified Permissions user guide for how to choose between using a per-tenant policy store or one shared policy store. To learn more, visit the Amazon Verified Permissions documentation and workshop.
If you have feedback about this post, submit comments in the Comments section below. If you have questions about this post, start a new thread on the Amazon Verified Permissions re:Post or contact AWS Support.
<!-- '"` -->
You must be logged in to post a comment.