jeudi 22 octobre 2020

API backend code structure - how to separate authorised and privileged parts of the code

Background

I'm writing a web API and webhooks (GraphQL for non-webhooks, but that's unimportant as that's just the outer layer) for a webapp where the vast majority of requests are authenticated, and the authorisation logic is slightly above trivial.

I am using Firestore which gives me an API to the database akin to a 'persistence layer' as well as my 'controllers' (either GQL queries/mutations or serverless function webhooks), and a 'service layer' which does the logic and calls to the persistence layer. These services also call each other, never reading each others collections in Firestore.

The app is a team chat room service, so there are terms such as "room", "membership" and "user" in the code below.

For authentication, I am receiving JWTs which I validate. I am not using custom claims in those JWTs - really just the user ID from them.

Current state

My services are constructed like this:

function createRoomService(
  userToken: {uid: string, email_verified: boolean},
  deps: {
    firestore: FirestoreInstance
    services: {
      user: UserService
    }
  }): RoomService {
  

  return {
    getRoom(id: string) {
      const room = deps.firestore.collection('rooms').get(id)

      if (!room.allowedUsers.includes(userToken.uid)) {
        return null
      }

      return room
    },

    changeUserStatus(roomId: string, status: string) {
      const room = await this.getRoom(roomId)
      if (!room) {
        throw new Error("No such room or not your room");
      }

      // yes could do these in a batch and I really do
      await deps.firestore.collection('rooms').doc(id).update({
        users: {[userToken.uid]: {status}}
      })
      await deps.services.user.updateLastSeen();

      return true // succceeded
    }
  }
}

GQL resolvers look like this:


getRoom(parent, args, context) {
  return context.services.roomService.getRoom(args.id)
}

The userToken has already been added to the constructed service at the beginning of the request.

The problem

This is all fine, and you'll notice that the roomService does its own authorisation on operations, and as the service makes calls between its own methods, in a sense "shares" some of that authorisation.

Now, I have a webhook call which comes from a trusted source which should update the user's status:

app.post("/subscription-lapsed", (req, res) => {
  verifyRequestIsTrusted(req.headers.appToken)

  const userId = req.body.userId
  const roomId = req.body.subscriptionId

  // I can't do this, because roomService is user-scoped, whereas this is not a user request
  await roomService.changeUserStatus(roomId, "deactivated")
})

My original roomService is user-scoped and requires a user token to create it, but this is being accessed outside of user context, but trusted.

Options I can think of

  1. Pass a fake user token to construct the service in the second case
  2. Don't require user token when creating service, but pass into each method (just pushes the problem somewhere else)
  3. Put the authorisation logic into the outer controller (GQL resolver or webhook handler) and make the roomService entirely privileged and assume it is supposed to make those changes. In this case, the controller would have to fetch data from the database to do the authorisation.
  4. Have two layers of services - one which is totally privileged that receives userId as params to its methods, and the other which is user-scoped as it is now that the GQL layer uses. The webhook uses the privileged layer directly.
  5. I'm sure there are others.

Questions

  1. Is this problem described, named and solved already somewhere?
  2. If so, what is it?!
  3. If not, how do the above options sound?

Aucun commentaire:

Enregistrer un commentaire