Skip to main content

bitwarden_organization_invite_link/
organization_invite_link.rs

1use bitwarden_api_api::models::{
2    OrganizationInviteLinkResponseModel, OrganizationInviteLinkSsoResponseModel,
3    OrganizationInviteLinkStatusResponseModel,
4};
5use bitwarden_core::{
6    OrganizationId,
7    key_management::{KeySlotIds, SymmetricKeySlotId},
8    require,
9};
10use bitwarden_crypto::KeyStoreContext;
11use bitwarden_organization_crypto::invite::Invite;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::InviteLinkError;
17
18/// An organization invite link as persisted by the server. Internal only: clients receive an
19/// [`OrganizationInviteLinkView`], built via [`OrganizationInviteLink::into_view`].
20#[derive(Debug, Clone)]
21pub(crate) struct OrganizationInviteLink {
22    /// Unique identifier of the invite link.
23    pub(crate) id: Uuid,
24    /// The invite code invitees present when accepting or confirming the invite.
25    pub(crate) code: Uuid,
26    /// The organization this invite link belongs to.
27    pub(crate) organization_id: OrganizationId,
28    /// Email domains permitted to redeem this invite link.
29    pub(crate) allowed_domains: Vec<String>,
30    /// The sealed cryptographic invite carried in the invite link.
31    pub(crate) invite: Invite,
32    /// Whether invitees can self-confirm using this invite link.
33    pub(crate) supports_confirmation: bool,
34    /// When the invite link was created.
35    pub(crate) creation_date: DateTime<Utc>,
36}
37
38impl TryFrom<OrganizationInviteLinkResponseModel> for OrganizationInviteLink {
39    type Error = InviteLinkError;
40
41    fn try_from(response: OrganizationInviteLinkResponseModel) -> Result<Self, Self::Error> {
42        Ok(Self {
43            id: require!(response.id),
44            code: require!(response.code),
45            organization_id: OrganizationId::new(require!(response.organization_id)),
46            allowed_domains: response.allowed_domains.unwrap_or_default(),
47            invite: require!(response.invite).parse()?,
48            supports_confirmation: response.supports_confirmation.unwrap_or(false),
49            creation_date: require!(response.creation_date)
50                .parse()
51                .map_err(|_| InviteLinkError::ParseFailure("creation_date"))?,
52        })
53    }
54}
55
56impl OrganizationInviteLink {
57    /// Converts the invite link to a view model with URL for display.
58    pub(crate) fn into_view(
59        self,
60        ctx: &mut KeyStoreContext<KeySlotIds>,
61    ) -> Result<OrganizationInviteLinkView, InviteLinkError> {
62        let org_id = self.organization_id;
63
64        // Unwrap the invite secret
65        let org_key = SymmetricKeySlotId::Organization(org_id);
66        let invite_key = self
67            .invite
68            .unseal_invite_key_with_organization_key(org_key, ctx)?;
69        let invite_secret = self.invite.get_invite_secret(invite_key, ctx)?;
70
71        let invite_secret_str: String = (&invite_secret).into();
72        let code = self.code;
73        let url_fragment = format!("#/join/{org_id}/{code}?key={invite_secret_str}");
74
75        Ok(OrganizationInviteLinkView {
76            id: self.id,
77            organization_id: self.organization_id,
78            allowed_domains: self.allowed_domains,
79            supports_confirmation: self.supports_confirmation,
80            creation_date: self.creation_date,
81            url_fragment,
82        })
83    }
84}
85
86/// An organization invite link with reconstructed URL for display by the client.
87#[derive(Serialize, Deserialize, Clone)]
88#[bitwarden_ffi::wasm_record]
89#[serde(rename_all = "camelCase")]
90pub struct OrganizationInviteLinkView {
91    /// Unique identifier of the invite link.
92    pub id: Uuid,
93    /// The organization this invite link belongs to.
94    pub organization_id: OrganizationId,
95    /// Email domains permitted to redeem this invite link.
96    pub allowed_domains: Vec<String>,
97    /// Whether invitees can self-confirm using this invite link.
98    pub supports_confirmation: bool,
99    /// When the invite link was created.
100    pub creation_date: DateTime<Utc>,
101    /// The invite link URL fragment (to be appended on the web vault URL).
102    /// Example format: #/join/{org_id}/{code}?key={key}
103    pub url_fragment: String,
104}
105
106// Manually implemented so the invite secret carried in `url_fragment` is never printed.
107impl std::fmt::Debug for OrganizationInviteLinkView {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.debug_struct("OrganizationInviteLinkView")
110            .field("id", &self.id)
111            .field("organization_id", &self.organization_id)
112            .field("allowed_domains", &self.allowed_domains)
113            .field("supports_confirmation", &self.supports_confirmation)
114            .field("creation_date", &self.creation_date)
115            .field("url_fragment", &"[REDACTED]")
116            .finish()
117    }
118}
119
120/// The status of an organization invite link, used to verify basic availability before an invitee
121/// attempts to accept.
122#[derive(Serialize, Deserialize, Debug, Clone)]
123#[bitwarden_ffi::wasm_record]
124#[serde(rename_all = "camelCase")]
125pub struct OrganizationInviteLinkStatusView {
126    /// The name of the organization the invite link belongs to.
127    pub organization_name: String,
128    /// Whether invite links are currently enabled for the organization.
129    pub links_enabled: bool,
130    /// Whether the organization has seats available for new members.
131    pub seats_available: bool,
132    /// Whether invitees can self-confirm using this invite link.
133    pub supports_confirmation: bool,
134    /// SSO details for the organization, when SSO is configured.
135    pub sso: Option<OrganizationInviteLinkSsoView>,
136}
137
138/// SSO details for an organization referenced by an invite link status.
139#[derive(Serialize, Deserialize, Debug, Clone)]
140#[bitwarden_ffi::wasm_record]
141#[serde(rename_all = "camelCase")]
142pub struct OrganizationInviteLinkSsoView {
143    /// The organization's SSO identifier, when configured.
144    pub org_sso_id: Option<String>,
145    /// Whether SSO is required to redeem this invite link.
146    pub required: bool,
147}
148
149impl From<OrganizationInviteLinkSsoResponseModel> for OrganizationInviteLinkSsoView {
150    fn from(response: OrganizationInviteLinkSsoResponseModel) -> Self {
151        Self {
152            org_sso_id: response.org_sso_id,
153            required: response.required.unwrap_or(false),
154        }
155    }
156}
157
158impl TryFrom<OrganizationInviteLinkStatusResponseModel> for OrganizationInviteLinkStatusView {
159    type Error = InviteLinkError;
160
161    fn try_from(response: OrganizationInviteLinkStatusResponseModel) -> Result<Self, Self::Error> {
162        Ok(Self {
163            organization_name: require!(response.organization_name),
164            links_enabled: response.links_enabled.unwrap_or(false),
165            seats_available: response.seats_available.unwrap_or(false),
166            supports_confirmation: response.supports_confirmation.unwrap_or(false),
167            sso: response.sso.map(|sso| (*sso).into()),
168        })
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn view_debug_redacts_url_fragment() {
178        let view = OrganizationInviteLinkView {
179            id: Uuid::new_v4(),
180            organization_id: OrganizationId::new_v4(),
181            allowed_domains: vec!["example.com".to_string()],
182            supports_confirmation: true,
183            creation_date: Utc::now(),
184            url_fragment: "#/join/org/code?key=super-secret".to_string(),
185        };
186
187        let debug = format!("{view:?}");
188
189        assert!(!debug.contains("super-secret"), "secret leaked: {debug}");
190        assert!(debug.contains("[REDACTED]"));
191        assert!(debug.contains("example.com"));
192    }
193}