Skip to main content

rc_core/admin/
iam.rs

1//! Typed contracts for IAM policy inspection and mutation.
2
3use async_trait::async_trait;
4use jiff::Timestamp;
5use serde::{Deserialize, Serialize};
6
7use crate::{Error, Result};
8
9/// Maximum accepted IAM archive size. This matches RustFS's import body limit.
10pub const MAX_IAM_ARCHIVE_BYTES: usize = 10 * 1024 * 1024;
11
12/// Maximum accepted structured response from an IAM import.
13pub const MAX_IAM_IMPORT_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
14
15/// Names contained in an IAM archive, used for conflict preflight.
16#[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/// One category in RustFS's structured IAM import report.
25#[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/// A failed IAM entity. Error text is intentionally discarded at the client
45/// boundary because a backend error may include credential material.
46#[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/// Secret-safe typed summary of an IAM import.
55#[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/// Bounded RustFS IAM archive transport.
64#[async_trait]
65pub trait IamArchiveApi: Send + Sync {
66    /// Download the server IAM archive.
67    async fn export_iam_archive(&self) -> Result<Vec<u8>>;
68
69    /// Import one already validated IAM archive. Implementations must not retry
70    /// this mutation because a disconnected response has an unknown outcome.
71    async fn import_iam_archive(&self, archive: Vec<u8>) -> Result<IamArchiveImportResult>;
72
73    /// Return names which already exist on the destination.
74    async fn iam_archive_conflicts(
75        &self,
76        inventory: &IamArchiveInventory,
77    ) -> Result<IamArchiveInventory>;
78}
79
80/// Capability name used to guard policy-entity inspection.
81pub const IAM_POLICY_ENTITIES_CAPABILITY: &str = "admin.iam.policy-entities";
82
83/// Capability name used to guard builtin policy detach mutations.
84pub const IAM_POLICY_DETACH_CAPABILITY: &str = "admin.iam.policy-detach";
85
86/// Maximum encoded size accepted for one policy-entity response.
87pub const MAX_IAM_POLICY_ENTITIES_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
88
89/// Maximum encoded size accepted for one policy detach response.
90pub const MAX_IAM_POLICY_DETACH_RESPONSE_BYTES: usize = 1024 * 1024;
91
92/// Maximum encoded size sent for one policy detach request.
93pub const MAX_IAM_POLICY_DETACH_REQUEST_BYTES: usize = 512 * 1024;
94
95/// Maximum number of selectors accepted in a single request.
96pub const MAX_IAM_POLICY_ENTITY_SELECTORS: usize = 1_000;
97
98/// Maximum UTF-8 byte length accepted for one selector.
99pub const MAX_IAM_POLICY_ENTITY_SELECTOR_BYTES: usize = 1_024;
100
101/// Maximum number of policies accepted by one detach request.
102pub const MAX_IAM_POLICY_DETACH_POLICIES: usize = 1_000;
103
104/// Maximum UTF-8 byte length accepted for a detach selector.
105pub const MAX_IAM_POLICY_DETACH_SELECTOR_BYTES: usize = 1_024;
106
107/// A builtin IAM entity affected by a policy mutation.
108#[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/// A validated, retry-safe builtin policy detach request.
125#[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    /// Validate and normalize selectors before any network request is made.
134    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/// Normalized outcome for an idempotent builtin policy detach.
183#[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/// Typed builtin IAM mutation operations.
194#[async_trait]
195pub trait IamMutationApi: Send + Sync {
196    /// Detach only the requested policies from exactly one user or group.
197    async fn detach_policies(&self, request: &PolicyDetachRequest) -> Result<PolicyDetachResult>;
198}
199
200/// Filters for a policy-entity inspection request.
201///
202/// An empty query asks RustFS for every policy-to-entity mapping. User and group
203/// filters return their direct and inherited policy mappings. Policy filters
204/// return the matching users and groups.
205#[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    /// Reject selectors that would create ambiguous or excessively large requests.
214    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/// Policy mappings returned by RustFS.
246#[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    /// Validate identifiers and normalize mapping order for deterministic output.
260    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/// Direct and inherited policy mappings for one user.
325#[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/// Direct policy mappings for one group.
336#[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/// Users and groups attached to one policy.
345#[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/// Read-only RustFS IAM policy-entity operations.
356#[async_trait]
357pub trait IamReadApi: Send + Sync {
358    /// Inspect direct and inherited policy associations.
359    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}