FQL v4 will be decommissioned on June 30, 2025. Ensure that you complete your migration from FQL v4 to FQL v10 by that date. Fauna accounts created after August 21, 2024 must use FQL v10. These accounts will not be able to run FQL v4 queries or access the v4 Dashboard. For more details, see the v4 EOL announcement and migration guide. Contact support@fauna.com with any questions. |
Attribute-based access control (ABAC)
This tutorial assumes that you have completed the Dashboard quick start. |
Attribute-based access control (ABAC) is an alternative to an all-or-nothing security model, and is commonly used in applications to restrict access to specific data based on the user’s role. ABAC is an extension of role-based access control (RBAC), where users are assigned roles that grant them specific privileges. The benefit of ABAC is that privileges can be dynamically determined based on attributes of the user, the documents to be accessed or modified, or context during a transaction (for example, time of day).
In this tutorial, we introduce Fauna’s Attribute-Based Access Control (ABAC) feature by simulating an employee hierarchy, and employing a "smart" role that permits users to see their own salary, and managers to see their own salary and the salaries of users that report to them.
For more information on ABAC, see Attribute-based access control (ABAC).
Step 1: Create a new database
Open a terminal and run:
fauna create-database abac
creating database abac
created database 'abac'
To start a shell with your new database, run:
fauna shell 'abac'
Or, to create an application key for your database, run:
fauna create-key 'abac'
Step 2: Connect to the new database using Fauna Shell
Start a Fauna Shell session:
fauna shell abac
Starting shell for database abac
Connected to http://faunadb:8443
Type Ctrl+D or .exit to exit the shell
Step 3: Create three separate collections
Do(
CreateCollection({ name: "users" }),
CreateCollection({ name: "salary" }),
CreateCollection({ name: "user_subordinate" })
)
The users
collection is used to store the user details, while the
salary
collection is used to collect the salary information. The
user_subordinate
collection is used to store the information of
managers and their subordinates.
Step 4: Create three indexes
In Fauna, indexes are required for pagination or searching. Here, we
create collection indexes on the users
and salary
collections, and a
specific index to retrieve users by name.
Do(
CreateIndex({
name: "all_users",
source: Collection("users"),
}),
CreateIndex({
name: "user_by_name",
source: Collection("users"),
terms: [{ field: ["data", "name"] }],
}),
CreateIndex({
name: "all_salaries",
source: Collection("salary"),
})
)
Step 5: Create user and salary data
Here, we create some users
and salary
data. The salary
collection
stores the user reference as a foreign key. The user
collection also
stores the user’s credentials, which is just a simple password for this
tutorial.
Map([
["Bob", 95000],
["Joe", 60000],
["John", 70000],
["Peter", 97000],
["Mary", 120000],
["Carol", 150000]
], Lambda("data", Let(
{
user: Create(Collection("users"), {
data: { name: Select(0, Var("data")) },
credentials: { password: "123" }
}),
salary: Select(1, Var("data"))
},
Create(Collection("salary"), { data: {
user: Select("ref", Var("user")),
salary: Var("salary")
}})
)))
Step 6: Verify that the data is correct
Now that the data is created, let us query the two collections to check out the usernames and salaries:
Map(
Paginate(Match(Index("all_salaries"))),
Lambda("salaryRef",
Let({
salary: Get(Var("salaryRef")),
user: Get(Select(["data", "user"], Var("salary")))
},
{
user: Select(["data", "name"], Var("user")),
salary: Select(["data", "salary"], Var("salary"))
}
)
)
)
{ data:
[
{ user: 'Carol', salary: 150000 },
{ user: 'Peter', salary: 97000 },
{ user: 'Joe', salary: 60000 },
{ user: 'Bob', salary: 95000 },
{ user: 'Mary', salary: 120000 },
{ user: 'John', salary: 70000 }
]
}
Step 7: Create manager→user relationship data
Now that the basic data is created, we create a similar sample data associating managers and their subordinates
Map([
["Bob", "Mary"],
["John", "Mary"],
["Peter", "Joe"]
], Lambda("data", Let(
{
user: Get(Match(Index("user_by_name"), Select(0, Var("data")))),
manager: Get(Match(Index("user_by_name"), Select(1, Var("data"))))
},
Create(Collection("user_subordinate"), { data: {
user: Select("ref", Var("user")),
reports_to: Select("ref", Var("manager"))
}})
)))
Here, we see that Bob and John work for Mary, while Peter works for Joe. Once our access controls are in place, Bob should only be able to see his salary, but Mary should be able to see her salary as well as the salary for Bob and John.
Step 8: Create an index for the user_subordinate
collection
CreateIndex({
name: "is_subordinate",
source: Collection("user_subordinate"),
terms: [
{ field: ["data", "user"] },
{ field: ["data", "reports_to"] }
]
})
Step 9: Create a role that provides the appropriate privileges
CreateRole({
name: "normal_user",
membership: {
resource: Collection("users")
},
privileges: [
{ resource: Collection("users"), actions: { read: true } },
{ resource: Index("all_users"), actions: { read: true } },
{ resource: Index("all_salaries"), actions: { read: true } },
{
resource: Collection("salary"),
actions: {
read: Query(
Lambda("salaryRef", Let(
{
salary: Get(Var("salaryRef")),
userRef: Select( ["data", "user"], Var("salary"))
},
Or(
Equals(Var("userRef"), CurrentIdentity()),
Exists(
Match(
Index("is_subordinate"),
[Var("userRef"), CurrentIdentity()]
)
)
))
)
)
}
}
]
})
This query defines the role that assigns privileges to members of the "users" collection. This is the critical part of this tutorial and the query is rather complex, so it deserves close inspection.
The role’s membership
is simple: any document in the "users"
collection that has been successfully authenticated by using the
Login
function gains the specified privileges, and is called an
"authenticated user".
The privileges
definition says, starting from the top, that:
-
Read access to the "users" collection is granted. Authenticated users can access documents describing other users.
-
Read access to the "all_users" index is granted. Authenticated users can use the "all_users" index to list all existing users.
-
Read access to the "all_salaries" index is granted. Authenticated users can use the "all_salaries" index to list all salaried users.
-
A predicate
Lambda
function dynamically determines the read access to the "salary" collection. When read access is not granted, the salary documents are not readable.
The predicate function grants read access when one of the following conditions is met:
-
The user reference in the "salary" document matches the
CurrentIdentity
of the authenticated user. -
The user reference in the "salary" document is a subordinate of the authenticated user.
Here is a detailed description of the predicate function:
Because the privilege defining the predicate function has its resource
defined as the "salary" collection, each time a "salary" document is to
be read, the predicate function is called with the salaryRef
parameter, which is a reference to the "salary" document being evaluated
for access.
The function first calls Let
to define variables that can be used
later on. The salary
variable is defined with the associated "salary"
document, acquired by calling Get
with the salaryRef
. The
userRef
variable is defined with the reference to the associated
"user" document, which is acquired by calling Select
on the value
of the salary
variable.
Then, the predicate function implicitly returns the value of calling the
Or
function, which includes both the equivalence check that the
userRef
variable matches the CurrentIdentity
of the currently
authenticated user, and the check that the userRef
and
CurrentIdentity
match an entry in the "is_subordinate" index. If
either check returns true
, Or
returns true
(granting read access),
otherwise false
is returned (denying read access).
Finally, if the predicate function fails for any reason, read access is not granted.
Step 10: Verify salary access for a user
Now we can log in to the database as Bob and run the salary listing query. First we have to create a token for Bob:
Login(Match(Index("user_by_name"), "Bob"), { password: "123" })
The output should look similar to:
{ ref: Ref(Tokens(), "231651464569684480"),
ts: 1557178902130000,
instance: Ref(Collection("users"), "231651384582210048"),
secret: 'fnEDNv3HmWACAAM2_aC3wAIAGOysa8knR3F3ZzvUkc0sq_O6chQ' }
Using the secret, we can log in to the database and run the user listing
query. In a separate terminal, start a new Fauna Shell session, and
be sure to copy the value of the secret
field as the value of the
--secret
argument in the following command:
fauna shell --secret="fnEDNv3HmWACAAM2_aC3wAIAGOysa8knR3F3ZzvUkc0sq_O6chQ"
Warning: You didn't specify a database. Starting the shell in the global scope.
Connected to http://faunadb:8443
Type Ctrl+D or .exit to exit the shell
Then run this query:
Map(
Paginate(Match(Index("all_salaries"))),
Lambda("salaryRef", Let({
salary: Get(Var("salaryRef")),
user: Get(Select(["data", "user"], Var("salary")))
},
{
user: Select(["data", "name"], Var("user")),
salary: Select(["data", "salary"], Var("salary"))
})
))
You should see the following output:
{ data: [ { user: 'Bob', salary: 95000 } ] }
So, we can see that Bob can only query his own salary.
Step 11: Verify salary access for a manager
In the original Fauna Shell session, create a login token for Mary:
Login(Match(Index("user_by_name"), "Mary"), { password: "123" })
You should see output similar to the following:
{ ref: Ref(Tokens(), "231573285766169088"),
ts: 1557104345000000,
instance: Ref(Collection("users"), "231573095978109440"),
secret: 'fnEDNv4fcEACAAM2_aC3wAIANL6untGn8nhY-NK2O90oHyIeWuY' }
In a new terminal, start a new Fauna Shell session, and be sure to
copy the value of the secret
field as the value of the --secret
argument in the following command:
fauna shell --secret="fnEDNv4fcEACAAM2_aC3wAIANL6untGn8nhY-NK2O90oHyIeWuY"
Warning: You didn't specify a database. Starting the shell in the global scope.
Connected to http://faunadb:8443
Type Ctrl+D or .exit to exit the shell
Then run the salary lookup query:
Map(
Paginate(Match(Index("all_salaries"))),
Lambda("salaryRef", Let({
salary: Get(Var("salaryRef")),
user: Get(Select(["data", "user"], Var("salary")))
},
{
user: Select(["data", "name"], Var("user")),
salary: Select(["data", "salary"], Var("salary"))
})
))
You should see the following output (the order may vary):
{ data:
[ { user: 'Bob', salary: 95000 },
{ user: 'Mary', salary: 120000 },
{ user: 'John', salary: 70000 } ] }
Mary can see the salaries for herself, Bob, and John.
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!