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