Using CloudKit for content hosting and feature flags

Dec 6 2021 12:00 PM

The most common application of CloudKit by far is to store private user data with the goal of keeping their devices in sync. This is mostly what my CloudKit 101 post is focused on, as well as explaining the basic concepts of how CloudKit works and the best practices around that type of data synchronization.

In that post, I did mention that you can use the public database offered by CloudKit for some interesting applications, such as storing app configuration and feature switches on CloudKit, making it possible to change things about your app without the need to release an update, and also without the need to use a third-party library or service such as Firebase.

This post is a continuation of that topic, detailing how exactly you can do such things with CloudKit, and it also includes a case study of how I use it for feature switches and content hosting in one of my apps, ChibiStudio.

CloudKit refresher: the public, shared and private databases

If you haven’t read my CloudKit 101 article and you’re not familiar with using the framework in general, I strongly recommend starting with that one, since it’ll make it a lot easier to understand the concepts explained here.

Regardless, let’s do a quick refresher on the three distinct database types that CloudKit has to offer:

Private Database

This is where the app stores private user data, such as the items that they have in a todo list app. Only the owner can access this data from a device that’s logged in to their iCloud account — you as the developer can’t read it, not even from the iCloud Console.

Data stored in the private database counts against the user’s iCloud account storage quota.

Shared Database

The shared database is where shared records are stored. These are records two or more users of your app have shared with each other such that they can collaborate remotely, like shared notes in the Notes app.

Public Database

This is the one we’ll be focusing on throughout this article. It’s a database that’s unique per iCloud container. Every authenticated user of your app can (by default) read, write, and create records in the public database.

As you might imagine, due to its public nature, usage of the public database can have some implications that we should be aware of when using it for things such as content hosting or feature flags, so that we don’t end up in an embarrassing situation such as having a hacker delete all of our app’s data, kinda like what happened to Shortcuts a while back (seriously).

Public Database Questions

Besides the subject of the aforementioned security risk when using the public database, which I’ll address shortly, people often ask me the same few questions when I mention that I use CloudKit for content hosting and similar applications, and I’ll try to go through them briefly:

What if the person who’s using my app doesn’t have an iCloud account?

By default, the contents of a record in the public database can be read by anyone, even if they don’t have an iCloud account. So if you plan on using the public CloudKit database for content hosting, feature flags, or other similar applications, there’s no need to worry about users who don’t have iCloud enabled, since all of the operations will be read-only.

But what if I decide to implement a web or Android app that can access the same content?

If you’d like to consume the same content that you’re hosting on the public CloudKit database from an Android app or from a web app, you can. You can use the CloudKit Web Services API, which lets you do pretty much everything that can be done through the CloudKit framework over HTTP.

Don’t believe me? Click the button below and the box will be filled with a JSON blob for the current feature flags configuration in ChibiStudio, fetched by your browser directly from CloudKit with just a few lines of plain Javascript code.

Live example: fetch from the public database over HTTP

CloudKit response will show up here

The code for this example is available on Gist.

What if my app becomes the next TikTok, won't it cost me a fortune?

This is a very interesting question. I briefly touched on this subject during my CloudKit 101 post, where I said that you as the developer are not going to pay for CloudKit, period. Back when I wrote that article, Apple still had a little widget on the website showing that for an app with 10 million active users, you had 1 petabyte of asset storage, 20 terabytes of database store, and some other things, completely for free.

They never said what would happen if you went over that limit, and they've since removed that from the website entirely. I also know of at least one app that has gone over the limit and so far the developer hasn't received a call from Eddy Cue. So I'm even more confident in saying that you are not going to have to pay Apple for your CloudKit usage.

With those questions out of the way, let’s look at how we can protect the public database from potential abuse by employing a feature of CloudKit that most developers are not familiar with.

Using CloudKit Security Roles

When using the public database for content hosting or feature flags, you don’t want any random iCloud user to have the rights to publish or edit content on your behalf or to change the feature flags that control your app’s behavior for all of your users, that would be really bad.

That’s where CloudKit’s security roles come in. You can think of them as Unix access control groups for record types. They allow you to restrict the types of operations that users belonging to a given group (security role) can perform on any given record type, and you can then assign security roles to specific users.

Every CloudKit container comes with three default security roles: World, iCloud, and Creator.

The World role means “everyone”, including users of your app who are not signed in to iCloud. This role has read access to every record type on the public database, but can’t write or create records. You can remove the read permission for a given record type from World in order to restrict access to only those users who have an iCloud account, but you can't add the write permission to this group.

The iCloud role means “authenticated iCloud users”, that is, users who are signed in to iCloud on their device while using your app. By default, users in this role can create records of any type, but they can’t read or write records.

You might be wondering what the point is of users having the create permission, but not the read or write one. That becomes clearer once you learn about the third and final default security role in every CloudKit container, which is the Creator role.

The creator role means “the user who has created this record”. So it’s possible to allow any authenticated user to create a record of a given type in the public database, but not read or write to any record other than the ones that they have created themselves. This can be useful for social media type apps that want to allow users to have public data that anyone can see, but that only the creator can modify. You could imagine having a Post record type where the creator has write permission, but other security roles only have the read permission, so that all users can view posts from other users, but only the owner of a given post can edit its contents or delete it.

But the most interesting thing about security roles for content hosting and similar applications is that we can define our own security roles and assign them to specific users.

Taking the example from my app ChibiStudio, we have two record types that are used for content hosting and feature flags: the PublishedChibi record type, which is used for the curated collection of chibis that can appear in the app’s widget, and the AppFeatures record type, which is used for feature flags.

Those records live in the public database so that all app users can access that content, but they can only be edited by me. To achieve that, I have created a new security role called “Admin”. I then granted that security role create, read, and write permission on the record types that I want only a subset of iCloud users to be able to edit.

The Admin security role in the CloudKit Console

Just adding the permissions to the new Admin role doesn't protect the records from being written by any authenticated user, so I also had to remove the create and write permissions from the iCloud security role for the same record types. Now, only users that belong to the Admin security role will be able to create or edit records of those types.

Remember that the World role is always read-only, and the Creator role is irrelevant in this case since no users other than those in the Admin role will be able to create those types of records.

The iCloud security role in the CloudKit Console

The end result is that in order to publish content for the widget or change the app’s feature flags, the user that’s authenticated in the app must have the Admin security role assigned to them. It's also possible to do those edits from the CloudKit Console, of course.

Speaking of the CloudKit Console, that’s where you can manage security roles. With a container selected, you can select “Security Roles” in the sidebar, and then you can edit the permissions for each security role or create new ones. These changes have to be done in the development environment and then promoted to production, just like with any other change you do to your CloudKit schema.

But how do you assign a security role to a user? To do that, you first have to know the record ID for the User record representing the user you’d like to assign the security role to. That can be done in your app with the fetchUserRecordID API. Note that this ID will be different between the development and production environments. You can run your app from Xcode with the production environment by following the tip I gave in the CloudKit 101 post (in the “Environments” section).

A user in the CloudKit Console has the Admin role assigned to them

You can then go to the CloudKit Console and query the User record type in the public database for the record name that you got from running fetchUserRecordID in your app, select that user record and check the box for the security role you’d like to assign. It’s a bit of a convoluted process, but the good thing is that it only has to be done once for each user that you want to assign special permissions to.

The risks of not implementing security roles

Some might be wondering if the correct application of security roles in CloudKit is worth the effort given that you as the developer control what your app does in relation to CloudKit. After all, if you don’t ship code in your app that allows users to modify your feature flags or hosted content, then you must be safe, right?

Well, not quite. Jailbreaking iPhones is still common practice by security researchers and other types of hackers, and once you have code injected into an app, you can call CloudKit APIs and do whatever you want.

Not only that, but with Macs running iOS apps natively and more and more apps offering Catalyst versions for macOS, it’s only getting more likely that someone might want to poke around your public database by attaching a debugger to your app. It's also possible to use any CloudKit container on macOS by disabling SIP, setting a couple of boot arguments and signing a binary with fake entitlements for another app's container.

So I would say that if you’re planning on using the public CloudKit database in a way where not every iCloud user is welcome to create or edit records, then you must be sure to implement the correct security roles in order to prevent bad actors from modifying your app’s content of features.

Case Study: ChibiStudio

I use CloudKit in ChibiStudio for regular user data sync, but there are a few “unusual” applications of the service, including the two examples given in this article which are content hosting and feature flags.

Feature flags

When I developed the feature flags system, I opted into having a single record of the AppFeatures type where each field in the record is an integer representing a feature flag state (0 for off, 1 for on). If I were to start over, I’d probably have individual records for each feature flag, since I think that’s more flexible.

Having each feature represented by an individual record would allow for things such as targeting features for a specific locale, OS version, device type, etc. However, our use of feature flags in ChibiStudio is extremely simple, so I didn’t feel the need to implement anything too fancy, I just wanted a way to roll out features with the ability to turn them off in case anything went wrong.

The way I implemented this in the app was to introduce a FeatureSwitch type like the one below:

typealias FeatureSwitchIdentifier = String

/// Defines a feature that can be switched on or off.
struct FeatureSwitch: Hashable, Codable {
    
    /// An unique identifier for this feature.
    let identifier: FeatureSwitchIdentifier
    
    /// Whether this feature is enabled by default (before the feature state is known).
    let isEnabledByDefault: Bool
    
#if DEBUG
    /// A debug display name for the feature (never shown to the end user).
    let displayName: String?
#endif
    
}

#if DEBUG
extension FeatureSwitch {
    
    /// A key that can be used to store an override for this feature.
    var overrideKey: String {
        "featureOverride_\(self.identifier)"
    }
    
}
#endif

The displayName and overrideKey properties are only available in debug and internal builds. I distribute internal builds on TestFlight and those are built with a copy of the release configuration, but that includes the -D DEBUG flag, so that debug-only features get included in the TestFlight build that I and the person who works with me in the app can use.

There’s also a FeatureSwitchProvider protocol that gets its implementation injected wherever there’s a need to know about the state of feature switches in the app. Having a protocol allows me to mock things out for tests or SwiftUI previews, and will also be handy in case I decide to use something other than CloudKit for my feature switches in the future.

To define the feature flags themselves, I have a Swift file where I extend FeatureSwitch defining the current flags that the app supports:

extension FeatureSwitchIdentifier {
    
    static let chibiVersionCheck = "chibiVersionCheck"
    static let tipJar = "tipJar"
    
    // ...
    
}

extension FeatureSwitch {
    
    static let chibiVersionCheck = FeatureSwitch(
        identifier: .chibiVersionCheck,
        isEnabledByDefault: true,
        displayName: "Chibi Version Check"
    )
    
    static let tipJar = FeatureSwitch(
        identifier: .tipJar,
        isEnabledByDefault: true,
        displayName: "Tip Jar"
    )
    
    // ...
    
}

Checking if a given feature is currently enabled looks like this:

if provider.isFeatureEnabled(.tipJar) {
    // Show the tip jar button
}

Every feature must have a default enabled/disabled fallback value in case its state is checked before the app has had a chance to download the current state from CloudKit. I haven’t had a situation yet where a feature is checked early enough in the app’s lifecycle where this would become a problem.

To actually change the feature flags in production, I use the CloudKit Console, I haven’t bothered with implementing a custom panel for this in internal builds of the app or as a separate tool, since it’s something that I don’t have to do very often.

There is of course a section in the app’s settings when running an internal build that lets the user override the state of any feature flag, but that’s only applied locally. That’s why every FeatureSwitch has a displayName and an overrideKey. The displayName is used in the debug UI, and the overrideKey is the UserDefaults key that’s used to override the state of the feature when toggled in that UI.

Feature switches internal UI in ChibiStudio

To actually fetch the feature state from CloudKit, a simple query operation is performed shortly after each app launch:

func fetchLatestFeatureStateRecord(with completion: @escaping (Result<CKRecord, Error>) -> Void) {
    let query = CKQuery(recordType: .appFeatures, predicate: NSPredicate(value: true))
    
    query.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
    
    let operation = CKQueryOperation(query: query)
    
    operation.resultsLimit = 1
    operation.queuePriority = .veryHigh
    
    operation.recordFetchedBlock = { record in
        completion(.success(record))
    }
    
    operation.queryCompletionBlock = { [weak self] _, error in
        // Error handling or completion(.failure(error)) if not recoverable
    }
    
    container.publicCloudDatabase.add(operation)
}

The resulting CKRecord is then parsed in order to get the state for each feature flag, which is stored in memory for when a feature flag is checked. The app also stores the latest feature state in UserDefaults in order to persist it between sessions.

Curated collection of chibis for the widget

When iOS 14 introduced Home Screen widgets, I wasn’t sure if there was going to be enough demand for it to be worth the effort of implementing widgets for ChibiStudio. When I saw the explosion of widgets on the internet and how people were using them to personalize their Home Screens with things that they like, that changed my mind.

I expected that not all users would have a particularly vast collection of chibis of their own creation, and I didn’t want to have “your library” and “random chibi” (a randomly generated chibi) as the only options for the widget’s contents.

The ChibiStudio widget and its collections

Throughout the development of the app, we have created thousands of chibis ourselves, and we also publish what we call the “Chibi of the Week” every Friday, and have been doing so for quite a while, so there’s a large collection of chibis that could be offered in the widget.

Bundling all of that content within the app would be impractical, especially considering how much work I put into making it smaller so that I could create an App Clip for it. I also wanted to be able to publish new chibis to that widget collection over time and to be able to target content for special occasions such as Halloween or Christmas.

The solution was once again CloudKit. The PublishedChibi record type that I mentioned earlier represents a chibi that’s part of the widget collection. Here’s what that record’s schema looks like:

The PublishedChibi record type in the CloudKit Console

As you can see, the main field is the entityData one, that stores the binary vector data for the chibi itself. Another important field is publishedAt, which allows us to send a chibi to the collection, but determine a date for it to start to show up on the widget. There are also other fields for targeting based on app version and limiting the chibi to specific locales.

To fetch a random chibi from the collection, a CuratedCollectionsManager class is used. There is no way to tell CloudKit to give you a “random” record, so it fetches a bunch of PublishedChibi records using a pseudo-random sort descriptor and then shuffles the array, before caching the data for the record that it has chosen to display. I do have some ideas on how this randomness could be improved, but the system is working just fine as-is.

Here’s an approximation of what that looks like in practice:

func fetchRandomChibiForWidget(with completion: @escaping (Result<PublishedChibi, Error>) -> Void) {
    let predicate = NSPredicate(format: "publishedAt <= %@", Date() as CVarArg)
    
    let query = CKQuery(recordType: .publishedChibi, predicate: predicate)
    
    query.sortDescriptors = randomSortDescriptors()
    
    let operation = CKQueryOperation(query: query)
    
    operation.database = container.publicCloudDatabase
    operation.qualityOfService = .userInteractive
    operation.resultsLimit = 30
    
    var candidates = [PublishedChibi]()
    
    operation.recordFetchedBlock = { record in
        do {
            let chibi = try PublishedChibi(record: record)
            candidates.append(chibi)
        } catch {
            // Error handling...
        }
    }
    
    operation.queryCompletionBlock = { _, error in
        guard error == nil else {
            // Error handling or completion(.failure(error))
            return
        }
        
        let filteredCandidates = candidates.filter {
            $0.isValidInCurrentEnvironment // Checks for expiration, locale, min app version, etc
        }
        
        guard let chibi = filteredCandidates.shuffled().first else {
            // Couldn't find any valid candidates, calls completion(.failure(...))
            return
        }
        
        let cacheURL = URL.publishingStorageURLForItem(with: chibi.id)
        
        do {
            try FileManager.default.copyItem(at: url, to: cacheURL)
            
            completion(.success(chibi))
        } catch {
            // Couldn't save to cache, calls completion(.failure(...))
        }
    }
    
    cloudOperationQueue.addOperation(operation)
}

I was a bit scared about including this CloudKit fetching as part of the widget pipeline, since it involves doing quite a bit of networking through CloudKit, a bunch of file I/O, not to mention the work that goes into rendering the image for the chibi based on the vector data that’s downloaded.

It turned out to be perfectly fine in the end though, and the widget works quite well. Another option to make this more robust would be to use background fetching or background tasks in the app itself to pre-warm the curated collections content, storing a small subset of the collection on-device so that the widget could just pick from that.

So as you can see, using CloudKit in app extensions such as widgets can also be done, but of course you have to keep in mind the limitations of those environments.

Finally, to actually publish a chibi to this widget collection, internal builds of ChibiStudio include a “Publish for Widget” option when you tap and hold one of the chibis in the library. The little form was written in SwiftUI and lets the publisher pick a start and end date, minimum version of the app and locales.

The internal UI that enables the publication of chibis

Thanks to CloudKit security roles, even if a regular user of the app were to get access to an internal build that includes this feature, they wouldn’t be able to publish one of their chibis to the collection because their iCloud User record wouldn’t have the Admin role assigned to it.

I hope this article has provided some inspiration around different uses of CloudKit other than the common use case that is to sync private user data. I still have more things about CloudKit that I would like to write, so be sure to add this blog to your RSS reader and follow me on Twitter for more.