Control access with ABAC

In Fauna, you implement attribute-based access control (ABAC) using role-related predicates. These predicates let you assign roles and grant privileges based on attributes.

You can further limit access using user-defined functions (UDFs). UDFs give you granular control over the way data is queried and returned.

In this tutorial, you’ll use ABAC and UDFs to dynamically control access to medical appointment data.

Before you start

To complete this tutorial, you’ll need:

Setup

Set up a database with sample data and a user-defined role.

  1. Log into Fauna using the CLI:

    fauna cloud-login
  2. Create an abac database:

    fauna create-database abac
  3. Create an abac directory and navigate to it:

    mkdir abac
    cd abac
  4. Initialize a project:

    fauna project init

    When prompted, use:

    • schema as the schema directory. The command creates the directory.

    • dev as the environment name.

    • The default endpoint.

    • abac as the database.

  5. Navigate to the schema directory:

    cd schema
  6. Create the collections.fsl file and add the following schema to it:

    // Stores identity documents for staff end users.
    collection Staff {
      // Each `Staff` collection document must have a
      // unique `name` field value.
      unique [.name]
    
      // Defines the `byName()` index.
      // Use the index to get `Staff` documents by `name` value.
      index byName {
        terms [.name]
      }
    }
    
    // Stores medical office data.
    collection Office {
      unique [.name]
    
      index byName {
        terms [.name]
      }
    }
    
    // Stores appointment data.
    collection Appointment {
      // Defines the `byDateAndOffice()` index.
      // Use the index to get `Appointment` documents by
      // `date` and `office` values.
      index byDateAndOffice {
        terms [.date, .office]
      }
    
      // Adds a computed `date` field.
      compute date = (appt => {
        // The `date` value is derived from each `Appointment`
        // document's `time` field.
        let timeStr = appt.time.toString()
        let dateStr = timeStr.split("T")[0]
        Date(dateStr)
      })
    }
  7. Create roles.fsl and add the following schema to it:

    // User-defined role for frontdesk staff.
    role frontdesk {
      // Assigns the `frontdesk` role to tokens with
      // an identity document in the `Staff` collection.
      membership Staff
    
      // Grants `read` access to
      // `Appointment` collection documents.
      privileges Appointment {
        read
      }
    
      // Grants `read` access to
      // `Office` collection documents.
      privileges Office {
        read
      }
    }
  8. Save collections.fsl and roles.fsl. Then push the schema to Fauna:

    fauna schema push

    When prompted, accept and push the changes. This creates the collections and role.

  9. Start a shell session in the Fauna CLI:

    fauna shell

    The shell runs with the default admin key you created when you logged in using fauna cloud-login.

  10. Enter editor mode to run multi-line queries:

    > .editor
  11. Run the following FQL query:

    // Creates an `Office` collection document.
    // Stores a reference to the document in
    // the `acmeOffice` variable.
    let acmeOffice = Office.create({
      name: "Acme Medical"
    })
    
    let baysideOffice = Office.create({
      name: "Bayside Hospital"
    })
    
    // Creates a `Staff` collection document.
    Staff.create({
      name: "John Doe"
    })
    
    // Creates an `Appointment` collection document.
    Appointment.create({
      patient: "Alice Appleseed",
      // Set appointment for now
      time: Time.now(),
      reason: "Allergy test",
      // Sets the previous `Office` collection document
      // as the `office` field value.
      office: acmeOffice
    })
    
    Appointment.create({
      patient: "Bob Brown",
      // Set the appointment for 30 minutes from now.
      time: Time.now().add(30, "minutes"),
      reason: "Fever",
      office: acmeOffice
    })
    
    Appointment.create({
      patient: "Carol Clark",
      // Set the appointment for 1 hour from now.
      time: Time.now().add(1, "hours"),
      reason: "Foot x-ray",
      office: baysideOffice
    })
    
    Appointment.create({
      patient: "Dave Dennis",
      // Set the appointment for tomorrow.
      time: Time.now().add(1, "days"),
      reason: "Foot x-ray",
      office: acmeOffice
    })

    The query creates all documents but only returns the last document.

Create an authentication token

Create a token tied to a user’s Staff identity document.

You typically create tokens using credentials and login-related UDFs. For simplicity, this tutorial uses Token.create() to create the token instead.

  1. Run the following query in the shell’s editor mode:

    // Gets `Staff` collection documents with a `name` of `John Doe`.
    // Because `name` values are unique, use the first document.
    let document = Staff.byName("John Doe").first()
    
    // Create a token using the `Staff` document
    // as the identity document.
    Token.create({ document: document })

    Copy the returned token’s secret. You’ll use the secret later in the tutorial.

  2. Press Ctrl+D to exit the shell.

Test user access

Use the token’s secret to run queries on the user’s behalf. Fauna assigns and evaluates the secret’s roles and privileges, including any predicates, at query time for every query.

  1. Start a new shell session using the token secret:

    fauna shell --secret <TOKEN_SECRET>
  2. Run the following query in editor mode:

    // Use the `byDateAndOffice()` index to get `Appointment` documents
    // with a:
    //   - `date` value of today
    //   - `office` value containing an `Office` collection document
    //      with a `name` of  "Acme Medical"
    Appointment.byDateAndOffice(
      Date.today(),
      Office.byName("Acme Medical").first()
    )

    If successful, the query returns Appointment documents with a date of today:

    {
      data: [
        {
          id: "400593000902688845",
          coll: Appointment,
          ts: Time("2099-06-13T15:55:06.320Z"),
          date: Date("2099-06-13"),
          patient: "Alice Appleseed",
          time: Time("2099-06-13T15:55:06.239185Z"),
          reason: "Allergy test",
          office: Office("400593000896397389")
        },
        ...
      ]
    }
  3. Press Ctrl+D to exit the shell.

Check environmental attributes

Add a membership predicate to only assign the frontdesk role to Staff users during their scheduled work hours.

  1. Edit roles.fsl as follows:

    role frontdesk {
      membership Staff {
        predicate (staff => {
          // Allow access after the user's
          // scheduled start hour in UTC.
          Time.now().hour >= staff.schedule[0] &&
          // Disallow access after the user's
          // scheduled end hour in UTC.
          Time.now().hour  < staff.schedule[1]
        })
      }
    
      privileges Appointment {
        read
      }
    
      privileges Office {
        read
      }
    }
  2. Save roles.fsl and push the schema to Fauna:

    fauna schema push
  3. Test the user’s access.

    The query returns a privileges-related error. The token’s identity document doesn’t have the required schedule field.

  4. Next, add the schedule field to the identity document. Start a shell session using your admin key:

    fauna shell
  5. Run the following query in editor mode:

    // Gets the token's identity document.
    let user = Staff.byName("John Doe").first()
    
    // Adds a `schedule` field to the above document.
    user?.update({
      schedule: [8, 18] // 08:00 (8 AM) to 18:00 (6 PM) UTC
    })
  6. Exit the admin shell session and test the user’s access.

    If the current UTC time is within the user’s scheduled hours, the query should run successfully. If needed, you can repeat the previous step and adjust the user’s schedule.

Check identity-based attributes

Add a privilege predicate to only allow the frontdesk role to read Appointment documents with the same office as the user’s identity document.

Fauna evaluates the privilege predicate at query time for every query. This lets you tread the user’s identity document as "living" document. If the office value in the user’s identity document changes, their access to Appointment documents also changes.

  1. Edit roles.fsl as follows:

    role frontdesk {
      membership Staff {
        predicate (staff => {
          Time.now().hour >= staff.schedule[0] &&
          Time.now().hour  < staff.schedule[1]
        })
      }
    
      // If the predicate is `true`,
      // grant `read` access to `Appointment` collection documents.
      privileges Appointment {
        read {
          predicate (doc =>
            // `Query.identity()` gets the token's `Staff` identity
            // document. The predicate checks that `Appointment`
            // document's `office` value is the same as the `Staff`
            // user's `office` value.
            doc.office == Query.identity()?.office
          )
        }
      }
    
      privileges Office {
        read
      }
    }
  2. Save roles.fsl and push the schema to Fauna:

    fauna schema push
  3. Test user access using the steps in Test user access.

    The query returns an empty set:

    {
      data: []
    }

    The token’s identity document doesn’t have the required office value.

  4. Next, add the office field to the identity document. Start a shell session using your admin key:

    fauna shell
  5. Run the following query in editor mode:

    // Gets the token's identity document.
    let user = Staff.byName("John Doe").first()
    
    // Get the first `Office` collection document with a
    // `name` of "Acme Medical".
    let acmeOffice = Office.byName("Acme Medical").first()
    
    // Updates the identity document to add the previous
    // `Office` document as the `office` field value.
    user!.update({
      office: acmeOffice
    })
  6. Exit the admin shell session and test the user’s access.

    The query should run successfully.

  7. Run the following query in editor mode:

    // Use the `byDateAndOffice()` index to get `Appointment` documents
    // with a:
    //   - `date` of tomorrow
    //   - `office` containing the "Bayside Hospital" document in the
    //     `Office` collection
    Appointment.byDateAndOffice(
      Date.today().add(1, "days"),
      Office.byName("Bayside Hospital").first()
    )

    Although the sample data includes Appointment documents with the requested office, the query returns an empty set. The frontdesk role can only read Appointment documents with the same office as the user.

Check data attributes

Update the privilege predicate to only allow the frontdesk role to access Appointment documents with a date of today’s date.

  1. Edit roles.fsl as follows:

    role frontdesk {
      membership Staff {
        predicate (staff => {
          Time.now().hour >= staff.schedule[0] &&
          Time.now().hour  < staff.schedule[1]
        })
      }
    
      // If the predicate is `true`,
      // grant `read` access to `Appointment` collection documents.
      privileges Appointment {
        read {
          predicate (doc =>
            doc.office == Query.identity()?.office &&
            // Only allow access to `Appointment` documents with
            // today's `date`
            doc.date == Date.today()
          )
        }
      }
    
      privileges Office {
        read
      }
    }
  2. Save roles.fsl and push the schema to Fauna:

    fauna schema push

Re-test user access

Use the token’s secret to run queries on the user’s behalf. The user should only be able to read Appointment documents with a date of today.

  1. Start a shell session with the user’s token secret:

    fauna shell --secret <TOKEN_SECRET>
  2. Run the following query in editor mode:

    // Use the `byDateAndOffice()` index to get `Appointment` documents
    // with a:
    //   - `date` value of tomorrow
    //   - `office` value containing an `Office` collection document
    //      with a `name` of  "Acme Medical"
    Appointment.byDateAndOffice(
      Date.today().add(1, "days"),
      Office.byName("Acme Medical").first()
    )

    Although the sample data includes Appointment documents with a date of tomorrow, the query returns an empty set. The user can only read Appointment documents with a date of today.

  3. Run the following query in editor mode to get Appointment documents with a date of today:

    // Use the `byDateAndOffice()` index to get `Appointment` documents
    // with a:
    //   - `date` value of today
    //   - `office` value containing an `Office` collection document
    //      with a `name` of  "Acme Medical"
    Appointment.byDateAndOffice(
      Date.today(),
      Office.byName("Acme Medical").first()
    )

    The query should run successfully.

Limit access with UDFs

For more control, you could only allow users to access Appointment data using a UDF.

With a UDF, you can remove direct access to Appointment collection documents. The UDF also lets you customize the format of returned data. In this case, we’ll exclude the reason field from results.

First, define a frontdeskAppts() UDF. Then update the frontdesk role to:

  • Remove privileges for the Appointment collection.

  • Add a call privilege for the frontdeskAppts() function.

  • Add a privilege predicate to only allow users to call frontdeskAppts() with today’s date and the user’s office.

  1. In the schema directory, create functions.fsl and add the following schema to it:

    // Defines the `frontdeskAppts()` function.
    // The function gets appointments for a specific date.
    // Runs with the built-in `server-readonly` role's privileges.
    @role("server-readonly")
    function frontdeskAppts(date, office) {
    
      // Uses the `byDateAndOffice()` index to get `Appointment`
      // documents by date and office. Returned documents only
      // contain the `patient` and `date` fields.
      let appt = Appointment.byDateAndOffice(date, office) {
        patient,
        date
      }
    
      // Output results as an array
      appt.toArray()
    }
  2. Edit roles.fsl as follows:

    role frontdesk {
      membership Staff {
        predicate (staff => {
          Time.now().hour >= staff.schedule[0] &&
          Time.now().hour  < staff.schedule[1]
        })
      }
    
      // Removed `Appointment` collection privileges
    
      // If the predicate is `true`,
      // grant the ability to call the `frondeskAppts` function.
      privileges frontdeskAppts {
        call {
          predicate ((date, office) =>
            // Only allow users to pass today's date to the function.
            date == Date.today() &&
            // Only allow users to pass their office to the function.
            office == Query.identity()?.office
          )
        }
      }
    
      privileges Office {
        read
      }
    }
  3. Save functions.fsl and roles.fsl. Then push the schema to Fauna:

    fauna schema push

Re-test user access

Use the token’s secret to run queries on the user’s behalf. The user should only be able to call frontdeskAppts() with today’s date.

  1. Start a shell session with the user’s token secret:

    fauna shell --secret <TOKEN_SECRET>
  2. Run the following query in editor mode. The query calls frontdeskAppts() with today’s date and the user’s office.

    // Get today's date.
    let date = Date.today()
    
    // Get the first `Office` collection document with a
    // `name` of "Acme Medical".
    let office = Office.byName("Acme Medical").first()
    
    // Pass tomorrow's date to the
    // `frondeskAppts` function call.
    frontdeskAppts(date, office)

    The returned array only contains the patient and date fields:

    [
      {
        patient: "Alice Appleseed",
        date: Date("2099-06-13")
      },
      {
        patient: "Bob Brown",
        date: Date("2099-06-13")
      }
    ]
  3. Run the following query in editor mode:

    // Get tomorrow's date.
    let date = Date.today().add(1, 'days')
    
    // Get the first `Office` collection document with a
    // `name` of "Acme Medical".
    let office = Office.byName("Acme Medical").first()
    
    // Pass tomorrow's date to the
    // `frondeskAppts` function call.
    frontdeskAppts(date, office)

    The query returns a privileges-related error. The frontdesk role only allows you to call frontdeskAppts() with today’s date.

  4. Run the following query in editor mode:

    // Get tomorrow's date.
    let date = Date.today()
    
    // Get the first `Office` collection document with a
    // `name` of "Bayside Hospital".
    let office = Office.byName("Bayside Hospital").first()
    
    // Pass tomorrow's date to the
    // `frondeskAppts` function call.
    frontdeskAppts(date, office)

    The query returns a privileges-related error. The frontdesk role only allows you to call frontdeskAppts() with user’s office.

  5. Run the following query in editor mode:

    // Attempt to directly read `Appointment` collection documents
    Appointment.byDateAndOffice(
      Date.today(),
      Office.byName("Acme Medical").first()
    )

    The query returns a privileges-related error. The frontdesk role does not grant direct access to Appointment collection documents.

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!