Skip to main content

bitwarden_pam/access_rules/
models.rs

1use bitwarden_api_api::models::{AccessRuleRequestModel, AccessRuleResponseModel};
2use bitwarden_collections::collection::CollectionId;
3use bitwarden_core::{OrganizationId, require};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6#[cfg(feature = "wasm")]
7use tsify::Tsify;
8
9use super::{conditions::AccessCondition, error::AccessRuleError};
10use crate::AccessRuleId;
11
12/// A decrypted view of an access rule, as returned by the server.
13#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
14#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
15#[serde(rename_all = "camelCase")]
16pub struct AccessRuleView {
17    /// The rule's unique identifier.
18    pub id: AccessRuleId,
19    /// The organization this rule belongs to.
20    pub organization_id: OrganizationId,
21    /// The rule's display name, shown wherever rules are listed and managed.
22    pub name: String,
23    /// Optional free-text describing the rule's intent.
24    pub description: Option<String>,
25    /// When false, the rule is inactive and does not gate access for the collections it governs.
26    pub enabled: bool,
27    /// The condition tree that decides how access is granted under this rule.
28    pub conditions: Vec<AccessCondition>,
29    /// When true, the rule enforces a per-cipher singleton (at most one active lease per cipher
30    /// across all users).
31    pub single_active_lease: bool,
32    /// Default lease duration in seconds, used to pre-fill a request opened under this rule. None
33    /// means the backend default applies.
34    pub default_lease_duration_seconds: Option<i32>,
35    /// Hard ceiling on the duration of any single lease granted under this rule, in seconds. None
36    /// means no per-rule cap.
37    pub max_lease_duration_seconds: Option<i32>,
38    /// When true, a member holding an active lease under this rule may extend it once.
39    pub allows_extensions: bool,
40    /// The longest a single extension may run, in seconds. Set when `allows_extensions` is true.
41    pub max_extension_duration_seconds: Option<i32>,
42    /// The complete set of collections this rule governs.
43    pub collections: Vec<CollectionId>,
44    /// When the rule was created (UTC).
45    pub creation_date: DateTime<Utc>,
46    /// When the rule was last modified (UTC).
47    pub revision_date: DateTime<Utc>,
48}
49
50/// Request to create or edit an access rule.
51#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
52#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
53#[serde(rename_all = "camelCase")]
54pub struct AccessRuleAddEditRequest {
55    /// The rule's display name, shown wherever rules are listed and managed. Must be non-empty
56    /// (after trimming whitespace) and no more than 256 characters.
57    pub name: String,
58    /// Optional free-text describing the rule's intent.
59    pub description: Option<String>,
60    /// When false, the rule is inactive and does not gate access for the collections it governs.
61    pub enabled: bool,
62    /// The condition tree that decides how access is granted under this rule. Limited to at most
63    /// 10 conditions.
64    pub conditions: Vec<AccessCondition>,
65    /// When true, the rule enforces a per-cipher singleton (at most one active lease per cipher
66    /// across all users).
67    pub single_active_lease: bool,
68    /// Default lease duration in seconds, used to pre-fill a request opened under this rule. None
69    /// means the backend default applies.
70    pub default_lease_duration_seconds: Option<i32>,
71    /// Hard ceiling on the duration of any single lease granted under this rule, in seconds. None
72    /// means no per-rule cap.
73    pub max_lease_duration_seconds: Option<i32>,
74    /// When true, a member holding an active lease under this rule may extend it once. Requires
75    /// `max_extension_duration_seconds` to be a positive value.
76    pub allows_extensions: bool,
77    /// The longest a single extension may run, in seconds. Required to be positive when
78    /// `allows_extensions` is true.
79    pub max_extension_duration_seconds: Option<i32>,
80    /// The complete set of collections this rule governs. The rule's associations are replaced
81    /// to match exactly this set; an empty list clears all associations.
82    pub collections: Vec<CollectionId>,
83}
84
85impl TryFrom<AccessRuleResponseModel> for AccessRuleView {
86    type Error = AccessRuleError;
87
88    fn try_from(response: AccessRuleResponseModel) -> Result<Self, Self::Error> {
89        let conditions = match response.conditions {
90            None => Vec::new(),
91            Some(value @ serde_json::Value::Array(_)) => serde_json::from_value(value)
92                .map_err(|e| AccessRuleError::InvalidConditions(e.to_string()))?,
93            Some(other) => {
94                return Err(AccessRuleError::InvalidConditions(format!(
95                    "expected `conditions` to be a JSON array, got: {other}"
96                )));
97            }
98        };
99
100        Ok(Self {
101            id: AccessRuleId::new(require!(response.id)),
102            organization_id: OrganizationId::new(require!(response.organization_id)),
103            name: require!(response.name),
104            description: response.description,
105            // The server defaults an omitted `enabled` to an active rule.
106            enabled: response.enabled.unwrap_or(true),
107            conditions,
108            single_active_lease: response.single_active_lease.unwrap_or(false),
109            default_lease_duration_seconds: response.default_lease_duration_seconds,
110            max_lease_duration_seconds: response.max_lease_duration_seconds,
111            allows_extensions: response.allows_extensions.unwrap_or(false),
112            max_extension_duration_seconds: response.max_extension_duration_seconds,
113            collections: response
114                .collections
115                .unwrap_or_default()
116                .into_iter()
117                .map(CollectionId::new)
118                .collect(),
119            creation_date: require!(response.creation_date).parse()?,
120            revision_date: require!(response.revision_date).parse()?,
121        })
122    }
123}
124
125impl TryFrom<AccessRuleAddEditRequest> for AccessRuleRequestModel {
126    type Error = AccessRuleError;
127
128    fn try_from(request: AccessRuleAddEditRequest) -> Result<Self, Self::Error> {
129        // The server requires `conditions` to always be present; an empty vec serializes to
130        // the empty array it expects.
131        let conditions = serde_json::to_value(&request.conditions)
132            .map_err(|e| AccessRuleError::InvalidConditions(e.to_string()))?;
133
134        Ok(Self {
135            name: request.name.trim().to_string(),
136            description: request.description,
137            enabled: Some(request.enabled),
138            conditions: Some(conditions),
139            single_active_lease: Some(request.single_active_lease),
140            default_lease_duration_seconds: request.default_lease_duration_seconds,
141            max_lease_duration_seconds: request.max_lease_duration_seconds,
142            allows_extensions: Some(request.allows_extensions),
143            max_extension_duration_seconds: request.max_extension_duration_seconds,
144            collections: request
145                .collections
146                .into_iter()
147                .map(uuid::Uuid::from)
148                .collect(),
149        })
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use bitwarden_collections::collection::CollectionId;
156    use uuid::Uuid;
157
158    use super::*;
159
160    fn full_response() -> AccessRuleResponseModel {
161        AccessRuleResponseModel {
162            id: Some(Uuid::new_v4()),
163            organization_id: Some(Uuid::new_v4()),
164            name: Some("My rule".to_string()),
165            description: Some("A description".to_string()),
166            enabled: Some(false),
167            conditions: Some(serde_json::json!([{ "kind": "human_approval" }])),
168            single_active_lease: Some(true),
169            default_lease_duration_seconds: Some(60),
170            max_lease_duration_seconds: Some(120),
171            allows_extensions: Some(true),
172            max_extension_duration_seconds: Some(30),
173            collections: Some(vec![Uuid::new_v4()]),
174            creation_date: Some("2025-01-01T00:00:00Z".to_string()),
175            revision_date: Some("2025-01-02T00:00:00Z".to_string()),
176            ..Default::default()
177        }
178    }
179
180    #[test]
181    fn full_response_converts_to_view() {
182        let response = full_response();
183        let expected_id = response.id.unwrap();
184        let expected_org_id = response.organization_id.unwrap();
185        let expected_collection_id = response.collections.as_ref().unwrap()[0];
186
187        let view = AccessRuleView::try_from(response).unwrap();
188
189        assert_eq!(
190            view,
191            AccessRuleView {
192                id: AccessRuleId::new(expected_id),
193                organization_id: OrganizationId::new(expected_org_id),
194                name: "My rule".to_string(),
195                description: Some("A description".to_string()),
196                enabled: false,
197                conditions: vec![AccessCondition::HumanApproval],
198                single_active_lease: true,
199                default_lease_duration_seconds: Some(60),
200                max_lease_duration_seconds: Some(120),
201                allows_extensions: true,
202                max_extension_duration_seconds: Some(30),
203                collections: vec![CollectionId::new(expected_collection_id)],
204                creation_date: "2025-01-01T00:00:00Z".parse().unwrap(),
205                revision_date: "2025-01-02T00:00:00Z".parse().unwrap(),
206            }
207        );
208    }
209
210    #[test]
211    fn missing_id_is_a_missing_field_error() {
212        let mut response = full_response();
213        response.id = None;
214
215        let result = AccessRuleView::try_from(response);
216
217        assert!(matches!(result, Err(AccessRuleError::MissingField(_))));
218    }
219
220    #[test]
221    fn null_conditions_become_empty_vec() {
222        let mut response = full_response();
223        response.conditions = None;
224
225        let view = AccessRuleView::try_from(response).unwrap();
226
227        assert_eq!(view.conditions, Vec::new());
228    }
229
230    #[test]
231    fn missing_enabled_defaults_to_true() {
232        let mut response = full_response();
233        response.enabled = None;
234
235        let view = AccessRuleView::try_from(response).unwrap();
236
237        assert!(view.enabled);
238    }
239
240    #[test]
241    fn non_array_conditions_is_an_error() {
242        let mut response = full_response();
243        response.conditions = Some(serde_json::json!({ "not": "an array" }));
244
245        let result = AccessRuleView::try_from(response);
246
247        assert!(matches!(result, Err(AccessRuleError::InvalidConditions(_))));
248    }
249
250    #[test]
251    fn request_always_sends_conditions_as_an_array() {
252        let request = AccessRuleAddEditRequest {
253            name: "My rule".to_string(),
254            description: None,
255            enabled: true,
256            conditions: Vec::new(),
257            single_active_lease: false,
258            default_lease_duration_seconds: None,
259            max_lease_duration_seconds: None,
260            allows_extensions: false,
261            max_extension_duration_seconds: None,
262            collections: Vec::new(),
263        };
264
265        let model = AccessRuleRequestModel::try_from(request).unwrap();
266
267        assert_eq!(model.conditions, Some(serde_json::json!([])));
268    }
269
270    #[test]
271    fn request_trims_name_before_sending() {
272        let request = AccessRuleAddEditRequest {
273            name: "  My rule  ".to_string(),
274            description: None,
275            enabled: true,
276            conditions: Vec::new(),
277            single_active_lease: false,
278            default_lease_duration_seconds: None,
279            max_lease_duration_seconds: None,
280            allows_extensions: false,
281            max_extension_duration_seconds: None,
282            collections: Vec::new(),
283        };
284
285        let model = AccessRuleRequestModel::try_from(request).unwrap();
286
287        assert_eq!(model.name, "My rule");
288    }
289}