DEV Community

Gordon Johnston for Lineup Ninja

Posted on

Modelling teams and user security with Hasura

When first designing the user security for Lineup Ninja I was keen for users to be able to be members of multiple teams (or organisations) from one login. Some of our clients are agencies and work with multiple different clients and need to be able to change between them easily and without remembering loads of different logins.

We recently migrated to Hasura from Firebase RTDB and this article details how I modelled the security to support this 'multiple team' configuration in Hasura.

The relationships look like this:

User-<Membership>-Team

Which is to say each user can have zero or more membership records. Each membership record belongs to a user and a team. Each team has at least one member.

The membership record details the permissions that member has on the team, it has boolean properties like

  • read_team
  • write_team
  • read_event
  • write_event

and so on.

At Lineup Ninja we write awesome software to help event planners manage their events. A core component of an event is a 'Session', which could be a presentation a band on stage, or perhaps a breakout discussion. For the user to be able to write to a Session they must have the write_event permission for that Session.

Sessions belong to an Event, which belongs to a Team. We can traverse this relationship to authenticate the User.

Here's the ERD through to the session:

User-<Membership>-Team-<Event-<Session

When the User makes a request for a Session object it will look like this

query  {
  session(where: {id: {_eq:"1234"}}) {
    id
    name
    description
  }
}

Additionally the request will be sent with a JWT that contains the User's ID as one of their claims:

{
...
  "https://hasura.io/jwt/claims": {
    "x-hasura-allowed-roles": [
      "user"
    ],
    "x-hasura-default-role": "user",
    "x-hasura-user": "bdb04fa3-4de3-4434-8d7f-75b10fe2669a",

  },
...
}

In Hasura security is applied per table. You start with creating a role, in this case user, then you apply insert, select, update and delete permissions for that role.

The permission for each type of operation consist of 'checks' and the fields you want to expose to that role. If you wish you can expose only a subset of fields to some users, making it easy to store both admin and user facing data in the same table.

The 'check' as a tree of relationships and logic tests. It can traverse the relationships in the schema and ultimately perform a check against the user's ID. You can build up the configuration in the UI, or you can import via migration files.

For the Session example the check looks like this:

{
    "event": {
        "team": {
            "memberships": {
                "_and": [
                    {
                        "event_write": {
                            "_eq": true
                        }
                    },
                    {
                        "user": {
                            "id": {
                                "_eq": "x-hasura-user"
                            }
                        }
                    }
                ]
            }
        }
    }
}

This is the fully denormalised way to perform this check. If the table you are checking is 'further away' from the team then you might want to consider a relationship directly from the table to the Team, like this:

User-<Membership>-Team-<Event-<Session-Team

This skips traversing the Event table when performing the security check, which should help performance a smidge, more so if you have to traverse many relationships to perform the check.

You'll notice in the rule above we are checking the User's ID by traversing the User relationship then checking the ID. This is unnecessary as the User's ID is a property on the Membership table itself, indeed it is this FK that is used to create the relationship. So we can save a bit of compute by checking the user_id value on the Membership table.

Putting both these changes in place we can update the security rule like so:

{
    "team": {
        "memberships": {
            "_and": [
                {
                    "event_write": {
                        "_eq": true
                    }
                },
                {
                    "user_id": {
                        "_eq": "x-hasura-user"
                    }
                }
            ]
        }
    }
}

And that's pretty much it!

If you wanted to take things one step further you could extend things to create a 'Role Based Access Control' (RBAC) style pattern. This is a particularly useful structure if you have large teams to manage.

You could implement this by adding a Roles table, defining the permissions of the role, then a Member Roles table, linking each Membership record to the users Roles in that Team (assuming you wanted a User to have multiple Roles). That would look like this:

User-<Membership>-Team-<Event-<Session-Team Membership-<MemberRoles-Roles

Then add a relationship from Membership->Roles using the 'Member Roles' joining the table and update the Hasura check like so:

{
    "team": {
        "memberships": {
            "_and": [
                {
                    "roles": {
                        "event_write": {
                            "_eq": true
                        }
                    }
                },
                {
                    "user_id": {
                        "_eq": "x-hasura-user"
                    }
                }
            ]
        }
    }
}

One thing I would like Hasura to add is the ability to directly check values in the users token. For example I would like to add the Team id and permission directly to the token like this

    "x-hasura-team": "1234",
    "x-hasura-event-write": "true",

Then simplify the rule to something like this

{
    "_and": [
        {
            "team_id": {
                "_eq": "x-hasura-team"
            }
        },
        {
            "x-hasura-event-write": {
                "_eq": true
            }
        }
    ]
}

This currently isn't possible because you can't express the check that x-hasura-event-write is true, or at least not without adding a column with the value true for every row in every table, which I'd obviously like to avoid!

I'd like to see this because, obviously it's more performant as it only needs to check the data in the row it is accessing, and it would have made the migration from Firebase a little easier as this is how I had initially implemented the checks :-)

I hope this brief run through was interesting, let me know if you have any comments!

P.S. I'll be at GraphQL Asia on the 12/13th April. If you're going, get in contact and let's say hi!

Top comments (3)

Collapse
 
richteri profile image
István Richter

Hi Gordon,

I was looking at Hasura's permission system from the other way around before I read your great post: leveraging on "default role", "allowed roles" and "current" (per request) role. I'd have managed composite roles in eg. Keycloak and use a custom auth hook to build the custom token for Hasura but then I would need to alter the "current" role for each request. This would force me to use http(s) instead of ws(s) until this one is closed:

github.com/hasura/graphql-engine/i...

This would also look odd on Hasura Console which lists ALL roles for ALL tables and would make managing permissions a lot messier.

Your solution looks more maintainable. Your post being almost 1 year old, do you have any advice for newcomers going down this road?

Thanks for the great article.

Collapse
 
dukedave profile image
Dave Tapley

Thanks for sharing! May I ask what you're using for auth?

Collapse
 
elgordino profile image
Gordon Johnston

Hi Dave. In general we use Cognito User Pools to authenticate the user then we have a serverless function that uses that token to vend a custom token for Hasura. We don't integrate directly with Cognito because we have some users that authenticate outside of Congito.