12.2 安全规则

安全规则是一个可以灵活地自定义数据库云存储读写权限的权限控制方式,通过配置安全规则,开发者可以在小程序端网页端精细化的控制云存储和集合中所有记录的增、删、改、查权限,自动拒绝不符合安全规则的前端数据库与云存储请求,保障数据和文件安全。

12.2.1 {openid} 变量

在前面我们建议使用安全规则取代简易版的权限设置,当使用安全规则之后,这里有一个重要的核心就是 {openid} 变量 ,无论在前端(小程序端、web端)查询时,它都是必不可少的(也就是说云函数,云开发控制台不受安全规则控制)。

1、查询写入都需明确指定 openid

{openid} 变量在小程序端使用时无需先通过云函数获取用户的 openid,直接使用'{openid}'即可,而我们在查询时都需要显式传入openid。之前我们使用简易权限配置时不需要这么做,这时因为查询时会默认给查询条件加上一条 _openid 必须等于用户 openid,但是使用安全规则之后,就没有这个默认的查询条件了。

比如我们在查询collection时,都需要在where里面添加如下如下的条件,{openid}变量就会附带当前用户的openid。

db.collection('china').where({ _openid: '{openid}', //安全规则里有auth.openid时都需要添加 })

更新、删除等数据库的写入请求也都需要明确在where里添加这样的一个条件(使用安全规则后,在小程序端也可以进行批量更新和删除)。

db.collection('goods').where({ _openid: '{openid}', category: 'mobile' }).update({ //批量更新 data:{ price: _.inc(1) } })

开启安全规则之后,都需要在where查询条件里指定_openid: '{openid}',这是因为大多数安全规则里都有auth.openid,也就是对用户的身份有要求,where查询条件为安全规则的子集,所以都需要添加。当然你也可以根据你的情况,安全规则不要求用户的身份,也就可以不传入_openid: '{openid}'了。

2、doc 操作需转为 where 操作

由于我们在进行执行doc操作db.collection('china').doc(id)时,没法传入openid的这个条件,那应该怎么控制权限呢?这时候,我们可以把doc操作都转化为where操作就可以了,在where查询里指定 _id 的值,这样就只会查询到一条记录了:

db.collection('china').where({ _id: 'tcb20200501', //条件里面加_id _openid: '{openid}', //安全规则里有auth.openid时都需要添加 })

至于其他的doc操作,都需要转化为基于collection的where操作,也就是说以后不再使用doc操作db.collection('china').doc(id)了。其中doc.update、doc.get和doc.remove可以用基于collection的update、get、remove取代,doc.set可以被更新指令_.set取代。当然安全规则只适用于前端(小程序端或Web端),后端不受安全规则的权限限制。

3、嵌套数组对象里的openid

在使用简易权限配置时,用户在小程序端往数据库里写入数据时,都会给记录doc里添加一个_openid的字段来记录用户的openid,使用安全规则之后同样也是如此。在创建记录时,可以把{openid}变量赋值给非_openid的字段或者写入到嵌套数组里,后台写入记录时发现该字符串时会自动替换为小程序用户的 openid:

db.collection('posts').add({ data:{ books:[{ title:"云开发快速入门", author:'{openid}' },{ title:"数据库入门与实战", author:'{openid}' }] } })

以往要进行openid的写入操作时需要先通过云函数返回用户openid,使用安全规则之后,直接使用{openid}变量即可,不过该方法仅支持add添加一条记录时,不支持update的方式。

12.2.2 安全规则的写法

使用安全规则之后,我们可以在控制台(开发者工具和网页)对每个集合以及云存储的文件夹分别配置安全规则,也就是自定义权限,配置的格式是json,仍然严格遵循json配置文件的写法(比如数组最后一项不能有逗号,,配置文件里不能有注释等)。

1、粒度更细的增删改查

我们先来看简易权限配置所有用户可读,仅创建者可写仅创建者可读写所有用户可读所有用户不可读写所对应的安全规则的写法,这个json配置文件的key表示操作类型,value是一个表达式,也是一个条件,解析为true时表示相应的操作符合安全规则。

// 所有人可读,仅创建者可读写 { "read": true, "write": "doc._openid == auth.openid" } //仅创建者可读写 { "read": "doc._openid == auth.openid", "write": "doc._openid == auth.openid" } //所有人可读 { "read": true, "write": false } //所有用户不可读写 { "read": false, "write": false }

简易的权限配置只有读read与写write,而使用安全规则之后,支持权限操作有除了读与写外,还将写权限细分为create新建、update更新、delete删除,也就是既可以只使用写,也可以细分为增、删、改,比如下面的案例为 所有人可读,创建者可写可更新,但是不能删除

"read": true, "create":"auth.openid == doc._openid", "update":"auth.openid == doc._openid", "delete":false

操作类型无外乎增删改查,不过安全规则的value是条件表达式,写法很多,让安全规则也就更加灵活。值得一提的是,如果我们不给read或者write赋值,它们的默认值为false。

2、所有用户可读可写的应用

安全规则还可以配置所有人可读可写的类型,也就是如下的写法,让所有登录用户(用户登录了之后才有openid,即openid不为空)可以对数据可读可写。

{ "read": "auth.openid != null", "write": "auth.openid != null" }

在小程序端,我们可以把数据库集合的安全规则操作read和write都写为true(这是所有人可读可写,而这里强调的是所有用户),因为只要用户使用开启了云开发的小程序,就会免鉴权登录有了openid,但是上面安全规则的写法则通用于云存储、网页端的安全规则。

集合里的数据让所有用户可读可写在很多方面都有应用,尤其是我们希望有其他用户可以对嵌套数组和嵌套对象里的字段进行更新时。比如集合posts存储的是所有资讯文章,而我们会把文章的评论嵌套在集合里。

{ _id:"tcb20200503112", _openid:"用户A", //用户A也是作者,他发表的文章 title:"云开发安全规则的使用经验总结", stars:223, comments:[{ _openid:"用户B", comment:"好文章,作者有心了", }] }

当用户A发表文章时,也就会创建这条记录,如果用户B希望可以评论(往数组comments里更新数据)、点赞文章(使用inc原子更新更新stars的值),就需要对该记录可读可写(至少是可以更新)。这在简易权限配置是无法做到的(只能使用云函数来操作),有了安全规则之后,一条记录就可以有被多个人同时维护的权限,而这样的场景在云开发这种文档型数据库里比较常见(因为涉及到嵌套数组嵌套对象)。

安全规则与查询where里的条件是相互配合的,但是两者之间又有一定的区别。所有安全规则的语句指向的都是符合条件的文档记录,而不是集合。使用了安全规则的where查询会先对文档进行安全规则的匹配,比如小程序端使用where查询不到记录,就会报错errCode: -502003 database permission denied | errMsg: Permission denied,然后再进行条件匹配,比如安全规则设置为所有人可读时,当没有符合条件的结果时,会显示查询的结果为0。我们要注意无权查询和查询结果为0的区别

3、全局变量

要搞清楚安全规则写法的意思,我们还需要了解一些全局变量,比如前面提及的auth.openid表示的是登录用户的openid,而doc._openid表示的是当前记录_openid这个字段的值,当用户的openid与当前记录的_openid值相同时,就对该记录有权限。全局变量还有now(当前时间戳)和resource(云存储相关)。

变量 类型 说明
auth object 用户登录信息,auth.openid 也就是用户的openid,如果是在web端它还有loginType登录方式、uid等值
doc object 表示当前记录的内容,用于匹配记录内容/查询条件
now number 当前时间的时间戳,也就是以从计时原点开始计算的毫秒
resource object resource.openid为云存储文件私有归属标识,标记所有者的openid

4、运算符

安全规则的表达式还支持运算符,比如等于==,不等于!=,大于>,大于等于>=,小于<,小于等于<=,与&&,或||等等,后面会有具体的介绍。

运算符 说明 示例
== 等于 auth.openid == 'zzz' 用户的 openid 为 zzz
!= 不等于 auth.openid != 'zzz' 用户的 openid 不为 zzz
> 大于 doc.age>10 查询条件的 age 属性大于 10
>= 大于等于 doc.age>=10 查询条件的 age 属性大于等于 10
< 小于 doc.age<10 查询条件的 age 属性小于 10
<= 小于等于 doc.age<=10 查询条件的 age 属性小于等于 10
in 存在在集合中 auth.openid in ['zzz','aaa'] 用户的 openid 是['zzz','aaa']中的一个
!(xx in []) 不存在在集合中,使用 in 的方式描述 !(a in [1,2,3]) !(auth.openid in ['zzz','aaa']) 用户的 openid 不是['zzz','aaa']中的任何一个
&& auth.openid == 'zzz' && doc.age>10 用户的 openid 为 zzz 并且查询条件的 age 属性大于 10
|| auth.openid == 'zzz' || doc.age>10 用户的 openid 为 zzz 或者查询条件的 age 属性大于 10
. 对象元素访问符 auth.openid 用户的 openid
[] 数组访问符属性 doc.favorites[0] == 'zzz' 查询条件的 favorites 数组字段的第一项的值等于 zzz

12.2.3 身份验证

全局变量auth与doc的组合使用可以让登录用户的权限依赖于记录的某个字段,auth表示的是登录用户,而doc、resource则是云开发环境的资源相关,使用安全规则之后用户与数据库、云存储之间就有了联系。resource只有resource.openid,而doc不只有_openid,还可以有很多个字段,也就让数据库的权限有了很大的灵活性,后面我们更多的是以doc全局变量为例。

1、记录的创建者

auth.openid是当前的登录用户,而记录doc里的openid则可以让该记录与登录用户之间有紧密的联系,或者可以说让该记录有了一个身份的验证。一般来说doc._openid所表示的是该记录的创建者的openid,简易权限控制比较的也是当前登录用户是否是该记录的创建者(或者为更加开放且粗放的权限)。

//登录用户为记录的创建者时,才有权限读 "read": "auth.openid == doc._openid", //不允许记录的创建者删除记录(只允许其他人删除) "delete": "auth.openid != doc._openid",

安全规则和where查询是配套使用的,如果你指定记录的权限与创建者的openid有关,你在前端的查询条件的范围就不能比安全规则的大(如果查询条件的范围比安全规则的范围大就会出现database permission denied:

db.collection('集合id').where({ _openid:'{openid}' //有doc._openid,因此查询条件里就需要有_openid这个条件, key:"value" }) .get().then(res=>{ console.log(res) })

2、指定记录的角色

1、把权限指定给某个人

安全规则的身份验证则不会局限于记录的创建者,登录用户的权限还可以依赖记录的其他字段,我们还可以给记录的权限指定为某一个人(非记录的创建者),比如很多个学生提交了作业之后,会交给某一个老师审阅批改,老师需要对该记录有读写的权限,在处理时,可以在学生提交作业(创建记录doc)时时可以指定teacher的openid,只让这个老师可以批阅,下面是文档的结构和安全规则示例:

//文档的结构 { _id:"handwork20201020", _openid:"学生的openid", //学生为记录的创建者, teacher:"老师的openid" //该学生被指定的老师的openid } //安全规则 { "read": "doc.teacher == auth.openid || doc._openid == auth.openid", "write": "doc.teacher == auth.openid || doc._openid == auth.openid", }

让登录用户auth.openid依赖记录的其他字段,在功能表现上相当于给该记录指定了一个角色,如直属老师、批阅者、直接上级、闺蜜、夫妻、任务的直接指派等角色。

对于查询或更新操作,输入的where查询条件必须是安全规则的子集,比如你的安全规则如果是doc.teacher == auth.openid,而你在where里没有teacher:'{openid}'这样的条件,就会出现权限报错。

由于安全规则和where查询需要配套使用,安全规则里有doc.teacherdoc._openid,在where里也就需要写安全规则的子集条件,比如_openid:'{openid}'teacher:'{openid}' ,由于这里老师也是用户,我们可以传入如下条件让学生和老师共用一个数据库请求:

const db = wx.cloud.database() const _ = db.command //一条记录可以同时被创建者(学生)和被指定的角色(老师)读取 db.collection('集合id').where(_.or([ {_openid:'{openid}' }, //与安全规则doc._openid == auth.openid对应 {teacher:'{openid}' } //与安全规则doc.teacher == auth.openid对应 ])) .get().then(res=>{ console.log(res) })

2、把权限指定给某些人

上面的这个角色指定是一对一、或多对一的指定,也可以是一对多的指定,可以使用in!(xx in [])运算符。比如下面是可以给一个记录指定多个角色(学生创建的记录,多个老师有权读写):

//文档的结构 { _id:"handwork20201020", _openid:"学生的openid", //学生为记录的创建者, teacher:["老师1的openid","老师2的openid","老师3的openid"] } //安全规则 { "read": "auth.openid in doc.teacher || doc._openid == auth.openid", "write": "auth.openid in doc.teacher || doc._openid == auth.openid", }

这里要再强调的是前端(小程序端)的where条件必须是安全规则权限的子集,比如我们在小程序端针对老师进行如下查询('{openid}'不支持查询指令,需要后端获取)

db.collection('集合id').where({ _openid:'{openid}', teacher:_.elemMatch(_.eq('老师的openid')) }).get() .then(res=>{ console.log(res) })

前面我们实现了将记录的权限指定给某个人或某几个人,那如何将记录的权限指定给某类人呢?比如打车软件为了数据的安全性会有司机、乘客、管理员、开发人员、运维人员、市场人员等,这都需要我们在数据库里新建一个字段来存储用户的类型,比如{role:3},用1、2、3、4等数字来标明,或者用{isManager:true}boolean类型来标明,这个新增的字段可以就在查询的集合文档里doc.role,或者是一个单独的集合(也就是存储权限的集合和要查询的集合是分离的,这需要使用get函数跨集合查询),后面会有具体介绍。

3、doc.auth与文档的创建者

下面有一个例子可以加深我们对安全规则的理解,比如我们在记录里指定文档的auth为其他人的openid,并配上与之相应的安全规则,即使当前用户实际上就是这个记录的创建者,这个记录有该创建者的_openid,他也没有操作的权限。安全规则会对查询条件进行评估,只要符合安全规则,查询才会成功,违反安全规则,查询就会失败。

//文档的结构,比如以下为一条记录 { _id:"handwork20201020", _openid:"创建者的openid", auth:"指定的auth的openid" } //安全规则 { "权限操作": "auth.openid == doc.auth" //权限操作为read、write、update等 } //前端查询,不符合安全规则,即使是记录的创建者也没有权限 db.collection('集合id').where({ auth:'{openid}' })

12.2.4 安全规则常用场景

简易版权限设置没法在前端实现记录跨用户的写权限(含update、create、delete),也就是说记录只有创建者可写。而文档型数据库一个记录因为反范式化嵌套的原因可以承载的信息非常多,B用户操作A用户创建的记录,尤其是使用更新指令update字段以及内嵌字段的值这样的场景是非常常见的。除此之外,仅安全规则可以实现前端对记录的批量更新和删除。

比如我们可以把评论、收藏、点赞、转发、阅读量等信息内嵌到文章的集合里,以往我们在小程序端(只能通过云函数)是没法让B用户对A用户创建的记录进行操作,比如点赞、收藏、转发时用更新指令inc更新次数,比如没法直接用更新指令将评论push写入到记录里:

{ _id:"post20200515001", title:"云开发安全规则实战", star:221, //点赞数 comments:[{ //评论和子评论 content:"安全规则确实是非常好用", nickName:"小明"subcomment:[{ content:"我也这么觉得", nickName:"小军", }] }], share:12, //转发数 collect:15 //收藏数 readNum:2335 //阅读量 }

在开启安全规则,我们就可以直接在前端让B用户修改A用户创建的记录,这样用户阅读、点赞、评论、转发、收藏文章等时,就可以直接使用更新指令对文章进行字段级别的更新。

"read":"auth.openid != null", "update":"auth.openid != null"

这个安全规则相比于所有人可读,仅创建者可读写,开放了update的权限,小程序端也有limit 20的限制。而如果不使用安全规则,把这些放在云函数里进行处理不仅处理速度更慢,而且非常消耗云函数的资源。

db.collection('post').where({ _id:"post20200515001", openid:'{openid}' }).update({ data:{ //更新指令的应用 } })

12.2.5 数据验证doc的规则匹配

我们还可以把访问权限的控制信息以字段的形式存储在数据库的集合文档里,而安全规则可以根据文档数据动态地允许或拒绝访问,也就是说doc的规则匹配可以让记录的权限动态依赖于记录的某一个字段的值

doc规则匹配的安全规则针对的是整个集合,而且要求集合里的所有记录都有相应的权限字段,而只有在权限字段满足一定条件时,记录才有权限被增删改查,是一个将集合的权限范围按照条件要求收窄的过程,where查询时的条件不能比安全规则规定的范围大(查询条件为安全规则子集);配置了安全规则的集合里的记录只有两种状态,有权限和没有权限。

这里仍然再强调的是使用where查询时要求查询条件是安全规则的子集,在进行where查询前会先解析规则与查询条件进行校验,如果where条件不是安全规则的子集就会出现权限报错,不能把安全规则看成是一个筛选条件,而是一个保护记录数据安全的不可逾越的规则。

1、记录的状态权限

doc的规则匹配,特别适合每个记录存在多个状态或每个记录都有一致的权限条件(要么全部是,要么全部否),而只有一个状态或满足条件才有权限被用户增删改查时的情形,比如文件审批生效(之前存在审批没有生效的多个状态),文章的发布状态为pubic(之前为private或其他状态),商品的上架(在上架前有多个状态),文字图片内容的安全检测不违规(之前在进行后置校验),消息是否撤回,文件是否删除,由于每个记录我们都需要标记权限,而只有符合条件的记录才有被增删改查的机会。

比如资讯文章的字段如下,每个记录对应着一篇文章,而status则存储着文章的多个状态,只有public时,文章才能被用户查阅到,我们可以使用安全规则"read": "doc.status=='public'"。而对于软删除(文章假删除),被删除可以作为一个状态,但是文章还是在数据库里。

{ _id:"post2020051314", title:"云开发发布新能力,支持微信支付云调用", status:"public" }, { _id:"post2020051312", title:"云函数灰度能力上线", status:"edit" }, { _id:"post2020051311", title:"云开发安全规则深度研究", status:"delete" }

而在前端(小程序端)与之对应的数据库查询条件则必须为安全规则的子集,也就是说安全规则不能作为你查询的过滤条件,安全规则会对查询进行评估,如果查询不符合安全规则设置的约束(非子集),那么前端的查询请求没有权限读取文档,而不是过滤文档:

db.collection('集合id').where({ status:"public" //你不能不写这个条件,而指望安全规则给你过滤 }).get() .then(res=>{ console.log(res) })

2、记录禁止为空

有时候我们需要对某些记录有着非常严格的要求,禁止为空,如何为空一律不予被前端增删改查,比如已经上架的shop集合里的商品列表,有些核心数据如价格、利润、库存等就不能为空,给企业造成损失,相应的安全规则和查询如下:

//安全规则 { "权限操作": "doc.profit != null", } //权限操作,profit = 0.65就是安全规则的子集 db.collection('shop').where({ profit:_.eq(0.65) })

3、记录的子集权限

安全规则记录的字段值不仅限于一个状态(字符串类型),还可以是可以运算的范围值,如大于>,小于<in等,比如商品的客单价都是100以上,管理员在后端(控制台,云函数等)把原本190元的价格写成了19,或者失误把价格写成了负数,这种情况下我们对商品集合使用安全规则doc.price > 100,前端将失去所有价格低于100的商品的操作权限,包括查询。

//安全规则 "操作权限":"doc.price > 100" //相应的查询 db.collection('shop').where({ price:_eq(125) })

安全规则的全局变量now表示的是当前时间的时间戳,这让安全规则可以给权限的时间节点和权限的时效性设置一些规则,这里就不具体讲述了。

12.2.5 全局函数get构建权限体系

全局函数get可以实现跨集合来限制权限。doc的权限匹配更多的是基于文档性质的权限,也就是集合内所有文档都有相同的字段,根据这个字段的值的不同来划分权限。但是有时候我们希望实现多个用户和多个用户角色来管理集合的文档,拥有不同的权限,如果把用户和角色都写进文档的每个记录里,就会非常难以管理。也就是说doc的权限匹配并不适合复杂的用户管理文档的权限体系。

我们可以把单个复杂的集合文档(反范式化的设计)拆分成多个集合文档(范式化设计),将用户和角色从文档里分离出来。比如博客有文章post集合,而user集合除了可以把用户划分为作者、编辑、投稿者这样的用户身份,还可以是管理员组,编辑组等。如果我们把记录的权限赋予给的人员比较多或群组比较复杂,则需要把角色存储在其独立的集合中,而不是作为目标文档中的一个字段,用全局函数get来实现跨集合的权限限制。

get 函数是全局函数,可以跨集合来获取指定的记录,用于在安全规则中获取跨集合的记录来参与到安全规则的匹配中,get函数的参数格式是 database.集合名.记录id

比如我们可以给文章post集合设置如下安全规则,只有管理员才可以删除记录,而判断用户是否为管理员则需要跨集合用user集合里的字段值来判断:

//user集合的结构 { _id:"oUL-m5FuRmuVmxvbYOGuXbuEDsn8", //用户的openid isManager:true } //post集合的权限 { "read": "true", "delete": "get(`database.user.${auth.openid}`).isManager== true" } db.collection('post').where({ //相应的条件,并不受子集的限制 })

get函数还可以接收变量,值可以通过多种计算方式得到,例如使用字符串模版进行拼接,这是一个查询的过程,如果相应的文档里有记录,则函数返回记录的内容,否则返回空(注意反引号的写法):

`(database.${doc.collction}.${doc._id})`

get函数的限制条件

  • 安全规则里的get函数 参数中存在的变量 doc 需要在 query 条件中以 == 或 in 方式出现,若以 in 方式出现,只允许 in 唯一值, 即 doc.shopId in array, array.length == 1
  • 一个表达式最多可以有 3 个 get 函数,最多可以访问 3 个不同的文档。
  • get 函数的嵌套深度最多为 2, 即 get(get(path))。

读操作触发与配额消耗说明

get 函数的执行会计入数据库请求数,同样受数据库配额限制。在未使用变量的情况下,每个 get 会产生一次读操作,在使用变量时,对每个变量值会产生一次 get 读操作。例如:

假设某集合 shop 上有如下规则:

{ "read": "auth.openid == get(`database.shop.${doc._id}`).owner", "write": false }

在执行如下查询语句时会产生 5 次读取。

db.collection('shop').where(_.or([{_id:1},{_id:2},{_id:3},{_id:4},{_id:5}])).get()