bitwarden_policies/models.rs
1//! Data models for the policy domain.
2
3use std::{any::TypeId, collections::HashMap};
4
5use bitwarden_core::OrganizationId;
6use bitwarden_organizations::{OrganizationUserStatusType, OrganizationUserType};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9#[cfg(feature = "wasm")]
10use tsify::Tsify;
11use uuid::Uuid;
12
13use crate::{
14 Policy,
15 policy_type::{PolicyDataType, PolicyType},
16};
17
18/// An organization policy in the raw data format that is sent over the FFI.
19///
20/// TODO: this is misnamed, but changing it is a breaking change.
21#[derive(Serialize, Deserialize, Debug, Clone)]
22#[serde(rename_all = "camelCase")]
23#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
24#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
25pub struct PolicyView {
26 /// The policy's unique ID.
27 pub id: Uuid,
28 /// The organization this policy belongs to.
29 pub organization_id: OrganizationId,
30 /// The type of policy.
31 pub r#type: PolicyType,
32 /// The policy's additional configuration data as a JSON string, if any.
33 pub data: Option<String>,
34 /// Whether the policy is enabled.
35 pub enabled: bool,
36 /// When the policy was last modified.
37 pub revision_date: Option<DateTime<Utc>>,
38}
39
40/// A minimal set of data for a user in an organization. This provides
41/// the context needed to evaluate the policies that are applied to the
42/// user.
43#[derive(Serialize, Deserialize, Debug, Clone)]
44#[serde(rename_all = "camelCase")]
45#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
46#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
47pub struct OrganizationUserPolicyContext {
48 /// The organization's unique ID.
49 pub id: OrganizationId,
50 /// The user's membership status in the organization.
51 pub status: OrganizationUserStatusType,
52 /// The user's role within the organization.
53 pub role: OrganizationUserType,
54 /// Whether the organization is enabled.
55 pub enabled: bool,
56 /// Whether the organization's plan supports policies.
57 pub use_policies: bool,
58 /// Whether the user is acting on behalf of a provider
59 /// that manages the organization.
60 pub is_provider_user: bool,
61}
62
63/// A per-organization enforcement decision for a single policy type.
64///
65/// Unlike [`PolicyView`] (the server-side record), this carries only the
66/// fields relevant to an enforcement decision: `enforced` reflects the
67/// user-specific evaluation rather than the policy's raw `enabled` flag, and
68/// `data` is strongly typed via [`Policy::Data`].
69///
70/// `data` is always populated. It is [`Default::default()`] whenever the policy
71/// is not enforced against the user, when no matching policy is found, or when
72/// the policy record's data could not be parsed.
73#[derive(Debug, Clone, PartialEq)]
74pub(crate) struct EnforcedPolicy<P: Policy> {
75 /// The organization this enforcement decision is for.
76 pub organization_id: OrganizationId,
77 /// The policy data, if any.
78 pub data: P::Data,
79 /// Whether the policy should be enforced against the current user for this
80 /// organization.
81 pub enforced: bool,
82}
83
84impl<P: Policy> EnforcedPolicy<P> {
85 /// The decision for an organization that has no matching policy: not
86 /// enforced, with [`Default`] data.
87 pub(crate) fn not_enforced(organization_id: OrganizationId) -> Self {
88 Self {
89 organization_id,
90 data: Default::default(),
91 enforced: false,
92 }
93 }
94
95 /// Consumes this decision into its FFI-friendly form, erasing the
96 /// strongly-typed `data` into a [`PolicyDataType`] via [`Policy::to_erased`].
97 pub(crate) fn into_erased(self, policy: &P) -> EnforcedPolicyErased {
98 EnforcedPolicyErased {
99 organization_id: self.organization_id,
100 data: policy.to_erased(self.data),
101 enforced: self.enforced,
102 }
103 }
104}
105
106/// The FFI-facing counterpart of the native `EnforcedPolicy`, with its
107/// strongly-typed `data` erased to [`PolicyDataType`] so it can cross the
108/// binding boundary.
109#[derive(Serialize, Deserialize, Debug, Clone)]
110#[serde(rename_all = "camelCase")]
111#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
112#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
113pub struct EnforcedPolicyErased {
114 /// The organization this enforcement decision is for.
115 pub organization_id: OrganizationId,
116 /// The policy data, if any.
117 pub data: PolicyDataType,
118 /// Whether the policy is being enforced against the current user for this
119 /// organization.
120 pub enforced: bool,
121}
122
123/// A [`PolicyView`] resolved against the concrete [`Policy`] that handles it.
124///
125/// This is the typed domain value the untyped wire [`PolicyView`] transforms into
126/// at the boundary: the `r#type` discriminant has been matched and the untyped
127/// `data` blob parsed into [`Policy::Data`], so a policy can only ever be paired
128/// with its own data type.
129pub(crate) struct ResolvedPolicyView<P: Policy> {
130 organization_id: OrganizationId,
131 enabled: bool,
132 data: P::Data,
133}
134
135impl<P: Policy> ResolvedPolicyView<P> {
136 /// Resolves `view` against `policy`, returning `Some` only when the view is
137 /// the type handled by `policy`.
138 ///
139 /// Deserializes the untyped `data` blob into `P::Data`, falling back to
140 /// [`Default`] (with a warning) when it is absent or fails to parse.
141 pub(crate) fn resolve(policy: &P, view: &PolicyView) -> Option<Self> {
142 if view.r#type != policy.policy_type() {
143 return None;
144 }
145
146 let data = match view.data.as_deref() {
147 Some(raw) => serde_json::from_str(raw).unwrap_or_else(|e| {
148 if TypeId::of::<P::Data>() == TypeId::of::<()>() {
149 // Any non-null value will fail to deserialize to ().
150 // This is a separate case to receiving malformed data - log it separately.
151 tracing::debug!(
152 policy_type = ?policy.policy_type(),
153 organization_id = %view.organization_id,
154 "Ignoring unexpected data for a policy type that models none"
155 );
156 } else {
157 tracing::warn!(
158 policy_type = ?policy.policy_type(),
159 organization_id = %view.organization_id,
160 "Failed to parse policy data, falling back to default: {e}"
161 );
162 }
163 Default::default()
164 }),
165 None => Default::default(),
166 };
167
168 Some(Self {
169 organization_id: view.organization_id,
170 enabled: view.enabled,
171 data,
172 })
173 }
174
175 /// Consumes the resolved view into an [`EnforcedPolicy`], evaluating whether
176 /// `policy` is enforced against the user.
177 ///
178 /// Pass in all organization contexts for this user; the specific context is looked
179 /// up by this method so that a mismatch between policy and organization is not possible.
180 /// If no context is present for the organization, the policy is enforced by default
181 /// (err on the side of enforcement).
182 pub(crate) fn into_enforced(
183 self,
184 policy: &P,
185 organization_user_policy_contexts: &HashMap<OrganizationId, &OrganizationUserPolicyContext>,
186 ) -> EnforcedPolicy<P> {
187 let context = organization_user_policy_contexts.get(&self.organization_id);
188
189 let enforced = self.enabled
190 && context.is_none_or(|ctx| {
191 ctx.enabled
192 && ctx.use_policies
193 && policy.enforced_statuses().contains(&ctx.status)
194 && !policy.exempt_roles().contains(&ctx.role)
195 && !(ctx.is_provider_user && policy.exempt_providers())
196 });
197
198 if enforced {
199 EnforcedPolicy {
200 organization_id: self.organization_id,
201 data: self.data,
202 enforced,
203 }
204 } else {
205 EnforcedPolicy::not_enforced(self.organization_id)
206 }
207 }
208}