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
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
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;
})
}
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
})
}
βοΈ 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)
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.
Top comments (0)