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:
-
The user
Matteo Roggia
is directly assigned to the policy with a role that has the permissionresourcemanager.groups.get
and is therefore correctly authorized to perform the query:
-
The user
Christian Roggia
is a member of the groupExecutives
and that is assigned to the policy with a role that has the permissionresourcemanager.groups.get
and is therefore correctly authorized to perform the query:
-
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
}
}
}
}
}
}
- 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.
- 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.