The way to apply this more generally leverages how Fauna treats permissions with reading Indexes Collections. A role can allow you to read an Index, but if there is a predicate on reading the source collection, Fauna will filter the results for only those Documents that are white listed by the current roles.
So how do we use that?
- Create a new Collection.
- Create one Document for each Role that uniquely represents each role.
- Create the required Indexes to read the new Collection.
- Update each Role with privileges to read the indexes, but a predicate that lets it only read the one specific Document that represents it.
Then, when you try to read the Indexes only those documents that the roles have access to will be read. You can interpret which documents are read as the roles that you have. And we can create Functions that make it easy to use this stuff.
NOTE: This technique gives you the ability to list all of the roles that a given key has, but it relies on each key being aware of which roles it has. That means that any key can read from this new Collection we are making, so be aware of that.
Example using the demo data
An easy way to try this out is with the demo database that can be created from the Dashboard. Create a new database and make sure to click Use demo data
Create a new Collection
We’ll call it "_role_checkers"
.
// as admin
CreateCollection({ name: "_role_checkers" })
Create the documents for each Role
This query will loop over each Role and create simple Documents with a name
field the same as the Role.
// as admin
Let(
{
role_names: Select("data", Map(Paginate(Roles()), Lambda("ref", Select("id", Var("ref"))))),
},
Map(
Var("role_names"),
Lambda(
"name",
Create(Collection("_role_checkers"), { data: { name: Var("name") } })
)
)
)
Create the required Indexes to read the new Collection
We will create an "_all_role_checkers"
Index that we will use for a "CurrentRoles"
Function, and a "_role_checker_by_name"
Index that we will use for a "HasRole"
Function.
// as admin
CreateIndex({
name: "_all_role_checkers",
source: Collection("_role_checkers")
})
CreateIndex({
name: "_role_checker_by_name",
source: Collection("_role_checkers"),
terms: [{ field: ["data", "name"] }]
})
IMPORTANT: These Indexes cannot specify values
fields. Otherwise, reading the Index entries will count as a read on the Document for permissions. We want to be able to use the read-the-index-but-filter-the-documents trick.
Create functions to use the Indexes
We want two functions
"CurrentRoles"
function that will return an array of Role name, like ["customer", "manager"]
"HasRole"
function that will take a Role name as an argument and return true
or false
.
These Functions will not have a role
field specified. That means they will rely on the Roles of the calling Key/Token.
// as admin
CreateFunction({
name: "CurrentRoles",
body: Query(
Lambda([], Map(
Select("data", Paginate(Match(Index("_all_role_checkers")))),
Lambda("ref", Select(["data", "name"], Get(Var("ref"))))
))
)
})
CreateFunction({
name: "HasRole",
body: Query(
Lambda("name", Let(
{
page: Paginate(Match(Index("_role_checker_by_name"), Var("name")))
},
Not(IsEmpty(Select("data", Var("page"))))
))
)
})
Update each Role
For each Role, we will add the privilege to read the Indexes, read the respective Document, and if we want, call the Functions.
Update(
Role("ROLE_NAME"),
{
Privileges: [
/* all existing privileges */
{
// Can only read the Document if the `name` field matches the Role's name
resource: Collection("_role_checkers"),
actions: { read: Query(Lambda("ref", Equals(
"ROLE_NAME",
Select(["data", "name"], Get(Var("ref")))
))) }
},
{
resource: Index("_all_role_checkers"),
actions: { read: true }
},
{
resource: Index("_role_checker_by_name"),
actions: { read: true }
},
{
resource: Function("HasRole"),
actions: { call: true }
},
{
resource: Function("CurrentRoles"),
actions: { call: true }
}
]
}
Test it out!
After running all of this on the Demo database we can try out our new functions
// as admin
Call("CurrentRoles")
// ["customer", "manager"]
Call("HasRole", "customer")
// true
Call("HasRole", "manager")
// true
// as a customer
Call("CurrentRoles")
// ["customer"]
Call("HasRole", "customer")
// true
Call("HasRole", "manager")
// false
// as a manager
Call("CurrentRoles")
// ["manager"]
Call("HasRole", "customer")
// false
Call("HasRole", "manager")
// true
Put it all together
FQL is quite powerful, and you can actually perform all of these steps in a single FQL query. The following actually needs to be at least twice so it will create the Collection then later when it exists the Documents will be created.
This query is also idempotent, so it will still work after it is run. You can make changes, add/remove Roles, or update privileges, and running the query should reset everything.
(I also dropped this into a gist, here)
Let(
{
role_checker_collection: If(
Exists(Collection("_role_checker")),
Collection("_role_checker"),
Let(
{
new_coll: CreateCollection({ name: "_role_checker" })
},
Select("ref", Var("new_coll"))
)
),
all_role_checkers_index: If(
Exists(Index("_all_role_checkers")),
Index("_all_role_checkers"),
Let(
{
new_index: CreateIndex({
name: "_all_role_checkers",
source: Var("role_checker_collection")
})
},
Select("ref", Var("new_index"))
)
),
role_checker_by_name_index: If(
Exists(Index("_role_checker_by_name")),
Index("_role_checker_by_name"),
Let(
{
new_index: CreateIndex({
name: "_role_checker_by_name",
source: Var("role_checker_collection"),
terms: [{ field: ["data", "name"] }]
})
},
Select("ref", Var("new_index"))
)
),
has_role_function: If(
Exists(Function("HasRole")),
Function("HasRole"),
Let(
{
new_function: CreateFunction({
name: "HasRole",
body: Query(
Lambda("name", Let(
{
page: Paginate(Match(Index("_role_checker_by_name"), Var("name")))
},
Not(IsEmpty(Select("data", Var("page"))))
))
)
})
},
Select("ref", Var("new_function"))
)
),
current_roles_function: If(
Exists(Function("CurrentRoles")),
Function("CurrentRoles"),
Let(
{
new_function: CreateFunction({
name: "CurrentRoles",
body: Query(
Lambda([], Map(
Select("data", Paginate(Match(Index("_all_role_checkers")))),
Lambda("ref", Select(["data", "name"], Get(Var("ref"))))
))
)
})
},
Select("ref", Var("new_function"))
)
),
role_names: Select("data", Map(Paginate(Roles()), Lambda("ref", Select("id", Var("ref"))))),
delete_existing_role_checkers: Map(
Paginate(Documents(Var("role_checker_collection"))),
Lambda("ref", Delete(Var("ref")))
),
new_role_checkers: Map(
Var("role_names"),
Lambda(
"name",
Create(Var("role_checker_collection"), { data: { name: Var("name") } })
)
),
role_updates: Map(
Var("role_names"),
Lambda(
"name",
Let(
{
role_ref: Role(Var("name")),
role: Get(Var("role_ref")),
privileges: Select("privileges", Var("role")),
privileges_filtered: Filter(
Var("privileges"),
Lambda(
"privilege",
And(
Not(Equals(Select(["resource"], Var("privilege")), Collection("_role_checker"))),
Not(Equals(Select(["resource"], Var("privilege")), Index("_all_role_checkers"))),
Not(Equals(Select(["resource"], Var("privilege")), Index("_role_checker_by_name"))),
Not(Equals(Select(["resource"], Var("privilege")), Function("HasRole"))),
Not(Equals(Select(["resource"], Var("privilege")), Function("CurrentRoles"))),
)
)
),
new_privileges: Append(
[
{
resource: Var("role_checker_collection"),
actions: { read: Query(Lambda("ref", Equals(
Var("name"),
Select(["data", "name"], Get(Var("ref")))
))) }
},
{
resource: Var("all_role_checkers_index"),
actions: { read: true }
},
{
resource: Var("role_checker_by_name_index"),
actions: { read: true }
},
{
resource: Var("has_role_function"),
actions: { call: true }
},
{
resource: Var("current_roles_function"),
actions: { call: true }
}
],
Var("privileges_filtered")
)
},
Update(Var("role_ref"), { privileges: Var("new_privileges") })
)
)
),
},
If(
IsEmpty(Select("data", Paginate(Documents(Var("role_checker_collection"))))),
"No _role_checker Documents created. Run again once you have roles",
Format("updated roles: %@", [Var("role_names")])
)
)