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, ListDocument, 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 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")]
192
+pub 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
+
199
+impl 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]
250
+pub 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]
293
+impl 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)]
362
+mod 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
+}