Skip to main content

bitwarden_pam/access_rules/
conditions.rs

1use serde::{Deserialize, Serialize};
2#[cfg(feature = "wasm")]
3use tsify::Tsify;
4
5/// A single condition that gates access under an access rule.
6///
7/// Serialized using the server's wire format: an object tagged by a `kind` discriminant, e.g.
8/// `{"kind":"human_approval"}` or `{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}`.
9#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
10#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
11#[serde(tag = "kind", rename_all = "snake_case")]
12pub enum AccessCondition {
13    /// Requires a human approval before access is granted.
14    HumanApproval,
15    /// Restricts access to a set of allow-listed CIDR ranges.
16    IpAllowlist {
17        /// The list of allowed CIDR ranges, e.g. `10.0.0.0/8`.
18        cidrs: Vec<String>,
19    },
20    /// Any kind this SDK version doesn't model (e.g. the server's `time_of_day` condition).
21    /// The whole object is preserved verbatim so list + enable/disable round-trips don't
22    /// destroy conditions this SDK can't interpret.
23    ///
24    /// This also captures a known `kind` whose payload doesn't match the shape this SDK
25    /// expects (e.g. an `ip_allowlist` condition missing its `cidrs` field) - the whole object
26    /// is preserved verbatim rather than being rejected as a deserialization error.
27    ///
28    /// Known limitation: preservation applies to whole objects only. When a payload *does*
29    /// match a modeled variant, any extra fields the server may add to that kind in the future
30    /// are dropped on a deserialize→reserialize round trip (serde matches the tagged variant
31    /// and discards unrecognized fields). Adding fields to a modeled kind therefore requires a
32    /// matching SDK update; see the `extra_fields_on_known_kind_are_dropped` test.
33    ///
34    /// Note for TypeScript consumers: the generated type shows `kind: "unknown"`, but at
35    /// runtime `kind` holds the server's actual discriminant string (e.g. `"time_of_day"`).
36    /// Filter with a known-kind guard instead of matching on `"unknown"`.
37    #[serde(untagged)]
38    Unknown(
39        #[cfg_attr(feature = "wasm", tsify(type = "Record<string, unknown>"))] serde_json::Value,
40    ),
41}
42
43#[cfg(test)]
44mod tests {
45    use serde_json::json;
46
47    use super::*;
48
49    #[test]
50    fn human_approval_roundtrips() {
51        let condition = AccessCondition::HumanApproval;
52        let json = serde_json::to_value(&condition).unwrap();
53        assert_eq!(json, json!({ "kind": "human_approval" }));
54
55        let parsed: AccessCondition = serde_json::from_value(json).unwrap();
56        assert_eq!(parsed, condition);
57    }
58
59    #[test]
60    fn ip_allowlist_roundtrips() {
61        let condition = AccessCondition::IpAllowlist {
62            cidrs: vec!["10.0.0.0/8".to_string(), "2001:db8::/32".to_string()],
63        };
64        let json = serde_json::to_value(&condition).unwrap();
65        assert_eq!(
66            json,
67            json!({ "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8", "2001:db8::/32"] })
68        );
69
70        let parsed: AccessCondition = serde_json::from_value(json).unwrap();
71        assert_eq!(parsed, condition);
72    }
73
74    #[test]
75    fn unknown_kind_is_preserved_verbatim() {
76        let raw = json!({
77            "kind": "time_of_day",
78            "tz": "UTC",
79            "windows": [{ "start": "09:00", "end": "17:00" }],
80        });
81
82        let parsed: AccessCondition = serde_json::from_value(raw.clone()).unwrap();
83        assert_eq!(parsed, AccessCondition::Unknown(raw.clone()));
84
85        // Re-serializing must be lossless: comparing as `serde_json::Value` (rather than the
86        // serialized string) because `serde_json::Value`'s underlying map does not guarantee to
87        // preserve key insertion order without the `preserve_order` feature.
88        let reserialized = serde_json::to_value(&parsed).unwrap();
89        assert_eq!(reserialized, raw);
90    }
91
92    /// A known `kind` whose payload doesn't match the shape this SDK expects degrades to
93    /// [`AccessCondition::Unknown`] rather than failing to deserialize. This is a consequence of
94    /// `ip_allowlist` being declared after the `#[serde(untagged)]` catch-all in enum-variant
95    /// order for internally tagged enums: serde first attempts every preceding tagged variant,
96    /// and falls back to the untagged variant when none match - including when a *recognized* tag
97    /// has an unexpected shape. This is the documented, intentional behavior for this type: it
98    /// keeps list/round-trip operations lossless instead of hard-failing on a shape mismatch.
99    #[test]
100    fn malformed_known_kind_degrades_to_unknown() {
101        let raw = json!({ "kind": "ip_allowlist" });
102
103        let parsed: AccessCondition = serde_json::from_value(raw.clone()).unwrap();
104        assert_eq!(parsed, AccessCondition::Unknown(raw));
105    }
106
107    /// Pins the documented limitation on [`AccessCondition::Unknown`]: a payload that matches a
108    /// modeled variant has any extra/unrecognized fields silently dropped on a round trip -
109    /// serde matches the tagged variant and never falls through to the untagged catch-all. If
110    /// the server adds fields to a modeled kind, the SDK must be updated in lockstep.
111    #[test]
112    fn extra_fields_on_known_kind_are_dropped() {
113        let raw = json!({ "kind": "human_approval", "future_field": "not preserved" });
114        let parsed: AccessCondition = serde_json::from_value(raw).unwrap();
115        assert_eq!(parsed, AccessCondition::HumanApproval);
116        assert_eq!(
117            serde_json::to_value(&parsed).unwrap(),
118            json!({ "kind": "human_approval" })
119        );
120
121        let raw = json!({ "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8"], "extra": "dropped" });
122        let parsed: AccessCondition = serde_json::from_value(raw).unwrap();
123        assert_eq!(
124            serde_json::to_value(&parsed).unwrap(),
125            json!({ "kind": "ip_allowlist", "cidrs": ["10.0.0.0/8"] })
126        );
127    }
128}