问题不只是三个布尔值
文档和文件夹的权限,要同时支持个人空间与组织空间。个人拥有者可能可以生成公开链接,组织成员虽然能够编辑同类文档,却可能被租户策略禁止公开分享。文件夹还会提供继承权限,文档本身则可能增加授权或限制。
界面最终要回答:能否打开、能否编辑、能否分享链接,以及这些能力由哪一层决定。
最初很容易按角色建立枚举:
enum AccessRole {
case viewer
case editor
case linkSharer
}
但权限可以重叠:能编辑的人不一定能公开分享,能管理链接的人也不一定能编辑。每增加一种能力,角色组合就更多。更贴合问题的抽象是一组可以组合的能力,Swift 的 OptionSet 正好提供了这种表达。
用稳定的位表示能力
每项权限占用一个独立 bit:
struct DocumentPermissions: OptionSet {
let rawValue: UInt64
static let read = DocumentPermissions(rawValue: 1 << 0)
static let edit = DocumentPermissions(rawValue: 1 << 1)
static let shareLink = DocumentPermissions(rawValue: 1 << 2)
}
| 能力 | 二进制位 | 原始值 |
|---|---|---|
| 读取 | 0001 | 1 |
| 编辑 | 0010 | 2 |
| 链接分享 | 0100 | 4 |
互不重叠的位可以组合,不丢失信息:
let viewer: DocumentPermissions = [.read] // 1
let editor: DocumentPermissions = [.read, .edit] // 3
let publisher: DocumentPermissions = [.read, .shareLink] // 5
let ownerLike: DocumentPermissions = [
.read,
.edit,
.shareLink
] // 7
网络与持久化边界传递原始值,业务代码则使用表达意图的名称:
struct ResourceAccessDTO: Decodable {
let permissionMask: UInt64
}
let permissions = DocumentPermissions(rawValue: dto.permissionMask)
位分配和整数宽度都属于数据协议。使用 UInt64 等固定宽度类型,避免客户端架构改变传输表示。已经发布的位不能重新排序,也不能回收后用于另一种含义。
重点在组合,而不是位运算
OptionSet 遵循 SetAlgebra,支持并集、交集、差集、包含判断与相等比较:
var permissions: DocumentPermissions = [.read]
permissions.insert(.edit)
permissions.remove(.shareLink)
if permissions.contains(.edit) {
editor.isEditable = true
}
UI 不必知道权限来自个人账号、组织、文件夹还是文档,只需要询问最终能力集合:
func configureActions(using permissions: DocumentPermissions) {
editor.isEditable = permissions.contains(.edit)
shareLinkButton.isHidden = !permissions.contains(.shareLink)
}
这样,文档列表、编辑器、右键菜单和分享面板就不会各自复制一份略有差异的判断逻辑。
授权、继承、拒绝与策略上限
不能因为 bitmask 紧凑,就过早把所有输入压成一个值。授权、显式拒绝与账号策略上限具有不同语义,应分别建模:
struct PermissionContext {
let directGrants: DocumentPermissions
let inheritedGrants: DocumentPermissions
let explicitDenials: DocumentPermissions
let policyCeiling: DocumentPermissions
var effective: DocumentPermissions {
let granted = directGrants
.union(inheritedGrants)
.addingPrerequisites()
let permitted = granted
.subtracting(explicitDenials)
.intersection(policyCeiling)
return permitted.removingInvalidDependents()
}
}
解析顺序应当明确:先合并文档与可继承的文件夹授权,移除显式拒绝的能力,再与账号或租户策略上限求交集,最后移除前置条件已经失效的能力。
let personalWorkspacePolicy: DocumentPermissions = [
.read,
.edit,
.shareLink
]
let managedWorkspacePolicy: DocumentPermissions = [
.read,
.edit
]
组织管理员禁止公开链接时,只需让策略上限不包含 .shareLink。即使文档直接授权包含这个 bit,交集也会将其移除。界面仍然只调用 contains(.shareLink)。
先补齐授权的前置能力,再执行拒绝与策略限制,最后移除缺少前置条件的能力。
语义依赖需要单独处理
bit 在表示上独立,产品语义上却可能有关联。例如编辑必须以能够读取为前提,这个约束应集中维护:
extension DocumentPermissions {
func addingPrerequisites() -> DocumentPermissions {
var result = self
if result.contains(.edit) || result.contains(.shareLink) {
result.insert(.read)
}
return result
}
func removingInvalidDependents() -> DocumentPermissions {
var result = self
if !result.contains(.read) {
result.remove(.edit)
result.remove(.shareLink)
}
return result
}
}
这里刻意区分两个方向:授予能力时可以补齐前置条件;执行拒绝和策略限制之后,只能移除依赖失效的能力,不能再把已经禁止的 bit 补回来。
如果未来允许“不读取正文也能管理链接”,修改这里的产品规则即可,不必到每个页面修补条件。
为什么不直接存三个 Boolean
局部 view model 使用三个布尔值没有问题。跨网络、缓存、模块和界面时,分散字段会增加相等判断、序列化和初始化的维护成本,也容易构造出不符合业务规则的组合。
OptionSet 的价值不只是少几个字段,而是让数据结构直接支持领域所需的集合运算,同时保留类型约束。
模型如何演进
新增能力使用新的 bit,已有调用点可以继续工作:
extension DocumentPermissions {
static let comment = DocumentPermissions(rawValue: 1 << 3)
static let download = DocumentPermissions(rawValue: 1 << 4)
static let manageMembers = DocumentPermissions(rawValue: 1 << 5)
}
旧客户端可能不理解新能力,协议必须约定未知位是否需要在解码再编码时保留。废弃位不能重新使用;兼容性来自稳定的含义,而不只是稳定的整数。
测试组合规则
最有价值的测试不是重复验证 1 << n,而是验证多层规则冲突时谁生效:
func testManagedWorkspacePolicyRemovesPublicLinkSharing() {
let context = PermissionContext(
directGrants: [.read, .edit, .shareLink],
inheritedGrants: [],
explicitDenials: [],
policyCeiling: [.read, .edit]
)
XCTAssertEqual(context.effective, [.read, .edit])
}
func testExplicitDenialWinsOverFolderGrant() {
let context = PermissionContext(
directGrants: [],
inheritedGrants: [.read, .edit],
explicitDenials: [.edit],
policyCeiling: [.read, .edit, .shareLink]
)
XCTAssertEqual(context.effective, [.read])
}
func testReadDenialAlsoRemovesDependentCapabilities() {
let context = PermissionContext(
directGrants: [.read, .edit, .shareLink],
inheritedGrants: [],
explicitDenials: [.read],
policyCeiling: [.read, .edit, .shareLink]
)
XCTAssertEqual(context.effective, [])
}
用表驱动测试覆盖直接授权、继承、显式拒绝与策略上限。测试同时也成为可执行的产品说明,让“为什么用户不能分享”有可追踪的答案。
真正的安全边界仍在服务端
客户端权限控制展示与本地行为,不负责建立最终授权边界。隐藏按钮无法阻止被修改的客户端发送请求;服务端必须在每次受保护操作中检查资源与租户策略。
权限在编辑过程中变化时,服务端可以拒绝操作,客户端刷新能力集合并回到有效状态。统一解析让这种变化更容易处理。
选择与问题形状一致的数据结构,是这次设计最重要的收获。把权限视为受继承、拒绝与策略约束的能力集合,个人与组织空间就可以共享功能代码,而每个页面都遵循同一份有效权限定义。