- Newest
- Most votes
- Most comments
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 :)
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:
- Use VTL in your resolvers to validate and enforce that the
companyIdmatches the user's tenant - Remove the ability to update this field by excluding it from update operations
- Implement field-level authorization to make the
companyIdimmutable 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:
- Implement a Per-Tenant Policy Store design pattern using AWS Verified Permissions, which allows more granular role-based access control within each tenant
- Create custom resolvers that implement more complex authorization logic based on both tenant ID and role
- Use a combination of AppSync authorization and custom business logic in Lambda functions for specific operations that require complex permission checks
Recommended Improvements
- 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
-
Custom Resolver Logic: Implement custom resolver templates that automatically enforce tenant isolation without requiring explicit filtering in your frontend code.
-
Separate Policy Stores: For more complex authorization requirements, consider implementing separate policy stores per tenant using AWS Verified Permissions.
-
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
Relevant content
asked 3 years ago
