全部文章

用 Swift OptionSet 建模可组合的文档权限

将读取、编辑和链接分享拆成能力集合,再统一处理文件夹继承、显式限制与账号策略,避免角色组合不断膨胀。

问题不只是三个布尔值

文档和文件夹的权限,要同时支持个人空间与组织空间。个人拥有者可能可以生成公开链接,组织成员虽然能够编辑同类文档,却可能被租户策略禁止公开分享。文件夹还会提供继承权限,文档本身则可能增加授权或限制。

界面最终要回答:能否打开、能否编辑、能否分享链接,以及这些能力由哪一层决定。

最初很容易按角色建立枚举:

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)
}
能力二进制位原始值
读取00011
编辑00102
链接分享01004

互不重叠的位可以组合,不丢失信息:

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, [])
}

用表驱动测试覆盖直接授权、继承、显式拒绝与策略上限。测试同时也成为可执行的产品说明,让“为什么用户不能分享”有可追踪的答案。

真正的安全边界仍在服务端

客户端权限控制展示与本地行为,不负责建立最终授权边界。隐藏按钮无法阻止被修改的客户端发送请求;服务端必须在每次受保护操作中检查资源与租户策略。

权限在编辑过程中变化时,服务端可以拒绝操作,客户端刷新能力集合并回到有效状态。统一解析让这种变化更容易处理。

选择与问题形状一致的数据结构,是这次设计最重要的收获。把权限视为受继承、拒绝与策略约束的能力集合,个人与组织空间就可以共享功能代码,而每个页面都遵循同一份有效权限定义。