All writing

Designing Composable Document Permissions with Swift OptionSet

Modeling read, edit, and link-sharing capabilities across personal and managed workspaces

The Problem Was Not Just Three Booleans

While developing document and folder features, I ran into a permission model that had to serve both personal and organization-managed workspaces. Document products rarely have one universal meaning of “access.” An individual may own a document and share it with a link. A member of a managed workspace may be allowed to read and edit the same kind of document while an organization policy disables public links. A folder can contribute inherited access, while a document can add a more specific grant or restriction.

At the UI layer, these differences eventually become concrete questions:

  • May the user open this document?
  • Should the editor accept changes?
  • Should the share-link action be visible and enabled?
  • Does the answer come from the resource, its parent folder, or the account policy?

Representing each product role as an enum looks attractive at first:

enum AccessRole {
    case viewer
    case editor
    case linkSharer
}

The model breaks as soon as permissions overlap. An editor can also be allowed to create a share link. A user who can share a link may not be allowed to edit. Adding a new capability multiplies the number of role combinations and pushes special cases into every screen.

The more durable abstraction is a set of independent capabilities. Swift already provides that abstraction through OptionSet.

Modeling Capabilities as Bits

Each permission receives one stable bit in the raw value:

struct DocumentPermissions: OptionSet {
    let rawValue: UInt

    static let read      = DocumentPermissions(rawValue: 1 << 0)
    static let edit      = DocumentPermissions(rawValue: 1 << 1)
    static let shareLink = DocumentPermissions(rawValue: 1 << 2)
}

The values are powers of two:

CapabilityBitRaw value
Read00011
Edit00102
Share link01004

Because no two capabilities occupy the same bit, they can be combined without losing information:

let viewer: DocumentPermissions = [.read]                 // 1
let editor: DocumentPermissions = [.read, .edit]          // 3
let publisher: DocumentPermissions = [.read, .shareLink]  // 5
let ownerLike: DocumentPermissions = [
    .read,
    .edit,
    .shareLink
]                                                         // 7

The raw value can cross a network or persistence boundary, while the rest of the application works with names that express intent.

struct ResourceAccessDTO: Decodable {
    let permissionMask: UInt
}

let permissions = DocumentPermissions(rawValue: dto.permissionMask)

The bit assignments are part of the data contract. Once shipped, they should not be reordered or reused for a different meaning.

Composition Is the Important Part

OptionSet conforms to SetAlgebra, so permission logic reads like the domain rather than like bit manipulation.

var permissions: DocumentPermissions = [.read]

permissions.insert(.edit)
permissions.remove(.shareLink)

if permissions.contains(.edit) {
    editor.isEditable = true
}

This is a better boundary for the UI. Screens do not need to know whether access came from a personal account, a managed workspace, a folder, or a direct document grant. They ask the effective capability set one focused question.

func configureActions(using permissions: DocumentPermissions) {
    editor.isEditable = permissions.contains(.edit)
    shareLinkButton.isHidden = !permissions.contains(.shareLink)
}

It also prevents slightly different permission rules from being recreated across the document list, editor, context menu, and sharing sheet.

Resource Grants, Folder Inheritance, and Account Policy

A bitmask is a compact representation, but it should not flatten every authorization concept into one value too early. Grants, denies, and policy ceilings have different meanings.

For a document inside a folder, I model the inputs separately:

struct PermissionContext {
    let directGrants: DocumentPermissions
    let inheritedGrants: DocumentPermissions
    let explicitDenials: DocumentPermissions
    let policyCeiling: DocumentPermissions

    var effective: DocumentPermissions {
        let granted = directGrants.union(inheritedGrants)
        let permitted = granted
            .subtracting(explicitDenials)
            .intersection(policyCeiling)

        return permitted.normalized()
    }
}

The order matters:

  1. Direct document grants and inheritable folder grants are combined.
  2. Explicit restrictions are removed.
  3. The result is intersected with the account or tenant policy ceiling.
  4. Semantic dependencies are normalized once.

The policy ceiling is where personal and managed-workspace behavior can differ without leaking account-type conditionals throughout the app.

let personalWorkspacePolicy: DocumentPermissions = [
    .read,
    .edit,
    .shareLink
]

let managedWorkspacePolicy: DocumentPermissions = [
    .read,
    .edit
]

A workspace administrator can disable public link sharing by omitting .shareLink from the ceiling. Even if a document grant contains that bit, the intersection removes it from the effective result. The same UI code continues to call contains(.shareLink).

This is the complete resolution path:

Normalizing Semantic Dependencies

Bits are independent mechanically, but the product may define dependencies between them. If editing a document necessarily includes reading it, the model should enforce that invariant in one place.

extension DocumentPermissions {
    func normalized() -> DocumentPermissions {
        var result = self

        if result.contains(.edit) || result.contains(.shareLink) {
            result.insert(.read)
        }

        return result
    }
}

Normalization should reflect an explicit product rule, not an assumption made by an individual screen. If a future workflow permits link administration without document access, the dependency can change here rather than through scattered UI patches.

Why Not Store Three Booleans?

Three booleans are workable for one local view model. They become expensive when the model crosses multiple layers:

  • The network payload needs three fields instead of one stable mask.
  • Equality, caching, and persistence require repeated field-by-field handling.
  • Adding a capability changes more schemas and initializers.
  • Different feature teams can accidentally construct invalid combinations.
  • Passing permissions between modules becomes verbose and easy to misread.

An OptionSet keeps the representation compact while preserving type safety. More importantly, it supports the operations the domain actually needs: union, subtraction, intersection, containment, and equality.

Evolving the Model

The permission set can grow without changing existing call sites:

extension DocumentPermissions {
    static let comment       = DocumentPermissions(rawValue: 1 << 3)
    static let download      = DocumentPermissions(rawValue: 1 << 4)
    static let manageMembers = DocumentPermissions(rawValue: 1 << 5)
}

Existing clients ignore capabilities they do not understand, while newer clients can adopt them incrementally. The raw-value contract still needs versioning discipline: never recycle a retired bit, and document whether unknown bits must be preserved when a payload is decoded and encoded again.

Testing the Permission Algebra

The most valuable tests cover policy composition rather than individual bit constants.

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

A small table-driven suite can cover the combinations of direct grants, inherited grants, denials, and policy ceilings. These tests become executable product documentation: they show exactly which layer wins when rules conflict.

The Security Boundary

The iOS permission model controls presentation and local behavior. It is not the security boundary.

Hiding an edit button does not prevent a modified client from sending an edit request. The server must evaluate the same resource and tenant policies for every protected operation. The client-side OptionSet exists so the interface accurately reflects that authoritative decision and so feature code handles capabilities consistently.

That separation also makes failure states easier to reason about. If a permission changes while a document is open, the server can reject the operation, the client can refresh the capability set, and the UI can transition back to a valid state.

Takeaway

The useful idea is not the bit shift itself. It is choosing a representation that matches the shape of the domain.

Document permissions are composable capabilities constrained by inheritance, explicit restrictions, and account policy. OptionSet gives Swift a native vocabulary for that algebra. Once permission resolution is centralized, personal and managed-workspace experiences can share the same feature code, new capabilities can be introduced without role explosion, and every screen reads from one consistent definition of what the user is allowed to do.