Blacklist over whitelist: Caveats of ABAC

I’ve implemented my own auth logic (using JWT as well), however, I have also tried out using ABAC and I realized a caveat of using it than implementing my own REST endpoints, that is this:

Create(Role("user"), {
  membership: {
    resource: Collection("users")
  },
  privileges: [
    {
      resource: Collection("users"),
      actions: {
        read: Query(
          Lambda(
            "ref",
            And(
              Equals(
                CurrentIdentity(),
                Var("ref")
              ),
              Call(Function("isUserNotDeleted"), CurrentIdentity())
            )
          )
        ),
        write: Query(
          Lambda(
            ["oldData", "newData", "ref"],
            And(
              Call(Function("isUserNotDeleted"), CurrentIdentity()),
              Equals(CurrentIdentity(), Var("ref")),
              Equals(
                Select(["data", "email"], Var("oldData")),
                Select(["data", "email"], Var("newData"))
              )
            )
          )
        )
      }
    }
  ]
});

The key there is this:

And(
  Call(Function("isUserNotDeleted"), CurrentIdentity()),
  Equals(CurrentIdentity(), Var("ref")),
  Equals(
    Select(["data", "email"], Var("oldData")),
    Select(["data", "email"], Var("newData"))
  )
)

Basically, this condition here ensures that the following are true:

  • The user associated with the secret used hasn’t deleted the account yet.
  • The user is editing his OWN account details.
  • The user did NOT change his email address.

On the other hand, if you have like an HTTP endpoint, you’d do something like this:

import { query } from 'faunadb';

type User = {
  ref: {
    id: string;
  };
  data: {
    firstName: string;
    middleName: string;
    lastName: string;
    deletedAt?: string;
  };
};

type payload = {
  user: User;
  formBody: {
    firstName: string;
    middleName: string;
    lastName: string;
  };
};

export async function handler({
  user,
  formBody: { firstName, lastName, middleName }
}: payload) {
  if (user.data.deletedAt) return { statusCode: 418 };

  // update user data
  await faunadb.query(query.Update(user.ref, {
    data: {
      firstName,
      lastName,
      middleName
    }
  }));

  return { statusCode: 200 };
}

You can even add yup in there and it will make security easier, and then you can use spread because yup handles removing excess fields for you.

import { query } from 'faunadb';
import * as yup from 'yup';

type User = {
  ref: {
    id: string;
  };
  data: {
    firstName: string;
    middleName: string;
    lastName: string;
    deletedAt?: string;
  };
};

type payload = {
  user: User;
  formBody: {
    firstName: string;
    middleName: string;
    lastName: string;
  };
};

const validationSchema = yup.object({
  firstName: yup
    .string()
    .required()
    .max(50),
  middleName: yup
    .string()
    .max(50),
  lastName: yup
    .string()
    .required()
    .max(50)
});

export async function handler({
  user,
  formBody
}: payload) {
  if (user.data.deletedAt) return { statusCode: 418 };

  const validForm = await validationSchema.validate(formBody, {
    abortEarly: true,
    stripUnknown: true,
    strict: true
  });

  // update user data
  await faunadb.query(
    query.Update(user.ref, {
      data: validForm
    })
  );

  return { statusCode: 200 };
}

What happened here?

HTTP API approach

The approach that uses an HTTP endpoint is so much better from a security standpoint, why? Because it uses whitelist over blacklist, basically it will only update fields that your form body accepts, anything else will be discarded, so if the user were you pass something like below as the form body:

{
  email: 'myNewEmail@gmail.com',
  firstName: 'April',
  middleName: 'Mintac',
  lastName: 'Pineda'
}

Only the firstName, middleName, lastName will be updated and the email will be discarded.

On the other hand, we have:

ABAC

ABAC uses the Blacklist approach, that is all fields are allowed to be changed unless you implicitly check for it, hence, the need to do 3 checks in the example, which are (1) check that the user is updating his own document, (2) check that the user account hasn’t been deleted, (3) Check that the user did not change the email field.

And that’s just one field, there could be 5 or even more fields that you define on the schema.graphql (or whichever) that you don’t want the user to be able to change, and you have to make sure that you put a check for every field, forgetting to do it will cause a critical bug in the backend, especially if you happen to deploy it in production. So this:

And(
  Call(Function("isUserNotDeleted"), CurrentIdentity()),
  Equals(CurrentIdentity(), Var("ref")),
  Equals(
    Select(["data", "email"], Var("oldData")),
    Select(["data", "email"], Var("newData"))
  )
)

can get really really really long, and you have to do that for every collection that you allow user to modify.

And that’s just the write, you also need to add the create, delete, and read permissions on top of this caveat, you would usually end up writing a lot of FQL custom resolvers and that render the autogenerated mutations/queries kind-of useless and more of a risky thing than a beneficial thing.

So if your condition was only:

And(
  Call(Function("isUserNotDeleted"), CurrentIdentity()),
  Equals(CurrentIdentity(), Var("ref"))
)

and the user-submitted form below to an autogenerated update mutation

{
  email: 'myNewEmail@gmail.com',
  firstName: 'April',
  middleName: 'Mintac',
  lastName: 'Pineda'
}

The user will be able to change his email. Even on custom resolvers, there is still a tendency to forget to add these kinds of checks, on the other hand, the HTTP API approach doesn’t even require you to think about this, it’s baked right into the pattern.

I’m looking for insights, opinions, counter arguments on this.

ABAC uses the Blacklist approach

No, it does not. An identity document authenticated with a token’s secret has no privileges except those that you grant. A blacklist approach would be “grant all privileges by default, and let roles remove them when necessary”. If your queries are accompanied by the secret from an admin key, “blacklist” would be the appropriate term, but then you’re using the equivalent of a “root” password to perform queries; don’t do that.

If you find that complex authentication requirements are hard to do within ABAC roles, by all means, use application code rather than FQL to enforce those.

However, I don’t think the choice is yup vs ABAC roles. yup is great for defining a validation form fields, but it doesn’t replace access control. Similarly, ABAC roles are a poor tool for field validation, but it’s all that you have, today, from FQL.

No, it does not. An identity document authenticated with a token’s secret has no privileges except those that you grant.

That’s not what I meant when I said blacklist approach, if in the role I wrote:

Create(Role("user"), {
  membership: {
    resource: Collection("users")
  },
  privileges: [
    {
      resource: Collection("users"),
      actions: {
        read: true,
        write: true
      }
    }
  ]
});

Then the user will be able to:

  • Read ALL DOCUMENTS and ALL FIELDS (in users collection) regardless if that document belongs to the user or not. Unless you verbosely define a validation instead of read: true and this validation can get really long.
  • Edit ALL FIELDS and ALL DOCUMENTS (in users collection) regardless if that fields should have been editable or not. Unless you verbosely define a validation instead of write: true nad that validation can get really long.

In our app we have a user.fql which defines the user role and the access rules that we grant and its now 367 lines. And there’s a tendency that you’ll forget, so one of these days you’ll be like “Oh shit, I forgot to add that in the rules”

ABAC roles are all about granting/denying actions for operating on documents. You are attempting to extend those to apply field-level permissions. ABAC roles are not well suited for that use case, but I understand why you are making the attempt: it’s the only tool available within Fauna to do so.

Whether you use a blacklist/whitelist approach in your implementation, your role predicates do have to be in sync with the schema itself, and that does represent maintenance overhead. That is, unfortunately, the state of the art in Fauna.

As you mentioned in the original post, performing field-level validations/permissions is significantly easier with host language capabilities and packages. You might consider gating field access through an application API rather than allowing clients to execute GraphQL queries directly.

That’s won’t be ideal considering that it’s the main thing that powers graphql, if we want to be able to use fauna graphql, we need to be able to use ABAC, which requires defining roles, where you allow/disallow operations.

One thing I can think of to make the experience better for us devs is to utilize directives, similar to what AWS AppSync is doing https://docs.amplify.aws/cli-legacy/graphql-transformer/auth/#owner-authorization

Indeed, it is not ideal. We are working towards a type enforcement solution for the FQL side of things; currently, only the GraphQL API enforces type constraints. And those type constraints are not implemented via ABAC roles. Once FQL has a type constraint facility, we’ll unify that facility across FQL/GraphQL so that it’s not possible to violate GraphQL types via FQL, and vice versa.

You can use ABAC with GraphQL, but, type constraints are not field-level authorizations: their concerns are similar but separate.

Since you are working in this space, it would be great to see your ideas for how field-level authorization should be expressed. What kind of configuration would allow you to express field permissions accurately, and not require vigilance while your schema evolves?