Add provisioning profiles to the signing crate and the CLI
Co-Authored-By: siGit Code <297239231+sigitc@users.noreply.github.com>
Seto Elkahfi committed
Aug 27, 2026 at 07:22 UTC
78d9effb2ec5817e6640c899332bafd45283b39b
9 files changed
+984
-4
README.md
+1
-1
@@ -9,7 +9,7 @@ from the other:
9
| --- | --- |
10
| `smbcloud-ascapi-core` | Shared transport: JWT auth, the HTTP client, JSON:API envelopes, error types |
11
| `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, bundle IDs, localizations, screenshots |
12
-| `smbcloud-ascapi-signing` | Code signing: certificates, plus local RSA key pair and CSR generation |
12
+| `smbcloud-ascapi-signing` | Code signing: certificates and provisioning profiles, plus local RSA key pair and CSR generation |
13
| `smbcloud-ascapi-frontend` | Operations both surfaces share, so the CLI and the MCP server agree by construction |
14
| `smbcloud-ascapi-mcp` | The MCP contract and stdio server |
15
| `smbcloud-ascapi-cli` | The `ascapi` binary: clap command tree, plus `--mcp` |
crates/aso/src/bundle_id.rs
+60
@@ -22,6 +22,16 @@ pub enum BundleIdPlatform {
22
MacOs,
23
#[serde(rename = "UNIVERSAL")]
24
Universal,
25
+ /// Not an app platform: the identifier kind Apple assigns to Services
26
+ /// IDs (Sign in with Apple, push-only identifiers, and similar).
27
+ ///
28
+ /// Present because `GET /v1/bundleIds` returns every identifier the
29
+ /// team owns, mixed together. Without this variant, one Services ID
30
+ /// anywhere in the account fails the deserialization of the whole
31
+ /// list, so listing breaks for reasons that have nothing to do with
32
+ /// the app being looked up.
33
+ #[serde(rename = "SERVICES")]
34
+ Services,
35
}
36
37
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -69,6 +79,11 @@ impl BundleIdsApi for Client {
79
if let Some(identifier) = filter_identifier {
80
query.push(("filter[identifier]", identifier));
81
}
82
+ // Apple's default page size is 20. Without this an unfiltered list
83
+ // silently stops at the first 20 identifiers, which reads as "that
84
+ // bundle ID is not registered" for anything further down.
85
+ query.push(("limit", "200"));
86
+
87
let doc: ListDocument<BundleIdAttributes> = self
88
.request(Method::GET, "/v1/bundleIds", &query, None::<&()>)
89
.await?;
@@ -89,3 +104,48 @@ impl BundleIdsApi for Client {
104
Ok(doc.data)
105
}
106
}
107
+
108
+#[cfg(test)]
109
+mod tests {
110
+ use super::*;
111
+
112
+ #[test]
113
+ fn a_services_identifier_does_not_break_the_list() {
114
+ // Regression: `GET /v1/bundleIds` returns every identifier a team
115
+ // owns, and a single Services ID used to fail the whole response's
116
+ // deserialization — so `bundle-ids list` reported nothing at all
117
+ // for accounts that have one.
118
+ let body = serde_json::json!({
119
+ "data": [
120
+ {
121
+ "id": "AAA",
122
+ "type": "bundleIds",
123
+ "attributes": {
124
+ "name": "An app",
125
+ "identifier": "xyz.example.app",
126
+ "platform": "UNIVERSAL",
127
+ "seedId": "TEAMID"
128
+ }
129
+ },
130
+ {
131
+ "id": "BBB",
132
+ "type": "bundleIds",
133
+ "attributes": {
134
+ "name": "Sign in with Apple",
135
+ "identifier": "xyz.example.service",
136
+ "platform": "SERVICES",
137
+ "seedId": "TEAMID"
138
+ }
139
+ }
140
+ ]
141
+ });
142
+
143
+ let doc: ListDocument<BundleIdAttributes> =
144
+ serde_json::from_value(body).expect("a Services identifier must not fail the list");
145
+ assert_eq!(doc.data.len(), 2);
146
+ assert_eq!(
147
+ doc.data[1].attributes.platform,
148
+ Some(BundleIdPlatform::Services)
149
+ );
150
+ }
151
+}
crates/cli/src/main.rs
+225
@@ -17,6 +17,9 @@ use smbcloud_ascapi_core::{ApiKey, Client};
17
use smbcloud_ascapi_signing::certificate::{CertificateCreateAttributes, CertificateType};
18
use smbcloud_ascapi_signing::csr::generate_certificate_request;
19
use smbcloud_ascapi_signing::prelude::*;
20
+use smbcloud_ascapi_signing::profile::{
21
+ ProfileCreateAttributes, ProfileCreateRelationships, ProfileType,
22
+};
23
use std::path::PathBuf;
24
25
/// Add/update App Store Connect app metadata (apps, app infos, app store
@@ -103,6 +106,131 @@ enum Command {
106
#[command(subcommand)]
107
command: CertificatesCommand,
108
},
109
+ /// Provisioning profiles: what a bundle ID is allowed to claim, and
110
+ /// which certificates may sign it. Create a new one after changing an
111
+ /// App ID's capabilities — existing profiles never pick the change up.
112
+ Profiles {
113
+ #[command(subcommand)]
114
+ command: ProfilesCommand,
115
+ },
116
+}
117
+
118
+#[derive(Subcommand)]
119
+enum ProfilesCommand {
120
+ /// List the team's profiles, newest expiry last.
121
+ ///
122
+ /// `profileContent` is deliberately not shown; use `download` for the
123
+ /// file itself.
124
+ List {
125
+ /// Only show profiles with this exact name. Apple allows
126
+ /// duplicates, so this can still match several.
127
+ #[arg(long)]
128
+ name: Option<String>,
129
+ /// Only show one type.
130
+ #[arg(long, value_enum)]
131
+ r#type: Option<CliProfileType>,
132
+ },
133
+ /// Create a profile over a bundle ID and one or more certificates.
134
+ ///
135
+ /// This is the only way to pick up a capability added to the App ID
136
+ /// after an existing profile was made: profiles are snapshots, and
137
+ /// Apple keeps serving the stale one as ACTIVE until its certificate
138
+ /// dies. Apple does not treat an equivalent profile as a duplicate, so
139
+ /// repeated runs accumulate profiles rather than replacing one.
140
+ Create {
141
+ /// Label shown in the developer portal.
142
+ #[arg(long)]
143
+ name: String,
144
+ #[arg(long, value_enum)]
145
+ r#type: CliProfileType,
146
+ /// Resource id of the bundle ID (from `bundle-ids list`), not the
147
+ /// reverse-DNS identifier.
148
+ #[arg(long)]
149
+ bundle_id: String,
150
+ /// Certificate resource id (from `certificates list`). Repeat for
151
+ /// several.
152
+ #[arg(long = "certificate", required = true)]
153
+ certificates: Vec<String>,
154
+ /// Device resource id. Only meaningful for development and ad hoc
155
+ /// types; ignored for store profiles, which Apple rejects if sent
156
+ /// devices.
157
+ #[arg(long = "device")]
158
+ devices: Vec<String>,
159
+ },
160
+ /// Write a profile's bytes to a file.
161
+ ///
162
+ /// The output is the binary `.provisionprofile`/`.mobileprovision` a
163
+ /// bundle embeds, not JSON.
164
+ Download {
165
+ id: String,
166
+ /// Where to write the profile.
167
+ #[arg(long)]
168
+ output: PathBuf,
169
+ },
170
+ /// Delete a profile.
171
+ ///
172
+ /// Narrower than revoking a certificate: builds already signed with it
173
+ /// keep working, but new signing against it fails.
174
+ Delete {
175
+ id: String,
176
+ /// Required. Pass the profile id again to confirm.
177
+ #[arg(long)]
178
+ confirm: String,
179
+ },
180
+}
181
+
182
+#[derive(Copy, Clone, Debug, ValueEnum)]
183
+enum CliProfileType {
184
+ /// iOS development — device-scoped.
185
+ IosAppDevelopment,
186
+ /// iOS App Store distribution.
187
+ IosAppStore,
188
+ /// iOS ad hoc — device-scoped.
189
+ IosAppAdhoc,
190
+ /// iOS in-house (Enterprise) distribution.
191
+ IosAppInhouse,
192
+ /// macOS development — device-scoped.
193
+ MacAppDevelopment,
194
+ /// Mac App Store distribution — the embedded.provisionprofile a MAS
195
+ /// submission carries.
196
+ MacAppStore,
197
+ /// Developer ID — distribution outside the App Store.
198
+ MacAppDirect,
199
+ /// tvOS development — device-scoped.
200
+ TvosAppDevelopment,
201
+ /// tvOS App Store distribution.
202
+ TvosAppStore,
203
+ /// tvOS ad hoc — device-scoped.
204
+ TvosAppAdhoc,
205
+ /// tvOS in-house (Enterprise) distribution.
206
+ TvosAppInhouse,
207
+ /// Mac Catalyst development — device-scoped.
208
+ MacCatalystAppDevelopment,
209
+ /// Mac Catalyst App Store distribution.
210
+ MacCatalystAppStore,
211
+ /// Mac Catalyst Developer ID distribution.
212
+ MacCatalystAppDirect,
213
+}
214
+
215
+impl From<CliProfileType> for ProfileType {
216
+ fn from(value: CliProfileType) -> Self {
217
+ match value {
218
+ CliProfileType::IosAppDevelopment => ProfileType::IosAppDevelopment,
219
+ CliProfileType::IosAppStore => ProfileType::IosAppStore,
220
+ CliProfileType::IosAppAdhoc => ProfileType::IosAppAdHoc,
221
+ CliProfileType::IosAppInhouse => ProfileType::IosAppInHouse,
222
+ CliProfileType::MacAppDevelopment => ProfileType::MacAppDevelopment,
223
+ CliProfileType::MacAppStore => ProfileType::MacAppStore,
224
+ CliProfileType::MacAppDirect => ProfileType::MacAppDirect,
225
+ CliProfileType::TvosAppDevelopment => ProfileType::TvOsAppDevelopment,
226
+ CliProfileType::TvosAppStore => ProfileType::TvOsAppStore,
227
+ CliProfileType::TvosAppAdhoc => ProfileType::TvOsAppAdHoc,
228
+ CliProfileType::TvosAppInhouse => ProfileType::TvOsAppInHouse,
229
+ CliProfileType::MacCatalystAppDevelopment => ProfileType::MacCatalystAppDevelopment,
230
+ CliProfileType::MacCatalystAppStore => ProfileType::MacCatalystAppStore,
231
+ CliProfileType::MacCatalystAppDirect => ProfileType::MacCatalystAppDirect,
232
+ }
233
+ }
234
}
235
236
#[derive(Subcommand)]
@@ -502,6 +630,7 @@ async fn main() -> Result<()> {
630
run_app_screenshots(&client, command, cli.dry_run).await
631
}
632
Command::Certificates { command } => run_certificates(&client, command, cli.dry_run).await,
633
+ Command::Profiles { command } => run_profiles(&client, command, cli.dry_run).await,
634
}
635
}
636
@@ -1007,6 +1136,102 @@ async fn run_certificates(
1136
}
1137
}
1138
1139
+async fn run_profiles(client: &Client, command: ProfilesCommand, dry_run: bool) -> Result<()> {
1140
+ use smbcloud_ascapi_frontend::profiles::{download_profile, ProfileSummary};
1141
+ use smbcloud_ascapi_frontend::time::now_iso8601;
1142
+
1143
+ match command {
1144
+ ProfilesCommand::List { name, r#type } => {
1145
+ let mut profiles = client
1146
+ .list_profiles(name.as_deref(), r#type.map(Into::into))
1147
+ .await?;
1148
+ profiles.sort_by(|a, b| {
1149
+ a.attributes
1150
+ .expiration_date
1151
+ .cmp(&b.attributes.expiration_date)
1152
+ });
1153
+ let now = now_iso8601();
1154
+ let summaries: Vec<_> = profiles
1155
+ .iter()
1156
+ .map(|profile| ProfileSummary::from_resource(profile, &now))
1157
+ .collect();
1158
+ print_json(&summaries)
1159
+ }
1160
+
1161
+ ProfilesCommand::Create {
1162
+ name,
1163
+ r#type,
1164
+ bundle_id,
1165
+ certificates,
1166
+ devices,
1167
+ } => {
1168
+ let profile_type: ProfileType = r#type.into();
1169
+
1170
+ // Say so rather than dropping them silently: a caller who
1171
+ // passed devices to a store profile has misunderstood
1172
+ // something, and the created profile will not be what they
1173
+ // expect.
1174
+ if !devices.is_empty() && !profile_type.takes_devices() {
1175
+ eprintln!(
1176
+ "note: {} does not take devices; ignoring the {} passed",
1177
+ profile_type.as_api_str(),
1178
+ devices.len()
1179
+ );
1180
+ }
1181
+
1182
+ let relationships =
1183
+ ProfileCreateRelationships::new(profile_type, &bundle_id, &certificates, &devices);
1184
+ let attributes = ProfileCreateAttributes {
1185
+ name: name.clone(),
1186
+ profile_type,
1187
+ };
1188
+
1189
+ if dry_run {
1190
+ print_json(&serde_json::json!({
1191
+ "data": {
1192
+ "type": "profiles",
1193
+ "attributes": attributes,
1194
+ "relationships": relationships,
1195
+ }
1196
+ }))?;
1197
+ return Ok(());
1198
+ }
1199
+
1200
+ let profile = client.create_profile(attributes, relationships).await?;
1201
+ print_json(&ProfileSummary::from_resource(&profile, &now_iso8601()))
1202
+ }
1203
+
1204
+ ProfilesCommand::Download { id, output } => {
1205
+ if dry_run {
1206
+ println!(
1207
+ "# dry run — would write profile {id} to {}",
1208
+ output.display()
1209
+ );
1210
+ return Ok(());
1211
+ }
1212
+ let downloaded = download_profile(client, &id, &output)
1213
+ .await
1214
+ .map_err(anyhow::Error::msg)?;
1215
+ print_json(&downloaded)
1216
+ }
1217
+
1218
+ ProfilesCommand::Delete { id, confirm } => {
1219
+ if confirm != id {
1220
+ anyhow::bail!(
1221
+ "refusing to delete: pass --confirm {id} to acknowledge that signing \
1222
+ against this profile stops working until a replacement exists"
1223
+ );
1224
+ }
1225
+ if dry_run {
1226
+ println!("# dry run — would delete profile {id}");
1227
+ return Ok(());
1228
+ }
1229
+ client.delete_profile(&id).await?;
1230
+ print_json(&serde_json::json!({ "deleted": id }))
1231
+ }
1232
+ }
1233
+}
1234
+
1235
/// Write a private key with owner-only permissions, set at creation time
1236
/// rather than chmod'ed afterwards so the key is never briefly readable.
1237
fn write_private_key(path: &std::path::Path, pem: &str) -> std::io::Result<()> {
crates/core/src/jsonapi.rs
+13
@@ -48,6 +48,19 @@ pub struct ToOne {
48
pub data: ResourceId,
49
}
50
51
+/// A to-many relationship payload, e.g. `relationships.certificates` on a
52
+/// `POST /v1/profiles` body.
53
+///
54
+/// Apple distinguishes an empty array from an absent relationship: sending
55
+/// `{"data": []}` for `devices` means "no devices", which a development
56
+/// profile rejects, while omitting the key entirely is what an App Store
57
+/// profile wants. Callers that mean "absent" should pass `None` for the
58
+/// whole field rather than an empty `ToMany`.
59
+#[derive(Debug, Clone, Serialize)]
60
+pub struct ToMany {
61
+ pub data: Vec<ResourceId>,
62
+}
63
+
64
#[derive(Debug, Clone, Serialize)]
65
pub struct CreateBody<A, R> {
66
pub data: CreateData<A, R>,
crates/frontend/src/enums.rs
+4
@@ -29,6 +29,10 @@ pub fn bundle_id_platform_from_str(value: &str) -> Result<BundleIdPlatform, Stri
29
"ios" => Ok(BundleIdPlatform::Ios),
30
"mac_os" | "macos" => Ok(BundleIdPlatform::MacOs),
31
"universal" => Ok(BundleIdPlatform::Universal),
32
+ // Accepted so a round trip through this mapper does not lose a
33
+ // value the API itself returns. Registering one is a different
34
+ // flow than an app bundle ID, and Apple rejects it here.
35
+ "services" => Ok(BundleIdPlatform::Services),
36
other => Err(format!(
37
"unknown bundle ID platform {other:?}; expected one of ios, mac_os, universal"
38
)),
crates/frontend/src/lib.rs
+5
@@ -16,10 +16,14 @@
16
//! refactor away from being bypassed.
17
//! - **No certificate bodies either.** They are large, never needed to
18
//! decide what to do next, and would otherwise land in a transcript.
19
+//! [`profiles::ProfileSummary`] applies the same rule to
20
+//! `profileContent`, and [`profiles::download_profile`] returns the path
21
+//! it wrote instead.
22
23
pub mod certificates;
24
pub mod enums;
25
pub mod env;
26
+pub mod profiles;
27
pub mod time;
28
29
pub use certificates::{
@@ -27,4 +31,5 @@ pub use certificates::{
31
};
32
pub use enums::{bundle_id_platform_from_str, display_type_from_str, platform_from_str};
33
pub use env::api_key_from_env;
34
+pub use profiles::{download_profile, profile_type_from_str, DownloadedProfile, ProfileSummary};
35
pub use time::{is_expired, now_iso8601};
crates/frontend/src/profiles.rs
new
+215
@@ -0,0 +1,215 @@
1
+//! Provisioning profile operations shared by both front ends.
2
+
3
+use serde::Serialize;
4
+use smbcloud_ascapi_core::Client;
5
+use smbcloud_ascapi_signing::prelude::*;
6
+use smbcloud_ascapi_signing::profile::{Profile, ProfileType};
7
+
8
+use crate::time::{is_expired, now_iso8601};
9
+
10
+/// Map a CLI/MCP profile-type string onto the API enum.
11
+///
12
+/// Accepts the App Store Connect spelling with either casing, so a caller
13
+/// that copied `MAC_APP_STORE` out of an API response does not have to
14
+/// translate it.
15
+pub fn profile_type_from_str(value: &str) -> Result<ProfileType, String> {
16
+ match value.to_ascii_lowercase().as_str() {
17
+ "ios_app_development" => Ok(ProfileType::IosAppDevelopment),
18
+ "ios_app_store" => Ok(ProfileType::IosAppStore),
19
+ "ios_app_adhoc" => Ok(ProfileType::IosAppAdHoc),
20
+ "ios_app_inhouse" => Ok(ProfileType::IosAppInHouse),
21
+ "mac_app_development" => Ok(ProfileType::MacAppDevelopment),
22
+ "mac_app_store" => Ok(ProfileType::MacAppStore),
23
+ "mac_app_direct" => Ok(ProfileType::MacAppDirect),
24
+ "tvos_app_development" => Ok(ProfileType::TvOsAppDevelopment),
25
+ "tvos_app_store" => Ok(ProfileType::TvOsAppStore),
26
+ "tvos_app_adhoc" => Ok(ProfileType::TvOsAppAdHoc),
27
+ "tvos_app_inhouse" => Ok(ProfileType::TvOsAppInHouse),
28
+ "mac_catalyst_app_development" => Ok(ProfileType::MacCatalystAppDevelopment),
29
+ "mac_catalyst_app_store" => Ok(ProfileType::MacCatalystAppStore),
30
+ "mac_catalyst_app_direct" => Ok(ProfileType::MacCatalystAppDirect),
31
+ other => Err(format!(
32
+ "unknown profile type {other:?}; expected one of ios_app_development, \
33
+ ios_app_store, ios_app_adhoc, ios_app_inhouse, mac_app_development, \
34
+ mac_app_store, mac_app_direct, tvos_app_development, tvos_app_store, \
35
+ tvos_app_adhoc, tvos_app_inhouse, mac_catalyst_app_development, \
36
+ mac_catalyst_app_store, mac_catalyst_app_direct"
37
+ )),
38
+ }
39
+}
40
+
41
+/// A profile as reported to callers.
42
+///
43
+/// Note what is missing: `profileContent`. It is the multi-kilobyte binary
44
+/// profile, it is never needed to decide what to do next, and keeping it
45
+/// out means a tool result can never carry one into a model's context.
46
+/// `profiles download` writes it to a path instead, the same bargain
47
+/// [`crate::certificates::IssuedCertificate`] makes for key material.
48
+#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
49
+pub struct ProfileSummary {
50
+ pub id: String,
51
+ pub name: Option<String>,
52
+ pub profile_type: Option<String>,
53
+ pub profile_state: Option<String>,
54
+ pub platform: Option<String>,
55
+ /// The UUID inside the profile, which is what `codesign` and Xcode
56
+ /// diagnostics quote. Different from `id`.
57
+ pub uuid: Option<String>,
58
+ pub created_date: Option<String>,
59
+ pub expiration_date: Option<String>,
60
+ /// Computed locally: App Store Connect offers no filter for it, and a
61
+ /// profile can also be unusable while unexpired if its certificate was
62
+ /// revoked — check `profile_state` for that case.
63
+ pub expired: Option<bool>,
64
+}
65
+
66
+impl ProfileSummary {
67
+ pub fn from_resource(profile: &Profile, now: &str) -> Self {
68
+ let expiration_date = profile.attributes.expiration_date.clone();
69
+ let expired = expiration_date
70
+ .as_ref()
71
+ .map(|at| is_expired(at.as_str(), now));
72
+
73
+ Self {
74
+ id: profile.id.clone(),
75
+ name: profile.attributes.name.clone(),
76
+ profile_type: profile
77
+ .attributes
78
+ .profile_type
79
+ .map(|t| t.as_api_str().to_string()),
80
+ profile_state: profile
81
+ .attributes
82
+ .profile_state
83
+ .map(|s| s.as_api_str().to_string()),
84
+ platform: profile.attributes.platform.clone(),
85
+ uuid: profile.attributes.uuid.clone(),
86
+ created_date: profile.attributes.created_date.clone(),
87
+ expiration_date,
88
+ expired,
89
+ }
90
+ }
91
+}
92
+
93
+/// The result of writing a profile to disk.
94
+///
95
+/// Carries the path, never the profile bytes.
96
+#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
97
+pub struct DownloadedProfile {
98
+ pub profile: ProfileSummary,
99
+ pub path: String,
100
+ pub bytes: usize,
101
+}
102
+
103
+/// Fetch a profile's content and write it to `path`.
104
+///
105
+/// Shared by the CLI and the MCP tool so the "ask for the field
106
+/// explicitly" detail has one implementation: a plain resource fetch omits
107
+/// `profileContent`, and the absence is indistinguishable from a profile
108
+/// that genuinely has none.
109
+pub async fn download_profile(
110
+ client: &Client,
111
+ id: &str,
112
+ path: &std::path::Path,
113
+) -> Result<DownloadedProfile, String> {
114
+ let profile = client
115
+ .get_profile_content(id)
116
+ .await
117
+ .map_err(|error| error.to_string())?;
118
+
119
+ let content = profile
120
+ .attributes
121
+ .profile_content
122
+ .as_deref()
123
+ .ok_or_else(|| {
124
+ format!("profile {id} came back without profileContent, so there is nothing to write")
125
+ })?;
126
+
127
+ let bytes = base64_decode(content)?;
128
+
129
+ if let Some(parent) = path.parent() {
130
+ if !parent.as_os_str().is_empty() {
131
+ std::fs::create_dir_all(parent)
132
+ .map_err(|error| format!("creating {}: {error}", parent.display()))?;
133
+ }
134
+ }
135
+ std::fs::write(path, &bytes).map_err(|error| format!("writing {}: {error}", path.display()))?;
136
+
137
+ Ok(DownloadedProfile {
138
+ profile: ProfileSummary::from_resource(&profile, &now_iso8601()),
139
+ path: path.display().to_string(),
140
+ bytes: bytes.len(),
141
+ })
142
+}
143
+
144
+fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
145
+ use base64::Engine;
146
+ base64::engine::general_purpose::STANDARD
147
+ .decode(input.trim())
148
+ .map_err(|error| format!("decoding the profile Apple returned: {error}"))
149
+}
150
+
151
+#[cfg(test)]
152
+mod tests {
153
+ use super::*;
154
+
155
+ #[test]
156
+ fn profile_type_accepts_the_api_spelling_in_either_casing() {
157
+ assert_eq!(
158
+ profile_type_from_str("MAC_APP_STORE").unwrap(),
159
+ ProfileType::MacAppStore
160
+ );
161
+ assert_eq!(
162
+ profile_type_from_str("mac_app_store").unwrap(),
163
+ ProfileType::MacAppStore
164
+ );
165
+ }
166
+
167
+ #[test]
168
+ fn errors_name_the_accepted_values() {
169
+ // A model that guessed wrong has to correct itself from the error
170
+ // alone, without another round trip.
171
+ let error = profile_type_from_str("mac_appstore").unwrap_err();
172
+ assert!(error.contains("mac_app_store"), "unhelpful error: {error}");
173
+ assert!(error.contains("ios_app_store"), "unhelpful error: {error}");
174
+ }
175
+
176
+ #[test]
177
+ fn summary_never_serializes_the_profile_body() {
178
+ let summary = ProfileSummary {
179
+ id: "ABC123".to_string(),
180
+ name: Some("smbCloud Browser MAS Distribution Profile".to_string()),
181
+ profile_type: Some("MAC_APP_STORE".to_string()),
182
+ profile_state: Some("ACTIVE".to_string()),
183
+ platform: Some("MAC_OS".to_string()),
184
+ uuid: Some("1a2b3c".to_string()),
185
+ created_date: Some("2026-08-26T20:03:07".to_string()),
186
+ expiration_date: Some("2027-08-06T19:49:55".to_string()),
187
+ expired: Some(false),
188
+ };
189
+ let json = serde_json::to_string(&summary).expect("serializes");
190
+ assert!(!json.contains("profileContent"));
191
+ assert!(!json.contains("profile_content"));
192
+ }
193
+
194
+ #[test]
195
+ fn downloaded_profile_reports_a_path_not_bytes() {
196
+ let downloaded = DownloadedProfile {
197
+ profile: ProfileSummary {
198
+ id: "ABC123".to_string(),
199
+ name: None,
200
+ profile_type: None,
201
+ profile_state: None,
202
+ platform: None,
203
+ uuid: None,
204
+ created_date: None,
205
+ expiration_date: None,
206
+ expired: None,
207
+ },
208
+ path: "/tmp/out/app.provisionprofile".to_string(),
209
+ bytes: 12577,
210
+ };
211
+ let json = serde_json::to_string(&downloaded).expect("serializes");
212
+ assert!(json.contains("app.provisionprofile"));
213
+ assert!(!json.contains("profileContent"));
214
+ }
215
+}
crates/signing/src/lib.rs
+6
-3
@@ -1,8 +1,9 @@
1
//! Apple code signing through App Store Connect.
2
//!
3
-//! Two halves that only make sense together: [`csr`] generates an RSA key
4
-//! pair and a signing request locally, and [`certificate`] asks Apple to
5
-//! certify it.
3
+//! Three parts. [`csr`] generates an RSA key pair and a signing request
4
+//! locally, [`certificate`] asks Apple to certify it, and [`profile`] binds
5
+//! a certificate to an App ID so signing knows which entitlements are
6
+//! allowed.
7
//!
8
//! The division of labour is the whole point. Apple never receives, and
9
//! never returns, a private key. You keep it; they vouch for its public
@@ -16,7 +17,9 @@
17
18
pub mod certificate;
19
pub mod csr;
20
+pub mod profile;
21
22
pub mod prelude {
23
pub use crate::certificate::CertificatesApi;
24
+ pub use crate::profile::ProfilesApi;
25
}
crates/signing/src/profile.rs
new
+455
@@ -0,0 +1,455 @@
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
+}