Skip to main content

smbcloud_ascapi_signing/
profile.rs

1//! Provisioning profiles: `/v1/profiles`.
2//!
3//! A profile is the document that ties three things together and says
4//! Apple approves of the combination: *which app* (a bundle ID), *signed by
5//! whom* (one or more certificates), and *allowed to run where* (a device
6//! list, for the profile types that use one). Code signing consults it to
7//! decide whether the entitlements a binary claims are ones the App ID is
8//! actually authorised for.
9//!
10//! That last part is why this module exists alongside [`crate::certificate`]
11//! rather than in the metadata crate. A certificate proves identity and
12//! nothing else; it says nothing about entitlements. Enabling a capability
13//! on an App ID does not reach an already-issued profile either — profiles
14//! are snapshots, so a capability added today is invisible to a profile
15//! created yesterday. The fix is always to create a new one, which is what
16//! [`ProfilesApi::create_profile`] is for.
17//!
18//! Two operational notes that are easy to learn the expensive way:
19//!
20//! - **Apple will happily hand back a stale profile.** A profile stays
21//!   `ACTIVE` until its certificate expires or is revoked, so a capability
22//!   change leaves an outdated-but-valid profile in place. Nothing in the
23//!   API marks it as superseded. Recreate rather than re-download.
24//! - **`profileContent` is the file.** It is base64 of the binary
25//!   `.mobileprovision`/`.provisionprofile` that goes into the bundle;
26//!   there is no separate download endpoint.
27//!
28//! App Groups are the common reason a profile needs regenerating and are
29//! also the one thing this API cannot help with: there is no `appGroups`
30//! resource, so assigning a group to an App ID is Developer Portal web UI
31//! work. Once assigned, a new profile picks it up.
32
33use async_trait::async_trait;
34use reqwest::Method;
35use serde::{Deserialize, Serialize};
36use smbcloud_ascapi_core::jsonapi::{
37    CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToMany, ToOne,
38};
39use smbcloud_ascapi_core::Client;
40use smbcloud_ascapi_core::Result;
41
42pub const RESOURCE_TYPE: &str = "profiles";
43
44/// The kind of profile, which fixes both the distribution channel and
45/// whether a device list applies.
46///
47/// The `*Development` and `*AdHoc` variants are device-scoped: a build
48/// signed with one runs only on hardware in the profile's device list.
49/// The `*AppStore` and `*InHouse` variants are not, and Apple rejects a
50/// create request that sends devices for them.
51///
52/// [`Self::MacAppStore`] is the one to use for a Mac App Store submission;
53/// [`Self::MacAppDirect`] is Developer ID distribution outside the store
54/// and is a different signing path entirely.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56pub enum ProfileType {
57    #[serde(rename = "IOS_APP_DEVELOPMENT")]
58    IosAppDevelopment,
59    #[serde(rename = "IOS_APP_STORE")]
60    IosAppStore,
61    #[serde(rename = "IOS_APP_ADHOC")]
62    IosAppAdHoc,
63    #[serde(rename = "IOS_APP_INHOUSE")]
64    IosAppInHouse,
65    #[serde(rename = "MAC_APP_DEVELOPMENT")]
66    MacAppDevelopment,
67    /// Mac App Store distribution — the profile a `productbuild` submission
68    /// embeds as `Contents/embedded.provisionprofile`.
69    #[serde(rename = "MAC_APP_STORE")]
70    MacAppStore,
71    #[serde(rename = "MAC_APP_DIRECT")]
72    MacAppDirect,
73    #[serde(rename = "TVOS_APP_DEVELOPMENT")]
74    TvOsAppDevelopment,
75    #[serde(rename = "TVOS_APP_STORE")]
76    TvOsAppStore,
77    #[serde(rename = "TVOS_APP_ADHOC")]
78    TvOsAppAdHoc,
79    #[serde(rename = "TVOS_APP_INHOUSE")]
80    TvOsAppInHouse,
81    #[serde(rename = "MAC_CATALYST_APP_DEVELOPMENT")]
82    MacCatalystAppDevelopment,
83    #[serde(rename = "MAC_CATALYST_APP_STORE")]
84    MacCatalystAppStore,
85    #[serde(rename = "MAC_CATALYST_APP_DIRECT")]
86    MacCatalystAppDirect,
87}
88
89impl ProfileType {
90    /// The string App Store Connect uses, for building
91    /// `filter[profileType]` query values without going through serde.
92    pub fn as_api_str(self) -> &'static str {
93        match self {
94            Self::IosAppDevelopment => "IOS_APP_DEVELOPMENT",
95            Self::IosAppStore => "IOS_APP_STORE",
96            Self::IosAppAdHoc => "IOS_APP_ADHOC",
97            Self::IosAppInHouse => "IOS_APP_INHOUSE",
98            Self::MacAppDevelopment => "MAC_APP_DEVELOPMENT",
99            Self::MacAppStore => "MAC_APP_STORE",
100            Self::MacAppDirect => "MAC_APP_DIRECT",
101            Self::TvOsAppDevelopment => "TVOS_APP_DEVELOPMENT",
102            Self::TvOsAppStore => "TVOS_APP_STORE",
103            Self::TvOsAppAdHoc => "TVOS_APP_ADHOC",
104            Self::TvOsAppInHouse => "TVOS_APP_INHOUSE",
105            Self::MacCatalystAppDevelopment => "MAC_CATALYST_APP_DEVELOPMENT",
106            Self::MacCatalystAppStore => "MAC_CATALYST_APP_STORE",
107            Self::MacCatalystAppDirect => "MAC_CATALYST_APP_DIRECT",
108        }
109    }
110
111    /// Whether Apple expects a device list for this profile type.
112    ///
113    /// Sending devices for a store profile is an error rather than a
114    /// harmless extra, so callers building a create request need to know
115    /// this before they assemble relationships.
116    pub fn takes_devices(self) -> bool {
117        matches!(
118            self,
119            Self::IosAppDevelopment
120                | Self::IosAppAdHoc
121                | Self::MacAppDevelopment
122                | Self::TvOsAppDevelopment
123                | Self::TvOsAppAdHoc
124                | Self::MacCatalystAppDevelopment
125        )
126    }
127}
128
129/// Whether Apple still considers the profile usable.
130///
131/// `Invalid` is not a separate deletion state: it is what a profile
132/// becomes when something it depends on goes away, most often a revoked or
133/// expired certificate. The profile stays listed so you can see why a
134/// build that used to sign no longer does.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136pub enum ProfileState {
137    #[serde(rename = "ACTIVE")]
138    Active,
139    #[serde(rename = "INVALID")]
140    Invalid,
141}
142
143impl ProfileState {
144    pub fn as_api_str(self) -> &'static str {
145        match self {
146            Self::Active => "ACTIVE",
147            Self::Invalid => "INVALID",
148        }
149    }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct ProfileAttributes {
155    pub name: Option<String>,
156    pub platform: Option<String>,
157    pub profile_type: Option<ProfileType>,
158    pub profile_state: Option<ProfileState>,
159    /// The UUID embedded in the profile itself, which is what Xcode and
160    /// `codesign` diagnostics refer to. Distinct from the resource `id`
161    /// this API uses.
162    pub uuid: Option<String>,
163    pub created_date: Option<String>,
164    /// ISO-8601. Bounded by the signing certificate's own expiry, so a
165    /// profile never outlives the certificate it embeds.
166    pub expiration_date: Option<String>,
167    /// Base64 of the binary profile file. Omitted from list responses
168    /// unless asked for via `fields[profiles]`; see
169    /// [`ProfilesApi::get_profile_content`].
170    pub profile_content: Option<String>,
171}
172
173pub type Profile = Resource<ProfileAttributes>;
174
175#[derive(Debug, Clone, Serialize)]
176#[serde(rename_all = "camelCase")]
177pub struct ProfileCreateAttributes {
178    /// Free-form label shown in the developer portal. Apple permits
179    /// duplicates, so a team can accumulate several same-named profiles
180    /// that differ only by creation date — worth making distinctive.
181    pub name: String,
182    pub profile_type: ProfileType,
183}
184
185/// The relationships a create request must carry.
186///
187/// Built explicitly rather than derived from ids alone, because `devices`
188/// has to be *absent* rather than empty for store profiles — see
189/// [`ProfileType::takes_devices`].
190#[derive(Debug, Clone, Serialize)]
191#[serde(rename_all = "camelCase")]
192pub struct ProfileCreateRelationships {
193    pub bundle_id: ToOne,
194    pub certificates: ToMany,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub devices: Option<ToMany>,
197}
198
199impl ProfileCreateRelationships {
200    /// Assemble relationships for `profile_type`, dropping `devices` when
201    /// the type does not take them.
202    pub fn new(
203        profile_type: ProfileType,
204        bundle_id: &str,
205        certificate_ids: &[String],
206        device_ids: &[String],
207    ) -> Self {
208        let devices = if profile_type.takes_devices() && !device_ids.is_empty() {
209            Some(ToMany {
210                data: device_ids
211                    .iter()
212                    .map(|id| ResourceId {
213                        resource_type: "devices",
214                        id: id.clone(),
215                    })
216                    .collect(),
217            })
218        } else {
219            None
220        };
221
222        Self {
223            bundle_id: ToOne {
224                data: ResourceId {
225                    resource_type: "bundleIds",
226                    id: bundle_id.to_string(),
227                },
228            },
229            certificates: ToMany {
230                data: certificate_ids
231                    .iter()
232                    .map(|id| ResourceId {
233                        resource_type: "certificates",
234                        id: id.clone(),
235                    })
236                    .collect(),
237            },
238            devices,
239        }
240    }
241}
242
243/// Provisioning profiles.
244///
245/// An extension trait rather than inherent methods, because `Client`
246/// lives in the core crate and Rust only allows inherent impls in the
247/// crate that defines the type. Import it, or the crate's `prelude`,
248/// to call these on a `Client`.
249#[async_trait]
250pub trait ProfilesApi {
251    /// `GET /v1/profiles`, optionally filtered by name and type.
252    ///
253    /// `profile_content` is left out of these results by Apple. That is
254    /// usually what you want — the content is a multi-kilobyte blob and
255    /// listing is for deciding *which* profile you need. Fetch the bytes
256    /// with [`ProfilesApi::get_profile_content`] once you know.
257    async fn list_profiles(
258        &self,
259        filter_name: Option<&str>,
260        filter_type: Option<ProfileType>,
261    ) -> Result<Vec<Profile>>;
262
263    /// `GET /v1/profiles/{id}` asking explicitly for `profileContent`.
264    ///
265    /// Separate from listing because the field has to be requested: a
266    /// plain `GET` of the same resource returns metadata with
267    /// `profileContent` absent, which reads as "this profile has no
268    /// content" rather than "you did not ask for it".
269    async fn get_profile_content(&self, id: &str) -> Result<Profile>;
270
271    /// `POST /v1/profiles` — creates a profile over a bundle ID and
272    /// certificates.
273    ///
274    /// This is the only way to pick up a capability that was added to the
275    /// App ID after an existing profile was made. Apple does not reject
276    /// the request as a duplicate when an equivalent profile already
277    /// exists; you get a second one, and both stay active.
278    async fn create_profile(
279        &self,
280        attributes: ProfileCreateAttributes,
281        relationships: ProfileCreateRelationships,
282    ) -> Result<Profile>;
283
284    /// `DELETE /v1/profiles/{id}`.
285    ///
286    /// Narrower than revoking a certificate: it invalidates only this
287    /// profile, and any build already signed with it keeps working. New
288    /// signing that referenced it fails until a replacement exists.
289    async fn delete_profile(&self, id: &str) -> Result<()>;
290}
291
292#[async_trait]
293impl ProfilesApi for Client {
294    async fn list_profiles(
295        &self,
296        filter_name: Option<&str>,
297        filter_type: Option<ProfileType>,
298    ) -> Result<Vec<Profile>> {
299        let mut query = Vec::new();
300        if let Some(name) = filter_name {
301            query.push(("filter[name]", name));
302        }
303        if let Some(profile_type) = filter_type {
304            query.push(("filter[profileType]", profile_type.as_api_str()));
305        }
306        // Apple's default page size is 20, which a team carrying a profile
307        // per app per platform will exceed.
308        query.push(("limit", "200"));
309
310        let doc: ListDocument<ProfileAttributes> = self
311            .request(Method::GET, "/v1/profiles", &query, None::<&()>)
312            .await?;
313        Ok(doc.data)
314    }
315
316    async fn get_profile_content(&self, id: &str) -> Result<Profile> {
317        let query = [(
318            "fields[profiles]",
319            "name,platform,profileType,profileState,uuid,createdDate,expirationDate,profileContent",
320        )];
321        let doc: Document<ProfileAttributes> = self
322            .request(
323                Method::GET,
324                &format!("/v1/profiles/{id}"),
325                &query,
326                None::<&()>,
327            )
328            .await?;
329        Ok(doc.data)
330    }
331
332    async fn create_profile(
333        &self,
334        attributes: ProfileCreateAttributes,
335        relationships: ProfileCreateRelationships,
336    ) -> Result<Profile> {
337        let body = CreateBody {
338            data: CreateData {
339                resource_type: RESOURCE_TYPE,
340                attributes,
341                relationships: Some(relationships),
342            },
343        };
344        let doc: Document<ProfileAttributes> = self
345            .request(Method::POST, "/v1/profiles", &[], Some(&body))
346            .await?;
347        Ok(doc.data)
348    }
349
350    async fn delete_profile(&self, id: &str) -> Result<()> {
351        self.request_no_content(
352            Method::DELETE,
353            &format!("/v1/profiles/{id}"),
354            &[],
355            None::<&()>,
356        )
357        .await
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn profile_type_round_trips_through_the_api_wire_format() {
367        // The serde rename and as_api_str() are two separate spellings of
368        // the same contract, and a filter built from the wrong one fails
369        // silently by matching nothing.
370        for profile_type in [
371            ProfileType::IosAppDevelopment,
372            ProfileType::IosAppStore,
373            ProfileType::IosAppAdHoc,
374            ProfileType::IosAppInHouse,
375            ProfileType::MacAppDevelopment,
376            ProfileType::MacAppStore,
377            ProfileType::MacAppDirect,
378            ProfileType::TvOsAppDevelopment,
379            ProfileType::TvOsAppStore,
380            ProfileType::TvOsAppAdHoc,
381            ProfileType::TvOsAppInHouse,
382            ProfileType::MacCatalystAppDevelopment,
383            ProfileType::MacCatalystAppStore,
384            ProfileType::MacCatalystAppDirect,
385        ] {
386            let json = serde_json::to_string(&profile_type).expect("serializes");
387            let unquoted = json.trim_matches('"');
388            assert_eq!(
389                unquoted,
390                profile_type.as_api_str(),
391                "serde and as_api_str disagree for {profile_type:?}"
392            );
393
394            let parsed: ProfileType = serde_json::from_str(&json).expect("deserializes");
395            assert_eq!(parsed, profile_type);
396        }
397    }
398
399    #[test]
400    fn profile_state_round_trips_through_the_api_wire_format() {
401        for state in [ProfileState::Active, ProfileState::Invalid] {
402            let json = serde_json::to_string(&state).expect("serializes");
403            assert_eq!(json.trim_matches('"'), state.as_api_str());
404            let parsed: ProfileState = serde_json::from_str(&json).expect("deserializes");
405            assert_eq!(parsed, state);
406        }
407    }
408
409    #[test]
410    fn store_profiles_omit_devices_entirely() {
411        // An empty `devices` array is not the same as no `devices` key:
412        // Apple rejects the former on a store profile. Serializing has to
413        // drop the field, not send `[]`.
414        let relationships = ProfileCreateRelationships::new(
415            ProfileType::MacAppStore,
416            "P59W5V5953",
417            &["CERT1".to_string()],
418            &["DEVICE1".to_string()],
419        );
420        let json = serde_json::to_value(&relationships).expect("serializes");
421        assert!(
422            json.get("devices").is_none(),
423            "a store profile must not carry devices: {json}"
424        );
425        assert_eq!(json["bundleId"]["data"]["type"], "bundleIds");
426        assert_eq!(json["certificates"]["data"][0]["type"], "certificates");
427    }
428
429    #[test]
430    fn development_profiles_keep_their_devices() {
431        let relationships = ProfileCreateRelationships::new(
432            ProfileType::IosAppDevelopment,
433            "P59W5V5953",
434            &["CERT1".to_string()],
435            &["DEVICE1".to_string(), "DEVICE2".to_string()],
436        );
437        let json = serde_json::to_value(&relationships).expect("serializes");
438        assert_eq!(json["devices"]["data"].as_array().expect("array").len(), 2);
439        assert_eq!(json["devices"]["data"][0]["type"], "devices");
440    }
441
442    #[test]
443    fn a_development_profile_with_no_devices_omits_the_key() {
444        // Rather than sending `{"data": []}`, which Apple reads as an
445        // explicit empty set and rejects.
446        let relationships = ProfileCreateRelationships::new(
447            ProfileType::IosAppDevelopment,
448            "P59W5V5953",
449            &["CERT1".to_string()],
450            &[],
451        );
452        let json = serde_json::to_value(&relationships).expect("serializes");
453        assert!(json.get("devices").is_none());
454    }
455}