Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add some auth tests #26

Open
tmeasday opened this issue Mar 21, 2017 · 38 comments
Open

Add some auth tests #26

tmeasday opened this issue Mar 21, 2017 · 38 comments

Comments

@tmeasday
Copy link
Owner

No description provided.

@tobkle
Copy link
Contributor

tobkle commented Apr 29, 2017

Collecting ideas:

  • Alternative 1: Providing a CGS command add-auth-for-type or something like, which is asking for authorization options to be selected by the user

  • Alternative 2: Providing a default authorization template for each type which always fires "authorized: true" and can be altered by the user in the code himself afterwards

Was wondering, where is the best place to add the authorization to keep it simple: in the model or in the resolver? Jonas Helfer wrote on GraphQL-Authorizations this post:
https://dev-blog.apollodata.com/auth-in-graphql-part-2-c6441bcc4302
... that led me thinking of doing something like this:

in ./models/User.js

export default class User {
  constructor(context) {
    this.context = context;
    this.collection = context.db.collection('user');
    this.pubsub = context.pubsub;
    this.loader = new DataLoader(ids => findByIds(this.collection, ids));
    this.allows = {
      create: (user, doc) => {
        // the user must be logged in, and the document must be owned by the user
        return user._id && user.role && user.role === 'admin';
      },
      read: (user, doc) => {
        // the user must be logged in, and the document must be owned by the user
        return user._id && doc._id.toString() === user._id.toString();
      },
      update: (user, id, doc, fields, modifier) => {
        // the user must be logged in, and the document must be owned by the user
        return user._id && id.toString() === user._id.toString();
      },
      delete: (user, id) => {
        // the user must be logged in, and the document must be owned by the user
        return user._id && id.toString() === user._id.toString();
      }
    };
  }

so referencing this in the resolver instead in the model!?!?
in ./resolvers/User.js

const resolvers = {
  User: {

    id(user) {
      return user._id;
    },

    employer(user, { lastCreatedAt, limit }, { User }) {
      return User.employer(user, { lastCreatedAt, limit });
    },

    addresses(user, { lastCreatedAt, limit }, { User }) {
      return User.addresses(user, { lastCreatedAt, limit });
    },

  },

  Query: {

    users(root, { lastCreatedAt, limit }, { User, user }) {
      //return User.all({ lastCreatedAt, limit });
      return User.all({ lastCreatedAt, limit, user })
        // do authorization check, according to https://dev-blog.apollodata.com/auth-in-graphql-part-2-c6441bcc4302
        .then( lists => {
          return lists.filter((doc) => {
              return User.allows.read(user, doc);
          });
        });
    },

    user(root, { id }, { User, user }) {
      // return User.findOneById(id);
      return User.findOneById(id)
        // do authorization check, according to https://dev-blog.apollodata.com/auth-in-graphql-part-2-c6441bcc4302
        .then(doc => {
          return User.allows.read(user, doc) ? doc : null;
        });
    },

  },

  Mutation: {

    async createUser(root, { input }, { User, user }) {
      // do authorization check
      if (User.allows.create(user, input)){
        const id = await User.insert(input);
        return User.findOneById(id);
      } 
      return null;
    },

    async updateUser(root, { id, input }, { User, user }) {
      // do authorization check
      if (User.allows.update(user, id, input)){
        await User.updateById(id, input);
        return User.findOneById(id);
      } 
      return null;
    },

    removeUser(root, { id }, { User, user }) {
      // do authorization check
      if (User.allows.delete(user, id)){
        return User.removeById(id);      
      }
      return null;
    },

  },

  Subscription: {
    userCreated: user => user,
    userUpdated: user => user,
    userRemoved: id => id,
  },
};

export default resolvers;

What do you think?

@tmeasday
Copy link
Owner Author

tmeasday commented May 1, 2017

Hey,

A few thoughts.

  1. I think the resolver level is the best spot to put the auth checks.

  2. I'm not against the idea of adding auth check implementation on the model though; perhaps we would leave this up to the user to implement though (i.e. add an allows namespace, but they implement the functions themselves?)

  3. If we want to codegen some common ones (I'm thinking (a) access by admins (b) document owner)

Type Post @canEdit('admin', 'owner') @canDelete('admin') {
  owner: User @belongsTo # <- this would be required for the `owner` auth to work.
}

I can imagine a world where we would want to introduce a whole plugin model to allow different types of auth to be generated, but OTOH, I think people should probably be at least carefully reading the generated code, if not writing it themselves, so I'm inclined to say we just do the minimum to avoid people having to have to just write a lot of boilerplate.

@tobkle
Copy link
Contributor

tobkle commented May 8, 2017

Created a prototype for the authorization. Please have a look in the README.md here:
https://github.com/tobkle/create-graphql-server
It is not yet fully implemented and tested, but you may see the idea behind the prototype.
Please tell me, if this fits in. If so I'll create a PR.

@tmeasday
Copy link
Owner Author

@tobkle I am pretty happy with the API, not exactly sure about the implementation.

Can I make a suggestion? In your branch, edit the test/output-app by adding some @authorize directives to the input files and implement the code that we would end up generating. Then we can follow a TDD approach to get the actually generating working once we agree on the final API and output code.

Some minor thoughts on what you have so far:

  1. Do we want to split (or allow splitting) the read mode into readOne and readMany?
  2. Is there a way of disallowing a mode completely (say we don't want to create the Query.posts resolver). Maybe setting a mode to the empty array?
  3. I think the roleField should get set by a decorator on the user type, and the user model can then have a User.role(user) function. Likewise for the various xUserRole options.
  4. What does firstUserRole do? <- does this mean a special role for the very first user created? I'm not sure I would have that code in a production app, would you?
  5. What does adminUserRole do? (i.e. what is different about the admin role? Does this automatically grant full access to everything?)

As for the implementation, I would probably add the authorize function to the model myself if I was implementing this. So it would be something like Post.isAuthorized(post, 'create', user). (Does it need to be async?). Or are you looking to separate the auth layer from the model layer?

I would be inclined to steer clear of making things too "library" like. We can generate the code, so we can keep things pretty simple in the implementation.

class Post {
  isAuthorized(post, mode, user) {
    if (mode === 'create') {
      return this.context.User.role(user) === 'admin';
    }
  }

Let me know what you think

@tobkle
Copy link
Contributor

tobkle commented May 21, 2017

opened a new branch 'authorization' based on your last master to do what you've suggested:

It all starts by defining type files such as in test/input:

Using this to authorize CRUD operations for Tweet:

type Tweet
@authorize(
  create: ["owner"]
  readOne: ["world"]
  readMany: ["world"]
  update: ["owner", "admin"]
  delete: ["owner", "admin"]
  ownerField: "author"
)

Meaning:

  • only the literals 'owner' and 'world' have a pre-defined meaning:
    • owner: is the owner of the document
    • world: is just everyone
  • any other role name will check if a user has this role assigned in its field defined with 'roleField'
  • create: only if a user signed in and there is an ownerId provided, the user can create a record
  • readOne: on single reads, everyone is allowed to read this record
  • readMany: on multi reads, everyone is allowed to read the records
  • update: only the owner of the document, or a user with the role "admin" is allowed to update
  • delete: dito
  • ownerField: to identify the owner of a document, the field author (Tweet.authorId) is used

Using this to authorize CRUD operations for User:

type User
@authorize(
  create: ["admin"]
  readOne: ["owner", "admin"]
  readMany: ["admin"]
  update: ["owner", "admin"]
  delete: ["owner", "admin"]
  ownerField: "_id"
  roleField: "role"
)

Meaning:

  • create: only a signedIn user with the role 'admin' is allowed to create a user
  • readOne: only the owner of the user or an admin is allowed to read a user
  • readMany: only a user with the role 'admin' is allowed to read multiple users
  • update: only the owner of the document, or a user with the role "admin" is allowed to update
  • delete: dito
  • ownerField: to identify the owner, the field '_id' is used
  • roleField: the role of the user is stored in the field 'role'

The role 'admin' itself is just a name of a role, you can use any role name. It gains its meaning only by using this role name in the @authorize directives in the different type definitions.

Later this will generate the following target code:
Target Code: Models

export default class User {
  constructor(context) {
    this.ownerField = '_id'; // @authorize(ownerField: "_id")
    this.roleField = 'role'; // @authorize(roleField: "role")
    this.context = context;
    this.collection = context.db.collection('user');
    this.pubsub = context.pubsub;
    this.loader = new DataLoader(ids => findByIds(this.collection, ids));
  }

  // returns the owner of the current document @authorize(ownerField)
  owner(doc){
    return (doc && doc[this.ownerField]) ? doc[this.ownerField] : null;
  }

  // returns the role of the current user @authorize(roleField)
  role(user){
    return (user && user[this.roleField]) ? user[this.roleField] : null;
  }

  // returns true, if the current user is authorized for the current mode and document
  isAuthorized({doc, mode, user, debug = true}){
    let authResult = false;
    const owner = this.owner(doc);
    const role = this.context.User.role(user);

    switch (mode) {
      case 'create':
        // @authorize(create: ["admin"])
        authResult = (!!role && role === 'admin');
        break;
      case 'readOne':
        // @authorize(readOne: ["owner", "admin"])
        authResult = (!!role && (user._id === owner || role === 'admin'));
        break;
      case 'readMany':
        // @authorize(readMany: ["admin"])
        authResult = (!!role && role === 'admin');
        break;
      case 'update':
        // @authorize(update: ["owner", "admin"])
        authResult = (!!role && (user._id === owner || role === 'admin'));
        break;
      case 'delete':
        // @authorize(delete: ["owner", "admin"])
        authResult = (!!role && (user._id === owner || role === 'admin'));
        break;
    }

    (debug) ? console.log('User', mode, owner, role, "===>", authResult) : null;
    return authResult;
  }
...

Target Code: Resolvers

An empty array in an @authorize argument will result into e.g. not allowing delete mutations:

      case 'delete':
        // @authorize(delete: [])
        authResult = false;
        break;
  Query: {
    async users(root, { lastCreatedAt, limit }, { User, user }) {
      const doc = await User.all({ lastCreatedAt, limit });
      return User.authorized({doc, mode: 'readMany', user});
    },

    async user(root, { id }, { User, user }) {
      const doc = await User.findOneById(id); 
      return User.authorized({doc, mode: 'readOne', user});
    },
  },
  Mutation: {
    async createUser(root, { input }, { User, user }) {
      const doc = input;
      const authorized = User.isAuthorized({doc, mode: 'create', user});
      if (!authorized) throw new Error('User: mode: create not authorized');
      const id = await User.insert(input);
      return User.findOneById(id);
    },

    async updateUser(root, { id, input }, { User, user }) {
      const doc = await User.findOneById(id);
      const authorized = User.isAuthorized({doc, mode: 'update', user});
      if (!authorized) throw new Error('User: mode: update not authorized');
      await User.updateById(id, input);
      return User.findOneById(id);
    },

    async removeUser(root, { id }, { User, user }) {
      const doc = await User.findOneById(id);
      const authorized = User.isAuthorized({doc, mode: 'delete', user});
      if (!authorized) throw new Error('User: mode: delete not authorized');
      return User.removeById(id);
    },
  },

End-to-end-test: Mutations for the role

  • Mutations Test Added the role field to the generated schema and test/mutations

Using client authentication for the end-to-end-test:

  • sending the authorization header in the sendQuery() with a token for test user "stubailo"
  • used this to generate the token out of the seed/User.json
  • had to change the payload from
      const payload = {
        userId: user._id.toString(),
      };

to:

      const payload = {
        userId: user._id.$oid.toString(),
      };

otherwise it would have only Object: [Object] inside instead of the real userId, maybe this error is also in server/authenticate.js, not yet checked.

So far the test passes.

Planned extensions:

  • cgs add-user User.graphql (like in my current master)
  • @authorize directive, to remove restricted fields on specific roles (like in my current master)

Did I get you right with this?

@tmeasday tmeasday self-assigned this May 22, 2017
@tobkle
Copy link
Contributor

tobkle commented May 28, 2017

  • did some error corrections on branch "authorization"
  • added further authorization tests in output-app-end-to-end test to check with different users and roles

@tmeasday
Copy link
Owner Author

Hey @tobkle, apologies for being a little slow to get to this, I will try and look at it today!

@tmeasday
Copy link
Owner Author

@tobkle this is great! I think this is a great way to do it. I have some minor comments about the generated code but perhaps let's start with bigger picture stuff.

  1. Do you think we should figure out a way to make the decorator a bit more succinct? Perhaps it would work better the other way around:
type Tweet
@authorize(
  owner: ["create", "update", "delete"],
  admin: ["update", "delete"],
  world: ["read"]
)

I guess in general people will have less roles than modes, so this will be briefer? I could go either way, up to you.

  1. What do you think about rather than special-casing owner and asking for ownerField, we instead:

If we see a named "role":
- First check if there is a defined field called "role" of type User (or even arrays of users if we want to get tricky) on the document with that name, and check if the current user === that related user for the document
- (for the above) Special case this to mean the current document.
- If not, check if the current user has a role named "role".

So for the tweet it would @authorize(author: [...], admin: [...], world), and for the user it would be @authorize(this: [...], admin: [...]).

We could also consider making those decorators work at the field level?

One other advantage of this approach is you could defined multiple document "owner" fields and give different permissions to each.

  1. I suspect it might be easier/clearer to split of the @authorize decorator on the user from another @role/@authRole decorator at the field level? so it's like
type User @authorize(...) {
  role: String @authRole;
}

These are just suggestions, I'm open to whatever way you want to go with it. It's looking great!

@tobkle
Copy link
Contributor

tobkle commented May 29, 2017

Don't mind.
On 1.): I agree. It is easier to read and understand.
On 2.): Need some time to think through. Coming back on that later.
On 3.) Was also thinking on something on field level to control crud for fields: Currently the part with restricting authorizations in type "user" on the "role" field, which was defined on input-type header level looks ugly. A directive on field level will provide more options for later enhancement.

@tmeasday tmeasday removed their assignment May 30, 2017
@tobkle
Copy link
Contributor

tobkle commented Jun 1, 2017

For verifying your points, let's use an example to check if I got you right...

type tweet
@authorize(
   author: ["create", "read", "update", "delete"], 
   coauthors: ["read", "update"],
   admin: ["readOne", "readMany", "update", "delete"], 
   world: ["readOne", "readMany"]
)
{
  body String!
  author User! @belongsTo
  coauthors [User!] @belongsTo
}
type user
@authorize(
   this: ["create", "readOne", "update", "delete"], 
   admin: ["create", "read", "update", "delete"]
)
{
  username String!
  role String! @authorize(this: ["read"], admin: ["read", "update"])
  bio String
}

So you suggest not to use "roleField" nor "ownerField" in the @authorize directive. Instead, if we have the "author" and "coauthors" role in the @authorize header directive, to check within the type field definitions, if there is a field with this specific name "author" or "coauthors" which has a type "User" or a list of users "[User]".

  • if so, then check in the model Type.authorize function, if the signedIn user === author.user or if the user is in the list of coauthor.users of the document
  • if not, check the signed in user's role === in the roles and allowed modes of a type

Can we use "read" to allow both "readOne" and "readMany" at the same time for keeping it short? If we want to handle "readOne" and "readMany" separately, we use them explicitely. So if either one or even both of them appear, then we know, to handle both cases separately.

For the field directive, I suggest to use the same roles and modes like in the header, which gives us more control on field level authorization.

@tobkle
Copy link
Contributor

tobkle commented Jun 1, 2017

...or using as you suggested @authRole on field level authorization instead of @authorize if it more robust while reading the AST.

@tmeasday
Copy link
Owner Author

tmeasday commented Jun 2, 2017

This looks great. A few quick points:

First part looks perfect.

Can we use "read" to allow both "readOne" and "readMany" at the same time for keeping it short?

Please!

Re: field directive -- is this doing auth for the field? I was talking about an @authRole directive to mark the field that sets the role. I would suggest we punt field-based auth for a later PR to keep things relatively contained. What do you think?

@tobkle
Copy link
Contributor

tobkle commented Jun 2, 2017

Ah, now I understand that point. So instead of using in the header @authorize a "roleField" and defining a field name, you suggest to use the field directive @authRole as a "connector" between role and field. Ok, this makes sense. Then the role in the @authorize and the field hosting the role can be different if necessary. -Agreed-

Field level authorization: If we don't consider some kind of field authorization, I don't know, how to secure the "role" field in the user type. If I want a default user to be allowed to change some of his fields such as "bio" or "address", I have to grant him an "update" authorization on his own "User" document. But then also the "role" field is open for updates. I don't want him to upgrade his role from "user" to "admin" by himself. This is something, only a user with higher authorization should be allowed to do. So whether we hard code it in the user model (what I have done so far), or we enable some kind of field based authorization directive with some auth logic. What was your intention on this?

@tmeasday
Copy link
Owner Author

tmeasday commented Jun 2, 2017

Good point!

What do you think? We could start by hard-coding it so the @authRole field is editable only by admins by default, but all other (non @readonly <- this already exists) fields are editable collectively?

Or we could just do field auth now too..

@tobkle
Copy link
Contributor

tobkle commented Jun 2, 2017

At the moment I have this logic in the User Model...

  // returns document without not authorized field
  // generated from 
  // role: String! @authorize(admin: ["read", "update"])
  allowedFields(input, user){
    if (user.role === 'admin'){
      return input;
    }
    if (input[this.roleField]){
      delete input[this.roleField];
    }
    return input;
  }

Can answer you this evening (CET) again, have some meetings now....

@tobkle
Copy link
Contributor

tobkle commented Jun 3, 2017

We are not far away from the field authorization though, as we have already the document authorization in place. Only the not authorized fields shouldn't be in the document, while doing the create, read, update operations on a specific user and role.

There is to distinguish between inbound and outbound operations: Inbound are create and update. Outbound are readOne and readMany.

In inbound operations, we are receiving the "input" document in the resolver. There, I would use a function like the above mentioned "allowedFields" function, to remove the fields of the input document, which aren't allowed for the user/role and mode. Then we have already the field level authorization. I wouldn't throw an exception for missing field authorizations.

In outbound operations, we are receiving the documents from the dataloader. There we have two alternatives:

Alternative 1: Before we query, we construct a field selection object, which holds all requested fields without not authorized fields. Then we use that for the find operation. So we read and return only documents with authorized fields.

Alternative 2: We remove the not authorized fields afterwards, meaning, removing the not authorized fields from the found documents, which requires an additional loop over the records. The easiest way is the second alternative, as it is very much like the upper one, but I assume it will have a performance impact.

Alternative 1 would be the better approach, but requires a new data access interface for e.g. the dataloader findById and collection find, so the easier and faster is for the moment alternative 2. Or do you see a nice way therefore?

We also need to agree upon the field authorization directive, for triggering the generator correctly:
For the user it is the easiest, if we just use the same naming on field level like on the header, such as

...
username: String!
role: String! @authorize(this: ["read"], admin: ["create", "read", "update"])
...

This would mean:

  • username field has no restrictions
  • role field:
    • the User is allowed to read only the role (readOne and readMany is controlled on header level = document level)
    • the admin User is allowed to set role field, is allowed to read and also update the role field
    • no one else

What do you think?

@tobkle
Copy link
Contributor

tobkle commented Jun 4, 2017

@tmeasday
Copy link
Owner Author

tmeasday commented Jun 5, 2017

My feeling is this is getting pretty complicated; I would prefer to leave the per-field stuff in a separate PR.

I think I am pretty happy with the API, now, it's going to be great!

I'm a bit concerned that the generated code is pretty complicated; remembering that an aim of the project is to generate simple, easy to understand code that a user can extend + modify themselves.

I can see two ways forward:

  1. Turn your current generated code into a library and use that library from CGS.
  2. See if we can figure out a simple (maybe less powerful) way to do it?

One thing I was thinking is rather than doing document checks, can we just alter the queries (and potentially check the output)?

For instance, if you are trying to update a tweet, and the permissions are as you have them in the example app, the query could look something like:

async updateById(id, doc, _user) {
  const query = { _id: id };
  if (!loggedIn) {
    throw new Error("Can't update");
  } else if (!User.isAdmin(user) {
   query.$or = { authorId: user._id, coauthorIds: user._id };
  }

  const success = this.collection.update(query, ...)
  if (!success) {
    throw new Error("Can't update!");
  }
  //...
}

Then the only abstraction I would make to the above would be to factor out the common "generate query for roles":

async updateById(id, doc, _user) {
  const baseQuery = {_id: id};

  // This line is generated by examining the `@authorize` directive + `@authRole`s
  const authQuery = Auth.queryForRoles(user, ['admin'], ['authorId', 'coauthorIds'], { User });

  const success = this.collection.update({ ...baseQuery, ...authQuery }, ...);
  if (!success) {
    throw new Error("Can't update!");
  }

And the implementation would look something like:

Auth.queryForRoles = function(user, userRoles, docRoles, { User }) {
  const role = User.authRole(user);
  if (userRoles.include(role)) {
    return {};
  }

  const query = {$or: {}};
  docRoles.forEach(docRole => query.$or[docRole] = user._id);
  return query;
}

We could then alter every query we run (fetch/insert/update/delete) to factor in the role query?

Or am I missing something?

@tobkle
Copy link
Contributor

tobkle commented Jun 15, 2017

Thanks for your suggestions. That's much better. Enhanced it a bit.

Please have a look on this readme and code.

The field authorization is necessary in my opinion. Of course it can be also easily removed, if not required. Avoiding the @authorize directive on field level also skips the field authorization logic.

It is not yet "prettier" and "eslint"-ed, can do it, if we stick to this approach.

@tmeasday
Copy link
Owner Author

Thanks @tobkle -- apologies, won't have a chance to look at this properly until later in the week.

@tobkle
Copy link
Contributor

tobkle commented Jun 19, 2017 via email

@tmeasday
Copy link
Owner Author

Apologies again that this took so long.

I still think the output here is more complicated than it needs to be. A few things:

  1. I think I would prefer literally writing the xRoles directly into the calls to queryForRoles rather than having a big complex this.auth layer of indirection. I'm not sure that really adds anything. i.e.

Rather than:

export default class User {
  this.auth = {
    ....
  }

  getOneById(id, _user = {}, resolver = 'getOneById') {
    const authQuery = queryForRoles(_user, this.auth, 'readOne', { User: this.context.User }, {_id: id}, resolver);
    return this.loader.load(id, authQuery);
  }
}

This seems simpler and easier if anything to understand:

export default class User {
  getOneById(id, _user = {}, resolver = 'getOneById') {
    // This means users with role 'admin', or who match the document's `_id` field
    const authQuery = queryForRoles(_user, ['admin'], ['_id'], 'readOne', { User: this.context.User }, resolver);
    return this.loader.load(id, authQuery);
  }
}
  1. I would consider doing the field auth again at the level of queries:
  • For reads, use a projection to filter out the fields we aren't allowed to see
  • For writes, simply filter out the fields we aren't allow to write from the $set operation (we create it after all)
  1. We need to be careful in our use of data loader, but perhaps you've already thought about this? I recall that we don't share data-loads between users/requests so it might be OK to just pass the on queryForRole into the data loader factory.

If it helps I can send a PR to your README showing what I mean.

@tobkle
Copy link
Contributor

tobkle commented Jul 9, 2017

New Authorization Version

Sorry for the delay, new version here.

0. New with Logfile

Now it writes a nice human-readable logfile for debugging purpose. Please have a look into the example logfile of a full end-to-end-test. It logs all user requests with relevant user information and its graphql query, and adds authorization information.

1. queryForRoles

-done-

2. Field authorization

-done-

  • reads: Did the projection only in some reads, should we add that better into all, for easier generating?
  • update: In case of 'role', I wanted to inform the user, that he isn't allowed to update the field. But I can do also an delete docToUpdate['role'] before updating, if you prefer that.

3. DataLoader

  • The DataLoader was a bit more complicated than expected. The "load" method accepts only one paramenter "ids". So I had to add the authQuery into the loader factory and to catch authorization errors in the factory, because of that, I had to implement a promise. Do you know a better way to do it?
  • What do you think about that approach? Does it make sense to have an own loader per authQuery?

Please feel free to add a PR to the README file.

@tmeasday
Copy link
Owner Author

tmeasday commented Jul 13, 2017

First off, love the logfile idea, that seems super useful.

Looking through the readme, some questions:

  1. How is this line generated?
const restrictedFields = (Object.keys(authQuery).length === 0) ? {} : { role: 0 };

It seems to me that we would in general need to do something more complicated here, as fieldRestrcitionsForRoles in the spirit of queryForRoles?

  1. Should we bundle up mode/resolver/other debugging symbols to make the function signatures a bit simpler? Let me send a PR with a suggestion. (Pass the logger rather than all the bits it needs tobkle/create-graphql-server#2)

  2. Re: loaders -- let me try and send a PR to simplify it a bit, same logic though :) (Simplify authorized loader. tobkle/create-graphql-server#1)

  3. Re: loaders -- Does it make sense to have an own loader per authQuery? I think so, but don't we only need one? (i.e. readOne) -- we don't use loader for readMany, right?

@tmeasday
Copy link
Owner Author

Oh and 5. What do you think about me rather than _user?

@tmeasday
Copy link
Owner Author

And 6 ;) -- why does authRole not return null/undefined if there is no role?

@tobkle
Copy link
Contributor

tobkle commented Jul 13, 2017

  1. You mean, if the code generator has to generate it later? Well it is the object for projection fields. Hm. Let me think over. We can also just send an array to a function fieldRestrictionsForRoles.
  2. yes, it isn't looking easy. That was also my initial intention with the auth object, but we can do it just for other fields.
  3. At the moment we only need one, that's right. But while implementing, I was wondering, if we implement a loader with a different authorization, we need an additional loader, because it will cache the data inside. Or, if it is better, to do the authorization check somehow inside the loader, not needing two caches, for maybe duplicate data?
  4. Looking forward to your suggestion.
  5. good idea
  6. have to check that

While hardcoding this role and field topics, I was wondering if it makes more sense, to add the authorization logic into data. Meaning, having a few types maybe 2 or 3, dedicated to store the user, userRoles and access to different types, and instead of coding the queryForRoles, do a GraphQL query to those types, to figure out, if an access is allowed. Maybe those types are only generated, if the user does something like: create-graphql-server add-authorization-with-roles or if the @authorize directive appears. But this would require some more query filters. Or do you have security/performance concerns?

@tmeasday
Copy link
Owner Author

if we implement a loader with a different authorization

What kind of loader are you thinking here?

While hardcoding this role and field topics, I was wondering if it makes more sense, to add the authorization logic into data
Or do you have security/performance concerns?

My concern would just be more that it makes the whole thing more complicated.

I actually think once we put the library functions like queryForRoles etc into an npm package the generated code here isn't actually going to be all that complex, and in my opinion not to difficult for a user to understand; especially if we have the nice debugging of query/field restrictions.

@tobkle
Copy link
Contributor

tobkle commented Jul 16, 2017

@tmeasday
Copy link
Owner Author

Comments:

  1. I am surprised to see auth logic in Tweet. coauthors etc--isn't the idea of the auth logic to restrict access to the top-level query and mutations? If you have access to the tweet shouldn't you have access to its users?

Hmm, I am not sure what to think about this actually. What would you expect as a CGS user?

  1. If we are doing the above, should we factor out the auth logic for multiple records, i.e. getAll, analogously to getOneById?

  2. Let's do the createdById and updatedById in a separate PR, this one is complex enough already!

  3. I would be inclined to not allow "document roles" for field authorization, as it's adding a lot of complexity and has a performance cost. Is it something you need? I feel like field-auth is more of a blunt instrument like "disallow access to role for everyone but the admin".

Or are you considering cases like "only the user themselves can update their profile"?

I think the complexity of understanding the generated code for this is going to be a security problem, I strongly believe people need to be able to easily understand what the code is doing to ensure they are doing their authorization correctly.

As a user of the tool I think I would rather just hand-code the special cases above and make sure I have them right.

  1. In fact, can we please just split the field authorization out into a separate PR? I think ultimately (a) we can get the document auth code out quicker (b) the code will be more modular (c) it will help me fit this all in my head at once!

I think if we just hard-code disallowed access to the role field in such a version it should be work ok?

@tobkle
Copy link
Contributor

tobkle commented Jul 28, 2017

Ok, I removed the field authorization completely, with this version.. Now it should be much easier and less code.

Concerning your points 1 and 2 and 4 and 5:

  • Authorization cannot be just on the main queries imho, as the other methods also accessed the database directly. So it would be easy to breach every authorization by just using an indirect access.
  • But I took your point 2 to have only two "real" reading database accesses in findOneById and find (meaning the former "all", but it has an additional usage though) and just use those two in the other methods to read records. This is reducing code a lot and makes it more "controllable" as the checks are done only by the two reading methods, which hold the checks.
  • We can change it back to "getById" and "getAll" or something else, if you see naming conflicts with mongodb methods.
  • Added an example for the "role" field also, which should be added by the user later, and won't be generated automatically any more. (This helped me in the short run, to not to recode all the test cases again.)

Concerning 3.:

  • It is much easier now by the most recent changes, do you still want me to PR out this?

@tmeasday
Copy link
Owner Author

tmeasday commented Aug 2, 2017

I think it's looking great! I don't have any more general changes/comments. To your questions above:

  1. findOneById and find are OK, I don't know if it matters what they are called; if we need direct access to the db, we can always use model.collection.find*.

  2. On the role thing--what is your plan here for this PR?

  3. No problem, leave the xById stuff.

Is it time to make a PR? I can make some line by comments on some minor things, then we can start talking about the code generation!

@tobkle
Copy link
Contributor

tobkle commented Aug 3, 2017

OK.

Had many commits, so I have to merge it first before creating a PR. But first I have to finalize the role thing. As a user of CGS I would expect this field protected as soon as @authorize comes into play. So I suggest to copy the following code into the authorize.js include, to have it available on demand.

  protectFields(me, authorizedUserRoles, protectedFields, inputObject){
    const result = Object.assign({}, inputObject);
    const role = this.context.User.authRole(me);
    // if user is not allowed to access specific fields...
    if (!authorizedUserRoles.includes(role)){
      protectedFields.every(protectedField => {
        if (result[protectedField]) delete result[protectedField];
      });
    }
    return result;
  }

In the end it remains only this line in mode/User.js to be added

docToUpdate.$set = this.protectFields(me, ['admin'], ['role'], docToUpdate.$set);
  • As I've removed the field auth @authorize tag from the type file on field level, the generator has no anchor to generate this line. So we can add this as a suggestion in the docu to add it manually. And I'll remove all role test cases.

I assume you don't want to have the field protection inside like so:
User type like:

type User
...
{
  role: String @authRole("admin") @authorizeField(admin: ["update"])
...
}

@tmeasday
Copy link
Owner Author

tmeasday commented Aug 3, 2017

I was thinking that we'd just hard code the role field on the user model (it can use the protectFields function as you like) in this first iteration. What do you think?

@tmeasday
Copy link
Owner Author

tmeasday commented Aug 3, 2017

Thinking about it a little bit, it would be quite easy for a user to make a critical mistake if they have to remember to set the authorizeField on role anyway. It seems like an important enough thing that you should have to opt out if you don't want it to admin only I would have thought (even in the final version)

@tobkle
Copy link
Contributor

tobkle commented Aug 3, 2017

Ok, I agree. So I merge it together this evening (German Time Zone) and create a PR.

@tobkle
Copy link
Contributor

tobkle commented Aug 13, 2017

Just updated, with your PR comments ...
tobkle/create-graphql-server, which includes now the new authorization package...

Created new authorization package...
tobkle/create-graphql-server-authorization

Please have a look on. Now, we can start with the generator setup.

Some thoughts about:

  • Later, if anyone wants to have an alternative authorization logic, it should be possible to exchange the authorization package easily by another one. But this requires, that we need some triggers in "create-graphql-server" while "add-type run" to transfer the "@authorize type info" and ask for "generated authentication code" from an outside npm authorization package. Also CGS requires then some hooks in the skeletons, to attach this authorization logic.
  • This is something to keep in mind for later. We should start first by preparing the skeletons inline.
  • Do you have some prerequisites or thoughts/hints before I start with?
  • Should we have to versions of model/resolver-templates in the generator? One for "without @authorize types", and one for "with @authorize types"? It should be still possible to generate code without authorization logic.

Roadmap:

  1. adjust the skel/model/index.js, with the changed logic (baseQuery/authQuery)
  2. adjust the skel/server/index.js
  3. adjust the skel/server/authenticate.js
  4. adjust the generate/model/templates/base.js.template
  5. adjust the generate/model/templates/paginatedAssociations.js.template
  6. adjust the generate/model/templates/singularAssociation.js.template
  7. adjust the generate/model/index.js (if necessary, not sure yet)
  8. adjust the generate/resolvers/templates/base.js.template
  9. adjust the generate/resolvers/templates/fieldOfType.js.template
  10. adjust the generate/resolvers/templates/singularAssociation.js.template

(I must admit that I haven't understood https://github.com/benjamn/ast-types, which you've recommended in your code.)

@tmeasday
Copy link
Owner Author

I agree about the auth "plugin" model that we can think about later..

The main thing I am thinking about, and not sure about yet, is that when generating types, the way it's currently implemented there is no "cross-type" communication. For instance when building the tweet type, we don't know anything about the user type. So we will need to be careful. But in theory it all seems possible.

Should we have to versions of model/resolver-templates in the generator? One for "without @authorize types", and one for "with @authorize types"? It should be still possible to generate code without authorization logic.

I think maybe we should at first try without two versions (and instead inject the auth code into the base version or strip it out if easier). The reason I say that is because otherwise I am sure the code will go out of sync and bugs will appear.

(I must admit that I haven't understood https://github.com/benjamn/ast-types, which you've recommended in your code.)

Yes I have not succeeding in doing this yet either ;)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants