ACL 权限管理开发指南
ACL 是 Access Control List 的缩写,称为访问控制列表,包含了对一个对象或一条记录可进行何种操作的权限定义。
TDS 云端使用的 ACL 机制是将每个操作授权给特定的 User 用户或者 Role 角色,只允许这些用户或角色对一个对象执行这些操作。
例如如下 ACL 定义:
{
"*":{
"read":true,
"write":false
},
"role:admin":{
"read":true,
"write":true
},
"58113fbda0bb9f0061ddc869":{
"read":true,
"write":true
}
}
- 所有人可读,但不能写(
*
代表所有人)。 - 角色为 admin(包含子角色)的用户可读可写。
- ID 为
58113fbda0bb9f0061ddc869
的用户可读可写。
我们使用内建数据表 _User
来维护 用户/账户系统,以及内建数据表 _Role
来维护角色。
角色既可以包含用户,也可以包含其他角色,也就是说角色有层次关系,将权限授予一个角色代表该角色所包含的其他角色也会得到相应的权限。
云端对客户端发过来的每一个请求都要进行用户身份鉴别和 ACL 访问授权的严格检查。因此,使用 ACL 可以灵活且最大程度地保护应用数据,提升访问安全。
默认 ACL
每个 Class 的初始默认 ACL 为所有人可读可写:
{
"*":{
"read":true,
"write":true
}
}
在创建 Class 对话框可以设置 Class 的默认 ACL:
你可以设置 read 和 write 权限开放给哪些用户,其中:
- 「所有用户」、「指定用户」可以参考数据安全指南的 Class 层面的访问权限一节的说明。
- 「数据创建者(Owner)」指创建数据的用户。也就是说,新增对象(create)时附带的
X-LC-Session
HTTP 头对应的用户。
除了分别设置 read 和 write 权限开放给哪些用户外,对话框中还提供了一些常用设定的快捷方式:
- 限制写入:数据创建者可读、可写,其他用户可读、不可写。
- 限制读取:数据创建者可读、可写,其他用户不可读、不可写。
- 限制所有:数据创建者可读、不可写,其他用户不可读、不可写。
- 无限制:所有人可读、可写。
对于已经存在的 Class,你可以更新默认 ACL 和访问权限。 进入开发者中心 > 你的游戏 > 游戏服务 > 云服务 > 数据存储 > 结构化数据,选择一个 Class,再点击权限标签页。 不过,修改默认 ACL 只作用于新增的对象,现存对象的 ACL 值保持不变。
除了设置默认 ACL 外,在控制台也可以设置单个对象的 ACL。
不过在控制台手工为每个对象设置 ACL 过于繁琐,并不现实。
所以通常我们在控制台设置默认 ACL,确保所有新创建的对象都有合适的初始 ACL 值。
为单个对象设置更复杂、更精细的 ACL,则通过代码设置其 ACL
字段。
例如,Post
Class 的默认 ACL 可能是这样的:
- read:所有用户
- write:数据创建者(Owner)
也就是说,所有用户都可以查看帖子,用户只能修改或者删除自己发的帖子。
这里数据创建者指创建对象的请求的 HTTP 头中携带的 session token 对应的用户,在我们的例子中是帖子的作者。
基于用户的权限管理
继续上面的例子,假设某个帖子的发布人允许另一个特定的用户(比如两个人合作编写一篇文章)修改帖子,除此之外的其他人不可修改,那么我们可以这样设置 ACL:
- Unity
- Android
- iOS
try {
LCQuery<LCUser> userQuery = LCUser.GetQuery();
LCUser otherUser = await userQuery.Get("55f1572460b2ce30e8b7afde");
// 新建一个帖子对象
LCObject post = LCObject("Post");
post["title"] = "这是我的第二条发言,谢谢大家!";
post["content"] = "我最近喜欢看足球和篮球了。";
//新建一个 ACL 实例
LCACL acl = new LCACL();
acl.PublicReadAccess = true; // 设置公开的「读」权限,任何人都可阅读
LCUser currentUser = await LCUser.GetCurrent();
acl.SetUserWriteAccess(currentUser, true); // 为当前用户赋予「写」权限,有且仅有当前用户可以修改这条 Post
acl.SetUserWriteAccess(otherUser, true);
post.ACL = acl;
await post.Save();
} catch (LCException e) {
print($"{e.Code} : {e.Message}");
}
LCQuery<LCUser> query = LCUser.getQuery();
query.getInBackground("55f1572460b2ce30e8b7afde").subscribe(new Observer<LCUser>() {
@Override
public void onSubscribe(Disposable d) {
}
@Override
public void onNext(LCUser anotherUser) {
// 新建一个帖子对象
LCObject post= new LCObject("Post");
post.put("title","这是我的第二条发言,谢谢大家!");
post.put("content","我最近喜欢看足球和篮球了。");
//新建一个 ACL 实例
LCACL acl = new LCACL();
acl.setPublicReadAccess(true);// 设置公开的「读」权限,任何人都可阅读
acl.setWriteAccess(LCUser.getCurrentUser(), true);//为当前用户赋予「写」权限
acl.setWriteAccess(anotherUser, true);
// 将 ACL 实例赋予 Post对象
post.setACL(acl);
//保存到云端
post.saveInBackground();
}
@Override
public void onError(Throwable e) {
System.out.println("errorMessage:" + e.getMessage());
}
@Override
public void onComplete() {
}
}
LCQuery *query = [LCUser query];
[query getObjectInBackgroundWithId:@"55f1572460b2ce30e8b7afde" block:^(LCObject * _Nullable object, NSError * _Nullable error) {
if (error == nil) {
// 新建一个帖子对象
LCObject *post = [LCObject objectWithClassName:@"Post"];
[post setObject:@"这是我的第二条发言,谢谢大家!" forKey:@"title"];
[post setObject:@"我最近喜欢看足球和篮球了。" forKey:@"content"];
//新建一个 ACL 实例
LCACL *acl = [LCACL ACL];
[acl setPublicReadAccess:YES];// 设置公开的「读」权限,任何人都可阅读
[acl setWriteAccess:YES forUser:[LCUser currentUser]];// 为当前用户赋予「写」权限
[acl setWriteAccess:YES forUser:otherUser];
post.ACL = acl;// 将 ACL 实例赋予 Post 对象
[post save];
} else {
NSLog(@"error");
}
}];
执行完毕上面的代码,回到控制台,可以看到,该条 Post 记录里面的 ACL 列的内容如下:
{
"*":{
"read":true
},
"55b9df0400b0f6d7efaa8801":{
"write":true
},
"55f1572460b2ce30e8b7afde":{
"write":true
}
}
从结果可以看出,该条 Post 已经允许 objectId
为 55b9df0400b0f6d7efaa8801
以及 55f1572460b2ce30e8b7afde
的两个用户(User)修改,他们拥有写权限 "write": ture
。
为了避免示例过于冗长,重点不突出,从下面的示例开始,都不再给出可以直接执行的完整代码,只保留关键部分。
假设论坛有一个管理员,可以编辑、删除所有帖子,那么我们可以用类似的方法给每个帖子加上相应的权限:
- Unity
- Android
- iOS
acl.SetUserWriteAccess(anAdministrator, true);
acl.setWriteAccess(anAdministrator, true);
[acl setWriteAccess:YES forUser:anAdministrator];
但是,未来可能会有新的管理员加入,老的管理员也可能卸任,所以单纯基于用户进行权限管理,任何人员变动都需要批量订正数据(修改 ACL 字段),太不灵活了。
因此,我们需要引入角色这一概念。
基于角色的权限管理
「角色」相当于用户组,并且可以嵌套。 换言之,一个角色的成员要么是用户,要么是另一个角色。
角色的名字由字母、数字、下划线组成,不可变更,在应用内唯一。
继续上面的例子,让我们看看如何赋予管理员角色写权限(假定已经存在一个名为 admin
的角色):
- Unity
- Android
- iOS
LCRole admin = LCRole.CreateWithoutData("_Role", "55fc0eb700b039e44440016c");
acl.SetRoleReadAccess(admin, true);
acl.setRoleWriteAccess("admin", true);
LCRole *admin = [LCRole objectWithClassName:@"_Role" objectId:@"55fc0eb700b039e44440016c"];
[acl setWriteAccess:YES forRole:admin];
角色的创建
下面让我们看看如何创建一个角色。
这里有一个需要特别注意的地方,因为 Role
本身也是一个 Object
,它自身也有 ACL 控制,并且它的权限控制应该更严谨。
所以通常我们在创建角色的时候会显式地设定该角色的 ACL。
如果不指定,那么 SDK 会默认设定角色的 ACL 为所有人可读、所有人不可写。
换言之,在不显式指定 ACL 的情况下,SDK 的默认设定会导致角色一经创建,未来无法在客户端修改,以后添加成员等操作都需要在控制台进行或在服务端使用 masterKey 进行。
为了便于测试,我们在下面的示例代码中暂且将角色的写权限赋予了创建该角色的用户(当前用户),实际项目中请根据具体需求设定合适的 ACL。
- Unity
- Android
- iOS
try {
// 角色本身的 ACL
LCACL acl = new LCACL();
acl.PublicReadAccess = true;
LCUser currentUser = await LCUser.GetCurrent();
acl.SetUserWriteAccess(currentUser, true);
LCRole admin = LCRole.Create(name, acl);
await admin.Save();
} catch (LCException e) {
print($"{e.Code} : {e.Message}");
}
// 角色本身的 ACL
LCACL roleACL = new LCACL();
roleACL.setPublicReadAccess(true);
roleACL.setWriteAccess(LCUser.getCurrentUser(),true);
LCRole admin = new LCRole("admin", roleACL);
admin.saveInBackground().blockingSubscribe();
// 角色本身的 ACL
LCACL *roleACL = [LCACL ACL];
[roleACL setPublicReadAccess:YES];
[roleACL setWriteAccess:YES forUser:[LCUser currentUser]];
LCRole *admin = [LCRole roleWithName:@"admin" acl:roleACL];
[admin save];
现在这个 admin
角色是空的,我们接下来把当前用户添加到这个角色:
- Unity
- Android
- iOS
LCUser currentUser = await LCUser.GetCurrent();
admin.AddRelation("users", currentUser);
admin.getUsers().add(LCUser.getCurrentUser());
[[admin users] addObject: [LCUser currentUser]];
如果我们又想从角色中移除用户:
- Unity
- Android
- iOS
LCUser currentUser = await LCUser.GetCurrent();
admin.RemoveRelation("users", currentUser);
admin.getUsers().remove(LCUser.getCurrentUser());
[[admin users] removeObject:[LCUser currentUser]];
如前所述,角色的成员可以是另一个角色。
假定有两个角色,一个「管理员」(admin
),一个「版主」(moderator
),我们想让 admin
成为 moderator
的子角色,因为管理员同时拥有版主的全部权限。
- Unity
- Android
- iOS
moderator.AddRelation("roles", admin);
moderator.getRoles().add(admin);
[[moderator roles] addObject:admin];
偶尔可能想要移除子角色,比如后来我们改变了主意,管理员应该专注于全局性的管理任务,帖子编辑、删除之类的任务全部由版主负责。
- Unity
- Android
- iOS
moderator.RemoveRelation("roles", admin);
moderator.getRoles().remove(admin);
[[moderator roles] removeObject:admin];
角色的查询
查询某个用户有哪些角色:
- Unity
- Android
- iOS
try {
LCUser currentUser = await LCUser.GetCurrent();
LCQuery roleQuery = LCRole.GetQuery();
roleQuery.WhereEqualTo("users", currentUser);
ReadOnlyCollection<LCRole> roles = await roleQuery.Find();
} catch (LCException e) {
print($"{e.Code} : {e.Message}");
}
LCUser user = LCUser.getCurrentUser();
user.getRolesInBackground().subscribe(new Observer<List<LCRole>>() {
@Override public void onSubscribe(Disposable d) {}
@Override public void onNext(List<LCRole> avRoles) {
// avRoles 是查询结果
}
@Override public void onError(Throwable e) {}
@Override public void onComplete() {}
});
LCUser *user = [LCUser currentUser];
[user getRolesInBackgroundWithBlock:^(NSArray<LCRole *> * _Nullable avRoles, NSError * _Nullable error) {
// avRoles 是查询结果
}];
查询某个角色包含的用户(这里只给出构建查询的代码):
- Unity
- Android
- iOS
LCQuery<LCUser> userQuery = moderator.Users.Query;
LCQuery<LCUser> userQuery = moderator.getUsers().getQuery();
LCUser *userQuery = [[moderator users] query];
当然,上面的代码没有考虑子角色中包含的用户。 如果想要查询角色中包含的所有用户(直接包含和间接包含),需要递归地查出角色的所有子角色(包括子角色的子角色,以此类推),接着查询所有这些角色包含的用户。 限于篇幅,就不在这里列出完整的代码了,只列出如何构建子角色查询的代码:
- Unity
- Android
- iOS
LCQuery<LCRole> subroleQuery = moderator.Roles.Query;
LCQuery<LCRole> subroleQuery = moderator.getRoles().getQuery();
LCRole *subroleQuery = [[moderator roles] query];
由于角色继承自结构化存储的对象,你也可以根据角色的其他属性执行各种查询,方式和一般的对象查询是一样的。
特殊规则
因为用户相关信息比较敏感,所以 _User
表会忽略 ACL 的设置,任何用户都无法修改其他用户的属性,比如当前登录的用户是 A,而他想通过请求去修改 B 用户的用户名、密码或者其他自定义属性,是不会生效的,即使 B 用户的 ACL 中赋予了 A 写权限也不行。
因为 LiveQuery 设计的使用场景是客户端,而客户端不应使用 MasterKey,否则会有极大的安全隐患。 所以,LiveQuery 订阅事件时会忽略 MasterKey。换言之,LiveQuery 订阅事件时不应使用 MasterKey,即使使用也不会跳过 ACL 等权限检查。
获取对象的 ACL 值
查询数据时,SDK 默认不会返回对象的 ACL 值。如果想在获取对象的同时返回对象的 ACL 值,需要同时满足下面两个条件:
- 进入 开发者中心 > 你的游戏 > 游戏服务 > 云服务 > 数据存储 > 服务设置 > 查询设置,勾选「查询时返回值包括 ACL」。
- 客户端查询对象时指定返回 ACL。
代码如下:
- Unity
- Android
- iOS
LCQuery<LCObject> query = new LCQuery<LCObject>("Todo");
query.IncludeACL = true;
LCQuery<LCObject> query = new LCQuery<>("Todo");
query.includeACL(true);
LCQuery *query = [LCQuery queryWithClassName:@"Todo"];
query.includeACL = YES;
最佳实践
如果应用的权限控制需求比较简单,我们推荐在控制台恰当设置 Class 权限、字段权限、默认 ACL,然后通过客户端代码设置个别需要精细权限控制的 ACL。
对于权限控制需求复杂的应用,我们推荐在控制台设置 Class 权限、字段权限、默认 ACL 后,在云引擎统一处理 ACL 相关的逻辑。 一方面,这样免去了在 iOS、Android、Web 等各处不断升级和维护逻辑十分类似的客户端代码。 另一方面,云引擎除了处理 ACL 外,还可以通过 hook 基于更复杂的条件进行权限控制,比如不允许发布超过一定字数的帖子。 详见 在云引擎中使用 ACL。
对于储存敏感数据、安全性要求非常严苛的 Class,开发者也可以考虑将对应的 Class 权限的写权限乃至读权限完全关闭,客户端所有请求都通过云引擎中转,这样与自己搭建后端具有同样的数据安全性保障。