Open lennybr opened 5 years ago
@mrsimply @ronaldocpontes I'll try to keep this example as simple as possible. It took me quite a while to come up with this and wrap my head around...
Hypothetical data resource types and relationships: Users are assigned to a Business (employee of) A Business has 1 or more Locations A User can also be assigned to 1 or more Locations of the Business (many-to-many relationship not shown in below schema)
Now let's say there are 2 types of roles for a user:
schema.graphql
type Business
@model
@auth(rules: [
# grants "root" admins CRUD operations on all Businesses
{ allow: groups, groups: ["Admin"], operations: [create, read, update, delete] }
# grants Business admins read, update operations if the calling user is an employee of (assigned to) the Business
{ allow: groups, groupsField: "id", operations: [read, update] }
# grants Location admins read access if the calling user is an employee of (assigned to) the Business
{ allow: groups, groupsField: "id", groupClaim: "locationAdmin", operations: [read] }
]) {
id: ID!
name: String!
locations: [Location] @connection(keyName: "byBusiness", fields: ["id"])
}
type Location
@model
@auth(rules: [
# grants "root" admins CRUD operations on all Locations
{ allow: groups, groups: ["Admin"], operations: [create, read, update, delete] }
# grants Business admins create, read, update operations if the calling user is an employee of (assigned to) the Business
{ allow: groups, groupsField: "businessID", operations: [create, read, update] }
# grants Location admins read, update access if the calling user is an employee of (assigned to) the Business
{ allow: groups, groupsField: "id", operations: [read, update] }
])
@key(name: "byBusiness", fields: ["businessID", "name"]) {
id: ID!
businessID: ID!
name: String!
}
type User
@model
@auth(rules: [
# grants "root" admins CRUD operations on all Users
{ allow: groups, groups: ["Admin"], operations: [create, read, update, delete] }
# allow the User to read, update their own User record
{ allow: owner, ownerField: "id", operations: [read, update]
# grants Business admins create, read, update operations if the calling user is an employee of (assigned to) the Business
{ allow: groups, groupsField: "businessID", operations: [create, read, update] }
# grants Location admins read access if the calling user is an employee of (assigned to) the Business
{ allow: groups, groupsField: "businessID", groupClaim: "locationAdmin", operations: [read] }
]) {
id: ID! # use the Cognito sub/id as the id here
businessID: ID!
emailAddress: AWSEmail!
role: Role
business: Business! @connection(fields: ["businessID"])
}
enum Role {
BUSINESS_ADMIN
LOCATION_ADMIN
}
Cognito pre-token generation lambda handler code:
const getUserQuery = `query GetUser(
$id: ID!
) {
getUser(id: $id) {
role
business {
id
locations(limit: 100) {
items {
id
}
}
}
locations {
items {
location {
id
}
}
}
}
}`;
exports.handler = async (event, context, callback) => {
// get the user ID (Cognito sub)
const userSub = event.request.userAttributes.sub;
// get the user groups assigned through Cognito groups
const groups = event.request.groupConfiguration.groupsToOverride;
// get the calling User record from the GraphQL API
// make sure to load the Business with id and all Locations belonging to
// the Business as well as all Locations the user is assigned to
// NOTE: api.gqlCall is a utility method for making AppSync/GraphQL API calls
const userResp = await api.gqlCall(
getUserQuery, 'GetUser', { id: userSub }, graphqlEndpoint, REGION,
);
// make sure we got user data returned from the GQL API call
if (userResp.data && userResp.data.getUser) {
const businessId = userResp.data.getUser.business.id
const claimsToAddOrOverride = {
business_id: businessId,
};
const customGroups = [];
if (!groups.includes('Admin')) {
# don't need to do any of this for "root" admins
if (userResp.data.getUser.role === "BUSINESS_ADMIN") {
customGroups.push(businessId);
// add all Business Location IDs to customGroups
userResp.data.getUser.business.locations.items.forEach((item) => {
customGroups.push(item.location.id);
});
} else if (userResp.data.getUser.role === "LOCATION_ADMIN") {
// add only the specific Location IDs that the user is assigned to
userResp.data.getUser.locations.items.forEach((item) => {
customGroups.push(item.location.id);
});
claimsToAddOrOverride.locationAdmin = businessId;
} else {
claimsToAddOrOverride.readAccess = businessId;
}
}
event.response = {
claimsOverrideDetails: {
groupOverrideDetails: {
groupsToOverride: [...groups, ...customGroups],
},
claimsToAddOrOverride,
}
};
}
callback(null, event);
}
I hope this can help give some good ideas for others!
Awesome @paulsson! This seems to solve our multi-tenant authentication requirements quite neatly with the current functionality in Amplify. Your solution should be added to the official Amplify docs. Thanks for sharing!
Thanks for the great example @paulsson. I'm new to this, as such still trying to wrap my head around the tricks one needs to pull to accomplish this feature, so apologies if these questions are common sense to you and others:
@theunsa
- Is the Cognito pre-token generation lambda handler relevant to identity tokens only or access tokens as well?
In @paulsson example:
event.response = {
claimsOverrideDetails: {
groupOverrideDetails: {
groupsToOverride: [...groups, ...customGroups],
},
claimsToAddOrOverride,
}
};
Back when I was working with tokens, it worked like this: the ID token gets the added groups and claims, while the access token gets the added groups but not the claims. So if you want to use the added claims from the lambda function, you have to override AWS Amplify's normal authorization behavior, as @paulsson says:
This requires you to pass the id_token instead of the access_token JWT in the 'Authorization' header unfortunately, but not a big deal.
Here's another example of doing that
If you just want to use the added groups in your authorization (for simpler use cases than @paulsson example), then you can use the access token as normal.
Hi @mikeparisstuff Any update on this ? :)
@paulsson We have implemented multi-tenant using your snippet and added all the required auth rules. These rules are working perfectly fine in AWS Console. But when we are trying to access the appsync graphql API to retrieve User records through Postman/ node scripts it's not fetching any data.
As similar to yours, we have the used tenantid => businessid, teamid => locationid and userid - userid. We have the many to many relation between user and team.
With this, the TenantAdmin has complete access towards the tenant and is working as expected. Team Manager should have access to his team and all the users with in his team. This is where we are facing the problem. In appsync UI console, the user data retrieved perfectly alright, but the same listusers query is not retrieving any data when we are making a request using nodescript or postman. Please find below screenshots for more details:
type User @model @auth(rules: [ { allow: public, provider: apiKey, operations: [read] },
{ allow: owner, ownerField: "id", identityClaim: "userID", operations: [create, read, update]},
# static group
{ allow: groups, groups: ["InternalAdmin"], operations: [create, read, update, delete]},
{ allow: groups, groupsField: "tenantID", operations: [create, read, update] }
{ allow: groups, groupsField: "tenantID", groupClaim: "teamAdmin", operations: [read] }
]) @key(name: "userByEmail", fields: ["email"], queryField: "userByEmail")
{
id: ID
email: String
tenantID: ID!
tenant: Tenant @connection (fields: ["tenantID"])
firstName: String!
lastName: String
teams: [UserTeam] @connection(keyName: "byUser", fields: ["id"])
userRole: [UserType!]!
}
type UserTeam @model
@auth(rules: [
{ allow: public, provider: apiKey, operations: [read] },
{allow: groups, groups: ["InternalAdmin"], operations: [create, read, update, delete]}
{ allow: groups, groupsField: "tenantID", operations: [create, read, update, delete] }
{ allow: groups, groupsField: "tenantID", groupClaim: "teamAdmin", operations: [read] }
])
@key(name: "byUser", fields: ["userID", "teamID"])
@key(name: "byTeam", fields: ["teamID", "userID"]) {
id: ID!
teamID: ID!
userID: ID!
tenantID: ID!
createdAt: AWSDateTime
updatedAt: AWSDateTime
team: Team! @connection(fields: ["teamID"])
user: User! @connection(fields: ["userID"])
}
Additional to it, the same issue is with the team member as well, the are not able to retrieve even his own user record when the request is made from nodescript or the postman. The same is working good with the appsync UI console. (Basically the owner auth rule on the User model.)
We tried logging, but it did not help much. If anyone would have faced this issue kindly provide your inputs and suggestions.
@RossWilliams @ronaldocpontes @dantasfiles @dabit3
In appsync UI console, the user data retrieved perfectly alright, but the same listusers query is not retrieving any data when we are making a request using nodescript or postman.
@rhorohit the AppSync Console automatically adds the authenticated Cognito user's JWT token to the request. In Postman or any script you develop you will have to add the Cognito user's JWT token to the request headers. The @auth
rules are applied to the encoded user details in the Cognito JWT token so if you aren't passing that then you won't get any data.
Hope that helps!
@paulsson I have added tokens explicitly in Postman OAuth 2.0 and also Auth PreTokenGeneration is correctly adding claims and groups. Also, it is fetching Teams records as per the auth rules but not retrieving users/ tenants records. Please find the screen-shots below:
To us, it looks like below 2 rules are causing troubles for Postman API call
UserTeam model: { allow: groups, groupsField: "tenantID", groupClaim: "teamAdmin", operations: [read] }
User model: { allow: owner, ownerField: "id", identityClaim: "userID", operations: [create, read, update]}
any suggestion would be helpful.
@paulsson @RossWilliams @dantasfiles @dabit3 We have written a set of auth rules to implement multitenant. It is working as expected in AWS Console Appsync GraphiQL. But the same is not working for all the models with Postman API call.
I am able to fetch records for Teams model using Postman API call but not for User model.
Please suggest if any additional configuration is required to make this work.
@paulsson @RossWilliams @dabit3 @dantasfiles We have created an issue to address this problem. Please find the issue link as below. It has step by step process to replicate it. we also have added a sample project with schema and pretoken function.
We have written a set of auth rules to implement multitenant. It is working as expected in AWS Console Appsync GraphiQL. But the same is not working for all the models with Postman API call.
@rhorohit the Appsync Console does not do anything "magical" when calling your API. The @auth
rules are all applied the same no matter what client is sending the API request. If you are making the Appsync API request correctly from Postman then it will work exactly the same.
I believe you are not passing your JWT token properly from Postman. I don't know what the "Manage Tokens" window is in your Postman screenshot. I suggest you pass your JWT token as described by the Appsync docs which says to pass it as a header named authorization
in your request.
Hint: If you open up your "Network" console in your browser when using the Appsync console to make a query you will see exactly how the request is made.
Thanks so much @paulsson, we compared the headers and noticed that appsync console query is using the IDToken as the Authorization param, whereas our amplify API call and the postman calls were using the Access Token. With the help of https://github.com/aws-amplify/amplify-js/issues/4751, I have made changes to have my API use the IDToken instead of the access token. My Problem Solved. Now we have the proper Auth rules working.
@paulsson Thanks for your schema, very useful.
I'm wondering about the following: "As a businessAdmin, am I able to inject a location for another business"
-> a businessAdmin has full CRU access to the Location type:
{ allow: groups, groupsField: "businessID", operations: [create, read, update] }
-> A businessAdmin can create a location with another businessID (other than his own), right?
Should we overwrite the createLocation resolver in this case?
I'm wondering about the following: "As a businessAdmin, am I able to inject a location for another business"
-> a businessAdmin has full CRU access to the Location type:
{ allow: groups, groupsField: "businessID", operations: [create, read, update] }
-> A businessAdmin can create a location with another businessID (other than his own), right?Should we overwrite the createLocation resolver in this case?
@Nxtra sorry I missed this. No, a businessAdmin will not be able to create a location with another businessID. This is enforced by the groupField
. They will only have create, read, update permissions for locations that have the same businessID assigned as the business that the calling user belongs to.
{ allow: groups, groupsField: "businessID", operations: [create, read, update] }
You can run this yourself in the AppSync console and see this being enforced. The location will not be created and the response will contain an unauthorized error like this:
{
"data": {
"createLocation": null
},
"errors": [
{
"path": [
"createLocation"
],
"data": null,
"errorType": "Unauthorized",
"errorInfo": null,
"locations": [
{
"line": 2,
"column": 3,
"sourceName": null
}
],
"message": "Not Authorized to access createLocation on type Location"
}
]
}
I implemented the solution in my app and using claims seems to work very well - downsides are I have to configure amplify to send id token instead of access token therefore exposing user information and every time the user logs in I have to hit the db incurring all associated costs.
I have a different issue however that I am not sure where to get an advice on - I asked on SO but very few people get to view my question and there are no answers -
What is the best forum to see advice on my issue?
Hi @paulsson , can you tell me what the require did you use for this lamba, trying to find for the api.gqlCall(), I'm new to this and I can't find it in anywhere.
Hope you can help me.
const userResp = await api.gqlCall( getUserQuery, 'GetUser', { id: userSub }, graphqlEndpoint, REGION, );
@paulsson I have the same question as @zepelega , where did this API call come from?
Separately, how is this solution possible when you have a circular dependency between Amplify's Auth (pre token lambda) and API (table that stores group). In order to create the Auth module with a pre token generation lambda, you have to give it access to the API through IAM. In order to set up the API with an IAM auth rule, you need an Auth module that depends on having a pre token generation lambda with API access. Trying to accomplish this via the Amplify CLI returns a circular dependency error.
@zepelega api.gqlCall
is just some shared common code that I wrote that is used in all my lambdas/code that needs to make GraphQL/AppSync API calls. Nothing fancy.
@nhruch here is another GitHub issue where I detailed how to break the circular dependency: https://github.com/aws-amplify/amplify-cli/issues/1874#issuecomment-606073890
@nhruch looks like Amplify CLI added built-in functionality to break these circular dependencies. https://github.com/aws-amplify/amplify-cli/issues/4568#issuecomment-858909745
Any update on this?
The following is a working Lambda that includes @paulsson 's code with the addition of an API call to graphql. This works by adding "type": "module"
to package.json as well as removing the loader function in function/src/index.js and replacing it with this code. Run amplify function update
, select Resource access permissions
, and add api
.
import crypto from '@aws-crypto/sha256-js';
import { defaultProvider } from '@aws-sdk/credential-provider-node';
import { SignatureV4 } from '@aws-sdk/signature-v4';
import { HttpRequest } from '@aws-sdk/protocol-http';
import fetch from "node-fetch";
import { Request as Request } from "node-fetch";
const { Sha256 } = crypto;
// use your own endpoint here
const GRAPHQL_ENDPOINT = <YOUR_API_ENDPOINT>;
const AWS_REGION = process.env.AWS_REGION || 'us-east-2';
const query = `query GetUser(
$id: ID!
) {
getUser(id: $id) {
role
business {
id
locations(limit: 100) {
items {
id
}
}
}
}
}`;
export async function handler(event) {
// get the user ID (Cognito sub)
const userSub = event.request.userAttributes.sub;
// get the user groups assigned through Cognito groups
const groups = event.request.groupConfiguration.groupsToOverride;
// Return early if user is admin, will have full auth access anyway
if (groups.includes('admin')) { return event }
const endpoint = new URL(GRAPHQL_ENDPOINT);
const signer = new SignatureV4({
credentials: defaultProvider(),
region: AWS_REGION,
service: 'appsync',
sha256: Sha256
});
const requestToBeSigned = new HttpRequest({
method: 'POST',
headers: {
host: endpoint.host
},
hostname: endpoint.host,
body: JSON.stringify({ query: query, variables: { id: userSub } }),
path: endpoint.pathname
});
const signed = await signer.sign(requestToBeSigned);
const request = new Request(endpoint, signed);
let statusCode = 200;
let body;
let response;
try {
response = await fetch(request);
body = await response.json();
if (body.errors) statusCode = 400;
} catch (error) {
statusCode = 400;
body = {
errors: [
{
status: response.status,
message: error.message,
stack: error.stack
}
]
};
return event
}
// Return if no user is found in DB, handle this case
if (!body.data.getUser) return event
const claimsToAddOrOverride = {}
const customGroups = [];
const businessId = body.data.getUser?.business?.id
if (businessId) {
claimsToAddOrOverride.business_id = businessId
}
if (body.data.getUser?.role === "BUSINESS_ADMIN") {
customGroups.push(businessId);
// add all Business Location IDs to customGroups
body.data.getUser?.business?.locations?.items.forEach((item) => {
customGroups.push(item.location.id);
});
} else if (body.data.getUser.role === "LOCATION_ADMIN") {
// add only the specific Location IDs that the user is assigned to
body.data.getUser?.locations?.items.forEach((item) => {
customGroups.push(item.location.id);
});
claimsToAddOrOverride.locationAdmin = businessId;
} else {
claimsToAddOrOverride.readAccess = businessId;
}
event.response = {
claimsOverrideDetails: {
groupOverrideDetails: {
groupsToOverride: [...groups, ...customGroups],
},
claimsToAddOrOverride,
}
};
return event;
}
Another hurdle as of CLI v8.5.0 is V2 of graphql. There is a breaking change where the auth rule doesn't like to read from the id's. I've changed my structure to the following and in the front end pass in the same UUID to both id
and authid
type Business
@model
@auth(
rules: [
# grants "root" admins CRUD operations on all Businesses
{
allow: groups
groups: ["admin"]
operations: [create, read, update, delete]
}
# grants Business admins read, update operations if the calling user is an employee of (assigned to) the Business
{ allow: groups, groupsField: "authid", operations: [read, update] }
# grants Location admins read access if the calling user is an employee of (assigned to) the Business
{
allow: groups
groupsField: "authid"
groupClaim: "locationAdmin"
operations: [read]
}
]
) {
id: ID!
authid: String!
name: String!
locations: [Location] @hasMany(indexName: "byBusiness", fields: ["id"])
}
AWS team - this feature of supporting multi tenancy is critical for enterprise grade apps. Cant believe this is open from 2018 and no action yet!! @dabit3 @kaustavghosh06 can you please help promote this ? also this is marked P4 ?!!!??? damn this is P1.
Subscription tiers is another use case for this. Very difficult to model different types of permissions for different users without logical AND.
Being able to use AND as well as OR to combine different auth rules would make it much easier. We are currently facing the Issue, where we need to have the User (owner) to have access, as well as a group of Admins per Tenant.
IF we would be able to use AND we could just have a group "admins" and the tenant_ID in the groupfield. Then you could gran access if ( Owner || ( group: "admins" && groupfield matches)).
I feel like this feature should have been added a long time ago. Idk why this has been open since 2018 since it's such a good feature suggestion
Given the complexity of this we’ve given up trying to manage using directives. In the end custom resolvers with the full power of a coding language and an auth a lambda is the better way to go for our use case when going off the beaten path rather than try to solve with a dsl. Probably not the solution people want to hear but perhaps will convince people to move off it before things go too far down a path.
Why not at least add an global "option" to switch logic of combining @auth rules from "OR" to "AND" between "ownership" and "group" rules? that would effectively mean that schema.graphql syntax NO needs to be changed, it would simply be interpreted with AND logic if switch is set somewhere in the AWS console. Feature of supporting multi tenancy is critical for enterprise grade apps and entrie @auth is only applicable to student projects and tutorials if we cant split by tenants.
Also interested in into this topic...
Any news on this? I was new to AWS and intimidated at first. But with Amplify it’s a piece of cake… until you try to do a multi tenant app :-((( PLEASE Implement this !!!
Dear AWS Team, this is critical in order to build any scalable SaaS product. We need it urgently, please let us know if that's coming or not anytime soon.
Any update on this?
Honestly, I don't see that here.
On Wed, Sep 13, 2023, 5:43 AM Redjon Zaci @.***> wrote:
Honestly, I don't see that here. [image: image] https://user-images.githubusercontent.com/73707194/267608646-fe29309b-4db0-4f4a-8ebe-94f7f11dd179.png
— Reply to this email directly, view it on GitHub https://github.com/aws-amplify/amplify-category-api/issues/449#issuecomment-1717297747, or unsubscribe https://github.com/notifications/unsubscribe-auth/AKJQCJZCBBLSSPDMUEKFGKTX2F53HANCNFSM5WGKWFMQ . You are receiving this because you commented.Message ID: @.***>
@mikeparisstuff @kaustavghosh06 @mdwt Is there anyone even looking at this? Is Amplify still actively supported by Amazon/AWS and if so, is multi-tenant app support a priority in Amplify's roadmap?
My best guess is that this is not prioritised, hence I avoid using amplify for multi-tenant applications 😩
It would be awesome if there were a multi-tenant auth/api setup and schema out-of-the-box, i.e. a template that we could apply to spin up a multi-tenant application without so many gymnastics to make it work.
@renebrandel Would https://github.com/aws-amplify/amplify-category-api/issues/430 enable this use case for supporting Combining Owner/Groups rules for Multi-Tenant Apps?
Now how about we talk about alternative services which might kick AWS out out of bed? Firebase/Firestore doesnt work for me as I'm based in Germany / DSGVO compliance. What other services have you considered as alternatives for these requirements?
Am I understanding this right that Gen2's introduction of composite identifiers is the answer to solving multi-tenancy? https://docs.amplify.aws/gen2/build-a-backend/data/data-modeling/identifiers/
One thing to look at in Gen 2 (that also exists to some degree in Gen 1), is the ability to use a Lambda for custom data access.
https://docs.amplify.aws/gen2/build-a-backend/data/customize-authz/custom-data-access-patterns/
Am I understanding this right that Gen2's introduction of composite identifiers is the answer to solving multi-tenancy? https://docs.amplify.aws/gen2/build-a-backend/data/data-modeling/identifiers/
It seems to me it just represents the primary key and sort key.
One thing to look at in Gen 2 (that also exists to some degree in Gen 1), is the ability to use a Lambda for custom data access.
https://docs.amplify.aws/gen2/build-a-backend/data/customize-authz/custom-data-access-patterns/ This looks closer to the topic. Thanks for pointing out.
Any updates?
Is your feature request related to a problem? Please describe. Ability to support multi-tenancy thru AppSync where individual items are "owned/belong" to a tenant instead of a user and we still have the ability to permission queries and mutations. Generated resolvers today effectively use
isOwner || isInGroup(x for x in cognitoGroups)
logic so multiple @auth rules cannot be combined to create more granular permissions.Describe the solution you'd like A few ideas:
isOwner && isInGroup(x for x in cognitoGroups)
when we have both rules types declaredisTenant && (isOwner || isInGroup(x for x in cognitoGroups))
Describe alternatives you've considered Currently using the existing @auth owner strategy with custom ownerField and identityFIeld values, and setting the tid claim on the token with a pre-token generation Lambda function:
When used as the only @auth strategy, it works as intended (e.g. inserting the correct tid value during mutations; filters by tid value during queries, etc.).
But when I combine with @auth static groups strategy for permissions, the authorisation checks use OR logic instead of AND logic. I can't check for instance that a record both belongs to Tenant A (which the user belongs to) and has Permission X.