Add provisioning profiles to the signing crate and the CLI

Profiles were the gap that kept callers on a hand-rolled script: the Profiles API is the only non-interactive way to get the embedded.provisionprofile a Mac App Store bundle needs, and ascapi covered certificates but stopped short of the document that says which entitlements an App ID may actually claim. `profiles list`, `create`, `download`, and `delete`. Download writes the bytes to a path and reports the path, matching what the crate already does for private keys — profileContent is a multi-kilobyte blob that never belongs in a result a model might read, so ProfileSummary omits it. Fetching it needs an explicit fields[profiles], because a plain GET returns the resource with profileContent absent, which reads as "no content" rather than "you did not ask". Devices are modelled as absent rather than empty for store profiles. Apple rejects `{"data": []}` there, so ProfileCreateRelationships drops the key entirely and ProfileType::takes_devices says which types care. Two fixes in bundle_id, both hit on the way to using the new command, since `profiles create` wants a bundle ID resource id that `bundle-ids list` is what you would reach for to find: - BundleIdPlatform had no SERVICES variant, so one Services ID anywhere in a team failed the whole list's deserialization. - The list sent no limit, so it stopped at Apple's default page of 20 and a bundle ID further down looked unregistered. This team has 66. Verified against the live API: a downloaded profile is byte-identical to what the script it replaces produces, App Group entitlement included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Seto Elkahfi committed Aug 27, 2026 at 07:17 UTC 1cf60b8dac5ac0ef3f2f60c404f4c90c2e0d6397
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 +}