Unexpected behavior with @auth rules when using fragments and @cascade

Problem

We’ve been fighting with the @auth rule as it is behaving very inconsistently when the @cascade directive is involved. My conclusion is that @cascade has a slightly different implementation when executed within @auth queries.

Context

The following query is used inside the @auth directive:

  query ($subject: String!) {
    queryGroup() @cascade(fields: ["policy"]) {
      id
      policy @cascade(fields: ["bindings"]) {
        bindings @cascade(fields: ["role", "members"]) {
          role @cascade(fields: ["permissions"]) {
            permissions(filter: {id: {eq: "resourcemanager.groups.get"}}) {
              id
            }
          }
          members(filter: {userFilter: {id: {eq: $subject}}, serviceAccountFilter: {id: {eq: $subject}}}) {
            ...on User {
              id
            }
            ...on ServiceAccount {
              id
            }
            ...on Group {
              identities(filter: {userFilter: {id: {eq: $subject}}, serviceAccountFilter: {id: {eq: $subject}}}) {
                ... on Metadata {
                  id
                }
              }
            }
            ...on AllUsers {
              id
            }
          }
        }
      }
    }
  }

The implementation shown above is for an IAM based on RBAC. The core idea is that given a policy assigned to a resource (in this case, assigned to a Group node), the user is authorized to query the node if and only if he is a member of a binding that belongs to the policy itself with a role that has as a permission resourcemanager.groups.get. The user can be directly assigned to a binding or could be in a group assigned to the binding.

Let’s see some practical example of a working behavior for our RBAC IAM:

  1. The user Matteo Roggia is directly assigned to the policy with a role that has the permission resourcemanager.groups.get and is therefore correctly authorized to perform the query:

  2. The user Christian Roggia is a member of the group Executives and that is assigned to the policy with a role that has the permission resourcemanager.groups.get and is therefore correctly authorized to perform the query:

  3. The user guest is not assigned directly or indirectly through a group to the policy, and should therefore denied accessing the resource (i.e. he should not see the Group). Here is where things break down:

The user guest is wrongly authorized to perform the request.

The issue with fragments and arrays

The first reason why this happens is that the fragment is returning an empty array instead of not returning anything. Let’s execute the query the @auth query described above from a GraphQL client with the $subject set to guest (authorization rules have now been disabled for debugging):

{
  "data": {
    "queryGroup": [
      {
        "id": "694c894a-e340-4918-b6d4-6237e1cb0483",
        "policy": {
          "bindings": [
            {
              "role": {
                "permissions": [
                  {
                    "id": "resourcemanager.groups.get"
                  }
                ]
              },
              "members": [
                {
                  "identities": []
                }
              ]
            }
          ]
        }
      }
    ]
  },
  "extensions": {
    ...
  }
}

There you go! The user does not belong to any group but an empty array is returned (wrongly) anyway by the query:

"members": [
  {
    "identities": []
  }
]

This prevents the parameterized @cascade from being executed!

The @cascade workaround

Alright, once we found the culprit we tried to work around this issue, so we added a new @cascade directive to the members field hoping that this would fix the bug momentarily. The new query looks like this:

  query ($subject: String!) {
    queryGroup() @cascade(fields: ["policy"]) {
      id
      policy @cascade(fields: ["bindings"]) {
        bindings @cascade(fields: ["role", "members"]) {
          role @cascade(fields: ["permissions"]) {
            permissions(filter: {id: {eq: "resourcemanager.groups.get"}}) {
              id
            }
          }
          members(filter: {userFilter: {id: {eq: $subject}}, serviceAccountFilter: {id: {eq: $subject}}}) @cascade { # <------- NOTICE THE NEW @CASCADE HERE
            ...on User {
              id
            }
            ...on ServiceAccount {
              id
            }
            ...on Group {
              identities(filter: {userFilter: {id: {eq: $subject}}, serviceAccountFilter: {id: {eq: $subject}}}) {
                ... on Metadata {
                  id
                }
              }
            }
            ...on AllUsers {
              id
            }
          }
        }
      }
    }
  }

So we jumped back to our GraphQL client and tried to execute the query manually again to see if the result was as expected:

{
  "data": {
    "queryGroup": [
      {
        "id": "694c894a-e340-4918-b6d4-6237e1cb0483",
        "policy": {
          "bindings": [
            {
              "role": {
                "permissions": [
                  {
                    "id": "resourcemanager.groups.get"
                  }
                ]
              },
              "members": [
                {
                  "identities": [
                    {
                      "id": "e4abd113-44ac-4c03-bb49-ead3fa686e31"
                    }
                  ]
                }
              ]
            }
          ]
        }
      }
    ]
  },
  "extensions": {
    ...
  }
}

It works correctly for the user Christian Roggia (here represented with ID e4abd113-44ac-4c03-bb49-ead3fa686e31).

{
  "data": {
    "queryGroup": []
  },
  "extensions": {
    ...
  }
}

Works also for the user guest (no results → not authorized). Amazing! Everything seems to be back to normal, except that things get a little spooky when the query is then updated in the @auth block.

The issue with @cascade and @auth

So we wiped the dgraph used for tests and we applied the new schema with the authorization enabled:

"""
GroupMember represents all entities that can be assing to a group.
"""
union GroupMember = User | ServiceAccount

"""
Group represents a group of users or service accounts.
"""
type Group implements Metadata @generate(
  query: {
    get: true,
    query: true,
    aggregate: false
  },
  mutation: {
    add: true,
    update: true,
    delete: true
  },
  subscription: false
) @auth(query: { or: [ { rule: """
  query ($subject: String!) {
    queryGroup() @cascade(fields: ["policy"]) {
      id
      policy @cascade(fields: ["bindings"]) {
        bindings @cascade(fields: ["role", "members"]) {
          role @cascade(fields: ["permissions"]) {
            permissions(filter: {id: {eq: "resourcemanager.groups.get"}}) {
              id
            }
          }
          members(filter: {userFilter: {id: {eq: $subject}}, serviceAccountFilter: {id: {eq: $subject}}}) @cascade {
            ...on User {
              id
            }
            ...on ServiceAccount {
              id
            }
            ...on Group {
              identities(filter: {userFilter: {id: {eq: $subject}}, serviceAccountFilter: {id: {eq: $subject}}}) {
                ... on Metadata {
                  id
                }
              }
            }
            ...on AllUsers {
              id
            }
          }
        }
      }
    }
  }"""
}
... other rules ...
}) {
  identities: [GroupMember!]!
  policy: GroupPolicy!
}

"""
BindingMember represents all entities that can be assing to an authorization binding.
"""
union BindingMember = User | ServiceAccount | Group | AllAuthenticatedUsers | AllUsers

type AllAuthenticatedUsers @generate(
  query: {
    get: true,
    query: false,
    aggregate: false
  },
  mutation: {
    add: false,
    update: false,
    delete: false
  },
  subscription: false
) {
  id: String! @id
}

type AllUsers @generate(
  query: {
    get: true,
    query: false,
    aggregate: false
  },
  mutation: {
    add: false,
    update: false,
    delete: false
  },
  subscription: false
) {
  id: String! @id
}

"""
Binding represents an authorization binding between a role and one or more members.
"""
type Binding implements Metadata @generate(
  query: {
    get: false,
    query: false,
    aggregate: false
  },
  mutation: {
    add: true,
    update: true,
    delete: true
  },
  subscription: false
) {
  role: Role!
  members: [BindingMember!]!
}

Looking good! The new @cascade directive that we added is there and everything looks correct.
Let’s test out whether the @auth rule is working as expected now by fetching all the groups that a user is allowed to see:

{
  queryGroup(first: 1000) {
    id
    identities {
      ... on User {
        id
        identity {
          username
        }
      }
      ... on ServiceAccount {
        id
      }
    }
    policy {
      id
      bindings {
        role {
          id
        }
        members {
          ... on Group {
            id
            identities {
              ... on User {
                id
                identity {
                  username
                }
              }
              ... on ServiceAccount {
                id
              }
            }
          }
          ... on User {
            id
            identity {
              username
            }
          }
          ... on ServiceAccount {
            id
          }
          ... on AllUsers {
            id
          }
        }
      }
    }
  }
}
  1. First we execute the query with the authenticated user Christian Roggia:
{
  "data": {
    "queryGroup": [
      {
        "id": "063dc028-b43e-4fcf-8156-3197bd932148",
        "identities": [
          {
            "id": "e4abd113-44ac-4c03-bb49-ead3fa686e31",
            "identity": {
              ...
            }
          }
        ],
        "policy": {
          "id": "policy:063dc028-b43e-4fcf-8156-3197bd932148",
          "bindings": [
            {
              "role": {
                "id": "roles/resourcemanager.groupAdmin"
              },
              "members": [
                {
                  "id": "e4abd113-44ac-4c03-bb49-ead3fa686e31",
                  "identity": {
                    ...
                  }
                }
              ]
            }
          ]
        }
      },
      {
        "id": "694c894a-e340-4918-b6d4-6237e1cb0483",
        "identities": [
          {
            "id": ...
          }
        ],
        "policy": {
          "id": "policy:694c894a-e340-4918-b6d4-6237e1cb0483",
          "bindings": [
            {
              "role": {
                "id": "roles/resourcemanager.groupAdmin"
              },
              "members": [
                {
                  "id": "063dc028-b43e-4fcf-8156-3197bd932148",
                  "identities": [
                    {
                      "id": "e4abd113-44ac-4c03-bb49-ead3fa686e31",
                      "identity": {
                        ...
                      }
                    }
                  ]
                }
              ]
            }
          ]
        }
      }
    ]
  },
  "extensions": {
    ...
  }
}

Summary: 2 groups have been returned, the group 063dc028-b43e-4fcf-8156-3197bd932148 where Christian Roggia is directly assigned the role roles/resourcemanager.groupAdmin and the group 694c894a-e340-4918-b6d4-6237e1cb0483 where Christian Roggia is a member of a group that has been assigned the role roles/resourcemanager.groupAdmin. This result is as expected.

  1. We executed the same query again with a guest user that should not be authorized to see any group:
{
  "data": {
    "queryGroup": [
      {
        "id": "694c894a-e340-4918-b6d4-6237e1cb0483",
        "identities": [
          {
            ...
          }
        ],
        "policy": {
          "id": "policy:694c894a-e340-4918-b6d4-6237e1cb0483",
          "bindings": [
            {
              "role": {
                "id": "roles/resourcemanager.groupAdmin"
              },
              "members": [
                {
                  "id": "063dc028-b43e-4fcf-8156-3197bd932148",
                  "identities": [
                    {
                      "id": "e4abd113-44ac-4c03-bb49-ead3fa686e31",
                      "identity": {
                        ...
                      }
                    }
                  ]
                }
              ]
            }
          ]
        }
      }
    ]
  },
  "extensions": {
    ...
  }
}

Only one group has been returned and it is the group that has a role assigned not directly to a User but to a Group of User(s). This is the same result we got before adding the new @cascade directive and it is wrong!

Conclusion

Our conclusion is that @cascade does not behave in the same way when executed from a query vs. when it is executed within an @auth rule. It actually looks like it is completely ignored in some cases.

We attempted to add a workaround by splitting the rule into a subset of 2 rules:

query ($subject: String!) {
    queryGroup() @cascade(fields: ["policy"]) {
      id
      policy @cascade(fields: ["bindings"]) {
        bindings @cascade(fields: ["role", "members"]) {
          role @cascade(fields: ["permissions"]) {
            permissions(filter: {id: {eq: "resourcemanager.groups.get"}}) {
              id
            }
          }
          members(filter: {
            memberTypes:[
              User,
              ServiceAccount,
              AllUsers,
              AllAuthenticatedUsers,
            ],
            userFilter: {id: {eq: $subject}},
            serviceAccountFilter: {id: {eq: $subject}},
          }) {
            ...on User {
              id
            }
            ...on ServiceAccount {
              id
            }
            ...on AllUsers {
              id
            }
          }
        }
      }
    }
  }
query ($subject: String!) {
    queryGroup() @cascade(fields: ["policy"]) {
      id
      policy @cascade(fields: ["bindings"]) {
        bindings @cascade(fields: ["role", "members"]) {
          role @cascade(fields: ["permissions"]) {
            permissions(filter: {id: {eq: "resourcemanager.groups.get"}}) {
              id
            }
          }
          members(filter: {memberTypes: [Group]}) @cascade {
            ...on Group {
              identities(filter: {userFilter: {id: {eq: $subject}}, serviceAccountFilter: {id: {eq: $subject}}}) {
                ... on Metadata {
                  id
                }
              }
            }
          }
        }
      }
    }
  }

Again, this works as expected when the query is performed directly via GraphQL client, but it shows the same behavior as before when included inside an @auth rule.

Hey @christian-roggia, we’re looking into this.

As I thought this issue was abandoned I did not update it with the latest development. We have continued to work on this and today I managed to find a workaround that is working as expected. I will follow up in 20-40 minutes with a longer wrap up of our findings.

FIRST

Our assumption is that either @cascade (non-parameterized) has no effect within the @auth directive, or it does not work as expected. The behavior the @cascade directive showed during a normal query is different from the behavior observed when applied to an @auth query.

SECOND

It seems that the problem could be related to the combination of union types + @cascade, or is entirely related to the @cascade directive, it might be that fragments have little to do with the behavior we observed.

THIRD

This weird behavior might also be the result of a union (GroupMember) within another union (BindingMember).

THE WORKAROUND

The following workaround is what we are using to go around this bug:

"""
Binding represents an authorization binding between a role and one or more members.
"""
type Binding implements Metadata @generate(
  query: {
    get: false,
    query: false,
    aggregate: false
  },
  mutation: {
    add: true,
    update: true,
    delete: true
  },
  subscription: false
) {
  role: Role!
  members: [BindingMember!]
  groups: [Group!] # ! https://discuss.dgraph.io/t/unexpected-behavior-with-auth-rules-when-using-fragments-and-cascade/12544
}

When we separated the groups from the members and removed the Group from the BindingMember union it started working as expected:

@auth(query: {
  or: [
    { rule: """
      query ($subject: String!) {
        queryGroup() @cascade(fields: ["policy"]) {
          id
          policy @cascade(fields: ["bindings"]) {
            bindings @cascade(fields: ["role", "members"]) {
              role @cascade(fields: ["permissions"]) {
                permissions(filter: {id: {eq: "resourcemanager.groups.get"}}) {
                  id
                }
              }
              members(filter: {userFilter: {id: {eq: $subject}}, serviceAccountFilter: {id: {eq: $subject}}}) {
                ...on User {
                  id
                }
                ...on ServiceAccount {
                  id
                }
                ...on AllUsers {
                  id
                }
              }
            }
          }
        }
      }"""
    },
    { rule: """
      query ($subject: String!) {
        queryGroup() @cascade(fields: ["policy"]) {
          id
          policy @cascade(fields: ["bindings"]) {
            bindings @cascade(fields: ["role", "groups"]) {
              role @cascade(fields: ["permissions"]) {
                permissions(filter: {id: {eq: "resourcemanager.groups.get"}}) {
                  id
                }
              }
              groups @cascade(fields: ["identities"]) {
                identities(filter: {userFilter: {id: {eq: $subject}}, serviceAccountFilter: {id: {eq: $subject}}}) {
                  ...on Metadata {
                    id
                  }
                }
              }
            }
          }
        }
      }"""
    }
  ]
})

CONCLUSION

Currently, dgraph does not allow the use of parameterized @cascade on unions, and it does not allow to apply @cascade on fragments, furthermore, it seems that @cascade does not behave as expected when executed inside the @auth directive.

APPENDIX

If the full schema is needed together with sample data, we can upload it and share it via private message so that you can internally test with it.

Hi @christian-roggia,

thanks for the very detailed bug report.

I think the underlying issue here is that fragments are not handled properly in auth queries.

Will fix it soon, Thanks!

1 Like

Amazing, thank you! I will mark this issue as solved as soon as the fix is available on master.

Hey @christian-roggia, can you share your full schema with auth rules and a sample data set in a private message to me?

There is more than one bug here, and I want to confirm that it works with your use-case.

Thanks

Hi @christian-roggia, the fix for this issue has been merged in master with this PR: https://github.com/dgraph-io/dgraph/pull/7467

I have verified that it works with the steps you had shared along with the schema and sample data.
Thanks for the nice set of steps in that README :slight_smile:

There are a few things to note:

  • Previously, auth queries didn’t used to expand fragments within them. Now they do. This was one part of the bug.
  • Also previously, auth queries always used to automatically apply @cascade irrespective of whether there was one already in auth rules or not. Now, they check that if there is @cascade already present in the auth rule, then they don’t apply it. This was the other part of the bug.
  • @cascade doesn’t work in an intuitive manner on abstract (Interface/Union) fields, where there are multiple fragments with different type conditions and any one of those fragments has a field that exclusively belongs to that type. The thing to note here is that @cascade works, but not in an intuitive manner. So, in such cases it is always a good idea to have only one kind of fragment on that abstract field, if you also want @cascade to work in an intuitive manner.

Thanks

2 Likes

@abhimanyusinghgaur thank you for the great news!
I am looking forward to the release of the new patch!

This would be part of the upcoming 21.03 release.
Let us know if you would like it to be part of any patch release for 20.11 too.

Thanks

We are planning an alpha release of our services which will require the RBAC system described here around mid/end of April 2021, we can work around the problem for the moment and wait until the 21.03 release. Thank you for asking!

1 Like