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