Absinthe - Restricting introspection of certain fields

Good day to you all, beloved Elixir community! :heart:

I was wondering if, for security reasons, introspection of certain fields could be restricted.

Being able to put restrictions based on certain conditions would be very cool but I don’t even need that level of control. A hard block would do the trick just fine.

@benwilson512 or anyone else, any idea / suggestion / observation?

If not, is there a way to totally disable introspection?

Any input is appreciated.

Cheers! :beers:

I’m not sure that would be useful, as the introspection is often required by clients to generate query code.

Sure, a middleware should do the job.

Did you end up figuring out how to do this?

I would like to disable introspection of fields depending on whether the current user is an admin or not (normal users shouldn’t need to see that there are fields that are meant only for admins)

One of the engineers on my team and I were discussing this a few days ago.

Two options we thought about:

  1. Have each field call/3 a resolver that checks against the schema for “normal user fields”, “admin only fields”. If atom in list, is the conditional of this resolver.

  2. Have two separate graphql objects, one for a admin viewing ObjectFoobar, and one for the end user viewing ObjectFoobar.


Option 1, you put the load on your server more (haven’t measured performance impact), but your frontend/mobile can just call ObjectFoobar and not care if the user is admin or normal.

Option 2, your server is lighter, but your frontend/mobile now needs to worry about calling admin ObjectFoobar or end user ObjectFoobar.

Curious of other approaches.

The two choices here are basically the real options; to change the introspection would require diving into parts of Absinthe that would potentially break compatibility with GraphQL clients.

I have sort of done both directions, although for different reasons. As an example, I have an Invoice object (we use Relay):

node object(:invoice) do
  field :invoice_date, :date
  field :sales, :integer
  field :margin, :integer, resolve: &InvoiceResolver.margin/3
  field :cost, :integer, resolve: &InvoiceResolver.cost/3
  field :profit, :integer, resolve: &InvoiceResolver.profit/3
end

In my InvoiceResolver, I have some /3 resolvers:

defmodule InvoiceResolver do
  # various includes and aliases, etc., including `Canada.can?/2`
  def margin(%{margin: value} = invoice, _, %{context: %{current_user: current_user}}) do
    if can?(current_user, show, {invoice, :margin}) do
      {:ok, value}
    else
      {:ok, nil}
    end
  end

  # &c…
end

This is actually important from my perspective because some employees can see the cost, profit, and margin…but others cannot (and customers definitely cannot).

In a different scenario, I’ve added a second Absinthe schema (plus endpoint and Absinthe.Plug instance). I’ve come up with some ways of sharing some of the definitions between the two schemas and some of the resolvers, but the is is less satisfying to me overall. The reason that I did it this way was that the purpose of the two APIs was different (one is primarily for consumption and customer-centric interaction; the other is primarily for creation and employee-centric interaction). We are thinking of adding a third schema for an administrative API, but I’d need to come up with some better abstractions about managing multiple related schema before I chase this further.

1 Like

Interesting, @halostatue, @sergio seems like you have both come to the same conclusions. Either do a check in each resolver to see if the user can access this field but then every resolver needs another check, or have another schema.

I had experimented with adding an Absinthe middleware instead of placing logic in the resolvers. I think this might be a little nicer than the resolver approach since it centralises this checking logic. The following is what I have so far, its very simple atm. It just checks whether you are an admin or not depending on how I call it, but it could easily be extended to become more granular:

defmodule ScribeWeb.Schema.Middleware.Authorize do
  @moduledoc """
  Authorizes the user to access a given field in the schema based off their role
  """
  @behaviour Absinthe.Middleware

  def call(resolution, role \\ :user) do
    case resolution.context do
      %{authenticated: true, current_user: user} ->
        if role == :moderator do
          case Scribe.Admin.get_moderator(user.id) do
            {:ok, _} ->
              resolution

            _ ->
              resolution
              |> Absinthe.Resolution.put_result({:error, message: "unauthorized"})
          end
        else
          resolution
        end

      _ ->
        resolution
        |> Absinthe.Resolution.put_result({:error, message: "unauthorized"})
    end
  end
end

and you can invoke it like:

    @desc "Get all users"
    field(:all_users, list_of(:user)) do
      middleware(Middleware.Authorize, :moderator)
      resolve(&Accounts.list_users/3)
    end

Only bummer with this is that anyone can still introspect my schema and see that the all_users query exists, they just can’t call it. I would love a way to be able to stop normal users from even being able to see it exists (in the case I offered an API).

@halostatue does that mean that restricting visibility of introspection is actually not part of the GraphQL spec?

I’m pretty sure that blocking partial introspection would be contra-spec, and would not help you in the cases where you might be generating code to hit your administrative API. This would mean that, if you built an iOS app using apollo-ios, your build system would have to have a moderator/admin account in order to get your GraphQL schema.

http://spec.graphql.org/June2018/#sec-Introspection

If you really want to make that work, use two different schema. You could define all of your types in common and import_types into each of your regular user and admin/moderator schema (and maybe even make it so that the admin/moderator schema is a strict superset of the regular user schema). You’ll need different endpoints, and your authentication system would have to somehow return which endpoint would be required for which type of user, but…

The main reason I haven’t done mine as middleware is that when I need to do authorization checks, I typically have to do so against the object being checked.

1 Like