Skip to content

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 exists
  • x$ : ["isNil"] : true if data is null or undefined
  • !$ : ["not"] : flip boolean
  • l$ : ["toLower"] : lowercase
  • u$ : ["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")