Skip to main content

coven_protocol/objects/
provider_binding.rs

1use super::*;
2
3/// Provider namespace/corpus facts signed once by the Store root.
4#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
5#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
6pub enum StoreProviderBinding {
7    S3 {
8        endpoint: S3EndpointBinding,
9        region: String,
10        bucket: String,
11        key_prefix: Option<String>,
12    },
13    GoogleDrive {
14        corpus: GoogleDriveCorpus,
15    },
16    Dropbox {
17        namespace_id: String,
18    },
19    OneDrive {
20        drive_id: String,
21        folder_id: String,
22    },
23    CloudKit {
24        container_id: String,
25        environment: CloudKitEnvironment,
26        owner_name: String,
27        zone_name: String,
28    },
29}
30
31#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
33pub enum S3EndpointBinding {
34    Aws { partition: String },
35    Custom { origin: String },
36}
37
38#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39#[serde(tag = "corpus", rename_all = "snake_case", deny_unknown_fields)]
40pub enum GoogleDriveCorpus {
41    MyDrive { folder_id: String },
42    SharedDrive { drive_id: String, folder_id: String },
43}
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum CloudKitEnvironment {
48    Development,
49    Production,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
54pub enum ProviderPrincipalId {
55    Aws {
56        account_id: String,
57        principal: AwsPrincipal,
58    },
59    CustomS3Credential {
60        access_key_id_hash: ObjectHash,
61    },
62    GoogleDrive {
63        permission_id: String,
64    },
65    Dropbox {
66        account_id: String,
67    },
68    OneDrive {
69        user_id: String,
70    },
71    CloudKitPrivateZoneOwner {
72        record_name: String,
73    },
74    CloudKitSharedZoneParticipant {
75        record_name: String,
76    },
77}
78
79#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
80#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
81pub enum AwsPrincipal {
82    Root,
83    User { arn: String, user_id: String },
84    Role { role_id: String },
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct ProviderDeviceBinding {
90    pub principal: ProviderPrincipalId,
91}
92
93#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct ResolvedProviderBinding {
96    pub store: StoreProviderBinding,
97    pub device: ProviderDeviceBinding,
98}
99
100impl StoreProviderBinding {
101    pub fn validate(&self) -> Result<(), StorageError> {
102        fn present(label: &str, value: &str) -> Result<(), StorageError> {
103            if value.is_empty() {
104                Err(StorageError::Configuration(format!("{label} is empty")))
105            } else {
106                Ok(())
107            }
108        }
109
110        match self {
111            Self::S3 {
112                endpoint,
113                region,
114                bucket,
115                key_prefix,
116            } => {
117                present("S3 region", region)?;
118                present("S3 bucket", bucket)?;
119                if key_prefix.as_deref().is_some_and(str::is_empty) {
120                    return Err(StorageError::Configuration(
121                        "S3 key prefix is empty instead of absent".to_string(),
122                    ));
123                }
124                match endpoint {
125                    S3EndpointBinding::Aws { partition } => present("AWS partition", partition),
126                    S3EndpointBinding::Custom { origin } => {
127                        let canonical = crate::provider::canonical_custom_s3_origin(origin)?;
128                        if canonical != *origin {
129                            return Err(StorageError::Configuration(
130                                "custom S3 origin is not canonical".to_string(),
131                            ));
132                        }
133                        Ok(())
134                    }
135                }
136            }
137            Self::GoogleDrive { corpus } => match corpus {
138                GoogleDriveCorpus::MyDrive { folder_id } => {
139                    present("Google Drive folder id", folder_id)
140                }
141                GoogleDriveCorpus::SharedDrive {
142                    drive_id,
143                    folder_id,
144                } => {
145                    present("Google Drive id", drive_id)?;
146                    present("Google Drive folder id", folder_id)
147                }
148            },
149            Self::Dropbox { namespace_id } => present("Dropbox namespace id", namespace_id),
150            Self::OneDrive {
151                drive_id,
152                folder_id,
153            } => {
154                present("OneDrive drive id", drive_id)?;
155                present("OneDrive folder id", folder_id)
156            }
157            Self::CloudKit {
158                container_id,
159                owner_name,
160                zone_name,
161                ..
162            } => {
163                present("CloudKit container id", container_id)?;
164                present("CloudKit owner name", owner_name)?;
165                present("CloudKit zone name", zone_name)
166            }
167        }
168    }
169}
170
171impl ProviderDeviceBinding {
172    pub fn validate_for(&self, store: &StoreProviderBinding) -> Result<(), StorageError> {
173        fn present(label: &str, value: &str) -> Result<(), StorageError> {
174            if value.is_empty() {
175                Err(StorageError::Configuration(format!("{label} is empty")))
176            } else {
177                Ok(())
178            }
179        }
180
181        let compatible = matches!(
182            (store, &self.principal),
183            (
184                StoreProviderBinding::S3 {
185                    endpoint: S3EndpointBinding::Aws { .. },
186                    ..
187                },
188                ProviderPrincipalId::Aws { .. }
189            ) | (
190                StoreProviderBinding::S3 {
191                    endpoint: S3EndpointBinding::Custom { .. },
192                    ..
193                },
194                ProviderPrincipalId::CustomS3Credential { .. }
195            ) | (
196                StoreProviderBinding::GoogleDrive { .. },
197                ProviderPrincipalId::GoogleDrive { .. }
198            ) | (
199                StoreProviderBinding::Dropbox { .. },
200                ProviderPrincipalId::Dropbox { .. }
201            ) | (
202                StoreProviderBinding::OneDrive { .. },
203                ProviderPrincipalId::OneDrive { .. }
204            ) | (
205                StoreProviderBinding::CloudKit { .. },
206                ProviderPrincipalId::CloudKitPrivateZoneOwner { .. }
207                    | ProviderPrincipalId::CloudKitSharedZoneParticipant { .. }
208            )
209        );
210        if !compatible {
211            return Err(StorageError::Configuration(
212                "provider principal is incompatible with the Store provider binding".to_string(),
213            ));
214        }
215        match &self.principal {
216            ProviderPrincipalId::Aws {
217                account_id,
218                principal,
219            } => {
220                if account_id.len() != 12 || !account_id.bytes().all(|byte| byte.is_ascii_digit()) {
221                    return Err(StorageError::Configuration(
222                        "AWS account id must contain exactly 12 decimal digits".to_string(),
223                    ));
224                }
225                match principal {
226                    AwsPrincipal::Root => Ok(()),
227                    AwsPrincipal::User { arn, user_id } => {
228                        present("AWS user id", user_id)?;
229                        let fields: Vec<_> = arn.splitn(6, ':').collect();
230                        let StoreProviderBinding::S3 {
231                            endpoint: S3EndpointBinding::Aws { partition },
232                            ..
233                        } = store
234                        else {
235                            return Err(StorageError::Configuration(
236                                "AWS user principal is bound to non-AWS S3".to_string(),
237                            ));
238                        };
239                        if fields.len() != 6
240                            || fields[0] != "arn"
241                            || fields[1] != partition
242                            || fields[2] != "iam"
243                            || !fields[3].is_empty()
244                            || fields[4] != account_id
245                            || !fields[5].starts_with("user/")
246                            || fields[5].len() == "user/".len()
247                        {
248                            return Err(StorageError::Configuration(
249                                "AWS IAM user ARN is malformed or differs from its Store binding"
250                                    .to_string(),
251                            ));
252                        }
253                        Ok(())
254                    }
255                    AwsPrincipal::Role { role_id } => {
256                        present("AWS role id", role_id)?;
257                        if role_id.contains(':') {
258                            return Err(StorageError::Configuration(
259                                "AWS role id must be the stable prefix before the session separator"
260                                    .to_string(),
261                            ));
262                        }
263                        Ok(())
264                    }
265                }
266            }
267            ProviderPrincipalId::CustomS3Credential { .. } => Ok(()),
268            ProviderPrincipalId::GoogleDrive { permission_id } => {
269                present("Google Drive permission id", permission_id)
270            }
271            ProviderPrincipalId::Dropbox { account_id } => {
272                present("Dropbox account id", account_id)
273            }
274            ProviderPrincipalId::OneDrive { user_id } => present("OneDrive user id", user_id),
275            ProviderPrincipalId::CloudKitPrivateZoneOwner { record_name } => {
276                present("CloudKit private-zone owner record name", record_name)
277            }
278            ProviderPrincipalId::CloudKitSharedZoneParticipant { record_name } => {
279                present("CloudKit shared-zone participant record name", record_name)
280            }
281        }
282    }
283}
284
285impl ResolvedProviderBinding {
286    pub fn validate(&self) -> Result<(), StorageError> {
287        self.store.validate()?;
288        self.device.validate_for(&self.store)
289    }
290}