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, 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 bundle ID behind a profile, as `GET /v1/profiles/{id}/bundleId`
186/// returns it. Only the fields a caller needs to match a profile to an
187/// app; the signing crate doesn't depend on the aso crate's full type.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct ProfileBundleIdAttributes {
191    pub identifier: Option<String>,
192    pub platform: Option<String>,
193}
194
195pub type ProfileBundleId = Resource<ProfileBundleIdAttributes>;
196
197/// The relationships a create request must carry.
198///
199/// Built explicitly rather than derived from ids alone, because `devices`
200/// has to be *absent* rather than empty for store profiles — see
201/// [`ProfileType::takes_devices`].
202#[derive(Debug, Clone, Serialize)]
203#[serde(rename_all = "camelCase")]
204pub struct ProfileCreateRelationships {
205    pub bundle_id: ToOne,
206    pub certificates: ToMany,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub devices: Option<ToMany>,
209}
210
211impl ProfileCreateRelationships {
212    /// Assemble relationships for `profile_type`, dropping `devices` when
213    /// the type does not take them.
214    pub fn new(
215        profile_type: ProfileType,
216        bundle_id: &str,
217        certificate_ids: &[String],
218        device_ids: &[String],
219    ) -> Self {
220        let devices = if profile_type.takes_devices() && !device_ids.is_empty() {
221            Some(ToMany {
222                data: device_ids
223                    .iter()
224                    .map(|id| ResourceId {
225                        resource_type: "devices",
226                        id: id.clone(),
227                    })
228                    .collect(),
229            })
230        } else {
231            None
232        };
233
234        Self {
235            bundle_id: ToOne {
236                data: ResourceId {
237                    resource_type: "bundleIds",
238                    id: bundle_id.to_string(),
239                },
240            },
241            certificates: ToMany {
242                data: certificate_ids
243                    .iter()
244                    .map(|id| ResourceId {
245                        resource_type: "certificates",
246                        id: id.clone(),
247                    })
248                    .collect(),
249            },
250            devices,
251        }
252    }
253}
254
255/// Provisioning profiles.
256///
257/// An extension trait rather than inherent methods, because `Client`
258/// lives in the core crate and Rust only allows inherent impls in the
259/// crate that defines the type. Import it, or the crate's `prelude`,
260/// to call these on a `Client`.
261#[async_trait]
262pub trait ProfilesApi {
263    /// `GET /v1/profiles`, optionally filtered by name and type.
264    ///
265    /// `profile_content` is left out of these results by Apple. That is
266    /// usually what you want — the content is a multi-kilobyte blob and
267    /// listing is for deciding *which* profile you need. Fetch the bytes
268    /// with [`ProfilesApi::get_profile_content`] once you know.
269    async fn list_profiles(
270        &self,
271        filter_name: Option<&str>,
272        filter_type: Option<ProfileType>,
273    ) -> Result<Vec<Profile>>;
274
275    /// `GET /v1/profiles/{id}` asking explicitly for `profileContent`.
276    ///
277    /// Separate from listing because the field has to be requested: a
278    /// plain `GET` of the same resource returns metadata with
279    /// `profileContent` absent, which reads as "this profile has no
280    /// content" rather than "you did not ask for it".
281    async fn get_profile_content(&self, id: &str) -> Result<Profile>;
282
283    /// `POST /v1/profiles` — creates a profile over a bundle ID and
284    /// certificates.
285    ///
286    /// This is the only way to pick up a capability that was added to the
287    /// App ID after an existing profile was made. Apple does not reject
288    /// the request as a duplicate when an equivalent profile already
289    /// exists; you get a second one, and both stay active.
290    async fn create_profile(
291        &self,
292        attributes: ProfileCreateAttributes,
293        relationships: ProfileCreateRelationships,
294    ) -> Result<Profile>;
295
296    /// `GET /v1/profiles/{id}/bundleId` — the bundle ID a profile is for.
297    ///
298    /// A list response carries only the relationship link, not the
299    /// identifier, so finding which app an expired profile belongs to
300    /// takes one of these per profile.
301    async fn get_profile_bundle_id(&self, id: &str) -> Result<ProfileBundleId>;
302
303    /// `DELETE /v1/profiles/{id}`.
304    ///
305    /// Narrower than revoking a certificate: it invalidates only this
306    /// profile, and any build already signed with it keeps working. New
307    /// signing that referenced it fails until a replacement exists.
308    async fn delete_profile(&self, id: &str) -> Result<()>;
309}
310
311#[async_trait]
312impl ProfilesApi for Client {
313    async fn list_profiles(
314        &self,
315        filter_name: Option<&str>,
316        filter_type: Option<ProfileType>,
317    ) -> Result<Vec<Profile>> {
318        let mut query = Vec::new();
319        if let Some(name) = filter_name {
320            query.push(("filter[name]", name));
321        }
322        if let Some(profile_type) = filter_type {
323            query.push(("filter[profileType]", profile_type.as_api_str()));
324        }
325        // 200 rows per page instead of Apple's default 20; `list_all` follows
326        // `links.next` so a team with one profile per app per platform is
327        // never truncated.
328        query.push(("limit", "200"));
329
330        self.list_all::<ProfileAttributes>("/v1/profiles", &query)
331            .await
332    }
333
334    async fn get_profile_content(&self, id: &str) -> Result<Profile> {
335        let query = [(
336            "fields[profiles]",
337            "name,platform,profileType,profileState,uuid,createdDate,expirationDate,profileContent",
338        )];
339        let doc: Document<ProfileAttributes> = self
340            .request(
341                Method::GET,
342                &format!("/v1/profiles/{id}"),
343                &query,
344                None::<&()>,
345            )
346            .await?;
347        Ok(doc.data)
348    }
349
350    async fn create_profile(
351        &self,
352        attributes: ProfileCreateAttributes,
353        relationships: ProfileCreateRelationships,
354    ) -> Result<Profile> {
355        let body = CreateBody {
356            data: CreateData {
357                resource_type: RESOURCE_TYPE,
358                attributes,
359                relationships: Some(relationships),
360            },
361        };
362        let doc: Document<ProfileAttributes> = self
363            .request(Method::POST, "/v1/profiles", &[], Some(&body))
364            .await?;
365        Ok(doc.data)
366    }
367
368    async fn get_profile_bundle_id(&self, id: &str) -> Result<ProfileBundleId> {
369        let doc: Document<ProfileBundleIdAttributes> = self
370            .request(
371                Method::GET,
372                &format!("/v1/profiles/{id}/bundleId"),
373                &[],
374                None::<&()>,
375            )
376            .await?;
377        Ok(doc.data)
378    }
379
380    async fn delete_profile(&self, id: &str) -> Result<()> {
381        self.request_no_content(
382            Method::DELETE,
383            &format!("/v1/profiles/{id}"),
384            &[],
385            None::<&()>,
386        )
387        .await
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn profile_type_round_trips_through_the_api_wire_format() {
397        // The serde rename and as_api_str() are two separate spellings of
398        // the same contract, and a filter built from the wrong one fails
399        // silently by matching nothing.
400        for profile_type in [
401            ProfileType::IosAppDevelopment,
402            ProfileType::IosAppStore,
403            ProfileType::IosAppAdHoc,
404            ProfileType::IosAppInHouse,
405            ProfileType::MacAppDevelopment,
406            ProfileType::MacAppStore,
407            ProfileType::MacAppDirect,
408            ProfileType::TvOsAppDevelopment,
409            ProfileType::TvOsAppStore,
410            ProfileType::TvOsAppAdHoc,
411            ProfileType::TvOsAppInHouse,
412            ProfileType::MacCatalystAppDevelopment,
413            ProfileType::MacCatalystAppStore,
414            ProfileType::MacCatalystAppDirect,
415        ] {
416            let json = serde_json::to_string(&profile_type).expect("serializes");
417            let unquoted = json.trim_matches('"');
418            assert_eq!(
419                unquoted,
420                profile_type.as_api_str(),
421                "serde and as_api_str disagree for {profile_type:?}"
422            );
423
424            let parsed: ProfileType = serde_json::from_str(&json).expect("deserializes");
425            assert_eq!(parsed, profile_type);
426        }
427    }
428
429    #[test]
430    fn profile_state_round_trips_through_the_api_wire_format() {
431        for state in [ProfileState::Active, ProfileState::Invalid] {
432            let json = serde_json::to_string(&state).expect("serializes");
433            assert_eq!(json.trim_matches('"'), state.as_api_str());
434            let parsed: ProfileState = serde_json::from_str(&json).expect("deserializes");
435            assert_eq!(parsed, state);
436        }
437    }
438
439    #[test]
440    fn store_profiles_omit_devices_entirely() {
441        // An empty `devices` array is not the same as no `devices` key:
442        // Apple rejects the former on a store profile. Serializing has to
443        // drop the field, not send `[]`.
444        let relationships = ProfileCreateRelationships::new(
445            ProfileType::MacAppStore,
446            "P59W5V5953",
447            &["CERT1".to_string()],
448            &["DEVICE1".to_string()],
449        );
450        let json = serde_json::to_value(&relationships).expect("serializes");
451        assert!(
452            json.get("devices").is_none(),
453            "a store profile must not carry devices: {json}"
454        );
455        assert_eq!(json["bundleId"]["data"]["type"], "bundleIds");
456        assert_eq!(json["certificates"]["data"][0]["type"], "certificates");
457    }
458
459    #[test]
460    fn development_profiles_keep_their_devices() {
461        let relationships = ProfileCreateRelationships::new(
462            ProfileType::IosAppDevelopment,
463            "P59W5V5953",
464            &["CERT1".to_string()],
465            &["DEVICE1".to_string(), "DEVICE2".to_string()],
466        );
467        let json = serde_json::to_value(&relationships).expect("serializes");
468        assert_eq!(json["devices"]["data"].as_array().expect("array").len(), 2);
469        assert_eq!(json["devices"]["data"][0]["type"], "devices");
470    }
471
472    #[test]
473    fn a_development_profile_with_no_devices_omits_the_key() {
474        // Rather than sending `{"data": []}`, which Apple reads as an
475        // explicit empty set and rejects.
476        let relationships = ProfileCreateRelationships::new(
477            ProfileType::IosAppDevelopment,
478            "P59W5V5953",
479            &["CERT1".to_string()],
480            &[],
481        );
482        let json = serde_json::to_value(&relationships).expect("serializes");
483        assert!(json.get("devices").is_none());
484    }
485
486    #[test]
487    fn reads_the_bundle_id_behind_a_profile() {
488        let doc: Document<ProfileBundleIdAttributes> = serde_json::from_value(serde_json::json!({
489            "data": { "id": "C3PP53MLM3", "type": "bundleIds",
490                      "attributes": { "identifier": "ai.splitfire.SplitfireAI",
491                                      "platform": "UNIVERSAL", "name": "SplitFire AI", "seedId": "2TQF86ZACD" } }
492        }))
493        .unwrap();
494        assert_eq!(doc.data.id, "C3PP53MLM3");
495        assert_eq!(
496            doc.data.attributes.identifier.as_deref(),
497            Some("ai.splitfire.SplitfireAI")
498        );
499    }
500}