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}