Many collaborative apps allow users to read and write different pieces of data based on a set of permissions. In a document editing app, for example, users may want to allow a few users to read and write their documents while blocking unwanted access.
Solution: Role-Based Access Control
You can take advantage of Cloud Firestore's data model as well as custom security rules to implement role-based access control in your app.
Suppose you are building a collaborative writing application in which users can create "stories" and "comments" with the following security requirements:
- Each story has one owner and can be shared with "writers", "commenters", and "readers".
- Readers can only see stories and comments. They cannot edit anything.
- Commenters have all the access of readers, and they can also add comments to a story.
- Writers have all the access of commenters, and they can also edit story content.
- Owners can edit any part of a story as well as control the access of other users.
Data Structure
Assume your app has a stories
collection where each document represents
a story. Each story also has a comments
subcollection where each document
is a comment on that story.
To keep track of access roles, add a roles
field which is a map of
user IDs to roles:
/stories/{storyid}
{
title: "A Great Story",
content: "Once upon a time ...",
roles: {
alice: "owner",
bob: "reader",
david: "writer",
jane: "commenter"
// ...
}
}
Comments contain only two fields, the author's user ID and some content:
/stories/{storyid}/comments/{commentid}
{
user: "alice",
content: "I think this is a great story!"
}
Rules
Now that you have users' roles recorded in the database, you need to write
Security Rules to validate them. These rules assume the app uses Firebase Auth
so that the request.auth.uid
variable is the user's ID.
Step 1: Start with a basic rules file, which includes empty rules for stories and comments:
service
cloud
.
firestore
{
match
/
databases
/
{
database
}
/
documents
{
match
/
stories
/
{
story
}
{
//
TODO
:
Story
rules
go
here
...
match
/
comments
/
{
comment
}
{
//
TODO
:
Comment
rules
go
here
...
}
}
}
}
Step 2: Add a simple write
rule that gives owners complete control over
stories. The functions defined help determine a user's roles and if new
documents are valid:
service
cloud
.
firestore
{
match
/databases/{database
}
/
documents
{
match
/stories/{story
}
{
function
isSignedIn()
{
return
request.auth
!=
null
;
}
function
getRole
(
rsc
)
{
//
Read
from
the
"roles"
map
in
the
resource
(rsc).
return
rsc.data.roles
[
request.auth.uid
]
;
}
function
isOneOfRoles
(
rsc
,
array
)
{
//
Determine
if
the
user
is
one
of
an
array
of
roles
return
isSignedIn()
&&
(getRole(rsc)
in
array)
;
}
function
isValidNewStory
()
{
//
Valid
if
story
does
not
exist
and
the
new
story
has
the
correct
owner.
return
resource
==
null
&&
isOneOfRoles(request.resource,
[
'owner'
]
)
;
}
//
Owners
can
read
,
write
,
and
delete
stories
allow
write
:
if
isValidNewStory
()
||
isOneOfRoles
(
resource
,
[
'owner'
]
);
match
/
comments
/
{
comment
}
{
//
...
}
}
}
}
Step 3: Write rules that allow a user of any role to read stories and comments. Using the functions defined in the previous step keeps the rules concise and readable:
service
cloud
.
firestore
{
match
/databases/{database
}
/
documents
{
match
/stories/{story
}
{
function
isSignedIn()
{
return
request.auth
!=
null
;
}
function
getRole
(
rsc
)
{
return
rsc.data.roles
[
request.auth.uid
]
;
}
function
isOneOfRoles
(
rsc
,
array
)
{
return
isSignedIn()
&&
(getRole(rsc)
in
array)
;
}
function
isValidNewStory
()
{
return
resource
==
null
&&
request.resource.data.roles
[
request.auth.uid
]
==
'owner'
;
}
allow
write
:
if
isValidNewStory
()
||
isOneOfRoles
(
resource
,
[
'owner'
]
);
//
Any
role
can
read
stories
.
allow
read
:
if
isOneOfRoles
(
resource
,
[
'owner'
,
'writer'
,
'commenter'
,
'reader'
]
);
match
/
comments
/
{
comment
}
{
//
Any
role
can
read
comments.
allow
read
:
if
isOneOfRoles
(
get
(
/
databases
/
$(
database
)
/
documents
/
stories
/
$(
story
)),
[
'owner'
,
'writer'
,
'commenter'
,
'reader'
]
);
}
}
}
}
Step 4: Allow story writers, commenters, and owners to post comments.
Note that this rule also validates that the owner
of the comment matches the
requesting user, which prevents users from writing over each other's comments:
service
cloud
.
firestore
{
match
/databases/{database
}
/
documents
{
match
/stories/{story
}
{
function
isSignedIn()
{
return
request.auth
!=
null
;
}
function
getRole
(
rsc
)
{
return
rsc.data.roles
[
request.auth.uid
]
;
}
function
isOneOfRoles
(
rsc
,
array
)
{
return
isSignedIn()
&&
(getRole(rsc)
in
array)
;
}
function
isValidNewStory
()
{
return
resource
==
null
&&
request.resource.data.roles
[
request.auth.uid
]
==
'owner'
;
}
allow
write
:
if
isValidNewStory
()
||
isOneOfRoles
(
resource
,
[
'owner'
]
)
allow
read
:
if
isOneOfRoles
(
resource
,
[
'owner'
,
'writer'
,
'commenter'
,
'reader'
]
);
match
/
comments
/
{
comment
}
{
allow
read
:
if
isOneOfRoles
(
get
(
/
databases
/
$(
database
)
/
documents
/
stories
/
$(
story
)),
[
'owner'
,
'writer'
,
'commenter'
,
'reader'
]
);
//
Owners,
writers,
and
commenters
can
create
comments.
The
//
user
id
in
the
comment
document
must
match
the
requesting
//
user's
id.
//
//
Note
:
we
have
to
use
get
()
here
to
retrieve
the
story
//
document
so
that
we
can
check
the
user
'
s
role
.
allow
create
:
if
isOneOfRoles
(
get
(
/
databases
/
$(
database
)
/
documents
/
stories
/
$(
story
)),
[
'owner'
,
'writer'
,
'commenter'
]
)
&&
request
.
resource
.
data
.
user
==
request
.
auth
.
uid
;
}
}
}
}
Step 5: Give writers the ability to edit story content, but not to edit story
roles or change any other properties of the document. This requires splitting
the stories write
rule into separate rules for create
, update
, and delete
since writers can only update stories:
service
cloud
.
firestore
{
match
/databases/{database
}
/
documents
{
match
/stories/{story
}
{
function
isSignedIn()
{
return
request.auth
!=
null
;
}
function
getRole
(
rsc
)
{
return
rsc.data.roles
[
request.auth.uid
]
;
}
function
isOneOfRoles
(
rsc
,
array
)
{
return
isSignedIn()
&&
(getRole(rsc)
in
array)
;
}
function
isValidNewStory
()
{
return
request.resource.data.roles
[
request.auth.uid
]
==
'owner'
;
}
function
onlyContentChanged
()
{
//
Ensure
that
title
and
roles
are
unchanged
and
that
no
new
//
fields
are
added
to
the
document.
return
request.resource.data.title
==
resource.data.title
&&
request.resource.data.roles
==
resource.data.roles
&&
request.resource.data.keys()
==
resource.data.keys()
;
}
//
Split
writing
into
creation
,
deletion
,
and
updating
.
Only
an
//
owner
can
create
or
delete
a
story
but
a
writer
can
update
//
story
content
.
allow
create
:
if
isValidNewStory
();
allow
delete
:
if
isOneOfRoles
(
resource
,
[
'owner'
]
);
allow
update
:
if
isOneOfRoles
(
resource
,
[
'owner'
]
)
||
(
isOneOfRoles
(
resource
,
[
'writer'
]
)
&&
onlyContentChanged
());
allow
read
:
if
isOneOfRoles
(
resource
,
[
'owner'
,
'writer'
,
'commenter'
,
'reader'
]
);
match
/
comments
/
{
comment
}
{
allow
read
:
if
isOneOfRoles
(
get
(
/
databases
/
$(
database
)
/
documents
/
stories
/
$(
story
)),
[
'owner'
,
'writer'
,
'commenter'
,
'reader'
]
);
allow
create
:
if
isOneOfRoles
(
get
(
/
databases
/
$(
database
)
/
documents
/
stories
/
$(
story
)),
[
'owner'
,
'writer'
,
'commenter'
]
)
&&
request
.
resource
.
data
.
user
==
request
.
auth
.
uid
;
}
}
}
}
Limitations
The solution shown above demonstrates securing user data using Security Rules, but you should be aware of the following limitations:
- Granularity: In the example above, multiple roles (writer and owner) have write access to the same document but with different limitations. This can become hard to manage with more complex documents and it may be better to split single documents into multiple documents each owned by a single role.
- Large Groups: If you need to share with very large or complex groups, consider a system where roles are stored in their own collection rather than as a field on the target document.