DEV Community

Cover image for 🛡 Blitz Guard
Gabriel Chertok
Gabriel Chertok

Posted on • Originally published at monolith-bias.com

🛡 Blitz Guard

This article was originally posted on Monolith Bias_ the technical blog of the Ingenious development team.


As great as Blitz is for developing full-stack applications, I still miss the batteries included feeling Ruby on Rails has. I know I can't compare a mature framework like Rails to Blitz that barely has a year old, but I certainly miss the "there must be a gem for that" feeling.

One of the things I miss the most when working on a web app is a centralized place to manage authorization. Authorization is the most common requirement a web app may have. While Blitz itself tries to address this issue with the $authorize method that's built-in on the session, it falls short when the authorization is dependent on data attributes; technically called attributes-based access control.

The good news is that Nico Torres, a friend and former Ingenious employee, created Blitz Guard, the API is close to RoR cancancan gem but adapted to work with Blitz.

Installation

With Blitz Guard latest release, you can execute the following:

$ blitz install ntgussoni/blitz-guard-recipe
Enter fullscreen mode Exit fullscreen mode

This line uses the Blitz Guard recipe to install the library. Recipes are an excellent, guided way to install new software in your app. It lets library developers update your codebase without breaking it.

What's inside

Once installeed you'll end up with several new files. The most important one is app/guard/ability.ts. This file is the core of your authorization logic, and it will serve as a single source of truth, whether a user can or cannot do things in your app.

import db from "db"
import { GuardBuilder, PrismaModelsType } from "@blitz-guard/core"
import { GetShoppingCartInput } from "app/shoppingCarts/queries/getShoppingCart"

type ExtendedResourceTypes = PrismaModelsType<typeof db>

type ExtendedAbilityTypes = ""

const Guard = GuardBuilder<ExtendedResourceTypes, ExtendedAbilityTypes>(
  async (ctx, { can, cannot }) => {
    cannot("manage", "all")
    if (ctx.session.$isAuthorized()) {
      can("read", "shoppingCart", async ({ where }: GetShoppingCartInput) => {
        return where.userId === ctx.session.userId
      })
    }
  }
)

export default Guard
Enter fullscreen mode Exit fullscreen mode

The ability file creates a Guard using the GuardBuilder function. The resulting guard will be executed on every authorized query or mutation.

For example, if we want to deny access to a shopping cart based on who's the owner. We can do something like this:

// app/guard/ability.ts

import { GetShoppingCartInput } from "app/shoppingCart/queries/getShoppingCart"
// ...

if (ctx.session.$isAuthorized()) {
  can("read", "shoppingCart", async ( { where }: GetShoppingCartInput ) => {
    return where.userId === ctx.session.userId;
  })
}
Enter fullscreen mode Exit fullscreen mode

Notice GetShoppingCartInput is the same TS type that we use in the getShoppingCart query

The can (and cannot) methods have three parameters. The first is the action to be performed (either create, read, update, delete, or manage), the second is which Prisma schema object this guard applies to (it doesn't need to be Prisma dependent, you can extend it using the ExtendedResourceTypes), and the third is an async function that should resolve to a boolean.

On this third argument you can implement any logic you want -like querying the DB- to assert whether the logged-in user has permissions to perform the action on the specified resource. A more interesting example could be the following:

//...
if (ctx.session.$isAuthorized()) {
  can("read", "shoppingCart", async ( { where }: GetShoppingCartInput ) => {
    if(where.userId === ctx.session.userId) return true
    const count = await db.shoppingCartShare.count({ where: { userId: ctx.session.userId, shoppingCartId: where.id } })
    return count > 0
  })
}
Enter fullscreen mode Exit fullscreen mode

☝️ Here we assert that the user is the shopping cart owner or that the cart has been shared with this user previously.

Authorizing functions

So far, so good, but changing the ability file will do nothing if we don't authorize our functions. Blitz Guard will intercept your functions call and execute the guard only if we wrap queries and mutations with the authorize method.

import { Ctx, NotFoundError } from "blitz"
import db, { Prisma } from "db"
import Guard from "app/guard/ability"

export type GetShoppingCartInput = Pick<Prisma.ShoppingCartFindFirstArgs, "where">

async function getShoppingCart({ where }: GetShoppingCartInput, ctx: Ctx) {
 ctx.session.$authorize()

 const cart = await db.shoppingCart.findFirst({ where })

 if (!cart) throw new NotFoundError()

 return cart
}

export default Guard.authorize("read", "shoppingCart", getShoppingCart)
Enter fullscreen mode Exit fullscreen mode

The first two arguments of this function should look familiar because they are the same arguments can and cannot functions receive. The third argument is the function we want to wrap, and that will only be called if the guard criteria are met. Otherwise, you'll get a 403.

This step is easy to forget, more so when the Blitz generator default exports the generated function. To aid this, Blitz Guard installs a middleware that warns you (in development) about queries and mutations not wrapped by the Guard.authorize function.

Final words

Blitz Guard is still under heavy development, but I don't think the API will change a lot. It's a great option if you plan to develop an ambitious Blitz app by moving scattered business logic into a single place.

Discussion (0)