fireplan
Compiler for an alternative YAML-based syntax for Firebase security rules. The new syntax is much more readable but retains the overall semantics of the traditional JSON format, so there's no surprises.
Getting Started
First, install the compiler:
npm install -g fireplan
Then create a rules.yaml
file like this:
functions:
- percentage: number && next >= 0 && next <= 100
- canUpdate(subject): root.users[auth.uid].permissions[subject].write
root:
data:
$subject:
.read: true
.write: canUpdate($subject)
value: required percentage
description: string
users:
$uid:
.read/write: auth.uid == $uid
.ref: user
role: required oneOf('visitor', 'user', 'admin')
permissions:
$subject:
.write: user.role == 'user' || user.role == 'admin'
write: required boolean
Then compile it into rules.json
like so:
fireplan rules.yaml
Syntax
Fireplan security rules are written in YAML, which gets translated to JSON by the compiler. Indentation indicates the hierarchical structure and there's no need for quotes, but otherwise it's pretty similar to the traditional syntax.
One simple up-front difference: the root of the rule hierarchy is root:
rather than "rules":
, to better match the predefined root
variable in security expressions.
Simple Expressions
Security expressions are used in .read
, .write
and .value
rules, as well as in function definitions (explained below). All traditional security expression are valid in Fireplan as well, but there's a few extra features you can take advantage of:
- You can use next and prev instead of newData and data (but those still work as well).
- You can use JavaScript-like syntax for accessing children, so that
data.child('foo').child($bar)
becomesdata.foo[$bar]
. - You can leave off the
.val()
calls altogether, as they'll be inferred automatically (unless you're calling aString
method likelength
orcontains()
, then you must keep the.val()
). - You can capture named references to parent nodes with
.ref: <name>
, then use these references in your expression. They'll be automatically transformed tonewData.parent().parent()...
so you can easily reference other parts of the new object in your rules. (In.read
expressionsdata
will be used instead.)
Putting all these together, an expression like:
newData.child('counter').val() == data.child('counter').val() + 1
becomes:
next.counter == prev.counter + 1
Rule Kinds
The three basic kinds of rules are .read
, .write
and .value
, corresponding directly to the original .read
, .write
and .validate
. There's also a couple bits of syntactic sugar:
- You can specify a single
.read/write
rule if the.read
and.write
expressions are the same. This is particularly useful for properties that will be updated transactionally, sincetransaction()
requires both read and write access to its data. - If a property only has a validation rule, you can specify it directly as its value. So
foo: auth.uid == 'admin'
is the same asfoo: {.value: auth.uid == 'admin'}
or
foo:
.value: auth.uid == 'admin'
Children Properties
A very common validation need is to check whether a property has the expected children. You can do this manually using hasChildren()
and $other: false
catchalls, but Fireplan has a special syntax that makes it much easier. By default, any child listed under a property is optional but you can make it required by starting its value constraint with the keyword required
. Normally no children other than the required and optional ones listed are allowed, but if you'd like to accept any others as well (with no further validation) you can add .more: true
to the property.
To make some Firebase queries run efficiently you also need to earmark some children for indexing. You can do this by adding the keyword indexed
to a child's value constraint. (If both required
and indexed
are used together, they can come in any order but must precede any other constraint code.) All children marked as indexed
will be collected into the Firebase .indexOn
property of the parent of their nearest wildcard ($keyCapture
) ancestor, creating "deep" indexes automatically as necessary.
Putting it all together looks like this:
root:
foo:
bar: required string
baz:
.value: required
qux: number
.more: true
$stuff:
name: required indexed string
This means that foo
is optional, but if written it must have children bar
(a string) and baz
, and no others. In turn, baz
can have any children at all, but if qux
is specified then it must be a number. Other children of root ($stuff
) need to have a name
property (a string), and will be indexed on it.
Functions
As security rules grow more complex, you may find yourself repeatedly writing out the same expression snippet in various contexts. To cut down on duplication, Fireplan allows you to define functions that can then be "called" from expressions (including other functions). The definitions go into a top-level functions:
block like this:
functions:
- foo(bar, baz): next.qux == bar || auth.uid == baz
- foo2: foo('arrr', 'matey')
A function can take any number of arguments; if it doesn't take any, you can leave out the empty parentheses. Function names must be unique (there's no dispatch on the number of arguments). A function's body is an expression just like that of any security rule, and can access the function's arguments as well as the usual security rules globals (auth
, next
, etc.).
Functions are called in the usual way, like foo('bar', next.baz)
. A function can call other functions in its body but recursion is forbidden (and will crash the compiler). If a function doesn't take arguments you can also call it without parentheses, like foo2
. This is especially convenient for defining new "value types", like percentage
in the example at the top.
Types
Fireplan predefines three value types string
, boolean
and number
like so:
functions:
- string: next.isString()
- boolean: next.isBoolean()
- number: next.isNumber()
- any: true # also implies .more: true for this child
There's also a special predefined function oneOf
that is used to constrain a property to one of a list of values (typically strings). Use it like this (and prefix with required
to taste):
root:
foo: oneOf('bar', 'baz', 'qux')
Finally, for object types, you can apply YAML's referencing mechanism to reuse a definition in multiple places:
root:
foo: &fooType # establish a reference called fooType
bar: string
baz: required number
qux: *fooType # dereference fooType
# bar and baz are filled in here automatically
If you want, you can set up a separate types:
hierarchy and define type references there—Fireplan doesn't care if you have extra top-level keys.
Environment
Fireplan makes available a special env
variable that lets you substitute environment variable values at compile time. For example, if you need to distinguish between the development and production datastores in your rules, you could set export DATASTORE=dev
or export DATASTORE=prod
in your build, then check for it in your rules like so:
root:
.write: env.DATASTORE == 'dev'
Encryption
Fireplan supports Firecrypt encryption annotations.
You can prefix a .value
rule with the keyword encrypted
(mixed in any order with required
and indexed
), or suffix a key with /encrypted
, to indicate that that value or key needs to be encrypted. You can additionally include a pattern after the keyword to indicate which parts of the key or string value should be encrypted, like encrypted[#-#-.]
, where #
indicates an encrypted chunk and .
an unencrypted one.
You can also suffix a $
wildcard key with /few
to indicate that you don't expect there to be a lot of children there, and that it's safe to try to load all of them at once. Judicious application of this annotation can greatly speed up bulk encryption / key rotation operations in Firecrypt.
If any encrypted
or few
annotations are present, Fireplan will emit a rules_firecrypt.json
file that you can then feed into Firecrypt and related tools.
That's All!
Please let me know if you have any problems.