mercurius-js / auth

Mercurius Auth Plugin
MIT License
83 stars 15 forks source link

Auth directive on Union type ignores the applyPolicy function #98

Open Eomm opened 1 year ago

Eomm commented 1 year ago

Given this schema:

directive @auth(
  role: String
) on OBJECT

type Query {
  searchData: Grid
}

union Grid = AdminGrid | ModeratorGrid | UserGrid

type AdminGrid @auth(role: "admin") {
  totalRevenue: Float
}

type ModeratorGrid @auth(role: "moderator") {
  banHammer: Boolean
}

type UserGrid @auth(role: "user") {
  basicColumn: String
}

and this plugin setup:

  app.register(require('mercurius-auth'), {
    authContext (context) {
      // you can validate the headers here
      return {
        identity: context.reply.request.headers['x-user-type']
      }
    },
    async applyPolicy (policy, parent, args, context, info) {
      const role = policy.arguments[0].value.value
      app.log.info('Applying policy %s on user %s', role, context.auth.identity)

      // we compare the schema role directive with the user role
      return context.auth.identity === role
    },
    authDirective: 'auth'
  })

The applyPolicy function is never executed.

If I change the schema to:

type Query {
-  searchData: Grid
+  searchData: AdminGrid
}

The function is executed instead.

Here a complete code example + test (skipped) https://github.com/Eomm/fastify-discord-bot-demo/pull/30/commits/7ec5f23bdba2458f0f7027c95b32175c01d3a7fc

jonnydgreen commented 1 year ago

Good find, sounds like a bug in the way the type-level auth works - would you be up for contributing a PR?

Eomm commented 1 year ago

No, sorry, I don't have enough time to work on it

jonnydgreen commented 1 year ago

Completely understand, no problem at all!

ninnjak commented 1 year ago

Hi @Eomm. From your example I think the issue is your Grid resolveType function. You're checking for obj.adminColumn and obj.moderatorColumn both of which do not exist, unless i'm missing something. Here is a working version based on the example from apollographql

Grid: {
  resolveType(obj, context, info) {
    if (obj.totalRevenue) return "AdminGrid";
    if (obj.banHammer) return "ModeratorGrid";
    return "UserGrid";
  },
}

Another option is to use the user role from the context like so:

Grid: {
  resolveType(obj, context, info) {
    const role = context.auth.identity;
    if (role === "admin") return "AdminGrid";
    if (role === "moderator") return "ModeratorGrid";
    return "UserGrid";
  },
},

Here are some unit tests which i've successfully tested locally:

"use strict";

const { test } = require("tap");
const Fastify = require("fastify");
const mercurius = require("mercurius");
const mercuriusAuth = require("..");

const schema = `
  directive @auth(role: String) on OBJECT

  union Grid = AdminGrid | ModeratorGrid | UserGrid

  type AdminGrid @auth(role: "admin") {
    totalRevenue: Float
  }

  type ModeratorGrid @auth(role: "moderator") {
    banHammer: Boolean
  }

  type UserGrid @auth(role: "user") {
    basicColumn: String
  }

  type Query {
    searchData: Grid
  }
`;

const resolvers = {
  Query: {
    searchData: async function (root, args, context, info) {
      return {
        totalRevenue: 42,
        banHammer: true,
        basicColumn: "basic",
      };
    },
  },
  Grid: {
    resolveType(obj, contextValue, info) {
      const role = context.auth.identity;
      if (role === "admin") return "AdminGrid";
      if (role === "moderator") return "ModeratorGrid";
      return "UserGrid";
    },
  },
};

test("A user with the `admin` role should only be able to retrieve the `totalRevenue` field", (t) => {
  const app = Fastify();
  t.teardown(app.close.bind(app));

  app.register(mercurius, {
    schema,
    resolvers,
  });

  app.register(mercuriusAuth, {
    authContext(context) {
      return {
        identity: context.reply.request.headers["x-user"],
      };
    },
    async applyPolicy(policy, parent, args, context, info) {
      const role = policy.arguments[0].value.value;
      return context.auth.identity === role;
    },
    authDirective: "auth",
  });

  const request = (query) => {
    return app.inject({
      method: "POST",
      headers: { "content-type": "application/json", "X-User": "admin" },
      url: "/graphql",
      body: JSON.stringify({ query }),
    });
  };

  t.plan(3);

  t.test("should be able to retrieve the `totalRevenue` field", async (t) => {
    const query = `query {
      searchData {
        ... on AdminGrid {
          totalRevenue
        }
      }
    }`;

    const response = await request(query);

    t.same(JSON.parse(response.body), {
      data: {
        searchData: {
          totalRevenue: 42,
        },
      },
    });
  });

  t.test("should not be able to retrieve the `banHammer` field", async (t) => {
    const query = `query {
      searchData {
        ... on ModeratorGrid {
          banHammer
        }
      }
    }`;

    const response = await request(query);

    t.same(JSON.parse(response.body), {
      data: {
        searchData: {},
      },
    });
  });

  t.test(
    "should not be able to retrieve the `basicColumn` field",
    async (t) => {
      const query = `query {
      searchData {
        ... on UserGrid {
          basicColumn
        }
      }
    }`;

      const response = await request(query);

      t.same(JSON.parse(response.body), {
        data: {
          searchData: {},
        },
      });
    }
  );

  t.end();
});