development
rs 500 lines 18.5 KB
Raw
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
33 use async_trait::async_trait;
34 use reqwest::Method;
35 use serde::{Deserialize, Serialize};
36 use smbcloud_ascapi_core::jsonapi::{
37 CreateBody, CreateData, Document, Resource, ResourceId, ToMany, ToOne,
38 };
39 use smbcloud_ascapi_core::Client;
40 use smbcloud_ascapi_core::Result;
41
42 pub 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)]
56 pub 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
89 impl 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)]
136 pub enum ProfileState {
137 #[serde(rename = "ACTIVE")]
138 Active,
139 #[serde(rename = "INVALID")]
140 Invalid,
141 }
142
143 impl 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")]
154 pub 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
173 pub type Profile = Resource<ProfileAttributes>;
174
175 #[derive(Debug, Clone, Serialize)]
176 #[serde(rename_all = "camelCase")]
177 pub 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")]
190 pub struct ProfileBundleIdAttributes {
191 pub identifier: Option<String>,
192 pub platform: Option<String>,
193 }
194
195 pub 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")]
204 pub 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
211 impl 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]
262 pub 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]
312 impl 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)]
392 mod 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 }