Today I'm "celebrating" the anniversary of my life with GraphQL in production Rails apps–1 year full of trials and errors, misses and hits, wtf-s and successes.
As an open-source addict, I especially loved working with GraphQL: it's a rather young technology, and hence there is a lot of opportunities for contributions (at least, in Ruby world) and experiments.
And now I'm presenting the result of one such experiment–the action_policy-graphql
gem, which glues together GraphQL Ruby and Action Policy authorization library.
Wait, Action Policy, was ist das*?
* "what is it" in German (I'm listening to Rammstein while writing this post 🎸)
About the same time as I've started working with GraphQL, I've presented a new Ruby authorization library, Action Policy, to the world (at RailsConf 2018).
Action Policy has been extracted from multiple projects I've been working on in the last few years. It's ideologically similar to Pundit (and initially was built on top of it) but provides a bunch of additional features out-of-the-box (and has a very different architecture inside).
One of these features is an ability to provide an additional context on why the authorization check failed–failure reasons tracking.
This feature has been a dark horse for a long time, we barely used it (mostly for debugging purposes) until we started working with GraphQL–that's when the ugly duckling turned into a beautiful swan.
To learn more about Action Policy and its features check out the slides from the most recent talk I gave at Seattle.rb early this year: https://speakerdeck.com/palkan/seattle-dot-rb-2019-a-denial.
Authorization & GraphQL
Authorization is an act of giving someone official permission to do something (not to be confused with authentication).
Every time we "ask" in our code, "Is a user allowed to do that?" we perform the act of authorization.
When dealing with GraphQL, this question could be divided into more specific ones:
- Is a user allowed to interact with the object (of a specific type or in a particular node of the graph)?
- Is a user allowed to perform this mutation or subscribe to this subscription?
Another related aspect is data scoping: filter collections according to the user's permissions instead of checking every single item.
The graphql
gem provides basic authorization support (via the #authorized?
hook) and scoping support (via the #scope_items
hook): it can be good enough in the beginning but doesn't scale well due to the amount of boilerplate.
That's why I've started the development of action_policy-graphql
gem–to provide a better API for fields authorization and scoping (similar to the one you can see in GraphQL Pro for pundit
and cancancan
). Now we describe our APIs like this:
# field authorization example (`authorize: true`)
field :home, Home, null: false, authorize: true do
argument :id, ID, required: true
end
# data scoping using policies (`authorized_scope: true`)
field :events, EventType.connection_type, null: false, authorized_scope: true
This implementation uses field extensions internally, which turned out to be more flexible than building on top of the #authorized?
and #scope_items
hooks.
The problem seemed to be solved: it became effortless to use the authorization and scoping rules defined in policy classes in the API to check permissions and raise exceptions ("Access Denied!"). Though the latter one–raising exceptions–wasn't the best way to inform users about the lack of permissions.
What about preventing these exceptions and telling frontend clients, which actions are permitted and not?
Let the client know about its rights
The problem of pushing current user's permissions information down to the client (frontend/mobile application) is not new; it existed years before GraphQL was born.
The problem could be translated into the following question: how to make the client know which actions are allowed to a user (and show/hide specific buttons/links/controls)?
How to solve it?
You may try, for example, to pass only the authorization model (role, permissions set) and implement (i.e., duplicate) the authorization rules client-side. Such duplication is the easiest way to shoot yourself in the foot.
You should rely on a single source of authorization truth.
And this single source of truth is, usually, a server (because that's where you perform the authorization checks themselves).
Thus, we need to transform our policy rules to client-compatible format–for example, JSON object.
Dumping all the possible authorization rules for a user into a JSON object doesn't seem to be a good idea, especially if we have dozens of policy classes. Hopefully, one of the GraphQL advantages is the ability to request only the data you need (and avoid overfetching).
So, we started with the simple idea of adding canDoSmth
boolean fields to our GraphQL types:
class EventType < Types::BaseType
# include Action Policy helpers (`authorize!`, `allowed_to?`)
include ActionPolicy::Behaviour
field :can_destroy, Boolean, null: false
def can_destroy
# checks the EventPolicy#destroy? rule for the object
allowed_to?(:destroy?, object, context: {user: context[:current_user]})
end
end
Now a client can ask the server whether it's allowed to delete the event:
query {
event(id: $id) {
canDestroy # true | false
}
}
We also added a macro to define authorization fields to reduce the boilerplate:
class EventType < Types::BaseType
include ActionPolicy::Behaviour
expose_authorization_rules :destroy?, :edit?, :rsvp?
end
"Are we there yet?". Nope.
Clients need more context
It turned out that in most cases knowing whether the action is allowed or not (true
or false
) is not enough: we also need to show a notification to the user (or answer the question "Why?"). And in some situations, this message should be different depending on the reason why we disallowed this action.
Consider a simplified example of a rule checking whether a user is allowed to RSVP to the event:
def rsvp?
allowed_to?(:show?) && # => User should have an access to this event
rsvp_open? && # => RSVP should be open
seats_available? # => There must be seats left
end
We're most interested in the last two checks (because if a user has no access to the event, it shouldn't ask for permission to RSVP).
When RSVP is closed, we want to show the "RSVP has been closed for this event" message; when no more seats available–"This event is sold out."
How can we get this information from our policy? Using the failure reasons functionality!
We need to change our rule a bit for that:
def rsvp?
allowed_to?(:show?) &&
# wrapping method call into a `check?` method
# tracks the failure of this check (i.e. when it returns false)
check?(:rsvp_open?) &&
check?(:seats_available?)
end
Now we can retrieve the additional context from the authorization check result:
policy = EventPolicy.new(record: event, user: user)
# first, apply the rule
policy.apply(:rsvp?)
# now we can access the result object and its reasons
# for example, details hash contains the checks names grouped
# by a policy
policy.result.reasons.details #=> {event: [:rsvp_open?]} or {event: [:seats_available?]}
Wait? Details hash? Identifiers? We need human-readable messages!
OK. Here come the Action Policy and i18n integration.
Let's add our message to the locales files:
en:
action_policy:
policy:
event:
rsvp?: "You cannot RSVP to this event"
rsvp_opened?: "RSVP has been closed for this event"
seats_available?: "This event is sold out"
To access the localized messages, you use the #full_messages
method on the result object:
policy.result.reasons.full_messages #=> ["RSVP has been closed for this event"]
# to access the top-level (rule) message
policy.result.message #=> "You cannot RSVP to this event"
Now go back to GraphQL and enhance our expose_authorization_rules
macro to define a field with an AuthorizationResult
type:
Which contains a reasons
field of a FailureReasons
type:
From the client perspective, it looks like this:
query {
event(id: $id) {
canDestroy {
value # true | false
message # top-level message
reasons {
details # JSON-encoded failure details
fullMessages # human-readable messages
}
}
}
}
What are the pros of this approach?
- You have a standardized API for fetching the authorization information
- You have a single abstraction layer for dealing with authorization (policy classes)
- Adding authorization rules to the schema is simple (and, thanks to Action Policy testability, testing is simple, too).
And all that functionality you get for free when using action_policy-graphql
gem!
Read more dev articles on https://evilmartians.com/chronicles!
Top comments (1)
This is amazing! I'll be kicking off a new project in the following weeks/months for a client and I'll take this for a ride. :)
Thanks for the writeup and all the work you did on the gem itself (and action policy)!