Auth Rules
Access Control Rules with FPJSON
One big constraint of FPJSON is we can only do pure functional programming with point-free style, which means functions cannot have arguments. Functional programming is extremely powerful, but pure FP sometimes makes it overly complicated and impractical to build a simple logic.
WeaveDB elegantly extends the base FPJSON to makes it easier and more practical by injecting side-effect variables and imperative programming features such as if-else conditional statement.
allow() / deny()
The simplest form of access control rules is just allow everything.
["set", ["allow()"]]
or deny everything.
["set", ["deny()"]]
Pattern Matching
The first element is an accepted operation and the condition will be evaluated only if the query matches the operation.
add
|set
|update
|upsert
|del
: these matche query types
You can always use the basic operation types, but a better solution is define custom operations such as add:post
and del:post
.
["add:post", ["allow()"]]
The first part of a custome tag matches query types, and the second part is an arbitrary operation name.
- custome tag:
type:name
- types:
add
|set
|update
|upsert
|del
In this way, users will only be able to execute the preset custom queries, so you will be in better control.
await db.set("add:post", {title: "Test", body: "hello"}, "posts")
Preset Variables
You can access preset variables in access rule evaluations as explained here.
let vars = {
op, // set:post ( op = opcode:operand )
opcode, // set
operand, // post
id, // database ID
owner // database owner
signer, // message signer
ts, // timestamp
dir, // directory ID
doc, // document ID
query, // query = [ req, dir, doc ]
req, // data in request query ( before + req = after )
before, // data before updating
after, // data after updating
allow: false, // auth passes if allow is eventually true
}
For instance, if { title: "Title", body: "hellow" }
is already stored, and the query is updating { body: "bye" }
, the following is what will be assigned.
$before
:{ title: "Title", body: "hellow" }
$after
:{ title: "Title", body: "bye" }
mod()
mod()
will manipulate the uploading data before commiting permanently.
[ "add:post", [ [ "mod()", { id: "$id", owner: "$signer", date: "$ts" } ], ["allow()"] ] ]
This will set id
to the auto-generated docID, owner
to the transaction signer, and date
to the transaction timestamp.
const tx = await db.set("add:post", {title: "Test", body: "hello"}, "posts")
const post = await db.get("posts", tx.docID)
// the post has the extra fields auto-assigned : { title, body, id, owner, date }
This is how you can control the values of updated fields and minimize the fields users will upload.
fields()
You can also constrain the user updated fields with fields()
, and it works great with mods()
.
In the previous example, you only want users to update title
and body
, not anything else.
Use ["fields()", ["title", "body"]]
for such an restriction.
[
"add:post",
[
["fields()", ["title", "body"]],
["mod()", { id: "$id", owner: "$signer", date: "$ts" }],
["allow()"],
],
]
*
will make the field mandatory. e.g. ["fields()", ["*title", "*body"]]
// these will be rejected
await db.set("add:post", {title: "Test", body: "hello", id: "abc"}, "posts")
await db.set("add:post", {title: "Test"}, "posts") // missing mandatory body
You can also individually whitelist and blacklist fields with requested_fields()
and disallowed_fields()
respectively.
=$
=$
will assign the result of the following block to a variable. You can use FPJSON logic in the second block.
// this will check if the signer is the post owner
["=$isOwner", ["equals", "$signer", "$before.owner"]]
allowif() / allowifall()
Assigned variables can be used in any later blocks. It's especially useful when combined with allowif()
.
[
"delete:post",
[
["=$isOwner", ["equals", "$signer", "$before.owner"]], // the second block is FPJSON
["allowif()", "$isOwner"], // allow if the second element is true
],
]
You can use multiple conditions with allowifall()
. The following also checks if the signer is the database owner.
[
"delete:post",
[
["=$isDataOwner", ["equals", "$signer", "$before.owner"]],
["=$isDBOwner", ["equals", "$signer", "$owner"]],
["allowifall()", ["$isOwner", "$isDBOwner"],
],
]
You can use allowifany()
, denyif()
, denyifall()
, denyifany()
, breakif()
in the same principle.
get()
get()
allows you to query other data during access evaluations.
The following checks if the signer exists in users
collection. It's equivalent to await data.get("users", "$signer")
.
[
"add:post",
[
["=$user", ["get()", ["users", "$signer"]]],
["=$existsUser", [["complement",["isNil"]], "$user"]],
["allowif()", "$existsUser"],
],
]
Shortcut Symbols
As you can see, functional programming can get a bit too verbose for simple logic like $existsUser
. So we have a bunch of shortcut symbols to make it more pleasant.
o$
:["complement",["isNil"]]
: true if data existsx$
:["isNil"]
: true if data isnull
orundefined
!$
:["not"]
: flip booleanl$
:["toLower"]
: lowercaseu$
:["toUpper"]
: uppercase$
:["tail"]
: remove the first element, useful for escaping in FPJSON
For instance, you can simplify the previous example as follows.
[
"add:post",
[
["=$user", ["get()", ["users", "$signer"]]],
["allowif()", "o$user"], // true if $user exists
],
]
if-else conditions
Sometimes you want to execute some blocks only if a certain condition is met.
if
executes the third block only if the second block evaluates true
.
["if", "o$user", ["=$existsUser", true]]
You can combine if
with elif
and else
.
["=$existsUser", ["if", "o$user", true, "else", false]]
User break
to exit the whole evaluation without allow()
and deny()
.
["if", "x$user", ["break"]]
In this case, the query validity depends on other matched conditions. For example, you could define conditions for add:post
, but also another condition for add
and the query matches both patterns.
Helper Functions
split()
It's often useful to make the docID deterministic with some document fields. For example, express follow relationships, you would set the docID fromUserID:toUserID
, in this case, split()
comes in handy.
[
"set:follow",
[
// split docID with ":" and assign to $from_id and $to_id
["split()", [":", "$id", ["=$from_id", "=$to_id"]]],
["=$isFromSigner", ["equals", "$from_id", "$signer"]],
["mod()", { from: "$from_id", to: "$to_id", date: "$ms" }],
["allowif()", "$isFromSigner"]
],
]
Now users can send an empty query as long as the docID checks out.
await db.set("set:follow", {}, "follows", "fromUserID:toUserID")
parse()
equivalent to JSON.parse()
.
stringify()
equivalent to JSON.stringify()
.
Set Auth Rules
const auth = [
[
"add:note",
[
["fields()", ["*content"]],
["mod()", { id: "$doc", actor: "$signer", published: "$ts", likes: 0 }],
["allow()"],
],
],
]
await db.setAuth(auth, "notes")