Keto RBAC - listing roles of a user

More thoughts on RBAC with Keto 0.6

There was an interesting question coming up related to the previous article on RBAC with Ory Keto[1].

The question was:

how do I list the roles of a user

At the end of the previous article, the solution allowed finding out if the user is allowed to access the resources. But, indeed, what I have not discussed was how to get the roles the user is assigned to.

The final tuples for Fry and Bender, after Fry was demoted, looked like this:

dev-director#member@Fry
dev-director#member@Bender

These relations tell that dev-director contains Fry and Bender. We can test this out with the following query:

1
curl --silent 'http://localhost:4466/expand?namespace=default-namespace&object=dev-director&relation=member&max-depth=2' | jq '.'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "type": "union",
  "subject": "default-namespace:dev-director#member",
  "children": [
    {
      "type": "leaf",
      "subject": "Bender"
    },
    {
      "type": "leaf",
      "subject": "Fry"
    }
  ]
}

What if we want to know if the roles Fry belongs to? We could naively ask using the subject:

1
curl --silent 'http://localhost:4466/expand?namespace=default-namespace&subject=Fry&relation=member&max-depth=2' | jq '.'

What we find out is, the result is completely wrong:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
  "type": "union",
  "subject": "default-namespace:#member",
  "children": [
    {
      "type": "leaf",
      "subject": "Bender"
    },
    {
      "type": "leaf",
      "subject": "Fry"
    },
    {
      "type": "leaf",
      "subject": "Hermes"
    },
    {
      "type": "leaf",
      "subject": "default-namespace:it-director#member"
    },
    {
      "type": "leaf",
      "subject": "default-namespace:it-director#member"
    },
    {
      "type": "leaf",
      "subject": "default-namespace:dev-director#member"
    },
    {
      "type": "leaf",
      "subject": "default-namespace:it-director#member"
    }
  ]
}

In a real-world application, this would not leak PII, because you’d use UUIDs instead of names. However, this path is a no go.

§reverse user to role binding

The new additional tuple looks like this:

Fry#is-member@dev-director
1
2
3
4
5
6
curl -XPUT --data '{
    "namespace": "default-namespace",
    "object": "Fry",
    "relation": "is-member",
    "subject": "dev-director"
}' http://localhost:4467/relation-tuples

Now, we can ask:

1
curl --silent 'http://localhost:4466/expand?namespace=default-namespace&object=Fry&relation=is-member&max-depth=2' | jq '.'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "type": "union",
  "subject": "default-namespace:Fry#is-member",
  "children": [
    {
      "type": "leaf",
      "subject": "dev-director"
    }
  ]
}

For each user / role mapping, there are two tuples:

role#member@user
user#is-member@role

§but there’s more

We could go one step further. In the previous article, the following was established:

at the time of permission evaluation, both the object for which the permission is being validated, and the subject, are known

When Fry has got his opportunity, we ended up with the following configuration (plus the new reverse binding):

production-viewer#member@default-namespace:dev-director#member
production-creator#member@default-namespace:fast-dev-director#member
production-deleter#member@default-namespace:fast-dev-director#member
dev-director#member@Fry
fast-dev-director#member@Fry
Fry#is-member@dev-director
Fry#is-member@fast-dev-director

What if we had this instead:

production-viewer#member@dev-director          # <--- this is added
production-creator#member@fast-dev-director    # <--- this is added
production-deleter#member@fast-dev-director    # <--- this is added
production-viewer#member@default-namespace:dev-director#member
production-viewer#member@default-namespace:fast-dev-director#member
dev-director#member@Fry
fast-dev-director#member@Fry
Fry#is-member@dev-director
Fry#is-member@fast-dev-director

where the new tuples are:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
curl -XPUT --data '{
    "namespace": "default-namespace",
    "object": "Fry",
    "relation": "is-member",
    "subject": "fast-dev-director"
}' http://localhost:4467/relation-tuples
curl -XPUT --data '{
    "namespace": "default-namespace",
    "object": "production-creator",
    "relation": "member",
    "subject": "fast-dev-director"
}' http://localhost:4467/relation-tuples
curl -XPUT --data '{
    "namespace": "default-namespace",
    "object": "production-deleter",
    "relation": "member",
    "subject": "fast-dev-director"
}' http://localhost:4467/relation-tuples

and all tuples together mean:

Anybody who is dev-director or fast-dev-director, a member of dev-director or a member of fast-dev-director, can act as production-viewer. Fry is a member of both using the bi-directional mapping and also a member of production-creator and production-creator via fast-dev-director.

We have all the benefits of the original solution with an extra superpower.

When we ask if create can be performed by Fry, what we really mean is is create allowed for any role Fry holds.

Thus, after asking for Fry’s membership, we would iterate over every role and ask Keto directly for the role permission:

1
2
3
4
5
6
7
8
9
for role in $(curl --silent 'http://localhost:4466/expand?namespace=default-namespace&object=Fry&relation=is-member&max-depth=2' | jq '.children[].subject' -r)
do
    echo "$role:" $(curl --silent -XPOST --data '{
        "namespace": "default-namespace",
        "object": "production",
        "relation": "create",
        "subject": "'$role'"
    }' http://localhost:4466/check)
done
dev-director: {"allowed":false}
fast-dev-director: {"allowed":true}

This is interesting because it allows implementing Keycloak style Authorization Services Decision Strategy[2].

When associating policies with a permission, you can also define a decision strategy to specify how to evaluate the outcome of the associated policies to determine access.

  • Unanimous: The default strategy if none is provided. In this case, all policies must evaluate to a positive decision for the final decision to be also positive.
  • Affirmative: In this case, at least one policy must evaluate to a positive decision for the final decision to be also positive.
  • Consensus: In this case, the number of positive decisions must be greater than the number of negative decisions. If the number of positive and negative decisions is equal, the final decision will be negative.

The result above could be the Affirmative strategy.

§closing words

The former solution, with less tuples, is definitely easier to reason about. Ha, but let’s be honest. Once UUIDs are used instead of literals, no sane mind can follow!

The latter solution opens the door for extra features. If I was rooting for a high level RBAC solution, I’d go for the more complex one. The complexity is an implementation detail.

Getting the consistency right might be tricky with Keto REST / gRPC API only. This would probably require some sort of stateful state machine, possibly on top of etcd but it’s definitely doable. The extra benefit of the decision strategy is a nice side effect.

Food for thought.