Skip to main content

Authorization

Zmaj uses a mix between RBAC (role based access control) and ABAC (attribute based access control) for securing your resources. User can be allowed to access only specific properties on resource, and only under certain condition. Different users with same role could have different access. It uses casl under the hood. Every permission is stored in zmaj_permissions table, and points to a single role stored in zmaj_roles table.

Roles

User can only have single role. There are 2 mandatory roles that can't be deleted:

  • Admin, user can do everything
  • Public, role assigned to users that are not signed it. You can also assign this role to registered user.

Permissions

Every permission is tied to a single role. Permission contains action that it allows, resource on which action is performed (it does not have to be collection), fields that user can access (if applicable), and conditions under which resource can be accessed.

Example permission:

import { Permission } from "@zmaj-js/full"
// User can update posts, but only `title` and `userId` fields,
// only if post does not have title "Secret Title" and has `userId` equal to current user's ID.
const examplePermission: Permission = {
id: "5621a3a8-c5b1-4b7b-9133-59f94be7ca04",
createdAt: new Date("2023-02-24T21:20:17.500Z"),
action: "update",
resource: "collections.posts",
fields: ["title", "userId"],
conditions: {
title: { $ne: "Secret Title" },
// this will inject current user
// only user this posts belongs to can update
userId: "$CURRENT_USER",
},
roleId: "534c05a3-f087-455d-bfeb-5b36c4d58c48",
}

Fields

It is possible to allow access only to some fields. Permission has fields array, and only allowed data, will be returned. If user tries to access or change forbidden resource, error will be thrown.

If fields are empty array ([]), that means that user does not have access to any field. If value is null, that means that user has access to all the fields. There is a difference between fields = null and fields = ["all", "fields", "specified", "manually"]. When new field is created, for example new_field, if fields are null, user will have access to that field, but that field won't be added automatically if fields is an array.

Conditions

You can provide custom condition that must be fulfilled so that user can access resource. It uses MongoDB query syntax (see casl docs for more).

// user can only access record if it's `title` is not "Secret Title",
// and if record's `userId` is same as current users.
const conditions = {
title: { $ne: "Secret Title" },
// this will inject current user
userId: "$CURRENT_USER",
}
caution

casl currently does not support $and, $or and $not operator, read more in casl docs.

Dynamic values

Since permissions are stored in database, zmaj has placeholder values that will be replaced at runtime with real values. Currently there are `"$CURRENT_USER", "$CURRENT_ROLE", "$DATE:some-value": It is possible for you to write custom transformer.

ExampleDescription
"$CURRENT_USER"Injects current user's ID
"$CURRENT_ROLE"Injects current user's role ID
"$DATE:2022-09-12T16:12:53.343Z"Creates Date object with provided value
"$DATE:10000"Creates Date object provided unix timestamp (in seconds)
<!--"$CURRENT_DATE"Injects current date
"$CURRENT_DATE:3d"Injects date that is 3 days in the future (uses ms)
"$CURRENT_DATE:-5s"Injects date that is 5 seconds in the past (uses ms)-->

Configuring

await runServer({
authorization: {
// you can fully disable authorization
disable: false,
// custom transformers that inject dynamic values
// see section bellow for more details
customConditionsTransformer: [],
},
})

Writing custom condition transformers

There is exported type that will help you write this function AuthzConditionTransformer.

import type { AuthzConditionTransformer } from "zmaj"

// this is code that is used for injecting current user in request
const currentUserTransformer: AuthzConditionTransformer = {
key: "MY_CURRENT_USER",
// currently only `user` and `modifier` are passed as params
// replace $MY_CURRENT_USER with current user's ID, or with `null` if user is not signed in
transform: (params) => params.user?.userId ?? null,
}

// Simplified version of how Zmaj's current date transformer is implemented
// modifier is everything after ":", if colon is included
// in `$MY_CURRENT_DATE:2d`, modifier is `2d`
// in `$MY_CURRENT_DATE`, modifier is `undefined`
// in `$MY_CURRENT_DATE:`, modifier is empty string
const currentDateTransformer: AuthzConditionTransformer<Date> = {
key: "MY_CURRENT_DATE",
transform: ({ modifier }) => {
if (modifier === undefined) return new Date()
const inMs = ms(modifier)
return addMilliseconds(new Date(), inMs)
},
}

// then provide them to runServer
await runServer({
authorization: {
customConditionsTransformer: [
currentUserTransformer,
currentDateTransformer, //
],
},
})

Custom authorization

There might be some case where Zmaj's authorization is not detailed enough for you. You can use OnCrudEvents to authorize CRUD endpoints with JavaScript.

@Injectable()
class MyAuthorizationService {
/**
* Prevent deleting post if user's email is gmail
*/
@OnCrudEvent({
action: "delete",
type: "before",
collection: "post",
})
async preventDeletingPostIfUserEmailIsFromGmail(event: DeleteBeforeEvent<Post>) {
if (event.user?.email.endsWith("@gmail.com")) throw new ForbiddenException()
}
}

Using guards, interceptors and middleware

Zmaj is a NestJS application. So that means you can also write custom authorization using guards, interceptors or middleware. In fact, part of Zmaj's authorization (main exception are CRUD requests) is taking place mostly in guards. You should first try to achieve this with Zmaj's permissions, and write custom authorization only if built in authorization and CRUD events are not enough.

import { runServer, ADMIN_ROLE_ID, AuthUser } from "zmaj"
import {
ExecutionContext,
Injectable,
CanActivate,
Module,
ForbiddenException,
} from "@nestjs/common"
import { APP_GUARD } from "@nestjs/core"

@Injectable()
export class MyAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest()
// Only use on users endpoints
if (!req.url.startsWith("/api/users")) return true

if (req.user instanceof AuthUser && req.user.roleId === ADMIN_ROLE_ID) return true

throw new ForbiddenException()
}
}

@Module({
providers: [{ provide: APP_GUARD, useClass: MyAdminGuard }],
})
export class MyModule {}

await runServer({
customModules: [MyModule],
})