1use async_trait::async_trait;
4use jiff::Timestamp;
5use serde::{Deserialize, Serialize};
6
7use crate::{Error, Result};
8
9pub const MAX_IAM_ARCHIVE_BYTES: usize = 10 * 1024 * 1024;
11
12pub const MAX_IAM_IMPORT_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
14
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct IamArchiveInventory {
18 pub users: Vec<String>,
19 pub groups: Vec<String>,
20 pub policies: Vec<String>,
21 pub service_accounts: Vec<String>,
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct IamArchiveResultEntities {
28 #[serde(default)]
29 pub policies: Vec<String>,
30 #[serde(default)]
31 pub users: Vec<String>,
32 #[serde(default)]
33 pub groups: Vec<String>,
34 #[serde(default)]
35 pub service_accounts: Vec<String>,
36 #[serde(default)]
37 pub user_policies: Vec<serde_json::Value>,
38 #[serde(default)]
39 pub group_policies: Vec<serde_json::Value>,
40 #[serde(default)]
41 pub sts_policies: Vec<serde_json::Value>,
42}
43
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
47pub struct IamArchiveImportSection {
48 #[serde(default)]
49 pub name: String,
50 #[serde(default)]
51 pub policies: Vec<String>,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
56pub struct IamArchiveImportResult {
57 pub skipped: IamArchiveResultEntities,
58 pub removed: IamArchiveResultEntities,
59 pub added: IamArchiveResultEntities,
60 pub failed: Vec<IamArchiveImportSection>,
61}
62
63#[async_trait]
65pub trait IamArchiveApi: Send + Sync {
66 async fn export_iam_archive(&self) -> Result<Vec<u8>>;
68
69 async fn import_iam_archive(&self, archive: Vec<u8>) -> Result<IamArchiveImportResult>;
72
73 async fn iam_archive_conflicts(
75 &self,
76 inventory: &IamArchiveInventory,
77 ) -> Result<IamArchiveInventory>;
78}
79
80pub const IAM_POLICY_ENTITIES_CAPABILITY: &str = "admin.iam.policy-entities";
82
83pub const IAM_POLICY_DETACH_CAPABILITY: &str = "admin.iam.policy-detach";
85
86pub const MAX_IAM_POLICY_ENTITIES_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
88
89pub const MAX_IAM_POLICY_DETACH_RESPONSE_BYTES: usize = 1024 * 1024;
91
92pub const MAX_IAM_POLICY_DETACH_REQUEST_BYTES: usize = 512 * 1024;
94
95pub const MAX_IAM_POLICY_ENTITY_SELECTORS: usize = 1_000;
97
98pub const MAX_IAM_POLICY_ENTITY_SELECTOR_BYTES: usize = 1_024;
100
101pub const MAX_IAM_POLICY_DETACH_POLICIES: usize = 1_000;
103
104pub const MAX_IAM_POLICY_DETACH_SELECTOR_BYTES: usize = 1_024;
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "lowercase")]
110pub enum PolicyDetachEntity {
111 User,
112 Group,
113}
114
115impl std::fmt::Display for PolicyDetachEntity {
116 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 match self {
118 Self::User => formatter.write_str("user"),
119 Self::Group => formatter.write_str("group"),
120 }
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct PolicyDetachRequest {
127 pub policies: Vec<String>,
128 pub entity: PolicyDetachEntity,
129 pub entity_name: String,
130}
131
132impl PolicyDetachRequest {
133 pub fn new(
135 mut policies: Vec<String>,
136 entity: PolicyDetachEntity,
137 entity_name: String,
138 ) -> Result<Self> {
139 validate_detach_selector("entity", &entity_name)?;
140 if policies.is_empty() {
141 return Err(Error::InvalidPath(
142 "At least one policy name is required".to_string(),
143 ));
144 }
145 if policies.len() > MAX_IAM_POLICY_DETACH_POLICIES {
146 return Err(Error::InvalidPath(format!(
147 "Policy detach accepts at most {MAX_IAM_POLICY_DETACH_POLICIES} policies"
148 )));
149 }
150 for policy in &policies {
151 validate_detach_selector("policy", policy)?;
152 }
153 policies.sort();
154 policies.dedup();
155 Ok(Self {
156 policies,
157 entity,
158 entity_name,
159 })
160 }
161}
162
163fn validate_detach_selector(kind: &str, selector: &str) -> Result<()> {
164 if selector.is_empty() || selector.trim() != selector {
165 return Err(Error::InvalidPath(format!(
166 "Policy detach {kind} selector cannot be empty or contain surrounding whitespace"
167 )));
168 }
169 if selector.len() > MAX_IAM_POLICY_DETACH_SELECTOR_BYTES {
170 return Err(Error::InvalidPath(format!(
171 "Policy detach {kind} selector exceeds {MAX_IAM_POLICY_DETACH_SELECTOR_BYTES} bytes"
172 )));
173 }
174 if selector.chars().any(char::is_control) {
175 return Err(Error::InvalidPath(format!(
176 "Policy detach {kind} selector cannot contain control characters"
177 )));
178 }
179 Ok(())
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct PolicyDetachResult {
185 pub entity: PolicyDetachEntity,
186 pub entity_name: String,
187 pub attached: Vec<String>,
188 pub detached: Vec<String>,
189 pub unchanged: Vec<String>,
190 pub updated_at: Timestamp,
191}
192
193#[async_trait]
195pub trait IamMutationApi: Send + Sync {
196 async fn detach_policies(&self, request: &PolicyDetachRequest) -> Result<PolicyDetachResult>;
198}
199
200#[derive(Debug, Clone, Default, PartialEq, Eq)]
206pub struct PolicyEntitiesQuery {
207 pub users: Vec<String>,
208 pub groups: Vec<String>,
209 pub policies: Vec<String>,
210}
211
212impl PolicyEntitiesQuery {
213 pub fn validate(&self) -> Result<()> {
215 let selector_count = self.users.len() + self.groups.len() + self.policies.len();
216 if selector_count > MAX_IAM_POLICY_ENTITY_SELECTORS {
217 return Err(Error::InvalidPath(format!(
218 "IAM policy-entity query accepts at most {MAX_IAM_POLICY_ENTITY_SELECTORS} selectors"
219 )));
220 }
221
222 for (kind, selectors) in [
223 ("user", self.users.as_slice()),
224 ("group", self.groups.as_slice()),
225 ("policy", self.policies.as_slice()),
226 ] {
227 for selector in selectors {
228 if selector.trim().is_empty() {
229 return Err(Error::InvalidPath(format!(
230 "IAM policy-entity {kind} selector cannot be empty"
231 )));
232 }
233 if selector.len() > MAX_IAM_POLICY_ENTITY_SELECTOR_BYTES {
234 return Err(Error::InvalidPath(format!(
235 "IAM policy-entity {kind} selector exceeds {MAX_IAM_POLICY_ENTITY_SELECTOR_BYTES} bytes"
236 )));
237 }
238 }
239 }
240
241 Ok(())
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase")]
248pub struct PolicyEntitiesResult {
249 pub timestamp: Timestamp,
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
251 pub user_mappings: Vec<UserPolicyEntities>,
252 #[serde(default, skip_serializing_if = "Vec::is_empty")]
253 pub group_mappings: Vec<GroupPolicyEntities>,
254 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub policy_mappings: Vec<PolicyEntities>,
256}
257
258impl PolicyEntitiesResult {
259 pub fn normalize(mut self) -> Result<Self> {
261 for mapping in &mut self.user_mappings {
262 validate_name("user", &mapping.user)?;
263 normalize_names("policy", &mut mapping.policies)?;
264 for inherited in &mut mapping.member_of_mappings {
265 normalize_group_mapping(inherited)?;
266 }
267 mapping
268 .member_of_mappings
269 .sort_by(|left, right| left.group.cmp(&right.group));
270 mapping
271 .member_of_mappings
272 .dedup_by(|left, right| left.group == right.group);
273 }
274 self.user_mappings
275 .sort_by(|left, right| left.user.cmp(&right.user));
276 self.user_mappings
277 .dedup_by(|left, right| left.user == right.user);
278
279 for mapping in &mut self.group_mappings {
280 normalize_group_mapping(mapping)?;
281 }
282 self.group_mappings
283 .sort_by(|left, right| left.group.cmp(&right.group));
284 self.group_mappings
285 .dedup_by(|left, right| left.group == right.group);
286
287 for mapping in &mut self.policy_mappings {
288 validate_name("policy", &mapping.policy)?;
289 normalize_names("user", &mut mapping.users)?;
290 normalize_names("group", &mut mapping.groups)?;
291 }
292 self.policy_mappings
293 .sort_by(|left, right| left.policy.cmp(&right.policy));
294 self.policy_mappings
295 .dedup_by(|left, right| left.policy == right.policy);
296
297 Ok(self)
298 }
299}
300
301fn normalize_group_mapping(mapping: &mut GroupPolicyEntities) -> Result<()> {
302 validate_name("group", &mapping.group)?;
303 normalize_names("policy", &mut mapping.policies)
304}
305
306fn normalize_names(kind: &str, names: &mut Vec<String>) -> Result<()> {
307 for name in names.iter() {
308 validate_name(kind, name)?;
309 }
310 names.sort();
311 names.dedup();
312 Ok(())
313}
314
315fn validate_name(kind: &str, name: &str) -> Result<()> {
316 if name.trim().is_empty() {
317 return Err(Error::General(format!(
318 "RustFS IAM policy-entity response contains an empty {kind} name"
319 )));
320 }
321 Ok(())
322}
323
324#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct UserPolicyEntities {
328 pub user: String,
329 #[serde(default)]
330 pub policies: Vec<String>,
331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
332 pub member_of_mappings: Vec<GroupPolicyEntities>,
333}
334
335#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct GroupPolicyEntities {
339 pub group: String,
340 #[serde(default)]
341 pub policies: Vec<String>,
342}
343
344#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "camelCase")]
347pub struct PolicyEntities {
348 pub policy: String,
349 #[serde(default)]
350 pub users: Vec<String>,
351 #[serde(default)]
352 pub groups: Vec<String>,
353}
354
355#[async_trait]
357pub trait IamReadApi: Send + Sync {
358 async fn policy_entities(&self, query: &PolicyEntitiesQuery) -> Result<PolicyEntitiesResult>;
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn response_decodes_current_rustfs_shape_without_secret_fields() {
368 let result: PolicyEntitiesResult = serde_json::from_str(
369 r#"{
370 "timestamp": "2026-07-24T08:00:00Z",
371 "userMappings": [{
372 "user": "alice",
373 "policies": ["readonly"],
374 "memberOfMappings": [{"group": "ops", "policies": ["diagnostics"]}],
375 "secretKey": "must-not-survive"
376 }],
377 "groupMappings": [{"group": "ops", "policies": ["diagnostics"]}],
378 "policyMappings": [{
379 "policy": "readonly",
380 "users": ["alice"],
381 "groups": []
382 }],
383 "sessionToken": "must-not-survive"
384 }"#,
385 )
386 .expect("decode RustFS policy entities");
387
388 assert_eq!(result.user_mappings[0].user, "alice");
389 assert_eq!(result.user_mappings[0].member_of_mappings[0].group, "ops");
390
391 let encoded = serde_json::to_string(&result).expect("encode typed response");
392 assert!(!encoded.contains("must-not-survive"));
393 assert!(!encoded.contains("secretKey"));
394 assert!(!encoded.contains("sessionToken"));
395 }
396
397 #[test]
398 fn query_validation_rejects_empty_and_oversized_selectors() {
399 let empty = PolicyEntitiesQuery {
400 users: vec![" ".to_string()],
401 ..Default::default()
402 };
403 assert!(matches!(empty.validate(), Err(Error::InvalidPath(_))));
404
405 let oversized = PolicyEntitiesQuery {
406 policies: vec!["p".repeat(MAX_IAM_POLICY_ENTITY_SELECTOR_BYTES + 1)],
407 ..Default::default()
408 };
409 assert!(matches!(oversized.validate(), Err(Error::InvalidPath(_))));
410 }
411
412 #[test]
413 fn query_validation_limits_aggregate_selector_count() {
414 let query = PolicyEntitiesQuery {
415 users: vec!["alice".to_string(); MAX_IAM_POLICY_ENTITY_SELECTORS + 1],
416 ..Default::default()
417 };
418 assert!(matches!(query.validate(), Err(Error::InvalidPath(_))));
419 }
420
421 #[test]
422 fn response_normalization_is_deterministic_and_rejects_empty_names() {
423 let result: PolicyEntitiesResult = serde_json::from_str(
424 r#"{
425 "timestamp": "2026-07-24T08:00:00Z",
426 "userMappings": [
427 {"user":"bob","policies":["write","read","read"]},
428 {"user":"alice","policies":["read"]}
429 ],
430 "groupMappings": [
431 {"group":"ops","policies":["write","read"]},
432 {"group":"dev","policies":["read"]}
433 ],
434 "policyMappings": [
435 {"policy":"write","users":["bob"],"groups":["ops"]},
436 {"policy":"read","users":["bob","alice","alice"],"groups":["ops","dev"]}
437 ]
438 }"#,
439 )
440 .expect("decode response");
441 let normalized = result.normalize().expect("normalize response");
442
443 assert_eq!(normalized.user_mappings[0].user, "alice");
444 assert_eq!(
445 normalized.user_mappings[1].policies,
446 vec!["read".to_string(), "write".to_string()]
447 );
448 assert_eq!(normalized.group_mappings[0].group, "dev");
449 assert_eq!(normalized.policy_mappings[0].policy, "read");
450 assert_eq!(
451 normalized.policy_mappings[0].users,
452 vec!["alice".to_string(), "bob".to_string()]
453 );
454
455 let invalid: PolicyEntitiesResult = serde_json::from_str(
456 r#"{
457 "timestamp": "2026-07-24T08:00:00Z",
458 "userMappings": [],
459 "groupMappings": [],
460 "policyMappings": [{"policy":" ","users":[],"groups":[]}]
461 }"#,
462 )
463 .expect("decode invalid response");
464 assert!(matches!(invalid.normalize(), Err(Error::General(_))));
465 }
466
467 #[test]
468 fn detach_request_normalizes_policies_and_rejects_ambiguous_selectors() {
469 let request = PolicyDetachRequest::new(
470 vec!["write".to_string(), "read".to_string(), "write".to_string()],
471 PolicyDetachEntity::User,
472 "alice".to_string(),
473 )
474 .expect("valid detach request");
475 assert_eq!(request.policies, ["read", "write"]);
476
477 assert!(matches!(
478 PolicyDetachRequest::new(
479 vec!["read".to_string()],
480 PolicyDetachEntity::Group,
481 " ops ".to_string(),
482 ),
483 Err(Error::InvalidPath(_))
484 ));
485 assert!(matches!(
486 PolicyDetachRequest::new(
487 vec!["".to_string()],
488 PolicyDetachEntity::User,
489 "alice".to_string(),
490 ),
491 Err(Error::InvalidPath(_))
492 ));
493 }
494}