Skip to content

Multi-Tenant Data Isolation for Simple CRUDL with Amplify Gen 2 + AppSync

0

My company is rolling out a SaaS solution on AWS and we're curious about best practices when it comes to multi-tenancy.

Background

The stack right now is Amplify Gen 2, AppSync for our CRUDL requests, and Cognito for authorization. We also use S3 for documents and signatures. The Amplify stack is Node + Typescript + React.

Right now for multi-tenancy we forbid user self-signup through some code in ./amplify/backend.ts like so:

const backend = defineBackend({
  auth,
...
});

const { cfnUserPool } = backend.auth.resources.cfnResources;

cfnUserPool.adminCreateUserConfig = {
  // Disable self sign-up for non-federated users
  allowAdminCreateUserOnly: true,
};
...

Then for our Cognito users in a User Pool, we have code like this in ./amplify/auth/resource.ts:

export const auth = defineAuth({
...
  userAttributes: {
...
    // Used for multi-tenancy
    'custom:companyId': {
      dataType: 'String',
      mutable: false,
    },
    'custom:role': {
      dataType: 'String',
      mutable: false,
    },
...

Which ensures that each Cognito user has two additional custom user attributes, custom:companyId and custom:role; companyId being one of particular importance that we use to enforce the main multi-tenancy of our application.

We also have a special database User object which further describes Cognito users in the User Pool via ./amplify/data/resource.ts like so:

...
const schema = a.schema({
  Company: a.model({
    name: a.string(),
    users: a.hasMany("User", "companyId"),
...
  }).authorization((allow) => [
    allow.ownerDefinedIn('id').identityClaim('custom:companyId'),
    allow.group('Admin'),
  ]),
...
  User: a.model({
    cognitoId: a.id(),
    email: a.string(),
    firstName: a.string(),
    lastName: a.string(),
    role: a.enum([...]),
    lastActive: a.datetime(),
    status: a.enum(['Active', 'Disabled']),

    companyId: a.id(),
    company: a.belongsTo("Company", "companyId"),
  }).authorization((allow) => [
    allow.ownerDefinedIn('companyId').identityClaim('custom:companyId'),
    allow.group('Admin'),
  ]),
...

Additional Architecture

Then we just have a lambda endpoint that is invoked by our web client after purchases are confirmed (called ./amplify/functions/onboard-user/...) which creates a new Company object, creates a provisional Cognito User in our User Pool with AdminCreateUserCommand (specifying custom:companyId and custom:role in doing so), adds the new Cognito user to the appropriate group, and finally creates the User object which describes the Cognito user account in our main database.

On the model for every database object we have some variation of this authorization: allow.ownerDefinedIn('companyId').identityClaim('custom:companyId'), which means, I believe, that any user that has a custom:companyId that matches the companyId field in the target object (or id in the case of root Company objects) can access those specific objects; and then Cognito users in the Admin group (my account, for instance) can access every single object regardless of the aforementioned identity claim.

Our Issues

Assuming this is correct, we've run into an array of issues that are becoming difficult to resolve:

  • Our front-end seems to be forced to add a companyId filter onto AppSync list queries (at the very least), whereas we would like for this companyId boundary to be enforced by the backend on all CRUDL operations all the time; and ideally invisibly to the caller.

  • Users are also, currently, able to modify the companyId fields on objects, and I honestly don't know what happens when they do that (maybe the object becomes orphaned and inaccessible to them?).

  • We don't seem to be able to chain authorization rules together such so that CRUDL requests. For example, we want the custom:role value of RootUser to be able to modify all objects (except their Company object) but a custom:role value of LessPrivilegedUser, for instance, to not be allowed to modify attributes on other User objects the same way RootUser roles can.

Our Question

Is there a way better way to be implementing this that anyone would be willing to share?

Perhaps it would be best to not go with AppSync and use something like API Gateway instead? Much of the code and resources that have been linked to usually reference Amplify Gen 1 ways of solving certain issues as well, which can be frustrating. It doesn't seem as though we can create some sort of lambda function that is invoked before/after every AppSync request to augment authorization of all CRUDL requests either.

Thank you for your time!

2 Answers
0
Accepted Answer

So just in case anyone stumbles upon this what we ended up doing was creating a lambda function that was the customAPI lambda and within that lambda function we do the checks on requestor permissions and then enforce granular permissions and just pass-through CRUDL requests which are valid (given the permissions or request type of the requestor).

If you're like us and using AppSync (in-built database schema + client.model.YourModel.get-style CRUDL requests that Amplify Gen 2 will setup automatically for you) follow the steps within their Functions doc page here: https://docs.amplify.aws/react/build-a-backend/functions/ and then head over to ./amplify/backend.ts and ensure that your lambda is defined within the defineBackend block, and also ensure that it has the permissions necessary to do cognito requests (like AdminGetUser/AdminCreateUser/AdminAddUserToGroup) and that it knows the User Pool ID of your users:

...
import * as iam from 'aws-cdk-lib/aws-iam';
...
const backend = defineBackend({
...
  customAPI,
...
});
...
backend.customAPI.addEnvironment(
  'USER_POOL_ID',
  backend.auth.resources.userPool.userPoolId
);

backend.customAPI.resources.lambda.addToRolePolicy(
  new iam.PolicyStatement({
    actions: [
      'cognito-idp:AdminGetUser',
    ],
    resources: [backend.auth.resources.userPool.userPoolArn],
  })
);
...

And then make sure to make this lambda function is part of the data group--another thing not often mentioned in the Amplify Gen 2 docs--within the resource.ts file for your lambda like so:

import { defineFunction } from '@aws-amplify/backend';

export const customAPI = defineFunction({
  name: 'customAPI',
  resourceGroupName: 'data',
});

And make sure that it has relevant permissions within your ./amplify/data/resource.ts file at where the schema definition ends like so:

...
import { customAPI } from '../functions/custom-api/resource';

const schema = a.schema({
...
}).authorization((allow) => [
...
  allow.resource(customAPI),
]);

And then you can do stuff like this in your lambda function:

import type { Schema } from '../../data/resource';

import { env } from '$amplify/env/customAPI';
import { getAmplifyDataClientConfig } from '@aws-amplify/backend/function/runtime';
import { Amplify } from 'aws-amplify';
import { generateClient } from 'aws-amplify/data';

import type { AppSyncIdentityCognito } from 'aws-lambda';

import {
  CognitoIdentityProviderClient,
  AdminGetUserCommand,
} from '@aws-sdk/client-cognito-identity-provider';

const { resourceConfig, libraryOptions } = await getAmplifyDataClientConfig(
  env
);

Amplify.configure(resourceConfig, libraryOptions);

const dataClient = generateClient<Schema>();

const cognitoClient = new CognitoIdentityProviderClient();

export const handler: Schema['customAPI']['functionHandler'] = async (event, context) => {
...
  const getUserCommand = new AdminGetUserCommand({
    UserPoolId: env.USER_POOL_ID,
    Username: event.identity.username,
  });

  const cognitoResponse = await cognitoClient.send(getUserCommand);

  if (!cognitoResponse || !cognitoResponse.UserAttributes) {
    throw new Error('Invalid cognito get user response');
  }
...
  const companyIdUserAttribute = cognitoResponse.UserAttributes.find(e => e.Name === 'custom:companyId');
...
  dataClient.models.Company.list({...}); // just like you would in the client front-end code

Oh and also make it so that the database models are not accessible by anyone (with the implication that only the lambda can then use them) within ./amplify/data/resource.ts like so:

  User: a.model({
...
    companyId: a.id(),
    company: a.belongsTo("Company", "companyId"),
  }).authorization((allow) => [
    allow.group('_NOBODY'),
  ]),

Hope this helps someone!

Oh P.S.: those calls like AdminGetUserCommand that get forwarded to Cognito are going to slow things down a little bit... You can, SUPPOSEDLY, get these custom user attributes out of the JWT, and I have a pre-token-generation lambda that is supposed to ensure that they are also in there, and yet in doing something like event.identity.claims['custom:companyId'] within the lambda function does not work even though those fields are definitely within the JWT (verified by dumping from the frontend with fetchAuthSession or whatever that function is). So if you're able to make that work you can probably speed things up quite a bit :)

answered a year ago

EXPERT

reviewed a year ago

0

Multi-Tenant Data Isolation in AWS SaaS Applications

Your approach to multi-tenancy has a solid foundation with the use of Cognito custom attributes for tenant identification, but there are several improvements that can address your current challenges.

Current Architecture Assessment

You're using a tenant identifier (custom:companyId) in Cognito and enforcing access through AppSync authorization rules. This is a good start for tenant isolation, but as you've discovered, it has limitations in your implementation.

Addressing Your Specific Issues

Automatic Tenant Filtering

For your concern about having to manually add companyId filters on queries: This is a common challenge in multi-tenant systems. Instead of relying solely on the authorization rules, you could implement tenant context propagation throughout your system.

One approach is to use AppSync resolvers with VTL (Velocity Template Language) to automatically inject the tenant context into all database operations. You can create resolver templates that automatically add the tenant filter to all queries without requiring explicit filtering in your frontend code.

Preventing Tenant ID Modification

To prevent users from modifying the companyId fields, you should implement input validation in your resolvers. You can:

  1. Use VTL in your resolvers to validate and enforce that the companyId matches the user's tenant
  2. Remove the ability to update this field by excluding it from update operations
  3. Implement field-level authorization to make the companyId immutable after creation

Hierarchical Authorization Rules

For your role-based permissions challenge, AppSync's authorization rules may be limited for complex hierarchical permissions. Consider these approaches:

  1. Implement a Per-Tenant Policy Store design pattern using AWS Verified Permissions, which allows more granular role-based access control within each tenant
  2. Create custom resolvers that implement more complex authorization logic based on both tenant ID and role
  3. Use a combination of AppSync authorization and custom business logic in Lambda functions for specific operations that require complex permission checks

Recommended Improvements

  1. Enhanced Tenant Isolation: Consider implementing tenant isolation at multiple layers:
  • Database layer: Use row-level security in your database if supported
  • API layer: Ensure tenant context is propagated and enforced in all operations
  • Application layer: Validate tenant context in your business logic
  1. Custom Resolver Logic: Implement custom resolver templates that automatically enforce tenant isolation without requiring explicit filtering in your frontend code.

  2. Separate Policy Stores: For more complex authorization requirements, consider implementing separate policy stores per tenant using AWS Verified Permissions.

  3. Pre/Post Request Hooks: While there's no direct "middleware" for all AppSync operations, you can implement similar functionality by:

  • Creating custom resolver templates that include common authorization logic
  • Using pipeline resolvers with a common authorization function
  • Implementing custom directives for common authorization patterns

You don't necessarily need to switch from AppSync to API Gateway. AppSync can handle multi-tenant scenarios effectively with the right implementation patterns. The key is to ensure tenant isolation is enforced consistently across all layers of your application.

Remember that tenant isolation is a foundational element of SaaS architecture, and sometimes you may need to build custom solutions if the out-of-the-box options don't fully address your specific requirements.
Sources
Implementing tenant isolation using Agents for Amazon Bedrock in a multi-tenant environment | AWS Machine Learning Blog
Example 3: Multi-tenant access control with RBAC - AWS Prescriptive Guidance
SaaS Tenant Isolation Strategies: Isolating Resources in a Multi-Tenant Environment - SaaS Tenant Isolation Strategies: Isolating Resources in a Multi-Tenant Environment
The isolation mindset - SaaS Tenant Isolation Strategies: Isolating Resources in a Multi-Tenant Environment
FAQ - AWS Prescriptive Guidance

answered a year ago

You are not logged in. Log in to post an answer.

A good answer clearly answers the question and provides constructive feedback and encourages professional growth in the question asker.