Create user-defined roles

Use the Role collection to create documents that define privileges for database resources.

Roles are part of the Role system collection, so the document structure is system-defined and immutable. Each document has a required unique name and optional membership and privileges arrays. When a user-defined role is used with a key, Fauna ignores membership and evaluates privileges.

This tutorial shows how you can create user-defined roles. The examples in this tutorial use the Fauna Dashboard and Fauna’s demo data.

Overlapping roles

When a user is a member of two or more roles, Fauna does its best to optimize for the most common access pattern. The first access granted wins. No further evaluation is done after Fauna authorizes a privilege for action on a resource. In this way, Fauna avoids evaluating roles unnecessarily, especially predicates.

When all actions for a resource involve predicates, Fauna sequentially evaluates the predicates until one of them returns true or access is denied. Fauna keeps track of the most successful predicates and evaluates them first.

The maximum number of overlapping roles is 64. Trying to create more overlapping roles result in an error.

Role stacking

Typically, when making a query, Fauna applies the role of the API key. Fauna can apply multiple roles if the user makes a query that uses a UDF. UDFs have a role field that Fauna uses when executing the function body. If a UDF calls another UDF, it can result in a role stack. Consider a situation where a query from a user requires a role stack like this:

Given:

Level Role

User token

customer

myUDF()

server

otherUDF()

admin

  1. The user runs the query, and Fauna first evaluates the user-defined customer role. This role is defined in Fauna’s Demo database.

  2. When the myUDF() function is executed, Fauna pushes the built-in server role on the stack.

    If the UDF returns a Document Fauna verifies that the caller has read permission on the document and permission to call the UDF.

  3. While executing myUDF(), Fauna invokes the otherUDF() function. Again, the Fauna pushes the built-in admin role on the stack and runs otherUDF().

  4. Returning from each function, Fauna pops each role from the stack in turn.

Create a user-defined role

You define roles in Fauna Schema Language (FSL) as a schema. You manage schemas using the Dashboard or the Fauna CLI.

Use the Dashboard to add the following owner role to the Demo database:

role owner {

  privileges Product {
    read
  }
}

The role grants read access to documents in the Product collection.

Test the role

Run some FQL queries to test the owner role’s privileges.

  1. In the Shell for the Demo database, select a Role of owner.

    owner privilege

    This lets you run queries using the owner role’s privileges.

  2. Run the following query to read documents in the Product collection:

    Product.sortedByPriceHighToLow()

    The response contains a list of Product documents:

    {
      data: [
        {
          id: "394783030761226306",
          coll: Product,
          ts: Time("2099-04-10T12:48:07.090Z"),
          name: "pinata",
          description: "Original Classic Donkey Pinata",
          ...
        },
        ...
        {
          id: "394783030767517762",
          coll: Product,
          ts: Time("2099-04-10T12:48:07.090Z"),
          name: "limes",
          description: "Conventional, 1 ct",
          ...
        }
      ]
    }
  3. Attempt to create a document in the Product collection:

    Product.create({ name: "key limes" })

    The query returns an error:

    permission_denied
    
    error: Insufficient privileges to perform the action.
    at *query*:1:15
      |
    1 | Product.create({ name: "key limes" })
      |               ^^^^^^^^^^^^^^^^^^^^^^^
      |

    The owner role doesn’t have create privileges for the Product collection.

  4. Attempt to read documents in the Manager collection:

    Manager.all()

    The query returns an empty set:

    {
      data: []
    }

    The owner role doesn’t have read privileges for the Manager collection.

Add an action predicate

You can also limit an action using a predicate. For example, you might want to ensure that the owner role can only create Product documents with a backordered field of false.

Each permission action requires distinct function parameters. For example, write requires a function with two parameters, while a create function requires one parameter.

Use the Dashboard to add the following create predicate to the owner role:

role owner {

  privileges Product {
    read
    create {
      predicate (data => data.backordered == false)
    }
  }
}

The predicate limits the create action on the Product collection. The owner role can only create Product documents with a backordered field of false.

Test the role

Next, run some FQL queries to test the role’s updated privileges.

  1. In the Shell for the Demo database, select a Role of owner.

  2. Create a Product document with a backordered field of false:

    Product.create({
      name: "key limes",
      backordered: false
    })

    The query runs successfully:

    {
      id: "394784162900344898",
      coll: Product,
      ts: Time("2024-04-10T13:06:06.690Z"),
      name: "key limes",
      backordered: false
    }
  3. Attempt to create a Product document with a backordered field of true:

    Product.create({
      name: "lemons",
      backordered: true
    })

    The query returns an error:

    permission_denied
    
    error: Insufficient privileges to perform the action.
    at *query*:1:15
      |
    1 |   Product.create({
      |  _______________^
    2 | |   name: "lemons",
    3 | |   backordered: true
    4 | | })
      | |__^
      |

    The owner role can only create a Product document with backordered field of false.

  4. Attempt to create a Product document without a backordered field:

    Product.create({ name: "lemons" })

    The query returns an error:

    permission_denied
    
    error: Insufficient privileges to perform the action.
    at *query*:12:15
       |
    12 | Product.create({ name: "lemons" })
       |               ^^^^^^^^^^^^^^^^^^^^
       |

Write predicates

Predicate functions for the write action accept two arguments: an object for the original document and an object for the document to write. For example:

role owner {

  privileges Product {
    ...
    write {
      predicate ((oldDoc, newDoc) => {
        oldDoc.backordered == false
        && newDoc.backordered == false
      })
    }
  }
}

Based on the above predicate, users with the owner role can only update Product documents if:

  • The original document has a backordered field of false, and …​

  • The updated document also has a backordered field of false.

If needed, use the _ placeholder for an unused argument:

role owner {

  privileges Product {
    ...
    write {
      predicate ((oldDoc, _) => {
        oldDoc.backordered == false
      })
    }
  }
}

Based on the above predicate, users with the owner role can only update Product documents if the original document has a backordered field of false. The predicate doesn’t evaluate the updated document.

Self-referential predicates

Use Query.identity() in a predicate to only allow a user or identity to perform an operation on itself.

For example, based on the following predicate, a user with the manager role can only read their own Manager document:

role manager {
  membership Manager

  ...

  privileges Manager {
    read {
      predicate (ref => Query.identity() == ref)
    }
  }
}

A similar predicate for the write action would look like this:

role manager {
  membership Manager

  ...

  privileges Manager {
    ...
    write {
      predicate ((oldDoc, newDoc) => {
        Query.identity() == oldDoc
        && Query.identity() == newDoc
      })
    }
  }
}

call predicates

The call action lets a role call a user-defined function (UDF). call predicates accept the UDF’s parameters as arguments. Use the _ placeholder for unused arguments.

For example, based on the following predicate, a user with the customer role can only call the submitOrder() function for their own orders:

role customer {
  membership Customer

  ...
  privileges submitOrder {
    call {
      predicate ((customer, _) => Query.identity() == customer)
    }
  }
  ...
}

Best practices

Fauna evaluates role privileges for every query. The role membership is evaluated when a Token document is included in the query. Membership is ignored if a user accesses the database with a Key or an AccessProvider.

The role document architecture is such that a role configuration can authorize all users regardless of how they enter the database. This design allows applications to use multiple ways of accessing data. For example, consider this role:

Role.create({
  name: 'CanReadUsers',
  privileges: [{ resource: 'Users', actions: { read: true }}],
  membership: [{ resource: 'Users', predicate: 'user => user.isAdmin'}]
})

An admin user logging in through the Credential.login() function can read Users because the CanReadUsers.membership.predicate evaluates as true, making the user a member with privileges. At the same time, a user with a key created by Key.create({ role: "CanReadUsers" }) can also read Users documents because the CanReadUsers.privileges allow it.

Fauna doesn’t favor a single attribute-based access control approach, but the following suggestions might be useful:

  • Define only the roles you need.

  • Follow the principle of least privilege by granting role membership to only those users who need it.

  • While roles can filter out documents that shouldn’t be readable by the current user, such filtering can involve evaluating every document in a collection. Instead, use indexes for filtering.

  • Limit the scope of operations that use membership and privileges predicates wherever possible. Use predicates only when needed.

Is this article helpful? 

Tell Fauna how the article can be improved:
Visit Fauna's forums or email docs@fauna.com

Thank you for your feedback!