Amplify-cli: Fine-grained Authentication with many-to-many relation

Created on 26 Dec 2018  Â·  22Comments  Â·  Source: aws-amplify/amplify-cli

* Which Category is your question related to? *
AppSync, Amplify, Cognito

* What AWS Services are you utilizing? *
AppSync, Amplify, Cognito

* Provide additional details e.g. code snippets *
The question is complicated. I write it down in the technical design doc to make it as clear as possible.

Project App - User Group Management

The app is for managing projects.

Relations

  • Task - N : 1 - Project

    • An project consists of many tasks

  • Project - N : M - User

    • An project is managed by many users

    • A user manages many projects

  • (Project, User) - 1 : 1 - Role

    • A user can have different roles in different projects he/she manages

    • For example, Jack manages both projects “Clean Jack’s home in SF” and “Clean Jack’s mom’s home in SEA”. Jack has super_user role in project “Clean Jack’s home in SF” so that he can CRUDL tasks in the project, while he has normal_user role in project “Jack’s mom’s home in SEA” where he cannot delete.

    • This is the difficult problem I am trying to resolve.

I propose modeling the relations as followed.

Models

Modified from https://github.com/aws-amplify/amplify-cli/issues/318#issuecomment-431471227.

    type Task @model {
      id: ID!
      name: String!
      project: Project! @connection(name: "ProjectTasks")
    }

    type Project @model {
      id: ID!
      name: String!
      tasks: [Task] @connection(name: "ProjectTasks")
      users: [ProjectMembership] @connection(name: "ProjectMembership_Project")
    }

    type User @model {
      id: ID!
      name: String!
      projects: [ProjectMembership] @connection(name: "ProjectMembership_User")
    }

    # join model to encode many-to-many relationship
    type ProjectMembership @model {
      id: ID!
      user: User! @connection(name: "ProjectMembership_User")
      project: Project! @connection(name: "ProjectMembership_Project")
      role: Role!
    }

    type Role {
      canCreate: Boolean!
      canUpdate: Boolean!
      canDelete: Boolean!
      canGet: Boolean!
      canList: Boolean!
      canSearch: Boolean!
      // custom fields
      canX: Boolean!
    }

User and ProjectMembership

I have User and ProjectMembership type - I use data model to encode group membership rather than using the credential that comes from Cognito, because I don’t know how to leverage @auth in this case.

Questions:

  • Can I leverage @auth here?
  • Also, I am thinking of rely on Cognito as much as possible, is it beneficial to use user sub from Cognito as User.id ?

    Role

It looks pretty much the same as { allow: ..., mutations: [...], queries: [...] }, but again I don’t know how to leverage @auth to reduce boilerplate.

APIs

I want to implement below APIs.

  • listProjects(userId) => List[Project]
  • ?getRole(userId, projectId) => Role
  • listTasks(userId, projectId, role) => List[Task]
  • getTask(taskId, role) => Task
  • searchTask(filter, role) => Task
  • createTask(userId, projectId, role) => Task
  • updateTask(taskId, role) => Task
  • deleteTask(taskId, role) => Task

    listProjects

The implementation seems straightforward. I will rely on codegen getUser resolver and below GraphQL query:

    query {
      getUser(id: "user-id") {
        projects {
          project
        }
      }
    }

?getRole

getRole is to serve task methods that will be discussed later. Not sure if I want to expose this to frontend.

All task methods

Even though all task methods (i.e., list, get, search, create, update, delete) have an argument role, it does NOT necessarily mean, I want to get an task (let’s use getTask as an example) for my frontend React App like this:

  1. Call getRole in frontend React App to get role into memory.
  2. Validate the request. If role.canGet=false, return unauthorized error, otherwise go to next step.
  3. Call API.graphql(graphqlOperation(getTaskQuery, {params: ...})) to get the task from GraphQL backend, where getTaskQuery is a GraphQL query such as query {getTask(…) {id name}} .

Instead, I am against authentication in the frontend and I hope it done implicitly in GraphQL backend. Ideally, to get an task:

  1. Call API.graphql(graphqlOperation(getTaskQuery, {params: ...})) with param taskId on GraphQL backend.
  2. In request template mapping, construct dynamoDB request
  3. In response template mapping:

    1. Get user info from $ctx.identity.

    2. Get project info from $ctx.result.

    3. Get role of the (User, Project) pair from the dynamoDB table backing up ProjectMembership model.

    4. If role.canGet=false, return $util.unauthorized(), otherwise return the result.

3.b can be saved if I also pass project info from React App to GraphQL backend, but here I prefer making the API simple to better clarify my problem.

Questions:

  • Does my proposal to get an task sound good?
  • If yes, seems I need to use pipeline resolvers in response template mapping, is that correct?

    Alternatives Considered

Let’s assume super_user role equals to can get/list/search/create/update/delete, while normal_user role equals to cannot delete, look back to Jack’s example (see Relations), I can try this:

    type Task 
        @model 
        @auth(rules: [
            # super user is allowed all operations
            { allow: groups, groups: ["CleanJacksHomeInSfSuperUser", "CleanJacksMomsHomeInSeaSuperUser"] },
            # normal user is not allowed delete operation
            { allow: groups, groups: ["CleanJacksHomeInSfNormalUser", "CleanJacksMomsHomeInSeaNormalUser"], mutations: [create, update] }
        ]) {
        id: ID!
        name: String!
        project: Project! @connection(name: "ProjectTasks")
    }

    type Project 
        @model
        @auth(rules: [
            { allow: owner }
        ]) {
        id: ID!
        name: String!
        tasks: [Task] @connection(name: "ProjectTasks")
    }

And in AWS Cognito, I create 4 groups:

  • CleanJacksHomeInSfSuperUser
  • CleanJacksHomeInSfNormalUser
  • CleanJacksMomsHomeInSeaSuperUser
  • CleanJacksMomsHomeInSeaNormalUser

And assign user Jack to groups:

  • CleanJacksHomeInSfSuperUser
  • CleanJacksMomsHomeInSeaNormalUser

This should work. However, it doesn’t seem scalable. I will have to maintains #(project) * #(user type) of groups, which will hit group count limit mentioned at https://github.com/aws-amplify/amplify-cli/issues/318 easily. I also need to update the schema every time there is a new project or a new user type. To me, the cons dominates its pros: less and easier code.

Questions

In summary:

  1. Can I leverage @auth in User and ProjectMembership ?
  2. Is it beneficial to use user sub from Cognito as User.id?
  3. Does my proposal to get an task sound good?
  4. If answer to 3 is yes, seems I need to use pipeline resolvers in response template mapping, is that correct?
graphql-transformer question

Most helpful comment

@YikSanChan Any chance you have a little code to share... It doesn't matter if it's unstructered :) I'm sitting with the exact same problem, and having a hard time, figuring out what to add to my resolver...

All 22 comments

@UnleashedMind This all looks pretty good. For something like this you may consider not using the @auth directives but instead implementing the fine-grained authorization / access control yourself (as to have more control over the actual implementation for each operation since there are not too many operations).

You could also use the @auth directive & edit the configuration we give to you.

re: Pipeline resolvers, this would be a possibility. Check out this post (not yet published) or check out the documentation if you'd like to see an example of something similar -> https://medium.com/@dabit3/intro-to-aws-appsync-graphql-pipeline-functions-3df87ceddac1

Pipeline resolvers would be great, but if they are to offer any value here, they'll need to be supported by the CLI.

@dabit3 This definitely works. Will post a solution here soon.

@YikSanChan Could you use Cognito groups to nest users' permissions?

@jkeys-ecg-nmsu From https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-user-groups.html#user-pool-user-groups-limitations, Groups cannot be nested.

Right, you'd put users in every pool that grants permissions.

implementing the fine-grained authorization / access control yourself (as to have more control over the actual implementation for each operation since there are not too many operations).

@dabit3 how would one implement that themselves in a way that doesn't only run client-side?

@troygoode I plan to have a post describing how I do this.

@troygoode Typically would be a matter of updating the resolvers to take care of most of this.

I.e. mutations (adding data about the user when creating a mutation):

$util.qr($context.args.input.put("userId", $context.identity.sub))

& queries (filtering / querying indexes for only the data you need based on identity):

{
    "version" : "2017-02-28",
    "operation" : "Query",
    "index": "userId-index",
    "query" : {
        "expression": "userId = :userId",
        "expressionValues" : {
            ":userId" : $util.dynamodb.toDynamoDBJson($ctx.identity.sub)
        }
    }
}

@dabit3 According to your post, is it possible to trigger createUser resolver whenever a new user is created in Cognito or via amplify-js withAuthenticator UI?

@YikSanChan only if you have a corresponding User table in DynamoDB, so yes it's possible but it needs to go through DyanmoDB / AppSync!

@dabit3 Yes, I have the User table in DynamoDB, and AppSync creates createUser for this table. Then where should I add the logic to ensure this createUser get triggered whenever I sign up a new user via Cognito console or via amplify-js withAuthenticator UI?

I have typically done this on the client.

I've implemented it before a couple of different ways, most recently like this:

When the user signs in, query for the user from your DB. If the user returns, then you know the user has already been created. If you do not get a response from the API (error, no such user), you create a new User.

The unique identifier I use to query / create is either the userId or the subject (sub), both are unique identifiers.

You can get the user's identity by calling Auth.currentAuthenticatedUser()

From client side, gotcha. Thanks @dabit3 !

Closed as I solved this already. Will update the thread once I have summarized the process in a post.

@YikSanChan Any chance you have a little code to share... It doesn't matter if it's unstructered :) I'm sitting with the exact same problem, and having a hard time, figuring out what to add to my resolver...

@YikSanChan I'm with @BrianAndersen78 - would love some direction on how you solved this We have the same exact use case and are quite confused...

Even just a high level overview if you don't have the time to share a code snippet will be immensely helpful. Did you use Pipeline functions/custom resolvers linked to DynamoDB? Something else?

Thanks in advance!

@YikSanChan

it exist pretty strange type of people which ask a question on github/stackoverflow/...,
then they received some advices/suggestions from the community,
then they say 'All is fine, i resolved the problem',
BUT HOW I DID THAT I WILL SAY TO YOU MAYBE Tomorrow/Later/Next_month/Next_months/Next_Life/...'!
then community try to ask topic starter during next time about his results, but without any luck. pretty cool situation

i always "like" such people

@BrianAndersen78 @HemalR @lon9man do you still need help in figuring out many-to-many auth? I was stuck but figured it out using resolver templates

@KandarpAjvalia

Yes please! I gave up on this (was a side project) but I am about to rewrite a more serious one in the next 2-4 weeks and would like Amplify as an option.

And thanks for the kind offer!

Let's take the example from above where we have the

Model

    type Task @model {
      id: ID!
      name: String!
      project: Project! @connection(name: "ProjectTasks")
    }

    type Project @model {
      id: ID!
      name: String!
      tasks: [Task] @connection(name: "ProjectTasks")
      users: [ProjectMembership] @connection(name: "ProjectMembership_Project")
    }

    type User @model {
      id: ID!
      name: String!
      projects: [ProjectMembership] @connection(name: "ProjectMembership_User")
    }

    # join model to encode many-to-many relationship
    type ProjectMembership @model {
      id: ID!
      user: User! @connection(name: "ProjectMembership_User")
      project: Project! @connection(name: "ProjectMembership_Project")
      role: Role!
    }

    type Role {
      canCreate: Boolean!
      canUpdate: Boolean!
      canDelete: Boolean!
      canGet: Boolean!
      canList: Boolean!
      canSearch: Boolean!
      // custom fields
      canX: Boolean!
    }

Main things here are

  1. User
  2. Project
  3. ProjectMembership

For example we need to provide access to the admins group and the owner of project to view all the users in the project, so we need to use @auth

type ProjectMembership 
@model
@auth(
    rules: [
      {
        allow: owner,
        queries: [get, list],
        mutations: [create, update]
      },
      {
        allow: groups,
        groups: ["admin"],
        queries: [get, list],
        mutations: [create, update, delete]
      }
    ]
 ){
    id: ID!
    user: User! @connection(name: "ProjectMembership_User")
    project: Project! @connection(name: "ProjectMembership_Project")
    role: Role!
}

type Project 
@model
@auth(
    rules: [
      {
        allow: owner,
        queries: [get, list],
        mutations: [create, update]
      },
      {
        allow: groups,
        groups: ["admin"],
        queries: [get, list],
        mutations: [create, update, delete]
      }
    ]
 ){
    id: ID!
    name: String!
    tasks: [Task] @connection(name: "ProjectTasks")
    users: [ProjectMembership] @connection(name: "ProjectMembership_Project")
}

Push the changes

Well this would work as expected if we are directly accessing project membership, but it would not work when it is in a nested call for example: when getting all Project.ProjectMemberships. You can go ahead and see the difference when you have a direct call and a nested call by going to your AppSync API > Schema

Example: where you can find the resolvers
Screen Shot 2020-06-30 at 7 49 09 AM
and finding resolvers for

  1. ProjectMembership: search "query" you will find a resolver for getProjectMembership or something like that and click on the resolver and find the response response mapping template
  2. Project.ProjectMembership: search "project" you will find the ProjectMembership Connection and its resolver, click on that resolver and compare the auth

Example: where you can find the response mapping template
Screen Shot 2020-06-30 at 7 52 25 AM

You will see that the Auth that exists in the response mapping template of ProjectMembership(1) does not exist in the response template of Project.ProjectMembership(2).

To get the auth for Project.ProjectMembership just as ProjectMembership, clear the resolver for Project.ProjectMembership the you can copy and paste the entire response mapping template from ProjectMembership to Project.ProjectMembership as ProjectMembership's response mapping template contains the Auth.

Save the resolver for Project.ProjectMembership and it should work.

This is how I got the basic auth for many-to-many working.

@KandarpAjvalia Brilliant thank you!

It makes sense (I haven't implemented it... yet!). But I'm a lot more confident of giving it another whack now. Much appreciated.

Was this page helpful?
0 / 5 - 0 ratings