From 5473a3b6729a6e17427733f35f635c38e5553ae2 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Mon, 20 Jan 2025 17:26:41 +0100 Subject: [PATCH 01/11] Update invite_info and invite_list protocol to support multiple greeters during a user invite --- bindings/electron/src/index.d.ts | 41 +++ bindings/electron/src/meths.rs | 231 +++++++++++++++ bindings/generator/api/invite.py | 29 ++ bindings/web/src/meths.rs | 269 ++++++++++++++++++ client/src/parsec/invitation.ts | 20 +- client/src/plugins/libparsec/definitions.ts | 49 ++++ libparsec/crates/client/src/invite/claimer.rs | 53 +++- .../client/tests/unit/invite/claimer.rs | 13 +- .../crates/client/tests/unit/invite/shamir.rs | 6 + .../authenticated_cmds/invite_list.json5 | 42 +++ .../schema/invited_cmds/invite_info.json5 | 105 +++++-- .../authenticated_cmds/v5/invite_list.rs | 79 +++-- .../tests/invited_cmds/v5/invite_info.rs | 136 +++++++-- libparsec/crates/protocol/tests/misc.rs | 13 +- libparsec/src/invite.rs | 18 +- .../authenticated_cmds/v5/invite_list.pyi | 31 +- .../protocol/invited_cmds/v5/invite_info.pyi | 73 ++++- server/parsec/components/invite.py | 79 ++++- server/parsec/components/memory/datamodel.py | 8 +- server/parsec/components/memory/invite.py | 118 +++++--- server/parsec/components/postgresql/invite.py | 146 +++++++--- .../api_v5/authenticated/test_invite_list.py | 20 ++ .../authenticated/test_invite_new_device.py | 18 +- .../test_invite_new_shamir_recovery.py | 16 +- .../authenticated/test_invite_new_user.py | 34 ++- .../tests/api_v5/invited/test_invite_info.py | 33 ++- 26 files changed, 1448 insertions(+), 232 deletions(-) diff --git a/bindings/electron/src/index.d.ts b/bindings/electron/src/index.d.ts index 427d8727e81..e465de8dffd 100644 --- a/bindings/electron/src/index.d.ts +++ b/bindings/electron/src/index.d.ts @@ -66,6 +66,12 @@ export enum RealmRole { Reader = 'RealmRoleReader', } +export enum UserOnlineStatus { + Offline = 'UserOnlineStatusOffline', + Online = 'UserOnlineStatusOnline', + Unknown = 'UserOnlineStatusUnknown', +} + export enum UserProfile { Admin = 'UserProfileAdmin', Outsider = 'UserProfileOutsider', @@ -267,6 +273,7 @@ export interface ShamirRecoveryRecipient { humanHandle: HumanHandle revokedOn: number | null shares: number + onlineStatus: UserOnlineStatus } @@ -400,6 +407,7 @@ export interface AnyClaimRetrievedInfoShamirRecovery { handle: number claimer_user_id: string claimer_human_handle: HumanHandle + invitation_created_by: InviteInfoInvitationCreatedBy shamir_recovery_created_on: number recipients: Array threshold: number @@ -1610,12 +1618,43 @@ export type ImportRecoveryDeviceError = | ImportRecoveryDeviceErrorTimestampOutOfBallpark +// InviteInfoInvitationCreatedBy +export interface InviteInfoInvitationCreatedByExternalService { + tag: "ExternalService" + service_label: string +} +export interface InviteInfoInvitationCreatedByUser { + tag: "User" + user_id: string + human_handle: HumanHandle +} +export type InviteInfoInvitationCreatedBy = + | InviteInfoInvitationCreatedByExternalService + | InviteInfoInvitationCreatedByUser + + +// InviteListInvitationCreatedBy +export interface InviteListInvitationCreatedByExternalService { + tag: "ExternalService" + service_label: string +} +export interface InviteListInvitationCreatedByUser { + tag: "User" + user_id: string + human_handle: HumanHandle +} +export type InviteListInvitationCreatedBy = + | InviteListInvitationCreatedByExternalService + | InviteListInvitationCreatedByUser + + // InviteListItem export interface InviteListItemDevice { tag: "Device" addr: string token: string created_on: number + created_by: InviteListInvitationCreatedBy status: InvitationStatus } export interface InviteListItemShamirRecovery { @@ -1623,6 +1662,7 @@ export interface InviteListItemShamirRecovery { addr: string token: string created_on: number + created_by: InviteListInvitationCreatedBy claimer_user_id: string shamir_recovery_created_on: number status: InvitationStatus @@ -1632,6 +1672,7 @@ export interface InviteListItemUser { addr: string token: string created_on: number + created_by: InviteListInvitationCreatedBy claimer_email: string status: InvitationStatus } diff --git a/bindings/electron/src/meths.rs b/bindings/electron/src/meths.rs index 7773c139e87..10b574a3ef6 100644 --- a/bindings/electron/src/meths.rs +++ b/bindings/electron/src/meths.rs @@ -269,6 +269,32 @@ fn enum_realm_role_rs_to_js(value: libparsec::RealmRole) -> &'static str { } } +// UserOnlineStatus + +#[allow(dead_code)] +fn enum_user_online_status_js_to_rs<'a>( + cx: &mut impl Context<'a>, + raw_value: &str, +) -> NeonResult { + match raw_value { + "UserOnlineStatusOffline" => Ok(libparsec::UserOnlineStatus::Offline), + "UserOnlineStatusOnline" => Ok(libparsec::UserOnlineStatus::Online), + "UserOnlineStatusUnknown" => Ok(libparsec::UserOnlineStatus::Unknown), + _ => cx.throw_range_error(format!( + "Invalid value `{raw_value}` for enum UserOnlineStatus" + )), + } +} + +#[allow(dead_code)] +fn enum_user_online_status_rs_to_js(value: libparsec::UserOnlineStatus) -> &'static str { + match value { + libparsec::UserOnlineStatus::Offline => "UserOnlineStatusOffline", + libparsec::UserOnlineStatus::Online => "UserOnlineStatusOnline", + libparsec::UserOnlineStatus::Unknown => "UserOnlineStatusUnknown", + } +} + // UserProfile #[allow(dead_code)] @@ -2185,11 +2211,19 @@ fn struct_shamir_recovery_recipient_js_to_rs<'a>( } } }; + let online_status = { + let js_val: Handle = obj.get(cx, "onlineStatus")?; + { + let js_string = js_val.value(cx); + enum_user_online_status_js_to_rs(cx, js_string.as_str())? + } + }; Ok(libparsec::ShamirRecoveryRecipient { user_id, human_handle, revoked_on, shares, + online_status, }) } @@ -2233,6 +2267,10 @@ fn struct_shamir_recovery_recipient_rs_to_js<'a>( } } as f64); js_obj.set(cx, "shares", js_shares)?; + let js_online_status = + JsString::try_new(cx, enum_user_online_status_rs_to_js(rs_obj.online_status)) + .or_throw(cx)?; + js_obj.set(cx, "onlineStatus", js_online_status)?; Ok(js_obj) } @@ -3514,6 +3552,10 @@ fn variant_any_claim_retrieved_info_js_to_rs<'a>( let js_val: Handle = obj.get(cx, "claimerHumanHandle")?; struct_human_handle_js_to_rs(cx, js_val)? }; + let invitation_created_by = { + let js_val: Handle = obj.get(cx, "invitationCreatedBy")?; + variant_invite_info_invitation_created_by_js_to_rs(cx, js_val)? + }; let shamir_recovery_created_on = { let js_val: Handle = obj.get(cx, "shamirRecoveryCreatedOn")?; { @@ -3565,6 +3607,7 @@ fn variant_any_claim_retrieved_info_js_to_rs<'a>( handle, claimer_user_id, claimer_human_handle, + invitation_created_by, shamir_recovery_created_on, recipients, threshold, @@ -3648,6 +3691,7 @@ fn variant_any_claim_retrieved_info_rs_to_js<'a>( handle, claimer_user_id, claimer_human_handle, + invitation_created_by, shamir_recovery_created_on, recipients, threshold, @@ -3671,6 +3715,9 @@ fn variant_any_claim_retrieved_info_rs_to_js<'a>( js_obj.set(cx, "claimerUserId", js_claimer_user_id)?; let js_claimer_human_handle = struct_human_handle_rs_to_js(cx, claimer_human_handle)?; js_obj.set(cx, "claimerHumanHandle", js_claimer_human_handle)?; + let js_invitation_created_by = + variant_invite_info_invitation_created_by_rs_to_js(cx, invitation_created_by)?; + js_obj.set(cx, "invitationCreatedBy", js_invitation_created_by)?; let js_shamir_recovery_created_on = JsNumber::new(cx, { let custom_to_rs_f64 = |dt: libparsec::DateTime| -> Result { Ok((dt.as_timestamp_micros() as f64) / 1_000_000f64) @@ -6630,6 +6677,166 @@ fn variant_import_recovery_device_error_rs_to_js<'a>( Ok(js_obj) } +// InviteInfoInvitationCreatedBy + +#[allow(dead_code)] +fn variant_invite_info_invitation_created_by_js_to_rs<'a>( + cx: &mut impl Context<'a>, + obj: Handle<'a, JsObject>, +) -> NeonResult { + let tag = obj.get::(cx, "tag")?.value(cx); + match tag.as_str() { + "InviteInfoInvitationCreatedByExternalService" => { + let service_label = { + let js_val: Handle = obj.get(cx, "serviceLabel")?; + js_val.value(cx) + }; + Ok(libparsec::InviteInfoInvitationCreatedBy::ExternalService { service_label }) + } + "InviteInfoInvitationCreatedByUser" => { + let user_id = { + let js_val: Handle = obj.get(cx, "userId")?; + { + let custom_from_rs_string = |s: String| -> Result { + libparsec::UserID::from_hex(s.as_str()).map_err(|e| e.to_string()) + }; + match custom_from_rs_string(js_val.value(cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + let human_handle = { + let js_val: Handle = obj.get(cx, "humanHandle")?; + struct_human_handle_js_to_rs(cx, js_val)? + }; + Ok(libparsec::InviteInfoInvitationCreatedBy::User { + user_id, + human_handle, + }) + } + _ => cx.throw_type_error("Object is not a InviteInfoInvitationCreatedBy"), + } +} + +#[allow(dead_code)] +fn variant_invite_info_invitation_created_by_rs_to_js<'a>( + cx: &mut impl Context<'a>, + rs_obj: libparsec::InviteInfoInvitationCreatedBy, +) -> NeonResult> { + let js_obj = cx.empty_object(); + match rs_obj { + libparsec::InviteInfoInvitationCreatedBy::ExternalService { service_label, .. } => { + let js_tag = JsString::try_new(cx, "InviteInfoInvitationCreatedByExternalService") + .or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + let js_service_label = JsString::try_new(cx, service_label).or_throw(cx)?; + js_obj.set(cx, "serviceLabel", js_service_label)?; + } + libparsec::InviteInfoInvitationCreatedBy::User { + user_id, + human_handle, + .. + } => { + let js_tag = JsString::try_new(cx, "InviteInfoInvitationCreatedByUser").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + let js_user_id = JsString::try_new(cx, { + let custom_to_rs_string = + |x: libparsec::UserID| -> Result { Ok(x.hex()) }; + match custom_to_rs_string(user_id) { + Ok(ok) => ok, + Err(err) => return cx.throw_type_error(err), + } + }) + .or_throw(cx)?; + js_obj.set(cx, "userId", js_user_id)?; + let js_human_handle = struct_human_handle_rs_to_js(cx, human_handle)?; + js_obj.set(cx, "humanHandle", js_human_handle)?; + } + } + Ok(js_obj) +} + +// InviteListInvitationCreatedBy + +#[allow(dead_code)] +fn variant_invite_list_invitation_created_by_js_to_rs<'a>( + cx: &mut impl Context<'a>, + obj: Handle<'a, JsObject>, +) -> NeonResult { + let tag = obj.get::(cx, "tag")?.value(cx); + match tag.as_str() { + "InviteListInvitationCreatedByExternalService" => { + let service_label = { + let js_val: Handle = obj.get(cx, "serviceLabel")?; + js_val.value(cx) + }; + Ok(libparsec::InviteListInvitationCreatedBy::ExternalService { service_label }) + } + "InviteListInvitationCreatedByUser" => { + let user_id = { + let js_val: Handle = obj.get(cx, "userId")?; + { + let custom_from_rs_string = |s: String| -> Result { + libparsec::UserID::from_hex(s.as_str()).map_err(|e| e.to_string()) + }; + match custom_from_rs_string(js_val.value(cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + let human_handle = { + let js_val: Handle = obj.get(cx, "humanHandle")?; + struct_human_handle_js_to_rs(cx, js_val)? + }; + Ok(libparsec::InviteListInvitationCreatedBy::User { + user_id, + human_handle, + }) + } + _ => cx.throw_type_error("Object is not a InviteListInvitationCreatedBy"), + } +} + +#[allow(dead_code)] +fn variant_invite_list_invitation_created_by_rs_to_js<'a>( + cx: &mut impl Context<'a>, + rs_obj: libparsec::InviteListInvitationCreatedBy, +) -> NeonResult> { + let js_obj = cx.empty_object(); + match rs_obj { + libparsec::InviteListInvitationCreatedBy::ExternalService { service_label, .. } => { + let js_tag = JsString::try_new(cx, "InviteListInvitationCreatedByExternalService") + .or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + let js_service_label = JsString::try_new(cx, service_label).or_throw(cx)?; + js_obj.set(cx, "serviceLabel", js_service_label)?; + } + libparsec::InviteListInvitationCreatedBy::User { + user_id, + human_handle, + .. + } => { + let js_tag = JsString::try_new(cx, "InviteListInvitationCreatedByUser").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + let js_user_id = JsString::try_new(cx, { + let custom_to_rs_string = + |x: libparsec::UserID| -> Result { Ok(x.hex()) }; + match custom_to_rs_string(user_id) { + Ok(ok) => ok, + Err(err) => return cx.throw_type_error(err), + } + }) + .or_throw(cx)?; + js_obj.set(cx, "userId", js_user_id)?; + let js_human_handle = struct_human_handle_rs_to_js(cx, human_handle)?; + js_obj.set(cx, "humanHandle", js_human_handle)?; + } + } + Ok(js_obj) +} + // InviteListItem #[allow(dead_code)] @@ -6680,6 +6887,10 @@ fn variant_invite_list_item_js_to_rs<'a>( } } }; + let created_by = { + let js_val: Handle = obj.get(cx, "createdBy")?; + variant_invite_list_invitation_created_by_js_to_rs(cx, js_val)? + }; let status = { let js_val: Handle = obj.get(cx, "status")?; { @@ -6691,6 +6902,7 @@ fn variant_invite_list_item_js_to_rs<'a>( addr, token, created_on, + created_by, status, }) } @@ -6735,6 +6947,10 @@ fn variant_invite_list_item_js_to_rs<'a>( } } }; + let created_by = { + let js_val: Handle = obj.get(cx, "createdBy")?; + variant_invite_list_invitation_created_by_js_to_rs(cx, js_val)? + }; let claimer_user_id = { let js_val: Handle = obj.get(cx, "claimerUserId")?; { @@ -6772,6 +6988,7 @@ fn variant_invite_list_item_js_to_rs<'a>( addr, token, created_on, + created_by, claimer_user_id, shamir_recovery_created_on, status, @@ -6818,6 +7035,10 @@ fn variant_invite_list_item_js_to_rs<'a>( } } }; + let created_by = { + let js_val: Handle = obj.get(cx, "createdBy")?; + variant_invite_list_invitation_created_by_js_to_rs(cx, js_val)? + }; let claimer_email = { let js_val: Handle = obj.get(cx, "claimerEmail")?; js_val.value(cx) @@ -6833,6 +7054,7 @@ fn variant_invite_list_item_js_to_rs<'a>( addr, token, created_on, + created_by, claimer_email, status, }) @@ -6852,6 +7074,7 @@ fn variant_invite_list_item_rs_to_js<'a>( addr, token, created_on, + created_by, status, .. } => { @@ -6889,6 +7112,8 @@ fn variant_invite_list_item_rs_to_js<'a>( } }); js_obj.set(cx, "createdOn", js_created_on)?; + let js_created_by = variant_invite_list_invitation_created_by_rs_to_js(cx, created_by)?; + js_obj.set(cx, "createdBy", js_created_by)?; let js_status = JsString::try_new(cx, enum_invitation_status_rs_to_js(status)).or_throw(cx)?; js_obj.set(cx, "status", js_status)?; @@ -6897,6 +7122,7 @@ fn variant_invite_list_item_rs_to_js<'a>( addr, token, created_on, + created_by, claimer_user_id, shamir_recovery_created_on, status, @@ -6936,6 +7162,8 @@ fn variant_invite_list_item_rs_to_js<'a>( } }); js_obj.set(cx, "createdOn", js_created_on)?; + let js_created_by = variant_invite_list_invitation_created_by_rs_to_js(cx, created_by)?; + js_obj.set(cx, "createdBy", js_created_by)?; let js_claimer_user_id = JsString::try_new(cx, { let custom_to_rs_string = |x: libparsec::UserID| -> Result { Ok(x.hex()) }; @@ -6964,6 +7192,7 @@ fn variant_invite_list_item_rs_to_js<'a>( addr, token, created_on, + created_by, claimer_email, status, .. @@ -7002,6 +7231,8 @@ fn variant_invite_list_item_rs_to_js<'a>( } }); js_obj.set(cx, "createdOn", js_created_on)?; + let js_created_by = variant_invite_list_invitation_created_by_rs_to_js(cx, created_by)?; + js_obj.set(cx, "createdBy", js_created_by)?; let js_claimer_email = JsString::try_new(cx, claimer_email).or_throw(cx)?; js_obj.set(cx, "claimerEmail", js_claimer_email)?; let js_status = diff --git a/bindings/generator/api/invite.py b/bindings/generator/api/invite.py index c66e68285d3..77054713c4b 100644 --- a/bindings/generator/api/invite.py +++ b/bindings/generator/api/invite.py @@ -158,11 +158,27 @@ class Cancelled: pass +class UserOnlineStatus(Enum): + Online = EnumItemUnit + Offline = EnumItemUnit + Unknown = EnumItemUnit + + class ShamirRecoveryRecipient(Structure): user_id: UserID human_handle: HumanHandle revoked_on: Optional[DateTime] shares: NonZeroU8 + online_status: UserOnlineStatus + + +class InviteInfoInvitationCreatedBy(Variant): + class User: + user_id: UserID + human_handle: HumanHandle + + class ExternalService: + service_label: str class AnyClaimRetrievedInfo(Variant): @@ -181,6 +197,7 @@ class ShamirRecovery: handle: Handle claimer_user_id: UserID claimer_human_handle: HumanHandle + invitation_created_by: InviteInfoInvitationCreatedBy shamir_recovery_created_on: DateTime recipients: list[ShamirRecoveryRecipient] threshold: NonZeroU8 @@ -584,11 +601,21 @@ async def client_cancel_invitation( raise NotImplementedError +class InviteListInvitationCreatedBy(Variant): + class User: + user_id: UserID + human_handle: HumanHandle + + class ExternalService: + service_label: str + + class InviteListItem(Variant): class User: addr: ParsecInvitationAddr token: InvitationToken created_on: DateTime + created_by: InviteListInvitationCreatedBy claimer_email: str status: InvitationStatus @@ -596,12 +623,14 @@ class Device: addr: ParsecInvitationAddr token: InvitationToken created_on: DateTime + created_by: InviteListInvitationCreatedBy status: InvitationStatus class ShamirRecovery: addr: ParsecInvitationAddr token: InvitationToken created_on: DateTime + created_by: InviteListInvitationCreatedBy claimer_user_id: UserID shamir_recovery_created_on: DateTime status: InvitationStatus diff --git a/bindings/web/src/meths.rs b/bindings/web/src/meths.rs index e0d89fe9c67..1a32c74c55a 100644 --- a/bindings/web/src/meths.rs +++ b/bindings/web/src/meths.rs @@ -279,6 +279,33 @@ fn enum_realm_role_rs_to_js(value: libparsec::RealmRole) -> &'static str { } } +// UserOnlineStatus + +#[allow(dead_code)] +fn enum_user_online_status_js_to_rs( + raw_value: &str, +) -> Result { + match raw_value { + "UserOnlineStatusOffline" => Ok(libparsec::UserOnlineStatus::Offline), + "UserOnlineStatusOnline" => Ok(libparsec::UserOnlineStatus::Online), + "UserOnlineStatusUnknown" => Ok(libparsec::UserOnlineStatus::Unknown), + _ => { + let range_error = RangeError::new("Invalid value for enum UserOnlineStatus"); + range_error.set_cause(&JsValue::from(raw_value)); + Err(JsValue::from(range_error)) + } + } +} + +#[allow(dead_code)] +fn enum_user_online_status_rs_to_js(value: libparsec::UserOnlineStatus) -> &'static str { + match value { + libparsec::UserOnlineStatus::Offline => "UserOnlineStatusOffline", + libparsec::UserOnlineStatus::Online => "UserOnlineStatusOnline", + libparsec::UserOnlineStatus::Unknown => "UserOnlineStatusUnknown", + } +} + // UserProfile #[allow(dead_code)] @@ -2354,11 +2381,23 @@ fn struct_shamir_recovery_recipient_js_to_rs( } } }; + let online_status = { + let js_val = Reflect::get(&obj, &"onlineStatus".into())?; + { + let raw_string = js_val.as_string().ok_or_else(|| { + let type_error = TypeError::new("value is not a string"); + type_error.set_cause(&js_val); + JsValue::from(type_error) + })?; + enum_user_online_status_js_to_rs(raw_string.as_str()) + }? + }; Ok(libparsec::ShamirRecoveryRecipient { user_id, human_handle, revoked_on, shares, + online_status, }) } @@ -2402,6 +2441,9 @@ fn struct_shamir_recovery_recipient_rs_to_js( JsValue::from(v) }; Reflect::set(&js_obj, &"shares".into(), &js_shares)?; + let js_online_status = + JsValue::from_str(enum_user_online_status_rs_to_js(rs_obj.online_status)); + Reflect::set(&js_obj, &"onlineStatus".into(), &js_online_status)?; Ok(js_obj) } @@ -3772,6 +3814,10 @@ fn variant_any_claim_retrieved_info_js_to_rs( let js_val = Reflect::get(&obj, &"claimerHumanHandle".into())?; struct_human_handle_js_to_rs(js_val)? }; + let invitation_created_by = { + let js_val = Reflect::get(&obj, &"invitationCreatedBy".into())?; + variant_invite_info_invitation_created_by_js_to_rs(js_val)? + }; let shamir_recovery_created_on = { let js_val = Reflect::get(&obj, &"shamirRecoveryCreatedOn".into())?; { @@ -3829,6 +3875,7 @@ fn variant_any_claim_retrieved_info_js_to_rs( handle, claimer_user_id, claimer_human_handle, + invitation_created_by, shamir_recovery_created_on, recipients, threshold, @@ -3930,6 +3977,7 @@ fn variant_any_claim_retrieved_info_rs_to_js( handle, claimer_user_id, claimer_human_handle, + invitation_created_by, shamir_recovery_created_on, recipients, threshold, @@ -3959,6 +4007,13 @@ fn variant_any_claim_retrieved_info_rs_to_js( &"claimerHumanHandle".into(), &js_claimer_human_handle, )?; + let js_invitation_created_by = + variant_invite_info_invitation_created_by_rs_to_js(invitation_created_by)?; + Reflect::set( + &js_obj, + &"invitationCreatedBy".into(), + &js_invitation_created_by, + )?; let js_shamir_recovery_created_on = { let custom_to_rs_f64 = |dt: libparsec::DateTime| -> Result { Ok((dt.as_timestamp_micros() as f64) / 1_000_000f64) @@ -7353,6 +7408,196 @@ fn variant_import_recovery_device_error_rs_to_js( Ok(js_obj) } +// InviteInfoInvitationCreatedBy + +#[allow(dead_code)] +fn variant_invite_info_invitation_created_by_js_to_rs( + obj: JsValue, +) -> Result { + let tag = Reflect::get(&obj, &"tag".into())?; + let tag = tag + .as_string() + .ok_or_else(|| JsValue::from(TypeError::new("tag isn't a string")))?; + match tag.as_str() { + "InviteInfoInvitationCreatedByExternalService" => { + let service_label = { + let js_val = Reflect::get(&obj, &"serviceLabel".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string"))? + }; + Ok(libparsec::InviteInfoInvitationCreatedBy::ExternalService { service_label }) + } + "InviteInfoInvitationCreatedByUser" => { + let user_id = { + let js_val = Reflect::get(&obj, &"userId".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string")) + .and_then(|x| { + let custom_from_rs_string = |s: String| -> Result { + libparsec::UserID::from_hex(s.as_str()).map_err(|e| e.to_string()) + }; + custom_from_rs_string(x).map_err(|e| TypeError::new(e.as_ref())) + }) + .map_err(|_| TypeError::new("Not a valid UserID"))? + }; + let human_handle = { + let js_val = Reflect::get(&obj, &"humanHandle".into())?; + struct_human_handle_js_to_rs(js_val)? + }; + Ok(libparsec::InviteInfoInvitationCreatedBy::User { + user_id, + human_handle, + }) + } + _ => Err(JsValue::from(TypeError::new( + "Object is not a InviteInfoInvitationCreatedBy", + ))), + } +} + +#[allow(dead_code)] +fn variant_invite_info_invitation_created_by_rs_to_js( + rs_obj: libparsec::InviteInfoInvitationCreatedBy, +) -> Result { + let js_obj = Object::new().into(); + match rs_obj { + libparsec::InviteInfoInvitationCreatedBy::ExternalService { service_label, .. } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"InviteInfoInvitationCreatedByExternalService".into(), + )?; + let js_service_label = service_label.into(); + Reflect::set(&js_obj, &"serviceLabel".into(), &js_service_label)?; + } + libparsec::InviteInfoInvitationCreatedBy::User { + user_id, + human_handle, + .. + } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"InviteInfoInvitationCreatedByUser".into(), + )?; + let js_user_id = JsValue::from_str({ + let custom_to_rs_string = + |x: libparsec::UserID| -> Result { Ok(x.hex()) }; + match custom_to_rs_string(user_id) { + Ok(ok) => ok, + Err(err) => return Err(JsValue::from(TypeError::new(err.as_ref()))), + } + .as_ref() + }); + Reflect::set(&js_obj, &"userId".into(), &js_user_id)?; + let js_human_handle = struct_human_handle_rs_to_js(human_handle)?; + Reflect::set(&js_obj, &"humanHandle".into(), &js_human_handle)?; + } + } + Ok(js_obj) +} + +// InviteListInvitationCreatedBy + +#[allow(dead_code)] +fn variant_invite_list_invitation_created_by_js_to_rs( + obj: JsValue, +) -> Result { + let tag = Reflect::get(&obj, &"tag".into())?; + let tag = tag + .as_string() + .ok_or_else(|| JsValue::from(TypeError::new("tag isn't a string")))?; + match tag.as_str() { + "InviteListInvitationCreatedByExternalService" => { + let service_label = { + let js_val = Reflect::get(&obj, &"serviceLabel".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string"))? + }; + Ok(libparsec::InviteListInvitationCreatedBy::ExternalService { service_label }) + } + "InviteListInvitationCreatedByUser" => { + let user_id = { + let js_val = Reflect::get(&obj, &"userId".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string")) + .and_then(|x| { + let custom_from_rs_string = |s: String| -> Result { + libparsec::UserID::from_hex(s.as_str()).map_err(|e| e.to_string()) + }; + custom_from_rs_string(x).map_err(|e| TypeError::new(e.as_ref())) + }) + .map_err(|_| TypeError::new("Not a valid UserID"))? + }; + let human_handle = { + let js_val = Reflect::get(&obj, &"humanHandle".into())?; + struct_human_handle_js_to_rs(js_val)? + }; + Ok(libparsec::InviteListInvitationCreatedBy::User { + user_id, + human_handle, + }) + } + _ => Err(JsValue::from(TypeError::new( + "Object is not a InviteListInvitationCreatedBy", + ))), + } +} + +#[allow(dead_code)] +fn variant_invite_list_invitation_created_by_rs_to_js( + rs_obj: libparsec::InviteListInvitationCreatedBy, +) -> Result { + let js_obj = Object::new().into(); + match rs_obj { + libparsec::InviteListInvitationCreatedBy::ExternalService { service_label, .. } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"InviteListInvitationCreatedByExternalService".into(), + )?; + let js_service_label = service_label.into(); + Reflect::set(&js_obj, &"serviceLabel".into(), &js_service_label)?; + } + libparsec::InviteListInvitationCreatedBy::User { + user_id, + human_handle, + .. + } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"InviteListInvitationCreatedByUser".into(), + )?; + let js_user_id = JsValue::from_str({ + let custom_to_rs_string = + |x: libparsec::UserID| -> Result { Ok(x.hex()) }; + match custom_to_rs_string(user_id) { + Ok(ok) => ok, + Err(err) => return Err(JsValue::from(TypeError::new(err.as_ref()))), + } + .as_ref() + }); + Reflect::set(&js_obj, &"userId".into(), &js_user_id)?; + let js_human_handle = struct_human_handle_rs_to_js(human_handle)?; + Reflect::set(&js_obj, &"humanHandle".into(), &js_human_handle)?; + } + } + Ok(js_obj) +} + // InviteListItem #[allow(dead_code)] @@ -7407,6 +7652,10 @@ fn variant_invite_list_item_js_to_rs(obj: JsValue) -> Result Result Result Result Result Result { @@ -7642,6 +7903,8 @@ fn variant_invite_list_item_rs_to_js( JsValue::from(v) }; Reflect::set(&js_obj, &"createdOn".into(), &js_created_on)?; + let js_created_by = variant_invite_list_invitation_created_by_rs_to_js(created_by)?; + Reflect::set(&js_obj, &"createdBy".into(), &js_created_by)?; let js_status = JsValue::from_str(enum_invitation_status_rs_to_js(status)); Reflect::set(&js_obj, &"status".into(), &js_status)?; } @@ -7649,6 +7912,7 @@ fn variant_invite_list_item_rs_to_js( addr, token, created_on, + created_by, claimer_user_id, shamir_recovery_created_on, status, @@ -7692,6 +7956,8 @@ fn variant_invite_list_item_rs_to_js( JsValue::from(v) }; Reflect::set(&js_obj, &"createdOn".into(), &js_created_on)?; + let js_created_by = variant_invite_list_invitation_created_by_rs_to_js(created_by)?; + Reflect::set(&js_obj, &"createdBy".into(), &js_created_by)?; let js_claimer_user_id = JsValue::from_str({ let custom_to_rs_string = |x: libparsec::UserID| -> Result { Ok(x.hex()) }; @@ -7724,6 +7990,7 @@ fn variant_invite_list_item_rs_to_js( addr, token, created_on, + created_by, claimer_email, status, .. @@ -7762,6 +8029,8 @@ fn variant_invite_list_item_rs_to_js( JsValue::from(v) }; Reflect::set(&js_obj, &"createdOn".into(), &js_created_on)?; + let js_created_by = variant_invite_list_invitation_created_by_rs_to_js(created_by)?; + Reflect::set(&js_obj, &"createdBy".into(), &js_created_by)?; let js_claimer_email = claimer_email.into(); Reflect::set(&js_obj, &"claimerEmail".into(), &js_claimer_email)?; let js_status = JsValue::from_str(enum_invitation_status_rs_to_js(status)); diff --git a/client/src/parsec/invitation.ts b/client/src/parsec/invitation.ts index 755d262a392..f17988266a8 100644 --- a/client/src/parsec/invitation.ts +++ b/client/src/parsec/invitation.ts @@ -16,7 +16,7 @@ import { UserInvitation, } from '@/parsec/types'; import { listUsers } from '@/parsec/user'; -import { InviteListItem, InviteListItemTag, libparsec } from '@/plugins/libparsec'; +import { InviteListInvitationCreatedByTag, InviteListItem, InviteListItemTag, libparsec } from '@/plugins/libparsec'; import { DateTime } from 'luxon'; export async function inviteUser(email: string): Promise> { @@ -110,6 +110,15 @@ export async function listUserInvitations(options?: { token: '12346565645645654645645645645645', createdOn: DateTime.now(), claimerEmail: 'shadowheart@swordcoast.faerun', + createdBy: { + tag: InviteListInvitationCreatedByTag.User, + humanHandle: { + email: 'gale@waterdeep.faerun', + // cspell:disable-next-line + label: 'Gale Dekarios', + }, + userId: '1234', + }, status: InvitationStatus.Ready, }, { @@ -118,6 +127,15 @@ export async function listUserInvitations(options?: { addr: 'parsec3://parsec.example.com/MyOrg?a=claim_user&token=xBjfbfjrnrnrjnrjnrnjrjnrjnrjnrjnrjk', token: '32346565645645654645645645645645', createdOn: DateTime.now(), + createdBy: { + tag: InviteListInvitationCreatedByTag.User, + humanHandle: { + email: 'gale@waterdeep.faerun', + // cspell:disable-next-line + label: 'Gale Dekarios', + }, + userId: '1234', + }, claimerEmail: 'gale@waterdeep.faerun', status: InvitationStatus.Ready, }, diff --git a/client/src/plugins/libparsec/definitions.ts b/client/src/plugins/libparsec/definitions.ts index 2427a2c4d9e..32a132a6560 100644 --- a/client/src/plugins/libparsec/definitions.ts +++ b/client/src/plugins/libparsec/definitions.ts @@ -65,6 +65,12 @@ export enum RealmRole { Reader = 'RealmRoleReader', } +export enum UserOnlineStatus { + Offline = 'UserOnlineStatusOffline', + Online = 'UserOnlineStatusOnline', + Unknown = 'UserOnlineStatusUnknown', +} + export enum UserProfile { Admin = 'UserProfileAdmin', Outsider = 'UserProfileOutsider', @@ -269,6 +275,7 @@ export interface ShamirRecoveryRecipient { humanHandle: HumanHandle revokedOn: DateTime | null shares: NonZeroU8 + onlineStatus: UserOnlineStatus } export interface StartedWorkspaceInfo { @@ -396,6 +403,7 @@ export interface AnyClaimRetrievedInfoShamirRecovery { handle: Handle claimerUserId: UserID claimerHumanHandle: HumanHandle + invitationCreatedBy: InviteInfoInvitationCreatedBy shamirRecoveryCreatedOn: DateTime recipients: Array threshold: NonZeroU8 @@ -1880,6 +1888,44 @@ export type ImportRecoveryDeviceError = | ImportRecoveryDeviceErrorStopped | ImportRecoveryDeviceErrorTimestampOutOfBallpark +// InviteInfoInvitationCreatedBy +export enum InviteInfoInvitationCreatedByTag { + ExternalService = 'InviteInfoInvitationCreatedByExternalService', + User = 'InviteInfoInvitationCreatedByUser', +} + +export interface InviteInfoInvitationCreatedByExternalService { + tag: InviteInfoInvitationCreatedByTag.ExternalService + serviceLabel: string +} +export interface InviteInfoInvitationCreatedByUser { + tag: InviteInfoInvitationCreatedByTag.User + userId: UserID + humanHandle: HumanHandle +} +export type InviteInfoInvitationCreatedBy = + | InviteInfoInvitationCreatedByExternalService + | InviteInfoInvitationCreatedByUser + +// InviteListInvitationCreatedBy +export enum InviteListInvitationCreatedByTag { + ExternalService = 'InviteListInvitationCreatedByExternalService', + User = 'InviteListInvitationCreatedByUser', +} + +export interface InviteListInvitationCreatedByExternalService { + tag: InviteListInvitationCreatedByTag.ExternalService + serviceLabel: string +} +export interface InviteListInvitationCreatedByUser { + tag: InviteListInvitationCreatedByTag.User + userId: UserID + humanHandle: HumanHandle +} +export type InviteListInvitationCreatedBy = + | InviteListInvitationCreatedByExternalService + | InviteListInvitationCreatedByUser + // InviteListItem export enum InviteListItemTag { Device = 'InviteListItemDevice', @@ -1892,6 +1938,7 @@ export interface InviteListItemDevice { addr: ParsecInvitationAddr token: InvitationToken createdOn: DateTime + createdBy: InviteListInvitationCreatedBy status: InvitationStatus } export interface InviteListItemShamirRecovery { @@ -1899,6 +1946,7 @@ export interface InviteListItemShamirRecovery { addr: ParsecInvitationAddr token: InvitationToken createdOn: DateTime + createdBy: InviteListInvitationCreatedBy claimerUserId: UserID shamirRecoveryCreatedOn: DateTime status: InvitationStatus @@ -1908,6 +1956,7 @@ export interface InviteListItemUser { addr: ParsecInvitationAddr token: InvitationToken createdOn: DateTime + createdBy: InviteListInvitationCreatedBy claimerEmail: string status: InvitationStatus } diff --git a/libparsec/crates/client/src/invite/claimer.rs b/libparsec/crates/client/src/invite/claimer.rs index a708fba35f2..d1983e868d8 100644 --- a/libparsec/crates/client/src/invite/claimer.rs +++ b/libparsec/crates/client/src/invite/claimer.rs @@ -8,6 +8,7 @@ use invited_cmds::latest::invite_claimer_step; use libparsec_client_connection::AuthenticatedCmds; use libparsec_client_connection::{protocol::invited_cmds, ConnectionError, InvitedCmds}; use libparsec_protocol::authenticated_cmds; +use libparsec_protocol::invited_cmds::latest::invite_info::InvitationCreatedBy as InviteInfoInvitationCreatedBy; use libparsec_protocol::invited_cmds::latest::invite_info::ShamirRecoveryRecipient; use libparsec_types::prelude::*; @@ -248,31 +249,49 @@ pub async fn claimer_retrieve_info( Rep::Ok(claimer) => match claimer { InvitationType::User { claimer_email, - greeter_user_id, - greeter_human_handle, - } => Ok(AnyClaimRetrievedInfoCtx::User(UserClaimInitialCtx::new( - config, - cmds, - claimer_email, - greeter_user_id, - greeter_human_handle, - time_provider, - ))), + created_by: + InviteInfoInvitationCreatedBy::User { + human_handle, + user_id, + }, + administrators: _administrators, + } => { + // TODO: Here we should let the user pick a greeter from the administrators + // instead of using the one that created the invitation. + Ok(AnyClaimRetrievedInfoCtx::User(UserClaimInitialCtx::new( + config, + cmds, + claimer_email, + user_id, + human_handle, + time_provider, + ))) + } + InvitationType::User { + created_by: InviteInfoInvitationCreatedBy::ExternalService { .. }, + .. + } => { + // TODO: Here we should let the user pick a greeter from the administrators + // when the invitation is created by an external service. + Err(anyhow::anyhow!("Unexpected user invitation from an external service").into()) + } InvitationType::Device { - greeter_user_id, - greeter_human_handle, + claimer_user_id, + claimer_human_handle, + created_by: _created_by, } => Ok(AnyClaimRetrievedInfoCtx::Device( DeviceClaimInitialCtx::new( config, cmds, - greeter_user_id, - greeter_human_handle, + claimer_user_id, + claimer_human_handle, time_provider, ), )), InvitationType::ShamirRecovery { claimer_user_id, claimer_human_handle, + created_by, shamir_recovery_created_on, recipients, threshold, @@ -282,6 +301,7 @@ pub async fn claimer_retrieve_info( cmds, claimer_user_id, claimer_human_handle, + invitation_created_by: created_by, shamir_recovery_created_on, recipients, threshold, @@ -332,6 +352,7 @@ pub struct ShamirRecoveryClaimPickRecipientCtx { cmds: Arc, claimer_user_id: UserID, claimer_human_handle: HumanHandle, + invitation_created_by: InviteInfoInvitationCreatedBy, shamir_recovery_created_on: DateTime, recipients: Vec, threshold: NonZeroU8, @@ -356,6 +377,10 @@ impl ShamirRecoveryClaimPickRecipientCtx { &self.claimer_human_handle } + pub fn invitation_created_by(&self) -> &InviteInfoInvitationCreatedBy { + &self.invitation_created_by + } + pub fn shamir_recovery_created_on(&self) -> DateTime { self.shamir_recovery_created_on } diff --git a/libparsec/crates/client/tests/unit/invite/claimer.rs b/libparsec/crates/client/tests/unit/invite/claimer.rs index 7fb973c021f..75afb1009a5 100644 --- a/libparsec/crates/client/tests/unit/invite/claimer.rs +++ b/libparsec/crates/client/tests/unit/invite/claimer.rs @@ -43,8 +43,17 @@ async fn claimer(tmp_path: TmpPath, env: &TestbedEnv) { protocol::invited_cmds::latest::invite_info::Rep::Ok( protocol::invited_cmds::latest::invite_info::InvitationType::User { claimer_email: "john@example.com".to_owned(), - greeter_human_handle: alice.human_handle.clone(), - greeter_user_id: alice.user_id.to_owned(), + created_by: protocol::invited_cmds::latest::invite_info::InvitationCreatedBy::User { + user_id: alice.user_id.to_owned(), + human_handle: alice.human_handle.to_owned(), + }, + administrators: vec![ + protocol::invited_cmds::latest::invite_info::UserGreetingAdministrator { + user_id: alice.user_id.to_owned(), + human_handle: alice.human_handle.to_owned(), + online_status: protocol::invited_cmds::latest::invite_info::UserOnlineStatus::Online, + }, + ], }, ) } diff --git a/libparsec/crates/client/tests/unit/invite/shamir.rs b/libparsec/crates/client/tests/unit/invite/shamir.rs index fe8e94eb046..30940e2e16a 100644 --- a/libparsec/crates/client/tests/unit/invite/shamir.rs +++ b/libparsec/crates/client/tests/unit/invite/shamir.rs @@ -79,18 +79,21 @@ async fn shamir_full_greeting(tmp_path: TmpPath, env: &TestbedEnv) { human_handle: bob.human_handle.clone(), shares: 2.try_into().unwrap(), revoked_on: None, + online_status: libparsec_protocol::invited_cmds::latest::invite_info::UserOnlineStatus::Unknown, }, ShamirRecoveryRecipient { user_id: mallory.user_id, human_handle: mallory.human_handle.clone(), shares: 1.try_into().unwrap(), revoked_on: mallory_revoked_on, + online_status: libparsec_protocol::invited_cmds::latest::invite_info::UserOnlineStatus::Unknown, }, ShamirRecoveryRecipient { user_id: mike.user_id, human_handle: mike.human_handle.clone(), shares: 1.try_into().unwrap(), revoked_on: None, + online_status: libparsec_protocol::invited_cmds::latest::invite_info::UserOnlineStatus::Unknown, } ] ); @@ -409,18 +412,21 @@ async fn unrecoverable_recovery(env: &TestbedEnv) { human_handle: bob.human_handle.clone(), shares: 2.try_into().unwrap(), revoked_on: bob_revoked_on, + online_status: libparsec_protocol::invited_cmds::latest::invite_info::UserOnlineStatus::Unknown, }, ShamirRecoveryRecipient { user_id: mallory.user_id, human_handle: mallory.human_handle.clone(), shares: 1.try_into().unwrap(), revoked_on: mallory_revoked_on, + online_status: libparsec_protocol::invited_cmds::latest::invite_info::UserOnlineStatus::Unknown, }, ShamirRecoveryRecipient { user_id: mike.user_id, human_handle: mike.human_handle.clone(), shares: 1.try_into().unwrap(), revoked_on: None, + online_status: libparsec_protocol::invited_cmds::latest::invite_info::UserOnlineStatus::Unknown, } ] ); diff --git a/libparsec/crates/protocol/schema/authenticated_cmds/invite_list.json5 b/libparsec/crates/protocol/schema/authenticated_cmds/invite_list.json5 index 4f04df8b40c..105eb1b3eea 100644 --- a/libparsec/crates/protocol/schema/authenticated_cmds/invite_list.json5 +++ b/libparsec/crates/protocol/schema/authenticated_cmds/invite_list.json5 @@ -18,6 +18,36 @@ } ], "nested_types": [ + { + "name": "InvitationCreatedBy", + "discriminant_field": "type", + "variants": [ + { + "name": "User", + "discriminant_value": "USER", + "fields": [ + { + "name": "user_id", + "type": "UserID" + }, + { + "name": "human_handle", + "type": "HumanHandle" + } + ] + }, + { + "name": "ExternalService", + "discriminant_value": "EXTERNAL_SERVICE", + "fields": [ + { + "name": "service_label", + "type": "String" + } + ] + } + ] + }, { "name": "InviteListItem", "discriminant_field": "type", @@ -34,6 +64,10 @@ "name": "created_on", "type": "DateTime" }, + { + "name": "created_by", + "type": "InvitationCreatedBy" + }, { "name": "claimer_email", "type": "String" @@ -56,6 +90,10 @@ "name": "created_on", "type": "DateTime" }, + { + "name": "created_by", + "type": "InvitationCreatedBy" + }, { "name": "status", "type": "InvitationStatus" @@ -74,6 +112,10 @@ "name": "created_on", "type": "DateTime" }, + { + "name": "created_by", + "type": "InvitationCreatedBy" + }, { "name": "claimer_user_id", "type": "UserID" diff --git a/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 b/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 index 029526cbf76..136c4587f9a 100644 --- a/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 +++ b/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 @@ -26,40 +26,31 @@ "type": "String" }, { - // TODO: merge into `created_by` - "name": "greeter_user_id", - "type": "UserID" + "name": "created_by", + "type": "InvitationCreatedBy" }, { - // TODO: merge into `created_by` - "name": "greeter_human_handle", - "type": "HumanHandle" + "name": "administrators", + "type": "List" } - // TODO: Add a `created_by` field and make it an `InviteInfoCreatedBy` type - // which is a nested variant of either: - // - OrganizationAdministrator - // - ExternalService - // - // TODO: Add an `administrators` field and make it a `List` type - // which is a nested type with the fields: - // - user_id: UserID - // - human_handle: HumanHandle - // - status: ONLINE | OFFLINE | UNKNOWN ] }, { "name": "Device", "discriminant_value": "DEVICE", "fields": [ + // Note: Greeter and claimer are the same for device invitations { - // TODO: Rename to `claimer_user_id` - "name": "greeter_user_id", + "name": "claimer_user_id", "type": "UserID" }, { - // TODO: Rename to `claimer_human_handle` - "name": "greeter_human_handle", + "name": "claimer_human_handle", "type": "HumanHandle" + }, + { + "name": "created_by", + "type": "InvitationCreatedBy" } ] }, @@ -75,6 +66,10 @@ "name": "claimer_human_handle", "type": "HumanHandle" }, + { + "name": "created_by", + "type": "InvitationCreatedBy" + }, { "name": "shamir_recovery_created_on", "type": "DateTime" @@ -91,6 +86,70 @@ } ] }, + { + "name": "UserOnlineStatus", + "variants": [ + { + "name": "Online", + "discriminant_value": "ONLINE" + }, + { + "name": "Offline", + "discriminant_value": "OFFLINE" + }, + { + "name": "Unknown", + "discriminant_value": "UNKNOWN" + } + ] + }, + { + "name": "InvitationCreatedBy", + "discriminant_field": "type", + "variants": [ + { + "name": "User", + "discriminant_value": "USER", + "fields": [ + { + "name": "user_id", + "type": "UserID" + }, + { + "name": "human_handle", + "type": "HumanHandle" + } + ] + }, + { + "name": "ExternalService", + "discriminant_value": "EXTERNAL_SERVICE", + "fields": [ + { + "name": "service_label", + "type": "String" + } + ] + } + ] + }, + { + "name": "UserGreetingAdministrator", + "fields": [ + { + "name": "user_id", + "type": "UserID" + }, + { + "name": "human_handle", + "type": "HumanHandle" + }, + { + "name": "online_status", + "type": "UserOnlineStatus" + } + ] + }, { "name": "ShamirRecoveryRecipient", "fields": [ @@ -109,9 +168,11 @@ { "name": "revoked_on", "type": "RequiredOption" + }, + { + "name": "online_status", + "type": "UserOnlineStatus" } - // TODO: Add a status field like: - // - status: ONLINE | OFFLINE | UNKNOWN ] } ] diff --git a/libparsec/crates/protocol/tests/authenticated_cmds/v5/invite_list.rs b/libparsec/crates/protocol/tests/authenticated_cmds/v5/invite_list.rs index 93a3b056e97..f796ea4516b 100644 --- a/libparsec/crates/protocol/tests/authenticated_cmds/v5/invite_list.rs +++ b/libparsec/crates/protocol/tests/authenticated_cmds/v5/invite_list.rs @@ -5,6 +5,7 @@ #![allow(clippy::unwrap_used)] use super::authenticated_cmds; +use libparsec_types::prelude::*; use libparsec_tests_lite::{hex, p_assert_eq}; use libparsec_types::{InvitationStatus, InvitationToken}; @@ -39,50 +40,66 @@ pub fn req() { // Responses pub fn rep_ok() { - // Generated from Parsec v3.0.0-b.11+dev + // Generated from Parsec 3.2.4-a.0+dev // Content: // invitations: [ // { // type: "USER" // claimer_email: "alice@dev1" // created_on: ext(1, 946774800.0) + // created_by: { type: 'USER', human_handle: [ 'bob@dev1', 'bob', ], user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), } // status: "IDLE" // token: ext(2, hex!("d864b93ded264aae9ae583fd3d40c45a")) // } // { // type: "DEVICE" // created_on: ext(1, 946774800.0) + // created_by: { type: 'USER', human_handle: [ 'carl@dev1', 'carl', ], user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4b), } // status: "IDLE" // token: ext(2, hex!("d864b93ded264aae9ae583fd3d40c45a")) // } // ] // status: "ok" - let raw = hex!( - "82a6737461747573a26f6bab696e7669746174696f6e739285a474797065a455534552" - "ad636c61696d65725f656d61696caa616c6963654064657631aa637265617465645f6f" - "6ed70100035d162fa2e400a6737461747573a449444c45a5746f6b656ec410d864b93d" - "ed264aae9ae583fd3d40c45a84a474797065a6444556494345aa637265617465645f6f" - "6ed70100035d162fa2e400a6737461747573a449444c45a5746f6b656ec410d864b93d" - "ed264aae9ae583fd3d40c45a" - ); + let raw: &[u8] = hex!( + "82a6737461747573a26f6bab696e7669746174696f6e739286a474797065a455534552" + "ad636c61696d65725f656d61696caa616c6963654064657631aa637265617465645f62" + "7983a474797065a455534552ac68756d616e5f68616e646c6592a8626f624064657631" + "a3626f62a7757365725f6964d802109b68ba5cdf428ea0017fc6bcc04d4aaa63726561" + "7465645f6f6ed70100035d162fa2e400a6737461747573a449444c45a5746f6b656ec4" + "10d864b93ded264aae9ae583fd3d40c45a85a474797065a6444556494345aa63726561" + "7465645f627983a474797065a455534552ac68756d616e5f68616e646c6592a9636172" + "6c4064657631a46361726ca7757365725f6964d802109b68ba5cdf428ea0017fc6bcc0" + "4d4baa637265617465645f6f6ed70100035d162fa2e400a6737461747573a449444c45" + "a5746f6b656ec410d864b93ded264aae9ae583fd3d40c45a" + ) + .as_ref(); let expected = authenticated_cmds::invite_list::Rep::Ok { invitations: vec![ authenticated_cmds::invite_list::InviteListItem::User { token: InvitationToken::from_hex("d864b93ded264aae9ae583fd3d40c45a").unwrap(), created_on: "2000-1-2T01:00:00Z".parse().unwrap(), + created_by: authenticated_cmds::invite_list::InvitationCreatedBy::User { + human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), + }, claimer_email: "alice@dev1".to_owned(), status: InvitationStatus::Idle, }, authenticated_cmds::invite_list::InviteListItem::Device { token: InvitationToken::from_hex("d864b93ded264aae9ae583fd3d40c45a").unwrap(), created_on: "2000-1-2T01:00:00Z".parse().unwrap(), + created_by: authenticated_cmds::invite_list::InvitationCreatedBy::User { + human_handle: HumanHandle::new("carl@dev1", "carl").unwrap(), + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4b").unwrap(), + }, status: InvitationStatus::Idle, }, ], }; - let data = authenticated_cmds::invite_list::Rep::load(&raw).unwrap(); + println!("***expected: {:?}", expected.dump().unwrap()); + let data = authenticated_cmds::invite_list::Rep::load(raw).unwrap(); p_assert_eq!(data, expected); @@ -93,7 +110,7 @@ pub fn rep_ok() { p_assert_eq!(data2, expected); - // Generated from Parsec 3.1.1-a.0+dev + // Generated from Parsec 3.2.4-a.0+dev // Content: // status: 'ok' // invitations: [ @@ -101,12 +118,14 @@ pub fn rep_ok() { // type: 'USER', // claimer_email: 'alice@example.com', // created_on: ext(1, 946774800000000) i.e. 2000-01-02T02:00:00Z, + // created_by: { type: 'USER', human_handle: [ 'bob@dev1', 'bob', ], user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), }, // status: 'IDLE', // token: 0xd864b93ded264aae9ae583fd3d40c45a, // }, // { // type: 'DEVICE', // created_on: ext(1, 946774800000000) i.e. 2000-01-02T02:00:00Z, + // created_by: { type: 'USER', human_handle: [ 'carl@dev1', 'carl', ], user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4b), }, // status: 'IDLE', // token: 0xd864b93ded264aae9ae583fd3d40c45a, // }, @@ -114,22 +133,29 @@ pub fn rep_ok() { // type: 'SHAMIR_RECOVERY', // claimer_user_id: ext(2, 0xa11cec00100000000000000000000000), // created_on: ext(1, 946774800000000) i.e. 2000-01-02T02:00:00Z, + // created_by: { type: 'EXTERNAL_SERVICE', service_label: 'LDAP', }, // shamir_recovery_created_on: ext(1, 946688400000000) i.e. 2000-01-02T01:00:00Z, // status: 'IDLE', // token: 0xd864b93ded264aae9ae583fd3d40c45a, // }, // ] let raw: &[u8] = hex!( - "82a6737461747573a26f6bab696e7669746174696f6e739385a474797065a455534552" - "ad636c61696d65725f656d61696cb1616c696365406578616d706c652e636f6daa6372" - "65617465645f6f6ed70100035d162fa2e400a6737461747573a449444c45a5746f6b65" - "6ec410d864b93ded264aae9ae583fd3d40c45a84a474797065a6444556494345aa6372" - "65617465645f6f6ed70100035d162fa2e400a6737461747573a449444c45a5746f6b65" - "6ec410d864b93ded264aae9ae583fd3d40c45a86a474797065af5348414d49525f5245" - "434f56455259af636c61696d65725f757365725f6964d802a11cec0010000000000000" - "0000000000aa637265617465645f6f6ed70100035d162fa2e400ba7368616d69725f72" - "65636f766572795f637265617465645f6f6ed70100035d0211cb8400a6737461747573" - "a449444c45a5746f6b656ec410d864b93ded264aae9ae583fd3d40c45a" + "82a6737461747573a26f6bab696e7669746174696f6e739386a474797065a455534552" + "ad636c61696d65725f656d61696cb1616c696365406578616d706c652e636f6daa6372" + "65617465645f627983a474797065a455534552ac68756d616e5f68616e646c6592a862" + "6f624064657631a3626f62a7757365725f6964d802109b68ba5cdf428ea0017fc6bcc0" + "4d4aaa637265617465645f6f6ed70100035d162fa2e400a6737461747573a449444c45" + "a5746f6b656ec410d864b93ded264aae9ae583fd3d40c45a85a474797065a644455649" + "4345aa637265617465645f627983a474797065a455534552ac68756d616e5f68616e64" + "6c6592a96361726c4064657631a46361726ca7757365725f6964d802109b68ba5cdf42" + "8ea0017fc6bcc04d4baa637265617465645f6f6ed70100035d162fa2e400a673746174" + "7573a449444c45a5746f6b656ec410d864b93ded264aae9ae583fd3d40c45a87a47479" + "7065af5348414d49525f5245434f56455259af636c61696d65725f757365725f6964d8" + "02a11cec00100000000000000000000000aa637265617465645f627982a474797065b0" + "45585445524e414c5f53455256494345ad736572766963655f6c6162656ca44c444150" + "aa637265617465645f6f6ed70100035d162fa2e400ba7368616d69725f7265636f7665" + "72795f637265617465645f6f6ed70100035d0211cb8400a6737461747573a449444c45" + "a5746f6b656ec410d864b93ded264aae9ae583fd3d40c45a" ) .as_ref(); @@ -138,17 +164,28 @@ pub fn rep_ok() { authenticated_cmds::invite_list::InviteListItem::User { token: InvitationToken::from_hex("d864b93ded264aae9ae583fd3d40c45a").unwrap(), created_on: "2000-1-2T01:00:00Z".parse().unwrap(), + created_by: authenticated_cmds::invite_list::InvitationCreatedBy::User { + human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), + }, claimer_email: "alice@example.com".to_owned(), status: InvitationStatus::Idle, }, authenticated_cmds::invite_list::InviteListItem::Device { token: InvitationToken::from_hex("d864b93ded264aae9ae583fd3d40c45a").unwrap(), created_on: "2000-1-2T01:00:00Z".parse().unwrap(), + created_by: authenticated_cmds::invite_list::InvitationCreatedBy::User { + human_handle: HumanHandle::new("carl@dev1", "carl").unwrap(), + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4b").unwrap(), + }, status: InvitationStatus::Idle, }, authenticated_cmds::invite_list::InviteListItem::ShamirRecovery { token: InvitationToken::from_hex("d864b93ded264aae9ae583fd3d40c45a").unwrap(), created_on: "2000-1-2T01:00:00Z".parse().unwrap(), + created_by: authenticated_cmds::invite_list::InvitationCreatedBy::ExternalService { + service_label: "LDAP".to_owned(), + }, claimer_user_id: "alice".parse().unwrap(), shamir_recovery_created_on: "2000-1-1T01:00:00Z".parse().unwrap(), status: InvitationStatus::Idle, diff --git a/libparsec/crates/protocol/tests/invited_cmds/v5/invite_info.rs b/libparsec/crates/protocol/tests/invited_cmds/v5/invite_info.rs index e626e3dd195..1a3258cdabe 100644 --- a/libparsec/crates/protocol/tests/invited_cmds/v5/invite_info.rs +++ b/libparsec/crates/protocol/tests/invited_cmds/v5/invite_info.rs @@ -45,68 +45,140 @@ pub fn req() { pub fn rep_ok() { let raw_expected = [ ( - // Generated from Rust implementation (Parsec 3.0.0-b.6+dev) + // Generated from Parsec 3.2.4-a.0+dev // Content: - // type: "USER" - // claimer_email: "alice@dev1" - // greeter_human_handle: ["bob@dev1", "bob"] - // greeter_user_id: "109b68ba5cdf428ea0017fc6bcc04d4a" - // status: "ok" + // status: 'ok' + // type: 'USER' + // administrators: [ { human_handle: [ 'bob@dev1', 'bob', ], online_status: 'UNKNOWN', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), }, { human_handle: [ 'carl@dev1', 'carl', ], online_status: 'ONLINE', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4b), }, ] + // claimer_email: 'alice@dev1' + // created_by: { type: 'USER', human_handle: [ 'bob@dev1', 'bob', ], user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), } &hex!( - "85a6737461747573a26f6ba474797065a455534552ad636c61696d65725f656d61696c" - "aa616c6963654064657631b4677265657465725f68756d616e5f68616e646c6592a862" - "6f624064657631a3626f62af677265657465725f757365725f6964d802109b68ba5cdf" + "85a6737461747573a26f6ba474797065a455534552ae61646d696e6973747261746f72" + "739283ac68756d616e5f68616e646c6592a8626f624064657631a3626f62ad6f6e6c69" + "6e655f737461747573a7554e4b4e4f574ea7757365725f6964d802109b68ba5cdf428e" + "a0017fc6bcc04d4a83ac68756d616e5f68616e646c6592a96361726c4064657631a463" + "61726cad6f6e6c696e655f737461747573a64f4e4c494e45a7757365725f6964d80210" + "9b68ba5cdf428ea0017fc6bcc04d4bad636c61696d65725f656d61696caa616c696365" + "4064657631aa637265617465645f627983a474797065a455534552ac68756d616e5f68" + "616e646c6592a8626f624064657631a3626f62a7757365725f6964d802109b68ba5cdf" "428ea0017fc6bcc04d4a" )[..], invited_cmds::invite_info::Rep::Ok(invited_cmds::invite_info::InvitationType::User { claimer_email: "alice@dev1".to_owned(), - greeter_user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), - greeter_human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), + created_by: invited_cmds::invite_info::InvitationCreatedBy::User { + human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), + }, + administrators: vec![ + invited_cmds::invite_info::UserGreetingAdministrator { + human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), + online_status: invited_cmds::invite_info::UserOnlineStatus::Unknown, + }, + invited_cmds::invite_info::UserGreetingAdministrator { + human_handle: HumanHandle::new("carl@dev1", "carl").unwrap(), + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4b").unwrap(), + online_status: invited_cmds::invite_info::UserOnlineStatus::Online, + }, + ], + }), + ), + ( + // Generated from Parsec 3.2.4-a.0+dev + // Content: + // status: 'ok' + // type: 'USER' + // administrators: [ { human_handle: [ 'bob@dev1', 'bob', ], online_status: 'UNKNOWN', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), }, { human_handle: [ 'carl@dev1', 'carl', ], online_status: 'ONLINE', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4b), }, ] + // claimer_email: 'alice@dev1' + // created_by: { type: 'EXTERNAL_SERVICE', service_label: 'LDAP', } + &hex!( + "85a6737461747573a26f6ba474797065a455534552ae61646d696e6973747261746f72" + "739283ac68756d616e5f68616e646c6592a8626f624064657631a3626f62ad6f6e6c69" + "6e655f737461747573a7554e4b4e4f574ea7757365725f6964d802109b68ba5cdf428e" + "a0017fc6bcc04d4a83ac68756d616e5f68616e646c6592a96361726c4064657631a463" + "61726cad6f6e6c696e655f737461747573a64f4e4c494e45a7757365725f6964d80210" + "9b68ba5cdf428ea0017fc6bcc04d4bad636c61696d65725f656d61696caa616c696365" + "4064657631aa637265617465645f627982a474797065b045585445524e414c5f534552" + "56494345ad736572766963655f6c6162656ca44c444150" + )[..], + invited_cmds::invite_info::Rep::Ok(invited_cmds::invite_info::InvitationType::User { + claimer_email: "alice@dev1".to_owned(), + created_by: invited_cmds::invite_info::InvitationCreatedBy::ExternalService { + service_label: "LDAP".to_owned(), + }, + administrators: vec![ + invited_cmds::invite_info::UserGreetingAdministrator { + human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), + online_status: invited_cmds::invite_info::UserOnlineStatus::Unknown, + }, + invited_cmds::invite_info::UserGreetingAdministrator { + human_handle: HumanHandle::new("carl@dev1", "carl").unwrap(), + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4b").unwrap(), + online_status: invited_cmds::invite_info::UserOnlineStatus::Online, + }, + ], }), ), ( - // Generated from Rust implementation (Parsec 3.0.0-b.6+dev) + // Generated from Parsec 3.2.4-a.0+dev // Content: - // type: "DEVICE" - // greeter_human_handle: ["bob@dev1", "bob"] - // greeter_user_id: "109b68ba5cdf428ea0017fc6bcc04d4a" - // status: "ok" + // status: 'ok' + // type: 'DEVICE' + // claimer_human_handle: [ 'bob@dev1', 'bob', ] + // claimer_user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a) + // created_by: { type: 'USER', human_handle: [ 'bob@dev1', 'bob', ], user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), } &hex!( - "84a6737461747573a26f6ba474797065a6444556494345b4677265657465725f68756d" - "616e5f68616e646c6592a8626f624064657631a3626f62af677265657465725f757365" - "725f6964d802109b68ba5cdf428ea0017fc6bcc04d4a" + "85a6737461747573a26f6ba474797065a6444556494345b4636c61696d65725f68756d" + "616e5f68616e646c6592a8626f624064657631a3626f62af636c61696d65725f757365" + "725f6964d802109b68ba5cdf428ea0017fc6bcc04d4aaa637265617465645f627983a4" + "74797065a455534552ac68756d616e5f68616e646c6592a8626f624064657631a3626f" + "62a7757365725f6964d802109b68ba5cdf428ea0017fc6bcc04d4a" )[..], invited_cmds::invite_info::Rep::Ok(invited_cmds::invite_info::InvitationType::Device { - greeter_user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), - greeter_human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), + claimer_user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), + claimer_human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), + created_by: invited_cmds::invite_info::InvitationCreatedBy::User { + human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), + }, }), ), ( - // Generated from Parsec 3.2.1-a.0+dev + // Generated from Parsec 3.2.4-a.0+dev // Content: // status: 'ok' // type: 'SHAMIR_RECOVERY' // claimer_human_handle: [ 'carl@example.com', 'carl', ] // claimer_user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4c) - // recipients: [ { human_handle: [ 'alice@example.com', 'alice', ], revoked_on: None, shares: 1, user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), }, { human_handle: [ 'bob@example.com', 'bob', ], revoked_on: ext(1, 946774800000000) i.e. 2000-01-02T02:00:00Z, shares: 1, user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4b), }, ] + // created_by: { type: 'USER', human_handle: [ 'bob@dev1', 'bob', ], user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), } + // recipients: [ { human_handle: [ 'alice@example.com', 'alice', ], online_status: 'ONLINE', revoked_on: None, shares: 1, user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), }, { human_handle: [ 'bob@example.com', 'bob', ], online_status: 'UNKNOWN', revoked_on: ext(1, 946774800000000) i.e. 2000-01-02T02:00:00Z, shares: 1, user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4b), }, ] // shamir_recovery_created_on: ext(1, 946688400000000) i.e. 2000-01-01T02:00:00Z // threshold: 2 &hex!( - "87a6737461747573a26f6ba474797065af5348414d49525f5245434f56455259b4636c" + "88a6737461747573a26f6ba474797065af5348414d49525f5245434f56455259b4636c" "61696d65725f68756d616e5f68616e646c6592b06361726c406578616d706c652e636f" "6da46361726caf636c61696d65725f757365725f6964d802109b68ba5cdf428ea0017f" - "c6bcc04d4caa726563697069656e74739284ac68756d616e5f68616e646c6592b1616c" - "696365406578616d706c652e636f6da5616c696365aa7265766f6b65645f6f6ec0a673" - "686172657301a7757365725f6964d802109b68ba5cdf428ea0017fc6bcc04d4a84ac68" - "756d616e5f68616e646c6592af626f62406578616d706c652e636f6da3626f62aa7265" - "766f6b65645f6f6ed70100035d162fa2e400a673686172657301a7757365725f6964d8" - "02109b68ba5cdf428ea0017fc6bcc04d4bba7368616d69725f7265636f766572795f63" - "7265617465645f6f6ed70100035d0211cb8400a97468726573686f6c6402" + "c6bcc04d4caa637265617465645f627983a474797065a455534552ac68756d616e5f68" + "616e646c6592a8626f624064657631a3626f62a7757365725f6964d802109b68ba5cdf" + "428ea0017fc6bcc04d4aaa726563697069656e74739285ac68756d616e5f68616e646c" + "6592b1616c696365406578616d706c652e636f6da5616c696365ad6f6e6c696e655f73" + "7461747573a64f4e4c494e45aa7265766f6b65645f6f6ec0a673686172657301a77573" + "65725f6964d802109b68ba5cdf428ea0017fc6bcc04d4a85ac68756d616e5f68616e64" + "6c6592af626f62406578616d706c652e636f6da3626f62ad6f6e6c696e655f73746174" + "7573a7554e4b4e4f574eaa7265766f6b65645f6f6ed70100035d162fa2e400a6736861" + "72657301a7757365725f6964d802109b68ba5cdf428ea0017fc6bcc04d4bba7368616d" + "69725f7265636f766572795f637265617465645f6f6ed70100035d0211cb8400a97468" + "726573686f6c6402" )[..], invited_cmds::invite_info::Rep::Ok( invited_cmds::invite_info::InvitationType::ShamirRecovery { claimer_user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4c").unwrap(), claimer_human_handle: HumanHandle::new("carl@example.com", "carl").unwrap(), + created_by: invited_cmds::invite_info::InvitationCreatedBy::User { + human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), + user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), + }, shamir_recovery_created_on: "2000-1-1T01:00:00Z".parse().unwrap(), recipients: vec![ invited_cmds::invite_info::ShamirRecoveryRecipient { @@ -114,12 +186,14 @@ pub fn rep_ok() { human_handle: HumanHandle::new("alice@example.com", "alice").unwrap(), shares: 1.try_into().unwrap(), revoked_on: None, + online_status: invited_cmds::invite_info::UserOnlineStatus::Online, }, invited_cmds::invite_info::ShamirRecoveryRecipient { user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4b").unwrap(), human_handle: HumanHandle::new("bob@example.com", "bob").unwrap(), shares: 1.try_into().unwrap(), revoked_on: Some("2000-1-2T01:00:00Z".parse().unwrap()), + online_status: invited_cmds::invite_info::UserOnlineStatus::Unknown, }, ], threshold: 2.try_into().unwrap(), diff --git a/libparsec/crates/protocol/tests/misc.rs b/libparsec/crates/protocol/tests/misc.rs index ed2b1ee8d40..416d443fae0 100644 --- a/libparsec/crates/protocol/tests/misc.rs +++ b/libparsec/crates/protocol/tests/misc.rs @@ -46,15 +46,14 @@ fn serde_block_read_rep(#[case] raw: &[u8], #[case] expected: authenticated_cmds // Generated from msgpack // Content: // status': 'ok' - // type: 'USER' - // claimer_email: 'a@a.com' - // greeter_user_id: ext(2, hex!("57c629b69d6c4abbaf651cafa46dbc93")) - // greeter_human_handle: 42 // Invalid field + // type: 'DEVICE' + // claimer_user_id: ext(2, hex!("57c629b69d6c4abbaf651cafa46dbc93")) + // claimer_human_handle: 42 // Invalid field // &hex!( - "85a6737461747573a26f6ba474797065a455534552ad636c61696d65725f656d61696c" - "a76140612e636f6daf677265657465725f757365725f6964d8021d3353157d7d4e95ad" - "2fdea7b3bd19c5b4677265657465725f68756d616e5f68616e646c652a" + "84a6737461747573a26f6ba474797065a6444556494345af636c61696d65725f757365" + "725f6964d8021d3353157d7d4e95ad2fdea7b3bd19c5b4636c61696d65725f68756d61" + "6e5f68616e646c652a" )[..], "invalid type: integer `42`, expected a tuple of size 2", )] diff --git a/libparsec/src/invite.rs b/libparsec/src/invite.rs index 937b41b5901..ba76e53f1b4 100644 --- a/libparsec/src/invite.rs +++ b/libparsec/src/invite.rs @@ -11,7 +11,11 @@ pub use libparsec_client::{ ShamirRecoveryClaimAddShareError, ShamirRecoveryClaimPickRecipientError, ShamirRecoveryClaimRecoverDeviceError, }; -pub use libparsec_protocol::invited_cmds::latest::invite_info::ShamirRecoveryRecipient; +pub use libparsec_protocol::authenticated_cmds::latest::invite_list::InvitationCreatedBy as InviteListInvitationCreatedBy; +pub use libparsec_protocol::invited_cmds::latest::invite_info::InvitationCreatedBy as InviteInfoInvitationCreatedBy; +pub use libparsec_protocol::invited_cmds::latest::invite_info::{ + ShamirRecoveryRecipient, UserOnlineStatus, +}; pub use libparsec_types::prelude::*; use crate::{ @@ -256,6 +260,7 @@ pub async fn claimer_retrieve_info( libparsec_client::AnyClaimRetrievedInfoCtx::ShamirRecovery(ctx) => { let claimer_user_id = ctx.claimer_user_id().to_owned(); let claimer_human_handle = ctx.claimer_human_handle().to_owned(); + let invitation_created_by = ctx.invitation_created_by().to_owned(); let shamir_recovery_created_on = ctx.shamir_recovery_created_on().to_owned(); let recipients = ctx.recipients().to_owned(); let threshold = ctx.threshold().to_owned(); @@ -265,6 +270,7 @@ pub async fn claimer_retrieve_info( handle, claimer_user_id, claimer_human_handle, + invitation_created_by, shamir_recovery_created_on, recipients, threshold, @@ -391,6 +397,7 @@ pub enum AnyClaimRetrievedInfo { handle: Handle, claimer_user_id: UserID, claimer_human_handle: HumanHandle, + invitation_created_by: InviteInfoInvitationCreatedBy, shamir_recovery_created_on: DateTime, recipients: Vec, threshold: NonZeroU8, @@ -1147,6 +1154,7 @@ pub enum InviteListItem { addr: ParsecInvitationAddr, token: InvitationToken, created_on: DateTime, + created_by: InviteListInvitationCreatedBy, claimer_email: String, status: InvitationStatus, }, @@ -1154,12 +1162,14 @@ pub enum InviteListItem { addr: ParsecInvitationAddr, token: InvitationToken, created_on: DateTime, + created_by: InviteListInvitationCreatedBy, status: InvitationStatus, }, ShamirRecovery { addr: ParsecInvitationAddr, token: InvitationToken, created_on: DateTime, + created_by: InviteListInvitationCreatedBy, claimer_user_id: UserID, shamir_recovery_created_on: DateTime, status: InvitationStatus, @@ -1182,6 +1192,7 @@ pub async fn client_list_invitations( libparsec_client::InviteListItem::User { claimer_email, created_on, + created_by, status, token, } => { @@ -1195,12 +1206,14 @@ pub async fn client_list_invitations( addr, claimer_email, created_on, + created_by, status, token, } } libparsec_client::InviteListItem::Device { created_on, + created_by, status, token, } => { @@ -1213,12 +1226,14 @@ pub async fn client_list_invitations( InviteListItem::Device { addr, created_on, + created_by, status, token, } } libparsec_client::InviteListItem::ShamirRecovery { created_on, + created_by, status, token, claimer_user_id, @@ -1233,6 +1248,7 @@ pub async fn client_list_invitations( InviteListItem::ShamirRecovery { addr, created_on, + created_by, status, token, claimer_user_id, diff --git a/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v5/invite_list.pyi b/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v5/invite_list.pyi index 0334becbb45..573eb287226 100644 --- a/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v5/invite_list.pyi +++ b/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v5/invite_list.pyi @@ -4,7 +4,22 @@ from __future__ import annotations -from parsec._parsec import DateTime, InvitationStatus, InvitationToken, UserID +from parsec._parsec import DateTime, HumanHandle, InvitationStatus, InvitationToken, UserID + +class InvitationCreatedBy: + pass + +class InvitationCreatedByUser(InvitationCreatedBy): + def __init__(self, user_id: UserID, human_handle: HumanHandle) -> None: ... + @property + def human_handle(self) -> HumanHandle: ... + @property + def user_id(self) -> UserID: ... + +class InvitationCreatedByExternalService(InvitationCreatedBy): + def __init__(self, service_label: str) -> None: ... + @property + def service_label(self) -> str: ... class InviteListItem: pass @@ -14,12 +29,15 @@ class InviteListItemUser(InviteListItem): self, token: InvitationToken, created_on: DateTime, + created_by: InvitationCreatedBy, claimer_email: str, status: InvitationStatus, ) -> None: ... @property def claimer_email(self) -> str: ... @property + def created_by(self) -> InvitationCreatedBy: ... + @property def created_on(self) -> DateTime: ... @property def status(self) -> InvitationStatus: ... @@ -28,9 +46,15 @@ class InviteListItemUser(InviteListItem): class InviteListItemDevice(InviteListItem): def __init__( - self, token: InvitationToken, created_on: DateTime, status: InvitationStatus + self, + token: InvitationToken, + created_on: DateTime, + created_by: InvitationCreatedBy, + status: InvitationStatus, ) -> None: ... @property + def created_by(self) -> InvitationCreatedBy: ... + @property def created_on(self) -> DateTime: ... @property def status(self) -> InvitationStatus: ... @@ -42,6 +66,7 @@ class InviteListItemShamirRecovery(InviteListItem): self, token: InvitationToken, created_on: DateTime, + created_by: InvitationCreatedBy, claimer_user_id: UserID, shamir_recovery_created_on: DateTime, status: InvitationStatus, @@ -49,6 +74,8 @@ class InviteListItemShamirRecovery(InviteListItem): @property def claimer_user_id(self) -> UserID: ... @property + def created_by(self) -> InvitationCreatedBy: ... + @property def created_on(self) -> DateTime: ... @property def shamir_recovery_created_on(self) -> DateTime: ... diff --git a/server/parsec/_parsec_pyi/protocol/invited_cmds/v5/invite_info.pyi b/server/parsec/_parsec_pyi/protocol/invited_cmds/v5/invite_info.pyi index fecd36fa866..6d3679f05f7 100644 --- a/server/parsec/_parsec_pyi/protocol/invited_cmds/v5/invite_info.pyi +++ b/server/parsec/_parsec_pyi/protocol/invited_cmds/v5/invite_info.pyi @@ -11,27 +11,38 @@ class InvitationType: class InvitationTypeUser(InvitationType): def __init__( - self, claimer_email: str, greeter_user_id: UserID, greeter_human_handle: HumanHandle + self, + claimer_email: str, + created_by: InvitationCreatedBy, + administrators: list[UserGreetingAdministrator], ) -> None: ... @property - def claimer_email(self) -> str: ... + def administrators(self) -> list[UserGreetingAdministrator]: ... @property - def greeter_human_handle(self) -> HumanHandle: ... + def claimer_email(self) -> str: ... @property - def greeter_user_id(self) -> UserID: ... + def created_by(self) -> InvitationCreatedBy: ... class InvitationTypeDevice(InvitationType): - def __init__(self, greeter_user_id: UserID, greeter_human_handle: HumanHandle) -> None: ... + def __init__( + self, + claimer_user_id: UserID, + claimer_human_handle: HumanHandle, + created_by: InvitationCreatedBy, + ) -> None: ... @property - def greeter_human_handle(self) -> HumanHandle: ... + def claimer_human_handle(self) -> HumanHandle: ... @property - def greeter_user_id(self) -> UserID: ... + def claimer_user_id(self) -> UserID: ... + @property + def created_by(self) -> InvitationCreatedBy: ... class InvitationTypeShamirRecovery(InvitationType): def __init__( self, claimer_user_id: UserID, claimer_human_handle: HumanHandle, + created_by: InvitationCreatedBy, shamir_recovery_created_on: DateTime, threshold: int, recipients: list[ShamirRecoveryRecipient], @@ -41,19 +52,65 @@ class InvitationTypeShamirRecovery(InvitationType): @property def claimer_user_id(self) -> UserID: ... @property + def created_by(self) -> InvitationCreatedBy: ... + @property def recipients(self) -> list[ShamirRecoveryRecipient]: ... @property def shamir_recovery_created_on(self) -> DateTime: ... @property def threshold(self) -> int: ... +class UserOnlineStatus: + VALUES: tuple[UserOnlineStatus] + ONLINE: UserOnlineStatus + OFFLINE: UserOnlineStatus + UNKNOWN: UserOnlineStatus + + @classmethod + def from_str(cls, value: str) -> UserOnlineStatus: ... + @property + def str(self) -> str: ... + +class InvitationCreatedBy: + pass + +class InvitationCreatedByUser(InvitationCreatedBy): + def __init__(self, user_id: UserID, human_handle: HumanHandle) -> None: ... + @property + def human_handle(self) -> HumanHandle: ... + @property + def user_id(self) -> UserID: ... + +class InvitationCreatedByExternalService(InvitationCreatedBy): + def __init__(self, service_label: str) -> None: ... + @property + def service_label(self) -> str: ... + +class UserGreetingAdministrator: + def __init__( + self, user_id: UserID, human_handle: HumanHandle, online_status: UserOnlineStatus + ) -> None: ... + @property + def human_handle(self) -> HumanHandle: ... + @property + def online_status(self) -> UserOnlineStatus: ... + @property + def user_id(self) -> UserID: ... + class ShamirRecoveryRecipient: def __init__( - self, user_id: UserID, human_handle: HumanHandle, shares: int, revoked_on: DateTime | None + self, + user_id: UserID, + human_handle: HumanHandle, + shares: int, + revoked_on: DateTime | None, + online_status: UserOnlineStatus, ) -> None: ... @property def human_handle(self) -> HumanHandle: ... @property + def online_status(self) -> UserOnlineStatus: ... + @property def revoked_on(self) -> DateTime | None: ... @property def shares(self) -> int: ... diff --git a/server/parsec/components/invite.py b/server/parsec/components/invite.py index 3dbf5ccaf25..c72fcee3018 100644 --- a/server/parsec/components/invite.py +++ b/server/parsec/components/invite.py @@ -48,6 +48,51 @@ from parsec.types import BadOutcome, BadOutcomeEnum ShamirRecoveryRecipient: TypeAlias = invited_cmds.latest.invite_info.ShamirRecoveryRecipient +UserOnlineStatus: TypeAlias = invited_cmds.latest.invite_info.UserOnlineStatus +UserGreetingAdministrator: TypeAlias = invited_cmds.latest.invite_info.UserGreetingAdministrator +InviteInfoInvitationCreatedBy: TypeAlias = invited_cmds.latest.invite_info.InvitationCreatedBy +InviteListInvitationCreatedBy: TypeAlias = authenticated_cmds.latest.invite_list.InvitationCreatedBy + + +@dataclass(slots=True) +class InvitationCreatedBy: + def for_invite_info(self) -> InviteInfoInvitationCreatedBy: + match self: + case InvitationCreatedByUser(user_id, human_handle): + return invited_cmds.latest.invite_info.InvitationCreatedByUser( + user_id, human_handle + ) + case InvitationCreatedByExternalService(service_label): + return invited_cmds.latest.invite_info.InvitationCreatedByExternalService( + service_label + ) + case unknown: + assert False, unknown + + def for_invite_list(self) -> InviteListInvitationCreatedBy: + match self: + case InvitationCreatedByUser(user_id, human_handle): + return authenticated_cmds.latest.invite_list.InvitationCreatedByUser( + user_id, human_handle + ) + case InvitationCreatedByExternalService(service_label): + return authenticated_cmds.latest.invite_list.InvitationCreatedByExternalService( + service_label + ) + case unknown: + assert False, unknown + + +@dataclass(slots=True) +class InvitationCreatedByUser(InvitationCreatedBy): + user_id: UserID + human_handle: HumanHandle + + +@dataclass(slots=True) +class InvitationCreatedByExternalService(InvitationCreatedBy): + service_label: str + logger = get_logger() @@ -55,32 +100,33 @@ @dataclass(slots=True) class UserInvitation: TYPE = InvitationType.USER - claimer_email: str - created_by_user_id: UserID - created_by_device_id: DeviceID - created_by_human_handle: HumanHandle + created_by: InvitationCreatedBy token: InvitationToken created_on: DateTime status: InvitationStatus + # User-specific fields + claimer_email: str + administrators: list[UserGreetingAdministrator] + @dataclass(slots=True) class DeviceInvitation: TYPE = InvitationType.DEVICE - created_by_user_id: UserID - created_by_device_id: DeviceID - created_by_human_handle: HumanHandle + created_by: InvitationCreatedBy token: InvitationToken created_on: DateTime status: InvitationStatus + # Device-specific fields + claimer_user_id: UserID + claimer_human_handle: HumanHandle + @dataclass(slots=True) class ShamirRecoveryInvitation: TYPE = InvitationType.SHAMIR_RECOVERY - created_by_user_id: UserID - created_by_device_id: DeviceID - created_by_human_handle: HumanHandle + created_by: InvitationCreatedBy token: InvitationToken created_on: DateTime status: InvitationStatus @@ -934,6 +980,7 @@ async def api_invite_list( cooked = authenticated_cmds.latest.invite_list.InviteListItemUser( token=invitation.token, created_on=invitation.created_on, + created_by=invitation.created_by.for_invite_list(), claimer_email=invitation.claimer_email, status=invitation.status, ) @@ -941,12 +988,14 @@ async def api_invite_list( cooked = authenticated_cmds.latest.invite_list.InviteListItemDevice( token=invitation.token, created_on=invitation.created_on, + created_by=invitation.created_by.for_invite_list(), status=invitation.status, ) case ShamirRecoveryInvitation(): cooked = authenticated_cmds.latest.invite_list.InviteListItemShamirRecovery( token=invitation.token, created_on=invitation.created_on, + created_by=invitation.created_by.for_invite_list(), status=invitation.status, claimer_user_id=invitation.claimer_user_id, shamir_recovery_created_on=invitation.shamir_recovery_created_on, @@ -967,15 +1016,16 @@ async def api_invite_info( return invited_cmds.latest.invite_info.RepOk( invited_cmds.latest.invite_info.InvitationTypeUser( claimer_email=invitation.claimer_email, - greeter_user_id=invitation.created_by_user_id, - greeter_human_handle=invitation.created_by_human_handle, + created_by=invitation.created_by.for_invite_info(), + administrators=invitation.administrators, ) ) case DeviceInvitation() as invitation: return invited_cmds.latest.invite_info.RepOk( invited_cmds.latest.invite_info.InvitationTypeDevice( - greeter_user_id=invitation.created_by_user_id, - greeter_human_handle=invitation.created_by_human_handle, + claimer_user_id=invitation.claimer_user_id, + claimer_human_handle=invitation.claimer_human_handle, + created_by=invitation.created_by.for_invite_info(), ) ) case ShamirRecoveryInvitation() as invitation: @@ -983,6 +1033,7 @@ async def api_invite_info( invited_cmds.latest.invite_info.InvitationTypeShamirRecovery( claimer_user_id=invitation.claimer_user_id, claimer_human_handle=invitation.claimer_human_handle, + created_by=invitation.created_by.for_invite_info(), shamir_recovery_created_on=invitation.shamir_recovery_created_on, threshold=invitation.threshold, recipients=invitation.recipients, diff --git a/server/parsec/components/memory/datamodel.py b/server/parsec/components/memory/datamodel.py index c146db13480..c1504e39a55 100644 --- a/server/parsec/components/memory/datamodel.py +++ b/server/parsec/components/memory/datamodel.py @@ -44,6 +44,7 @@ VerifyKey, VlobID, ) +from parsec.components.invite import InvitationCreatedBy from parsec.components.organization import TermsOfService from parsec.components.sequester import SequesterServiceType @@ -296,14 +297,15 @@ class MemoryInvitationDeletedReason(Enum): class MemoryInvitation: token: InvitationToken type: InvitationType - created_by_user_id: UserID - created_by_device_id: DeviceID + created_by: InvitationCreatedBy # Required for when type=USER claimer_email: str | None - # Required for when type=SHAMIR_RECOVERY + # Required for when type=DEVICE or type=SHAMIR_RECOVERY claimer_user_id: UserID | None + + # Required for when type=SHAMIR_RECOVERY shamir_recovery_index: int | None created_on: DateTime diff --git a/server/parsec/components/memory/invite.py b/server/parsec/components/memory/invite.py index 3e112353203..dc3fb522ed3 100644 --- a/server/parsec/components/memory/invite.py +++ b/server/parsec/components/memory/invite.py @@ -21,6 +21,7 @@ DeviceInvitation, GreetingAttemptCancelledBadOutcome, Invitation, + InvitationCreatedByUser, InviteAsInvitedInfoBadOutcome, InviteCancelBadOutcome, InviteClaimerCancelGreetingAttemptBadOutcome, @@ -39,7 +40,9 @@ SendEmailBadOutcome, ShamirRecoveryInvitation, ShamirRecoveryRecipient, + UserGreetingAdministrator, UserInvitation, + UserOnlineStatus, ) from parsec.components.memory.datamodel import ( AdvisoryLock, @@ -106,12 +109,12 @@ def _get_shamir_recovery_invitation( human_handle=org.users[user_id].cooked.human_handle, shares=shares, revoked_on=org.users[user_id].revoked_on, + online_status=UserOnlineStatus.UNKNOWN, ) for user_id, shares in par_recipient_shares.items() ] recipients.sort(key=lambda x: x.human_handle.label) status = self._get_invitation_status(org.organization_id, invitation) - created_by_human_handle = org.users[invitation.created_by_user_id].cooked.human_handle claimer_human_handle = org.users[invitation.claimer_user_id].cooked.human_handle # Consider an active invitation as CANCELLED if the corresponding shamir recovery is deleted @@ -121,9 +124,7 @@ def _get_shamir_recovery_invitation( return ShamirRecoveryInvitation( token=invitation.token, created_on=invitation.created_on, - created_by_device_id=invitation.created_by_device_id, - created_by_user_id=invitation.created_by_user_id, - created_by_human_handle=created_by_human_handle, + created_by=invitation.created_by, status=status, claimer_user_id=invitation.claimer_user_id, claimer_human_handle=claimer_human_handle, @@ -133,6 +134,17 @@ def _get_shamir_recovery_invitation( shamir_recovery_deleted_on=shamir_recovery.deleted_on, ) + def _get_administrators(self, org: MemoryOrganization) -> list[UserGreetingAdministrator]: + return [ + UserGreetingAdministrator( + user_id=user_id, + human_handle=user.cooked.human_handle, + online_status=UserOnlineStatus.UNKNOWN, + ) + for user_id, user in org.users.items() + if user.current_profile == UserProfile.ADMIN and not user.is_revoked + ] + @override async def new_for_user( self, @@ -180,8 +192,11 @@ async def new_for_user( force_token is None and not invitation.is_deleted and invitation.type == InvitationType.USER - and invitation.created_by_user_id == author_user_id and invitation.claimer_email == claimer_email + # This allows to have multiple invitations for the same email for different administrators + # TODO: Remove this when implementing https://github.com/Scille/parsec-cloud/issues/9413 + and isinstance(invitation.created_by, InvitationCreatedByUser) + and invitation.created_by.user_id == author_user_id ): # An invitation already exists for what the user has asked for token = invitation.token @@ -189,13 +204,15 @@ async def new_for_user( else: # Must create a new invitation - token = force_token or InvitationToken.new() + created_by = InvitationCreatedByUser( + user_id=author_user_id, + human_handle=author_user.cooked.human_handle, + ) org.invitations[token] = MemoryInvitation( token=token, type=InvitationType.USER, - created_by_user_id=author_user_id, - created_by_device_id=author, + created_by=created_by, claimer_email=claimer_email, claimer_user_id=None, shamir_recovery_index=None, @@ -262,7 +279,7 @@ async def new_for_device( force_token is None and not invitation.is_deleted and invitation.type == InvitationType.DEVICE - and invitation.created_by_user_id == author_user_id + and invitation.claimer_user_id == author_user_id ): # An invitation already exists for what the user has asked for token = invitation.token @@ -270,13 +287,15 @@ async def new_for_device( else: # Must create a new invitation - token = force_token or InvitationToken.new() + created_by = InvitationCreatedByUser( + user_id=author_user_id, + human_handle=author_user.cooked.human_handle, + ) org.invitations[token] = MemoryInvitation( token=token, type=InvitationType.DEVICE, - created_by_user_id=author_user_id, - created_by_device_id=author, + created_by=created_by, claimer_user_id=author_user_id, claimer_email=None, shamir_recovery_index=None, @@ -376,11 +395,14 @@ async def new_for_shamir_recovery( # Must create a new invitation token = force_token or InvitationToken.new() + created_by = InvitationCreatedByUser( + user_id=author_user_id, + human_handle=author_user.cooked.human_handle, + ) org.invitations[token] = MemoryInvitation( token=token, type=InvitationType.SHAMIR_RECOVERY, - created_by_user_id=author_user_id, - created_by_device_id=author, + created_by=created_by, claimer_email=claimer_human_handle.email, created_on=now, claimer_user_id=claimer_user_id, @@ -484,10 +506,14 @@ async def list( for invitation in org.invitations.values(): match invitation.type: case InvitationType.USER: - # In the future, this might change to: - # if author_user.current_profile == UserProfile.ADMIN - # so that any admin can greet a user - if invitation.created_by_user_id != author_user_id: + if author_user.current_profile != UserProfile.ADMIN: + continue + # This removes the invitations created by other administrators + # TODO: Remove this when implementing https://github.com/Scille/parsec-cloud/issues/9413 + if ( + isinstance(invitation.created_by, InvitationCreatedByUser) + and invitation.created_by.user_id != author_user_id + ): continue assert invitation.claimer_email is not None status = self._get_invitation_status(organization_id, invitation) @@ -495,22 +521,23 @@ async def list( claimer_email=invitation.claimer_email, token=invitation.token, created_on=invitation.created_on, - created_by_device_id=invitation.created_by_device_id, - created_by_user_id=invitation.created_by_user_id, - # This should also change once any admin can greet a user - created_by_human_handle=author_user.cooked.human_handle, + created_by=invitation.created_by, + administrators=self._get_administrators(org), status=status, ) case InvitationType.DEVICE: - if invitation.created_by_user_id != author_user_id: + if invitation.claimer_user_id != author_user_id: continue + assert invitation.claimer_user_id is not None status = self._get_invitation_status(organization_id, invitation) item = DeviceInvitation( token=invitation.token, created_on=invitation.created_on, - created_by_device_id=invitation.created_by_device_id, - created_by_user_id=invitation.created_by_user_id, - created_by_human_handle=author_user.cooked.human_handle, + created_by=invitation.created_by, + claimer_user_id=invitation.claimer_user_id, + claimer_human_handle=org.users[ + invitation.claimer_user_id + ].cooked.human_handle, status=status, ) case InvitationType.SHAMIR_RECOVERY: @@ -549,7 +576,6 @@ async def info_as_invited( return InviteAsInvitedInfoBadOutcome.INVITATION_NOT_FOUND if invitation.is_deleted: return InviteAsInvitedInfoBadOutcome.INVITATION_DELETED - created_by_human_handle = org.users[invitation.created_by_user_id].cooked.human_handle match invitation.type: case InvitationType.USER: @@ -558,18 +584,18 @@ async def info_as_invited( claimer_email=invitation.claimer_email, created_on=invitation.created_on, status=self._get_invitation_status(organization_id, invitation), - created_by_user_id=invitation.created_by_user_id, - created_by_device_id=invitation.created_by_device_id, - created_by_human_handle=created_by_human_handle, + created_by=invitation.created_by, token=invitation.token, + administrators=self._get_administrators(org), ) case InvitationType.DEVICE: + assert invitation.claimer_user_id is not None return DeviceInvitation( created_on=invitation.created_on, status=self._get_invitation_status(organization_id, invitation), - created_by_user_id=invitation.created_by_user_id, - created_by_device_id=invitation.created_by_device_id, - created_by_human_handle=created_by_human_handle, + created_by=invitation.created_by, + claimer_user_id=invitation.claimer_user_id, + claimer_human_handle=org.users[invitation.claimer_user_id].cooked.human_handle, token=invitation.token, ) case InvitationType.SHAMIR_RECOVERY: @@ -627,12 +653,15 @@ async def test_dump_all_invitations( org = self._data.organizations[organization_id] per_user_invitations = {} for invitation in org.invitations.values(): + # TODO: Update method to also return invitation created by external services + if not isinstance(invitation.created_by, InvitationCreatedByUser): + continue try: - current_user_invitations = per_user_invitations[invitation.created_by_user_id] + current_user_invitations = per_user_invitations[invitation.created_by.user_id] except KeyError: current_user_invitations = [] - per_user_invitations[invitation.created_by_user_id] = current_user_invitations - created_by_human_handle = org.users[invitation.created_by_user_id].cooked.human_handle + per_user_invitations[invitation.created_by.user_id] = current_user_invitations + match invitation.type: case InvitationType.USER: assert invitation.claimer_email is not None @@ -641,21 +670,23 @@ async def test_dump_all_invitations( claimer_email=invitation.claimer_email, created_on=invitation.created_on, status=self._get_invitation_status(organization_id, invitation), - created_by_user_id=invitation.created_by_user_id, - created_by_device_id=invitation.created_by_device_id, - created_by_human_handle=created_by_human_handle, + created_by=invitation.created_by, token=invitation.token, + administrators=self._get_administrators(org), ) ) case InvitationType.DEVICE: + assert invitation.claimer_user_id is not None current_user_invitations.append( DeviceInvitation( created_on=invitation.created_on, status=self._get_invitation_status(organization_id, invitation), - created_by_user_id=invitation.created_by_user_id, - created_by_device_id=invitation.created_by_device_id, - created_by_human_handle=created_by_human_handle, + created_by=invitation.created_by, token=invitation.token, + claimer_user_id=invitation.claimer_user_id, + claimer_human_handle=org.users[ + invitation.claimer_user_id + ].cooked.human_handle, ) ) case InvitationType.SHAMIR_RECOVERY: @@ -674,7 +705,8 @@ def is_greeter_allowed( self, org: MemoryOrganization, invitation: MemoryInvitation, greeter: MemoryUser ) -> bool: if invitation.type == InvitationType.DEVICE: - return invitation.created_by_user_id == greeter.cooked.user_id + assert invitation.claimer_user_id is not None + return invitation.claimer_user_id == greeter.cooked.user_id elif invitation.type == InvitationType.USER: return greeter.current_profile == UserProfile.ADMIN elif invitation.type == InvitationType.SHAMIR_RECOVERY: diff --git a/server/parsec/components/postgresql/invite.py b/server/parsec/components/postgresql/invite.py index 61ceceff3fa..6651cc41fdf 100644 --- a/server/parsec/components/postgresql/invite.py +++ b/server/parsec/components/postgresql/invite.py @@ -29,6 +29,7 @@ DeviceInvitation, GreetingAttemptCancelledBadOutcome, Invitation, + InvitationCreatedByUser, InviteAsInvitedInfoBadOutcome, InviteCancelBadOutcome, InviteClaimerCancelGreetingAttemptBadOutcome, @@ -46,7 +47,9 @@ NotReady, SendEmailBadOutcome, ShamirRecoveryInvitation, + UserGreetingAdministrator, UserInvitation, + UserOnlineStatus, ) from parsec.components.organization import Organization, OrganizationGetBadOutcome from parsec.components.postgresql import AsyncpgConnection, AsyncpgPool @@ -55,7 +58,6 @@ from parsec.components.postgresql.user import PGUserComponent, UserInfo from parsec.components.postgresql.utils import ( Q, - q_device, q_device_internal_id, q_organization_internal_id, q_user, @@ -75,7 +77,6 @@ class InvitationInfo: token: InvitationToken type: InvitationType created_by_user_id: UserID - created_by_device_id: DeviceID created_by_email: str created_by_label: str claimer_email: str | None @@ -101,7 +102,6 @@ def from_record(cls, record: Record) -> InvitationInfo: token, type, created_by_user_id_str, - created_by_device_id_str, created_by_email, created_by_label, claimer_email, @@ -125,7 +125,6 @@ def from_record(cls, record: Record) -> InvitationInfo: token=InvitationToken.from_hex(token), type=InvitationType.from_str(type), created_by_user_id=UserID.from_hex(created_by_user_id_str), - created_by_device_id=DeviceID.from_hex(created_by_device_id_str), created_by_email=created_by_email, created_by_label=created_by_label, claimer_email=claimer_email, @@ -382,7 +381,6 @@ class ShamirRecoverySetupInfo: invitation.token, invitation.type, user_.user_id AS created_by_user_id, - device.device_id AS created_by_device_id, human.email, human.label, invitation.claimer_email, @@ -417,7 +415,6 @@ class ShamirRecoverySetupInfo: invitation.token, invitation.type, user_.user_id AS created_by_user_id, - device.device_id as created_by_device_id, human.email as created_by_email, human.label as created_by_label, invitation.claimer_email, @@ -470,8 +467,7 @@ def make_q_info_invitation( invitation._id AS invitation_internal_id, invitation.token, invitation.type, - { q_user(_id=q_device(_id="invitation.created_by", select="user_"), select="user_id") } as created_by_user_id, - { q_device(_id="invitation.created_by", select="device_id") } as created_by_device_id, + user_.user_id as created_by_user_id, human.email, human.label, invitation.claimer_email, @@ -483,7 +479,8 @@ def make_q_info_invitation( FROM invitation INNER JOIN selected_invitation ON invitation._id = selected_invitation.invitation_internal_id INNER JOIN device ON invitation.created_by = device._id - INNER JOIN human ON human._id = (SELECT user_.human FROM user_ WHERE user_._id = device.user_) + INNER JOIN user_ ON device.user_ = user_._id + INNER JOIN human ON human._id = user_.human LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id """) @@ -754,6 +751,23 @@ async def q_take_invitation_create_write_lock( ) +_q_list_administrators = Q( + """ +SELECT + user_.user_id, + human.email, + human.label +FROM user_ +INNER JOIN human ON user_.human = human._id +INNER JOIN organization ON user_.organization = organization._id +WHERE + organization.organization_id = $organization_id + AND user_.current_profile = 'ADMIN' + AND user_.revoked_on IS NULL +""" +) + + async def query_retrieve_active_human_by_email( conn: AsyncpgConnection, organization_id: OrganizationID, email: str ) -> UserID | None: @@ -783,6 +797,8 @@ async def _do_new_invitation( match invitation_type: case InvitationType.USER: assert claimer_email is not None + # This request allows to have multiple invitations for the same email for different administrators + # TODO: Update this when implementing https://github.com/Scille/parsec-cloud/issues/9413 q = _q_retrieve_compatible_user_invitation( organization_id=organization_id.str, type=invitation_type.str, @@ -1137,6 +1153,7 @@ async def _get_shamir_recovery_info( human_handle=HumanHandle(email=row["email"], label=row["label"]), shares=row["shares"], revoked_on=row["revoked_on"], + online_status=UserOnlineStatus.UNKNOWN, ) for row in rows ] @@ -1151,6 +1168,34 @@ async def _get_shamir_recovery_info( deleted_on=deleted_on, ) + async def _get_administrators( + self, conn: AsyncpgConnection, organization_id: OrganizationID + ) -> list[UserGreetingAdministrator]: + administrators = [] + rows = await conn.fetch(*_q_list_administrators(organization_id=organization_id.str)) + for row in rows: + match row["user_id"]: + case str() as raw_user_id: + user_id = UserID.from_hex(raw_user_id) + case unknown: + assert False, repr(unknown) + + match (row["email"], row["label"]): + case (str() as raw_email, str() as raw_label): + human_handle = HumanHandle(email=raw_email, label=raw_label) + case unknown: + assert False, repr(unknown) + + administrators.append( + UserGreetingAdministrator( + user_id=user_id, + human_handle=human_handle, + online_status=UserOnlineStatus.UNKNOWN, + ) + ) + + return administrators + @override @transaction async def cancel( @@ -1225,6 +1270,8 @@ async def list( case CheckDeviceBadOutcome.USER_REVOKED: return InviteListBadOutcome.AUTHOR_REVOKED + # This request does not select the user invitations created by other administrators + # TODO: Update this when implementing https://github.com/Scille/parsec-cloud/issues/9413 rows = await conn.fetch( *_q_list_invitations(organization_id=organization_id.str, user_id=author_user_id) ) @@ -1246,20 +1293,29 @@ async def list( match invitation_info.type: case InvitationType.USER: assert invitation_info.claimer_email is not None + # Note that the `administrators` field is actually not used in the context of the `invite_list` command. + # Still, we compute it here for consistency. In order to save this unnecessary query to the database, + # we could update the invite API to take this difference into account. + administrators = await self._get_administrators(conn, organization_id) invitation = UserInvitation( - created_by_user_id=invitation_info.created_by_user_id, - created_by_device_id=invitation_info.created_by_device_id, - created_by_human_handle=invitation_info.created_by_human_handle, + created_by=InvitationCreatedByUser( + user_id=invitation_info.created_by_user_id, + human_handle=invitation_info.created_by_human_handle, + ), claimer_email=invitation_info.claimer_email, token=invitation_info.token, created_on=invitation_info.created_on, + administrators=administrators, status=status, ) case InvitationType.DEVICE: invitation = DeviceInvitation( - created_by_user_id=invitation_info.created_by_user_id, - created_by_device_id=invitation_info.created_by_device_id, - created_by_human_handle=invitation_info.created_by_human_handle, + claimer_user_id=invitation_info.created_by_user_id, + claimer_human_handle=invitation_info.created_by_human_handle, + created_by=InvitationCreatedByUser( + user_id=invitation_info.created_by_user_id, + human_handle=invitation_info.created_by_human_handle, + ), token=invitation_info.token, created_on=invitation_info.created_on, status=status, @@ -1271,9 +1327,10 @@ async def list( ) invitation = ShamirRecoveryInvitation( - created_by_user_id=invitation_info.created_by_user_id, - created_by_device_id=invitation_info.created_by_device_id, - created_by_human_handle=invitation_info.created_by_human_handle, + created_by=InvitationCreatedByUser( + user_id=invitation_info.created_by_user_id, + human_handle=invitation_info.created_by_human_handle, + ), token=invitation_info.token, created_on=invitation_info.created_on, status=status, @@ -1309,26 +1366,29 @@ async def _info_as_invited( if invitation_info.deleted_on: return InviteAsInvitedInfoBadOutcome.INVITATION_DELETED - greeter_human_handle = HumanHandle( - email=invitation_info.created_by_email, label=invitation_info.created_by_label - ) match invitation_info.type: case InvitationType.USER: assert invitation_info.claimer_email is not None + administrators = await self._get_administrators(conn, organization_id) return UserInvitation( - created_by_user_id=invitation_info.created_by_user_id, - created_by_device_id=invitation_info.created_by_device_id, - created_by_human_handle=greeter_human_handle, + created_by=InvitationCreatedByUser( + user_id=invitation_info.created_by_user_id, + human_handle=invitation_info.created_by_human_handle, + ), claimer_email=invitation_info.claimer_email, token=token, created_on=invitation_info.created_on, + administrators=administrators, status=InvitationStatus.READY, ) case InvitationType.DEVICE: return DeviceInvitation( - created_by_user_id=invitation_info.created_by_user_id, - created_by_device_id=invitation_info.created_by_device_id, - created_by_human_handle=greeter_human_handle, + claimer_user_id=invitation_info.created_by_user_id, + claimer_human_handle=invitation_info.created_by_human_handle, + created_by=InvitationCreatedByUser( + user_id=invitation_info.created_by_user_id, + human_handle=invitation_info.created_by_human_handle, + ), token=token, created_on=invitation_info.created_on, status=InvitationStatus.READY, @@ -1339,9 +1399,10 @@ async def _info_as_invited( conn, invitation_info.shamir_recovery_setup_internal_id ) return ShamirRecoveryInvitation( - created_by_user_id=invitation_info.created_by_user_id, - created_by_device_id=invitation_info.created_by_device_id, - created_by_human_handle=greeter_human_handle, + created_by=InvitationCreatedByUser( + user_id=invitation_info.created_by_user_id, + human_handle=invitation_info.created_by_human_handle, + ), token=token, created_on=invitation_info.created_on, status=InvitationStatus.READY, @@ -1449,14 +1510,17 @@ async def test_dump_all_invitations( match invitation_info.type: case InvitationType.USER: assert invitation_info.claimer_email is not None + administrators = await self._get_administrators(conn, organization_id) current_user_invitations.append( UserInvitation( claimer_email=invitation_info.claimer_email, created_on=invitation_info.created_on, status=status, - created_by_user_id=invitation_info.created_by_user_id, - created_by_device_id=invitation_info.created_by_device_id, - created_by_human_handle=invitation_info.created_by_human_handle, + created_by=InvitationCreatedByUser( + user_id=invitation_info.created_by_user_id, + human_handle=invitation_info.created_by_human_handle, + ), + administrators=administrators, token=invitation_info.token, ) ) @@ -1464,10 +1528,13 @@ async def test_dump_all_invitations( current_user_invitations.append( DeviceInvitation( created_on=invitation_info.created_on, + created_by=InvitationCreatedByUser( + user_id=invitation_info.created_by_user_id, + human_handle=invitation_info.created_by_human_handle, + ), status=status, - created_by_user_id=invitation_info.created_by_user_id, - created_by_device_id=invitation_info.created_by_device_id, - created_by_human_handle=invitation_info.created_by_human_handle, + claimer_human_handle=invitation_info.created_by_human_handle, + claimer_user_id=invitation_info.created_by_user_id, token=invitation_info.token, ) ) @@ -1480,9 +1547,10 @@ async def test_dump_all_invitations( ShamirRecoveryInvitation( created_on=invitation_info.created_on, status=status, - created_by_user_id=invitation_info.created_by_user_id, - created_by_device_id=invitation_info.created_by_device_id, - created_by_human_handle=invitation_info.created_by_human_handle, + created_by=InvitationCreatedByUser( + user_id=invitation_info.created_by_user_id, + human_handle=invitation_info.created_by_human_handle, + ), token=invitation_info.token, claimer_human_handle=shamir_recovery_info.claimer_human_handle, claimer_user_id=shamir_recovery_info.claimer_user_id, diff --git a/server/tests/api_v5/authenticated/test_invite_list.py b/server/tests/api_v5/authenticated/test_invite_list.py index d36dd0dd120..1620a5753dd 100644 --- a/server/tests/api_v5/authenticated/test_invite_list.py +++ b/server/tests/api_v5/authenticated/test_invite_list.py @@ -23,6 +23,10 @@ async def test_authenticated_invite_list_ok_with_shamir_recovery( expected_invitations = [ authenticated_cmds.latest.invite_list.InviteListItemShamirRecovery( created_on=shamirorg.shamir_invited_alice.event.created_on, + created_by=authenticated_cmds.latest.invite_list.InvitationCreatedByUser( + user_id=shamirorg.bob.user_id, + human_handle=shamirorg.bob.human_handle, + ), status=InvitationStatus.IDLE, claimer_user_id=shamirorg.alice.user_id, shamir_recovery_created_on=shamirorg.alice_brief_certificate.timestamp, @@ -64,6 +68,10 @@ async def test_authenticated_invite_list_ok( expected_invitations.append( authenticated_cmds.latest.invite_list.InviteListItemDevice( created_on=t1, + created_by=authenticated_cmds.latest.invite_list.InvitationCreatedByUser( + user_id=minimalorg.alice.user_id, + human_handle=minimalorg.alice.human_handle, + ), status=InvitationStatus.IDLE, token=outcome[0], ) @@ -82,6 +90,10 @@ async def test_authenticated_invite_list_ok( expected_invitations.append( authenticated_cmds.latest.invite_list.InviteListItemUser( created_on=t2, + created_by=authenticated_cmds.latest.invite_list.InvitationCreatedByUser( + user_id=minimalorg.alice.user_id, + human_handle=minimalorg.alice.human_handle, + ), status=InvitationStatus.IDLE, claimer_email="zack@example.invalid", token=outcome[0], @@ -128,6 +140,10 @@ async def test_authenticated_invite_list_ok( expected_invitations.append( authenticated_cmds.latest.invite_list.InviteListItemUser( created_on=t4, + created_by=authenticated_cmds.latest.invite_list.InvitationCreatedByUser( + user_id=minimalorg.alice.user_id, + human_handle=minimalorg.alice.human_handle, + ), status=InvitationStatus.CANCELLED, claimer_email="deleted@example.invalid", token=outcome[0], @@ -168,6 +184,10 @@ async def test_authenticated_invite_list_with_deleted_shamir( expected = authenticated_cmds.latest.invite_list.InviteListItemShamirRecovery( token=previous_invitation.token, created_on=previous_invitation.created_on, + created_by=authenticated_cmds.latest.invite_list.InvitationCreatedByUser( + user_id=shamirorg.bob.user_id, + human_handle=shamirorg.bob.human_handle, + ), claimer_user_id=previous_invitation.claimer_user_id, shamir_recovery_created_on=previous_invitation.shamir_recovery_created_on, status=InvitationStatus.CANCELLED, diff --git a/server/tests/api_v5/authenticated/test_invite_new_device.py b/server/tests/api_v5/authenticated/test_invite_new_device.py index a80dc17c56a..1eda0786c88 100644 --- a/server/tests/api_v5/authenticated/test_invite_new_device.py +++ b/server/tests/api_v5/authenticated/test_invite_new_device.py @@ -5,7 +5,7 @@ import pytest from parsec._parsec import DateTime, InvitationStatus, authenticated_cmds -from parsec.components.invite import DeviceInvitation, SendEmailBadOutcome +from parsec.components.invite import DeviceInvitation, InvitationCreatedByUser, SendEmailBadOutcome from parsec.events import EventInvitation from tests.common import Backend, CoolorgRpcClients, HttpCommonErrorsTester, MinimalorgRpcClients @@ -42,9 +42,11 @@ async def test_authenticated_invite_new_device_ok_new( DeviceInvitation( token=invitation_token, created_on=ANY, - created_by_device_id=minimalorg.alice.device_id, - created_by_user_id=minimalorg.alice.user_id, - created_by_human_handle=minimalorg.alice.human_handle, + created_by=InvitationCreatedByUser( + user_id=minimalorg.alice.user_id, human_handle=minimalorg.alice.human_handle + ), + claimer_user_id=minimalorg.alice.user_id, + claimer_human_handle=minimalorg.alice.human_handle, status=InvitationStatus.IDLE, ) ] @@ -154,9 +156,11 @@ async def _mocked_send_email(*args, **kwargs): DeviceInvitation( token=invitation_token, created_on=ANY, - created_by_device_id=minimalorg.alice.device_id, - created_by_user_id=minimalorg.alice.user_id, - created_by_human_handle=minimalorg.alice.human_handle, + created_by=InvitationCreatedByUser( + user_id=minimalorg.alice.user_id, human_handle=minimalorg.alice.human_handle + ), + claimer_user_id=minimalorg.alice.user_id, + claimer_human_handle=minimalorg.alice.human_handle, status=InvitationStatus.IDLE, ) ] diff --git a/server/tests/api_v5/authenticated/test_invite_new_shamir_recovery.py b/server/tests/api_v5/authenticated/test_invite_new_shamir_recovery.py index 906c1411058..f2f8c63a584 100644 --- a/server/tests/api_v5/authenticated/test_invite_new_shamir_recovery.py +++ b/server/tests/api_v5/authenticated/test_invite_new_shamir_recovery.py @@ -10,9 +10,11 @@ authenticated_cmds, ) from parsec.components.invite import ( + InvitationCreatedByUser, SendEmailBadOutcome, ShamirRecoveryInvitation, ShamirRecoveryRecipient, + UserOnlineStatus, ) from parsec.events import EventInvitation from tests.common import Backend, CoolorgRpcClients, HttpCommonErrorsTester, ShamirOrgRpcClients @@ -51,9 +53,9 @@ async def test_authenticated_invite_new_shamir_recovery_ok_new( ShamirRecoveryInvitation( token=invitation_token, created_on=ANY, - created_by_device_id=shamirorg.mike.device_id, - created_by_user_id=shamirorg.mike.user_id, - created_by_human_handle=shamirorg.mike.human_handle, + created_by=InvitationCreatedByUser( + user_id=shamirorg.mike.user_id, human_handle=shamirorg.mike.human_handle + ), status=InvitationStatus.IDLE, threshold=1, claimer_user_id=shamirorg.mallory.user_id, @@ -64,6 +66,7 @@ async def test_authenticated_invite_new_shamir_recovery_ok_new( human_handle=shamirorg.mike.human_handle, shares=1, revoked_on=None, + online_status=UserOnlineStatus.UNKNOWN, ), ], shamir_recovery_created_on=shamirorg.mallory_brief_certificate.timestamp, @@ -184,9 +187,9 @@ async def _mocked_send_email(*args, **kwargs): ShamirRecoveryInvitation( token=invitation_token, created_on=ANY, - created_by_device_id=shamirorg.mike.device_id, - created_by_user_id=shamirorg.mike.user_id, - created_by_human_handle=shamirorg.mike.human_handle, + created_by=InvitationCreatedByUser( + user_id=shamirorg.mike.user_id, human_handle=shamirorg.mike.human_handle + ), status=InvitationStatus.IDLE, threshold=1, claimer_user_id=shamirorg.mallory.user_id, @@ -197,6 +200,7 @@ async def _mocked_send_email(*args, **kwargs): human_handle=shamirorg.mike.human_handle, shares=1, revoked_on=None, + online_status=UserOnlineStatus.UNKNOWN, ), ], shamir_recovery_created_on=shamirorg.mallory_brief_certificate.timestamp, diff --git a/server/tests/api_v5/authenticated/test_invite_new_user.py b/server/tests/api_v5/authenticated/test_invite_new_user.py index e9468d6474e..8798a9b01b1 100644 --- a/server/tests/api_v5/authenticated/test_invite_new_user.py +++ b/server/tests/api_v5/authenticated/test_invite_new_user.py @@ -5,7 +5,13 @@ import pytest from parsec._parsec import DateTime, InvitationStatus, UserProfile, authenticated_cmds -from parsec.components.invite import SendEmailBadOutcome, UserInvitation +from parsec.components.invite import ( + InvitationCreatedByUser, + SendEmailBadOutcome, + UserGreetingAdministrator, + UserInvitation, + UserOnlineStatus, +) from parsec.events import EventInvitation from tests.common import ( Backend, @@ -47,9 +53,16 @@ async def test_authenticated_invite_new_user_ok_new( token=invitation_token, created_on=ANY, claimer_email="new@example.invalid", - created_by_device_id=minimalorg.alice.device_id, - created_by_user_id=minimalorg.alice.user_id, - created_by_human_handle=minimalorg.alice.human_handle, + created_by=InvitationCreatedByUser( + user_id=minimalorg.alice.user_id, human_handle=minimalorg.alice.human_handle + ), + administrators=[ + UserGreetingAdministrator( + minimalorg.alice.user_id, + minimalorg.alice.human_handle, + UserOnlineStatus.UNKNOWN, + ) + ], status=InvitationStatus.IDLE, ) ] @@ -197,9 +210,16 @@ async def _mocked_send_email(*args, **kwargs): token=invitation_token, created_on=ANY, claimer_email="new@example.invalid", - created_by_device_id=minimalorg.alice.device_id, - created_by_user_id=minimalorg.alice.user_id, - created_by_human_handle=minimalorg.alice.human_handle, + created_by=InvitationCreatedByUser( + user_id=minimalorg.alice.user_id, human_handle=minimalorg.alice.human_handle + ), + administrators=[ + UserGreetingAdministrator( + minimalorg.alice.user_id, + minimalorg.alice.human_handle, + UserOnlineStatus.UNKNOWN, + ) + ], status=InvitationStatus.IDLE, ) ] diff --git a/server/tests/api_v5/invited/test_invite_info.py b/server/tests/api_v5/invited/test_invite_info.py index 09531cc2a53..ac52322c3d1 100644 --- a/server/tests/api_v5/invited/test_invite_info.py +++ b/server/tests/api_v5/invited/test_invite_info.py @@ -3,6 +3,11 @@ import pytest from parsec._parsec import DateTime, RevokedUserCertificate, invited_cmds +from parsec.components.invite import ( + InvitationCreatedByUser, + UserGreetingAdministrator, + UserOnlineStatus, +) from tests.common import Backend, CoolorgRpcClients, HttpCommonErrorsTester, ShamirOrgRpcClients @@ -14,8 +19,17 @@ async def test_invited_invite_info_ok(user_or_device: str, coolorg: CoolorgRpcCl assert rep == invited_cmds.latest.invite_info.RepOk( invited_cmds.latest.invite_info.InvitationTypeUser( claimer_email=coolorg.invited_zack.claimer_email, - greeter_user_id=coolorg.alice.user_id, - greeter_human_handle=coolorg.alice.human_handle, + created_by=InvitationCreatedByUser( + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + ).for_invite_info(), + administrators=[ + UserGreetingAdministrator( + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + online_status=UserOnlineStatus.UNKNOWN, + ), + ], ) ) @@ -23,8 +37,12 @@ async def test_invited_invite_info_ok(user_or_device: str, coolorg: CoolorgRpcCl rep = await coolorg.invited_alice_dev3.invite_info() assert rep == invited_cmds.latest.invite_info.RepOk( invited_cmds.latest.invite_info.InvitationTypeDevice( - greeter_user_id=coolorg.alice.user_id, - greeter_human_handle=coolorg.alice.human_handle, + claimer_user_id=coolorg.alice.user_id, + claimer_human_handle=coolorg.alice.human_handle, + created_by=InvitationCreatedByUser( + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + ).for_invite_info(), ) ) @@ -56,6 +74,10 @@ async def test_invited_invite_info_ok_with_shamir( invited_cmds.latest.invite_info.InvitationTypeShamirRecovery( claimer_user_id=shamirorg.alice.user_id, claimer_human_handle=shamirorg.alice.human_handle, + created_by=InvitationCreatedByUser( + user_id=shamirorg.bob.user_id, + human_handle=shamirorg.bob.human_handle, + ).for_invite_info(), shamir_recovery_created_on=shamirorg.alice_brief_certificate.timestamp, threshold=2, recipients=[ @@ -64,18 +86,21 @@ async def test_invited_invite_info_ok_with_shamir( human_handle=shamirorg.bob.human_handle, shares=2, revoked_on=None, + online_status=UserOnlineStatus.UNKNOWN, ), invited_cmds.latest.invite_info.ShamirRecoveryRecipient( user_id=shamirorg.mallory.user_id, human_handle=shamirorg.mallory.human_handle, shares=1, revoked_on=now, + online_status=UserOnlineStatus.UNKNOWN, ), invited_cmds.latest.invite_info.ShamirRecoveryRecipient( user_id=shamirorg.mike.user_id, human_handle=shamirorg.mike.human_handle, shares=1, revoked_on=None, + online_status=UserOnlineStatus.UNKNOWN, ), ], ) From 87fd6c623302f197d7ce96d15e8379f64fdf881f Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Thu, 23 Jan 2025 13:27:07 +0100 Subject: [PATCH 02/11] Add created_by_service_label to invitation table in postgresql model --- server/parsec/components/invite.py | 10 +- server/parsec/components/postgresql/invite.py | 209 +++++++++++------- .../0009_external_service_for_invitation.sql | 26 +++ .../postgresql/migrations/datamodel.sql | 9 +- .../components/postgresql/test_queries.py | 11 +- 5 files changed, 182 insertions(+), 83 deletions(-) create mode 100644 server/parsec/components/postgresql/migrations/0009_external_service_for_invitation.sql diff --git a/server/parsec/components/invite.py b/server/parsec/components/invite.py index c72fcee3018..1d165c4a709 100644 --- a/server/parsec/components/invite.py +++ b/server/parsec/components/invite.py @@ -47,6 +47,8 @@ from parsec.templates import get_template from parsec.types import BadOutcome, BadOutcomeEnum +logger = get_logger() + ShamirRecoveryRecipient: TypeAlias = invited_cmds.latest.invite_info.ShamirRecoveryRecipient UserOnlineStatus: TypeAlias = invited_cmds.latest.invite_info.UserOnlineStatus UserGreetingAdministrator: TypeAlias = invited_cmds.latest.invite_info.UserGreetingAdministrator @@ -55,7 +57,7 @@ @dataclass(slots=True) -class InvitationCreatedBy: +class _InvitationCreatedBy: def for_invite_info(self) -> InviteInfoInvitationCreatedBy: match self: case InvitationCreatedByUser(user_id, human_handle): @@ -84,17 +86,17 @@ def for_invite_list(self) -> InviteListInvitationCreatedBy: @dataclass(slots=True) -class InvitationCreatedByUser(InvitationCreatedBy): +class InvitationCreatedByUser(_InvitationCreatedBy): user_id: UserID human_handle: HumanHandle @dataclass(slots=True) -class InvitationCreatedByExternalService(InvitationCreatedBy): +class InvitationCreatedByExternalService(_InvitationCreatedBy): service_label: str -logger = get_logger() +InvitationCreatedBy: TypeAlias = InvitationCreatedByUser | InvitationCreatedByExternalService @dataclass(slots=True) diff --git a/server/parsec/components/postgresql/invite.py b/server/parsec/components/postgresql/invite.py index 6651cc41fdf..7526f372bda 100644 --- a/server/parsec/components/postgresql/invite.py +++ b/server/parsec/components/postgresql/invite.py @@ -29,6 +29,8 @@ DeviceInvitation, GreetingAttemptCancelledBadOutcome, Invitation, + InvitationCreatedBy, + InvitationCreatedByExternalService, InvitationCreatedByUser, InviteAsInvitedInfoBadOutcome, InviteCancelBadOutcome, @@ -60,7 +62,6 @@ Q, q_device_internal_id, q_organization_internal_id, - q_user, q_user_internal_id, transaction, ) @@ -76,18 +77,22 @@ class InvitationInfo: internal_id: int token: InvitationToken type: InvitationType - created_by_user_id: UserID - created_by_email: str - created_by_label: str - claimer_email: str | None - shamir_recovery_setup_internal_id: int | None created_on: DateTime deleted_on: DateTime | None deleted_reason: InvitationStatus | None - @property - def created_by_human_handle(self) -> HumanHandle: - return HumanHandle(email=self.created_by_email, label=self.created_by_label) + # Available if the invitation was created by a user + created_by: InvitationCreatedBy + + # Available if the invitation is a user invitation + claimer_email: str | None + + # Available if the invitation is a device invitation + claimer_user_id: UserID | None + claimer_human_handle: HumanHandle | None + + # Available if the invitation is a shamir recovery invitation + shamir_recovery_setup_internal_id: int | None def is_finished(self) -> bool: return self.deleted_reason == InvitationStatus.FINISHED @@ -99,18 +104,65 @@ def is_cancelled(self) -> bool: def from_record(cls, record: Record) -> InvitationInfo: ( invitation_internal_id, - token, - type, + raw_token, + raw_type, created_by_user_id_str, created_by_email, created_by_label, + created_by_service_label, claimer_email, + claimer_user_id_str, + claimer_human_email, + claimer_human_label, shamir_recovery_setup, shamir_recovery_setup_deleted_on, created_on, deleted_on, deleted_reason, ) = record + + type = InvitationType.from_str(raw_type) + token = InvitationToken.from_hex(raw_token) + + if created_by_user_id_str is None: + assert created_by_service_label is not None + created_by = InvitationCreatedByExternalService(service_label=created_by_service_label) + else: + assert created_by_email is not None + assert created_by_label is not None + created_by_user_id = UserID.from_hex(created_by_user_id_str) + human_handle = HumanHandle(email=created_by_email, label=created_by_label) + created_by = InvitationCreatedByUser( + user_id=created_by_user_id, human_handle=human_handle + ) + + if type == InvitationType.USER: + assert claimer_email is not None + assert claimer_user_id_str is None + assert claimer_human_label is None + assert claimer_human_email is None + assert shamir_recovery_setup is None + claimer_user_id = None + claimer_human_handle = None + elif type == InvitationType.DEVICE: + assert claimer_email is None + assert claimer_user_id_str is not None + assert claimer_human_label is not None + assert claimer_human_email is not None + assert shamir_recovery_setup is None + claimer_user_id = UserID.from_hex(claimer_user_id_str) + claimer_human_handle = HumanHandle(email=claimer_human_email, label=claimer_human_label) + elif type == InvitationType.SHAMIR_RECOVERY: + assert claimer_email is not None + assert claimer_user_id_str is None + assert claimer_human_label is None + assert claimer_human_email is None + assert shamir_recovery_setup is not None + claimer_user_id = None + claimer_human_handle = None + else: + assert False, type + deleted_reason = ( InvitationStatus.from_str(deleted_reason) if deleted_reason is not None else None ) @@ -122,12 +174,12 @@ def from_record(cls, record: Record) -> InvitationInfo: return cls( internal_id=invitation_internal_id, - token=InvitationToken.from_hex(token), - type=InvitationType.from_str(type), - created_by_user_id=UserID.from_hex(created_by_user_id_str), - created_by_email=created_by_email, - created_by_label=created_by_label, + type=type, + token=token, + created_by=created_by, claimer_email=claimer_email, + claimer_user_id=claimer_user_id, + claimer_human_handle=claimer_human_handle, shamir_recovery_setup_internal_id=shamir_recovery_setup, created_on=created_on, deleted_on=deleted_on, @@ -245,7 +297,7 @@ class ShamirRecoverySetupInfo: SELECT token FROM invitation -LEFT JOIN device ON invitation.created_by = device._id +LEFT JOIN device ON invitation.created_by_device = device._id WHERE invitation.organization = { q_organization_internal_id("$organization_id") } AND type = $type @@ -275,7 +327,7 @@ class ShamirRecoverySetupInfo: SELECT token FROM invitation -INNER JOIN device ON invitation.created_by = device._id +INNER JOIN device ON invitation.created_by_device = device._id WHERE invitation.organization = { q_organization_internal_id("$organization_id") } AND type = $type @@ -292,11 +344,9 @@ class ShamirRecoverySetupInfo: SELECT token FROM invitation -INNER JOIN device ON invitation.created_by = device._id WHERE invitation.organization = { q_organization_internal_id("$organization_id") } AND type = $type - AND device.user_ = { q_user_internal_id(organization_id="$organization_id", user_id="$user_id") } AND claimer_email IS NOT NULL AND shamir_recovery = $shamir_recovery_setup AND deleted_on IS NULL @@ -345,8 +395,9 @@ class ShamirRecoverySetupInfo: token, type, claimer_email, + claimer_user_id, shamir_recovery, - created_by, + created_by_device, created_on ) VALUES ( @@ -354,11 +405,12 @@ class ShamirRecoverySetupInfo: $token, $type, $claimer_email, + { q_user_internal_id(organization_id="$organization_id", user_id="$claimer_user_id") }, $shamir_recovery_setup, { q_device_internal_id(organization_id="$organization_id", device_id="$created_by") }, $created_on ) -RETURNING _id, created_by +RETURNING _id, created_by_device """ ) @@ -383,16 +435,22 @@ class ShamirRecoverySetupInfo: user_.user_id AS created_by_user_id, human.email, human.label, + invitation.created_by_service_label, invitation.claimer_email, + claimer_user.user_id as claimer_user_id, + claimer_human.email AS claimer_human_email, + claimer_human.label AS claimer_human_label, invitation.shamir_recovery, shamir_recovery_setup.deleted_on, invitation.created_on, invitation.deleted_on, invitation.deleted_reason FROM invitation -LEFT JOIN device ON invitation.created_by = device._id +LEFT JOIN device ON invitation.created_by_device = device._id LEFT JOIN user_ ON device.user_ = user_._id LEFT JOIN human ON human._id = user_.human +LEFT JOIN user_ AS claimer_user ON invitation.claimer_user_id = claimer_user._id +LEFT JOIN human AS claimer_human ON claimer_user.human = claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id LEFT JOIN shamir_recovery_share ON shamir_recovery_share.shamir_recovery = shamir_recovery_setup._id LEFT JOIN user_ AS recipient_user_ ON shamir_recovery_share.recipient = recipient_user_._id @@ -417,16 +475,22 @@ class ShamirRecoverySetupInfo: user_.user_id AS created_by_user_id, human.email as created_by_email, human.label as created_by_label, + invitation.created_by_service_label, invitation.claimer_email, + claimer_user.user_id as claimer_user_id, + claimer_human.email AS claimer_human_email, + claimer_human.label AS claimer_human_label, invitation.shamir_recovery, shamir_recovery_setup.deleted_on, invitation.created_on, invitation.deleted_on, invitation.deleted_reason FROM invitation -LEFT JOIN device ON invitation.created_by = device._id +LEFT JOIN device ON invitation.created_by_device = device._id LEFT JOIN user_ ON device.user_ = user_._id LEFT JOIN human ON human._id = user_.human +LEFT JOIN user_ AS claimer_user ON invitation.claimer_user_id = claimer_user._id +LEFT JOIN human AS claimer_human ON claimer_user.human = claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id WHERE invitation.organization = { q_organization_internal_id("$organization_id") } @@ -470,7 +534,11 @@ def make_q_info_invitation( user_.user_id as created_by_user_id, human.email, human.label, + invitation.created_by_service_label, invitation.claimer_email, + claimer_user.user_id as claimer_user_id, + claimer_human.email AS claimer_human_email, + claimer_human.label AS claimer_human_label, invitation.shamir_recovery, shamir_recovery_setup.deleted_on, invitation.created_on, @@ -478,9 +546,11 @@ def make_q_info_invitation( invitation.deleted_reason FROM invitation INNER JOIN selected_invitation ON invitation._id = selected_invitation.invitation_internal_id - INNER JOIN device ON invitation.created_by = device._id - INNER JOIN user_ ON device.user_ = user_._id - INNER JOIN human ON human._id = user_.human + LEFT JOIN device ON invitation.created_by_device = device._id + LEFT JOIN user_ ON device.user_ = user_._id + LEFT JOIN human ON human._id = user_.human + LEFT JOIN user_ AS claimer_user ON invitation.claimer_user_id = claimer_user._id + LEFT JOIN human AS claimer_human ON claimer_user.human = claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id """) @@ -493,11 +563,11 @@ def make_q_info_invitation( _q_greeting_attempt_info = Q( - f""" + """ SELECT greeting_attempt._id, greeting_attempt.greeting_attempt_id, - { q_user(_id="greeting_session.greeter", select="user_id") } as greeter, + user_.user_id as greeter, greeting_attempt.claimer_joined, greeting_attempt.greeter_joined, greeting_attempt.cancelled_by, @@ -506,8 +576,7 @@ def make_q_info_invitation( FROM greeting_attempt INNER JOIN greeting_session ON greeting_attempt.greeting_session = greeting_session._id INNER JOIN invitation ON greeting_session.invitation = invitation._id -INNER JOIN device ON invitation.created_by = device._id -INNER JOIN human ON human._id = (SELECT user_.human FROM user_ WHERE user_._id = device.user_) +INNER JOIN user_ ON greeting_session.greeter = user_._id INNER JOIN organization ON invitation.organization = organization._id WHERE organization.organization_id = $organization_id AND greeting_attempt.greeting_attempt_id = $greeting_attempt_id @@ -789,6 +858,7 @@ async def _do_new_invitation( author_user_id: UserID, author_device_id: DeviceID, claimer_email: str | None, + claimer_user_id: UserID | None, shamir_recovery_setup: int | None, created_on: DateTime, invitation_type: InvitationType, @@ -816,7 +886,6 @@ async def _do_new_invitation( q = _q_retrieve_compatible_shamir_recovery_invitation( organization_id=organization_id.str, type=invitation_type.str, - user_id=author_user_id, shamir_recovery_setup=shamir_recovery_setup, ) case _: @@ -837,6 +906,7 @@ async def _do_new_invitation( type=invitation_type.str, token=token.hex, claimer_email=claimer_email, + claimer_user_id=claimer_user_id, shamir_recovery_setup=shamir_recovery_setup, created_by=author_device_id, created_on=created_on, @@ -924,6 +994,7 @@ async def new_for_user( author_user_id=author_user_id, author_device_id=author, claimer_email=claimer_email, + claimer_user_id=None, shamir_recovery_setup=None, created_on=now, invitation_type=InvitationType.USER, @@ -985,6 +1056,7 @@ async def new_for_device( author_user_id=author_user_id, author_device_id=author, claimer_email=None, + claimer_user_id=author_user_id, shamir_recovery_setup=None, created_on=now, invitation_type=InvitationType.DEVICE, @@ -1076,6 +1148,7 @@ async def new_for_shamir_recovery( author_user_id=author_user_id, author_device_id=author, claimer_email=claimer_human_handle.email, + claimer_user_id=None, # This field is exclusively used for device invitation shamir_recovery_setup=shamir_recovery_setup, created_on=now, invitation_type=InvitationType.SHAMIR_RECOVERY, @@ -1298,10 +1371,7 @@ async def list( # we could update the invite API to take this difference into account. administrators = await self._get_administrators(conn, organization_id) invitation = UserInvitation( - created_by=InvitationCreatedByUser( - user_id=invitation_info.created_by_user_id, - human_handle=invitation_info.created_by_human_handle, - ), + created_by=invitation_info.created_by, claimer_email=invitation_info.claimer_email, token=invitation_info.token, created_on=invitation_info.created_on, @@ -1309,13 +1379,12 @@ async def list( status=status, ) case InvitationType.DEVICE: + assert invitation_info.claimer_user_id is not None + assert invitation_info.claimer_human_handle is not None invitation = DeviceInvitation( - claimer_user_id=invitation_info.created_by_user_id, - claimer_human_handle=invitation_info.created_by_human_handle, - created_by=InvitationCreatedByUser( - user_id=invitation_info.created_by_user_id, - human_handle=invitation_info.created_by_human_handle, - ), + claimer_user_id=invitation_info.claimer_user_id, + claimer_human_handle=invitation_info.claimer_human_handle, + created_by=invitation_info.created_by, token=invitation_info.token, created_on=invitation_info.created_on, status=status, @@ -1327,10 +1396,7 @@ async def list( ) invitation = ShamirRecoveryInvitation( - created_by=InvitationCreatedByUser( - user_id=invitation_info.created_by_user_id, - human_handle=invitation_info.created_by_human_handle, - ), + created_by=invitation_info.created_by, token=invitation_info.token, created_on=invitation_info.created_on, status=status, @@ -1371,10 +1437,7 @@ async def _info_as_invited( assert invitation_info.claimer_email is not None administrators = await self._get_administrators(conn, organization_id) return UserInvitation( - created_by=InvitationCreatedByUser( - user_id=invitation_info.created_by_user_id, - human_handle=invitation_info.created_by_human_handle, - ), + created_by=invitation_info.created_by, claimer_email=invitation_info.claimer_email, token=token, created_on=invitation_info.created_on, @@ -1382,13 +1445,12 @@ async def _info_as_invited( status=InvitationStatus.READY, ) case InvitationType.DEVICE: + assert invitation_info.claimer_user_id is not None + assert invitation_info.claimer_human_handle is not None return DeviceInvitation( - claimer_user_id=invitation_info.created_by_user_id, - claimer_human_handle=invitation_info.created_by_human_handle, - created_by=InvitationCreatedByUser( - user_id=invitation_info.created_by_user_id, - human_handle=invitation_info.created_by_human_handle, - ), + claimer_user_id=invitation_info.claimer_user_id, + claimer_human_handle=invitation_info.claimer_human_handle, + created_by=invitation_info.created_by, token=token, created_on=invitation_info.created_on, status=InvitationStatus.READY, @@ -1399,10 +1461,7 @@ async def _info_as_invited( conn, invitation_info.shamir_recovery_setup_internal_id ) return ShamirRecoveryInvitation( - created_by=InvitationCreatedByUser( - user_id=invitation_info.created_by_user_id, - human_handle=invitation_info.created_by_human_handle, - ), + created_by=invitation_info.created_by, token=token, created_on=invitation_info.created_on, status=InvitationStatus.READY, @@ -1494,8 +1553,13 @@ async def test_dump_all_invitations( rows = await conn.fetch(*_q_list_all_invitations(organization_id=organization_id.str)) for record in rows: invitation_info = InvitationInfo.from_record(record) + + # TODO: Update method to also return invitation created by external services + if not isinstance(invitation_info.created_by, InvitationCreatedByUser): + continue + current_user_invitations = per_user_invitations.setdefault( - invitation_info.created_by_user_id, [] + invitation_info.created_by.user_id, [] ) if invitation_info.deleted_on: @@ -1516,25 +1580,21 @@ async def test_dump_all_invitations( claimer_email=invitation_info.claimer_email, created_on=invitation_info.created_on, status=status, - created_by=InvitationCreatedByUser( - user_id=invitation_info.created_by_user_id, - human_handle=invitation_info.created_by_human_handle, - ), + created_by=invitation_info.created_by, administrators=administrators, token=invitation_info.token, ) ) case InvitationType.DEVICE: + assert invitation_info.claimer_user_id is not None + assert invitation_info.claimer_human_handle is not None current_user_invitations.append( DeviceInvitation( created_on=invitation_info.created_on, - created_by=InvitationCreatedByUser( - user_id=invitation_info.created_by_user_id, - human_handle=invitation_info.created_by_human_handle, - ), + created_by=invitation_info.created_by, status=status, - claimer_human_handle=invitation_info.created_by_human_handle, - claimer_user_id=invitation_info.created_by_user_id, + claimer_human_handle=invitation_info.claimer_human_handle, + claimer_user_id=invitation_info.claimer_user_id, token=invitation_info.token, ) ) @@ -1547,10 +1607,7 @@ async def test_dump_all_invitations( ShamirRecoveryInvitation( created_on=invitation_info.created_on, status=status, - created_by=InvitationCreatedByUser( - user_id=invitation_info.created_by_user_id, - human_handle=invitation_info.created_by_human_handle, - ), + created_by=invitation_info.created_by, token=invitation_info.token, claimer_human_handle=shamir_recovery_info.claimer_human_handle, claimer_user_id=shamir_recovery_info.claimer_user_id, @@ -1680,7 +1737,7 @@ async def is_greeter_allowed( greeter_profile: UserProfile, ) -> bool: if invitation_info.type == InvitationType.DEVICE: - return invitation_info.created_by_user_id == greeter_id + return invitation_info.claimer_user_id == greeter_id elif invitation_info.type == InvitationType.USER: return greeter_profile == UserProfile.ADMIN elif invitation_info.type == InvitationType.SHAMIR_RECOVERY: diff --git a/server/parsec/components/postgresql/migrations/0009_external_service_for_invitation.sql b/server/parsec/components/postgresql/migrations/0009_external_service_for_invitation.sql new file mode 100644 index 00000000000..8f24b74eff4 --- /dev/null +++ b/server/parsec/components/postgresql/migrations/0009_external_service_for_invitation.sql @@ -0,0 +1,26 @@ +-- Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +------------------------------------------------------- +-- Migration +-- +-- Rename `created_by` to `created_by_device` in invitation table +-- Remove NOT NULL constraint on `created_by_device` column +-- Add `created_by_service_label` column to invitation table +-- Add `claimer_user_id` column to invitation table +-- +------------------------------------------------------- + +ALTER TABLE invitation RENAME COLUMN created_by TO created_by_device; +ALTER TABLE invitation ALTER COLUMN created_by_device DROP NOT NULL; +ALTER TABLE invitation RENAME CONSTRAINT "invitation_created_by_fkey" TO "invitation_created_by_device_fkey"; +ALTER TABLE invitation ADD COLUMN created_by_service_label VARCHAR(254); +ALTER TABLE invitation ADD COLUMN claimer_user_id INTEGER REFERENCES user_ (_id); + +-- Update `claimer_user_id` using the `created_by_device` column +UPDATE invitation +SET claimer_user_id = user_._id +FROM device +INNER JOIN user_ ON device.user_ = user_._id +WHERE + invitation.type = 'DEVICE' + AND invitation.created_by_device = device._id; diff --git a/server/parsec/components/postgresql/migrations/datamodel.sql b/server/parsec/components/postgresql/migrations/datamodel.sql index 723d818872e..c17a8f76bb7 100644 --- a/server/parsec/components/postgresql/migrations/datamodel.sql +++ b/server/parsec/components/postgresql/migrations/datamodel.sql @@ -231,7 +231,9 @@ CREATE TABLE invitation ( token VARCHAR(32) NOT NULL, type INVITATION_TYPE NOT NULL, - created_by INTEGER REFERENCES device (_id) NOT NULL, + -- Updated in migration 0009 + created_by_device INTEGER REFERENCES device (_id), + -- Required when type=USER or type=SHAMIR_RECOVERY claimer_email VARCHAR(255), @@ -242,6 +244,11 @@ CREATE TABLE invitation ( -- Required when type=SHAMIR_RECOVERY shamir_recovery INTEGER REFERENCES shamir_recovery_setup (_id), + -- Added in migration 0009 + created_by_service_label VARCHAR(254), + -- Required when type=DEVICE + claimer_user_id INTEGER REFERENCES user_ (_id), + UNIQUE (organization, token) ); diff --git a/server/parsec/components/postgresql/test_queries.py b/server/parsec/components/postgresql/test_queries.py index 4ec31656fc4..fed828c62c9 100644 --- a/server/parsec/components/postgresql/test_queries.py +++ b/server/parsec/components/postgresql/test_queries.py @@ -404,8 +404,10 @@ organization, token, type, - created_by, + created_by_device, + created_by_service_label, claimer_email, + claimer_user_id, created_on, deleted_on, deleted_reason, @@ -417,9 +419,14 @@ type, ( SELECT _id FROM new_devices - WHERE device_id = { q_device(_id="invitation.created_by", select="device_id") } + WHERE device_id = { q_device(_id="invitation.created_by_device", select="device_id") } ), + created_by_service_label, claimer_email, + ( + SELECT _id FROM new_users + WHERE user_id = { q_user(_id="invitation.claimer_user_id", select="user_id") } + ), created_on, deleted_on, deleted_reason, From af133a1e7705949924378a92223936a92c52d5b9 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Thu, 23 Jan 2025 15:14:23 +0100 Subject: [PATCH 03/11] Refactor postgresql invitation handling --- server/parsec/components/memory/datamodel.py | 2 +- server/parsec/components/memory/invite.py | 2 +- server/parsec/components/postgresql/invite.py | 158 +++++++++--------- .../postgresql/migrations/datamodel.sql | 22 ++- 4 files changed, 101 insertions(+), 83 deletions(-) diff --git a/server/parsec/components/memory/datamodel.py b/server/parsec/components/memory/datamodel.py index c1504e39a55..ca8d4a7e84f 100644 --- a/server/parsec/components/memory/datamodel.py +++ b/server/parsec/components/memory/datamodel.py @@ -299,7 +299,7 @@ class MemoryInvitation: type: InvitationType created_by: InvitationCreatedBy - # Required for when type=USER + # Required for when type=USER or type=SHAMIR_RECOVERY claimer_email: str | None # Required for when type=DEVICE or type=SHAMIR_RECOVERY diff --git a/server/parsec/components/memory/invite.py b/server/parsec/components/memory/invite.py index dc3fb522ed3..418fc89fe02 100644 --- a/server/parsec/components/memory/invite.py +++ b/server/parsec/components/memory/invite.py @@ -1042,7 +1042,7 @@ async def complete( # Only the greeter or the claimer can complete the invitation if not self.is_greeter_allowed(org, invitation, author_user): - if invitation.claimer_email != author_user.cooked.human_handle.email: + if invitation.claimer_user_id != author_user.cooked.user_id: return InviteCompleteBadOutcome.AUTHOR_NOT_ALLOWED invitation.deleted_on = now diff --git a/server/parsec/components/postgresql/invite.py b/server/parsec/components/postgresql/invite.py index 7526f372bda..0fcd0561f54 100644 --- a/server/parsec/components/postgresql/invite.py +++ b/server/parsec/components/postgresql/invite.py @@ -77,17 +77,15 @@ class InvitationInfo: internal_id: int token: InvitationToken type: InvitationType + created_by: InvitationCreatedBy created_on: DateTime deleted_on: DateTime | None deleted_reason: InvitationStatus | None - # Available if the invitation was created by a user - created_by: InvitationCreatedBy - # Available if the invitation is a user invitation claimer_email: str | None - # Available if the invitation is a device invitation + # Available if the invitation is a device or a shamir recovery invitation claimer_user_id: UserID | None claimer_human_handle: HumanHandle | None @@ -115,6 +113,9 @@ def from_record(cls, record: Record) -> InvitationInfo: claimer_human_email, claimer_human_label, shamir_recovery_setup, + shamir_recovery_setup_user_id_str, + shamir_recovery_setup_human_email, + shamir_recovery_setup_human_label, shamir_recovery_setup_deleted_on, created_on, deleted_on, @@ -142,6 +143,9 @@ def from_record(cls, record: Record) -> InvitationInfo: assert claimer_human_label is None assert claimer_human_email is None assert shamir_recovery_setup is None + assert shamir_recovery_setup_user_id_str is None + assert shamir_recovery_setup_human_email is None + assert shamir_recovery_setup_human_label is None claimer_user_id = None claimer_human_handle = None elif type == InvitationType.DEVICE: @@ -150,16 +154,24 @@ def from_record(cls, record: Record) -> InvitationInfo: assert claimer_human_label is not None assert claimer_human_email is not None assert shamir_recovery_setup is None + assert shamir_recovery_setup_user_id_str is None + assert shamir_recovery_setup_human_email is None + assert shamir_recovery_setup_human_label is None claimer_user_id = UserID.from_hex(claimer_user_id_str) claimer_human_handle = HumanHandle(email=claimer_human_email, label=claimer_human_label) elif type == InvitationType.SHAMIR_RECOVERY: - assert claimer_email is not None + assert claimer_email is None assert claimer_user_id_str is None assert claimer_human_label is None assert claimer_human_email is None assert shamir_recovery_setup is not None - claimer_user_id = None - claimer_human_handle = None + assert shamir_recovery_setup_user_id_str is not None + assert shamir_recovery_setup_human_email is not None + assert shamir_recovery_setup_human_label is not None + claimer_user_id = UserID.from_hex(shamir_recovery_setup_user_id_str) + claimer_human_handle = HumanHandle( + email=shamir_recovery_setup_human_email, label=shamir_recovery_setup_human_label + ) else: assert False, type @@ -242,12 +254,9 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: @dataclass class ShamirRecoverySetupInfo: - claimer_user_id: UserID - claimer_human_handle: HumanHandle threshold: int recipients: list[ShamirRecoveryRecipient] created_on: DateTime - deleted_on: DateTime | None _q_retrieve_shamir_recovery_setup = Q( @@ -292,48 +301,48 @@ class ShamirRecoverySetupInfo: """ ) -_q_retrieve_compatible_user_invitation = Q( +_q_get_human_handle_from_user_id = Q( f""" SELECT - token -FROM invitation -LEFT JOIN device ON invitation.created_by_device = device._id + human.email, + human.label +FROM human LEFT JOIN user_ ON human._id = user_.human WHERE - invitation.organization = { q_organization_internal_id("$organization_id") } - AND type = $type - AND device.user_ = { q_user_internal_id(organization_id="$organization_id", user_id="$user_id") } - AND claimer_email = $claimer_email - and shamir_recovery IS NULL - AND deleted_on IS NULL + human.organization = { q_organization_internal_id("$organization_id") } + AND user_.user_id = $user_id LIMIT 1 """ ) -_q_get_human_handle_from_user_id = Q( - f""" +_q_retrieve_compatible_user_invitation = Q( + """ SELECT - human.email, - human.label -FROM human LEFT JOIN user_ ON human._id = user_.human + token +FROM invitation +INNER JOIN organization ON invitation.organization = organization._id +INNER JOIN device ON invitation.created_by_device = device._id +INNER JOIN user_ ON device.user_ = user_._id WHERE - human.organization = { q_organization_internal_id("$organization_id") } + organization.organization_id = $organization_id + AND type = 'USER' AND user_.user_id = $user_id + AND claimer_email = $claimer_email + AND deleted_on IS NULL LIMIT 1 """ ) _q_retrieve_compatible_device_invitation = Q( - f""" + """ SELECT token FROM invitation -INNER JOIN device ON invitation.created_by_device = device._id +LEFT JOIN organization ON invitation.organization = organization._id +INNER JOIN user_ ON invitation.claimer_user_id = user_._id WHERE - invitation.organization = { q_organization_internal_id("$organization_id") } - AND type = $type - AND device.user_ = { q_user_internal_id(organization_id="$organization_id", user_id="$user_id") } - AND claimer_email IS NULL - AND shamir_recovery IS NULL + organization.organization_id = $organization_id + AND type = 'DEVICE' + AND user_.user_id = $claimer_user_id AND deleted_on IS NULL LIMIT 1 """ @@ -346,8 +355,7 @@ class ShamirRecoverySetupInfo: FROM invitation WHERE invitation.organization = { q_organization_internal_id("$organization_id") } - AND type = $type - AND claimer_email IS NOT NULL + AND type = 'SHAMIR_RECOVERY' AND shamir_recovery = $shamir_recovery_setup AND deleted_on IS NULL LIMIT 1 @@ -357,15 +365,9 @@ class ShamirRecoverySetupInfo: _q_retrieve_shamir_recovery_setup_info = Q( """ SELECT - user_.user_id as claimer_user_id, - human.email as claimer_email, - human.label as claimer_label, shamir_recovery_setup.threshold, - shamir_recovery_setup.created_on, - shamir_recovery_setup.deleted_on + shamir_recovery_setup.created_on FROM shamir_recovery_setup -INNER JOIN user_ ON shamir_recovery_setup.user_ = user_._id -INNER JOIN human ON human._id = user_.human WHERE shamir_recovery_setup._id = $internal_shamir_recovery_setup_id """ @@ -441,6 +443,9 @@ class ShamirRecoverySetupInfo: claimer_human.email AS claimer_human_email, claimer_human.label AS claimer_human_label, invitation.shamir_recovery, + shamir_recovery_user.user_id AS shamir_recovery_user_id, + shamir_recovery_human.email AS shamir_recovery_human_email, + shamir_recovery_human.label AS shamir_recovery_human_label, shamir_recovery_setup.deleted_on, invitation.created_on, invitation.deleted_on, @@ -452,6 +457,8 @@ class ShamirRecoverySetupInfo: LEFT JOIN user_ AS claimer_user ON invitation.claimer_user_id = claimer_user._id LEFT JOIN human AS claimer_human ON claimer_user.human = claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id +LEFT JOIN user_ AS shamir_recovery_user ON shamir_recovery_setup.user_ = shamir_recovery_user._id +LEFT JOIN human AS shamir_recovery_human ON shamir_recovery_user.human = shamir_recovery_human._id LEFT JOIN shamir_recovery_share ON shamir_recovery_share.shamir_recovery = shamir_recovery_setup._id LEFT JOIN user_ AS recipient_user_ ON shamir_recovery_share.recipient = recipient_user_._id WHERE @@ -481,6 +488,9 @@ class ShamirRecoverySetupInfo: claimer_human.email AS claimer_human_email, claimer_human.label AS claimer_human_label, invitation.shamir_recovery, + shamir_recovery_user.user_id AS shamir_recovery_user_id, + shamir_recovery_human.email AS shamir_recovery_human_email, + shamir_recovery_human.label AS shamir_recovery_human_label, shamir_recovery_setup.deleted_on, invitation.created_on, invitation.deleted_on, @@ -492,6 +502,8 @@ class ShamirRecoverySetupInfo: LEFT JOIN user_ AS claimer_user ON invitation.claimer_user_id = claimer_user._id LEFT JOIN human AS claimer_human ON claimer_user.human = claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id +LEFT JOIN user_ AS shamir_recovery_user ON shamir_recovery_setup.user_ = shamir_recovery_user._id +LEFT JOIN human AS shamir_recovery_human ON shamir_recovery_user.human = shamir_recovery_human._id WHERE invitation.organization = { q_organization_internal_id("$organization_id") } ORDER BY created_on @@ -540,6 +552,9 @@ def make_q_info_invitation( claimer_human.email AS claimer_human_email, claimer_human.label AS claimer_human_label, invitation.shamir_recovery, + shamir_recovery_user.user_id AS shamir_recovery_user_id, + shamir_recovery_human.email AS shamir_recovery_human_email, + shamir_recovery_human.label AS shamir_recovery_human_label, shamir_recovery_setup.deleted_on, invitation.created_on, invitation.deleted_on, @@ -552,6 +567,8 @@ def make_q_info_invitation( LEFT JOIN user_ AS claimer_user ON invitation.claimer_user_id = claimer_user._id LEFT JOIN human AS claimer_human ON claimer_user.human = claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id + LEFT JOIN user_ AS shamir_recovery_user ON shamir_recovery_setup.user_ = shamir_recovery_user._id + LEFT JOIN human AS shamir_recovery_human ON shamir_recovery_user.human = shamir_recovery_human._id """) @@ -871,21 +888,18 @@ async def _do_new_invitation( # TODO: Update this when implementing https://github.com/Scille/parsec-cloud/issues/9413 q = _q_retrieve_compatible_user_invitation( organization_id=organization_id.str, - type=invitation_type.str, user_id=author_user_id, claimer_email=claimer_email, ) case InvitationType.DEVICE: q = _q_retrieve_compatible_device_invitation( organization_id=organization_id.str, - type=invitation_type.str, - user_id=author_user_id, + claimer_user_id=author_user_id, ) case InvitationType.SHAMIR_RECOVERY: assert shamir_recovery_setup is not None q = _q_retrieve_compatible_shamir_recovery_invitation( organization_id=organization_id.str, - type=invitation_type.str, shamir_recovery_setup=shamir_recovery_setup, ) case _: @@ -1147,8 +1161,8 @@ async def new_for_shamir_recovery( organization_id=organization_id, author_user_id=author_user_id, author_device_id=author, - claimer_email=claimer_human_handle.email, - claimer_user_id=None, # This field is exclusively used for device invitation + claimer_email=None, + claimer_user_id=None, shamir_recovery_setup=shamir_recovery_setup, created_on=now, invitation_type=InvitationType.SHAMIR_RECOVERY, @@ -1185,18 +1199,6 @@ async def _get_shamir_recovery_info( ) assert row is not None - match row["claimer_user_id"]: - case str() as raw_claimer_user_id: - claimer_user_id = UserID.from_hex(raw_claimer_user_id) - case unknown: - assert False, repr(unknown) - - match (row["claimer_email"], row["claimer_label"]): - case (str() as raw_claimer_email, str() as raw_claimer_label): - claimer_human_handle = HumanHandle(email=raw_claimer_email, label=raw_claimer_label) - case unknown: - assert False, repr(unknown) - match row["threshold"]: case int() as threshold: pass @@ -1209,12 +1211,6 @@ async def _get_shamir_recovery_info( case unknown: assert False, repr(unknown) - match row["deleted_on"]: - case DateTime() | None as deleted_on: - pass - case unknown: - assert False, repr(unknown) - rows = await conn.fetch( *_q_retrieve_shamir_recovery_recipients( internal_shamir_recovery_setup_id=internal_shamir_recovery_setup_id @@ -1233,12 +1229,9 @@ async def _get_shamir_recovery_info( recipients.sort(key=lambda x: x.human_handle.label) return ShamirRecoverySetupInfo( - claimer_user_id=claimer_user_id, - claimer_human_handle=claimer_human_handle, threshold=threshold, recipients=recipients, created_on=created_on, - deleted_on=deleted_on, ) async def _get_administrators( @@ -1391,21 +1384,22 @@ async def list( ) case InvitationType.SHAMIR_RECOVERY: assert invitation_info.shamir_recovery_setup_internal_id is not None + assert invitation_info.claimer_user_id is not None + assert invitation_info.claimer_human_handle is not None shamir_recovery_info = await self._get_shamir_recovery_info( conn, invitation_info.shamir_recovery_setup_internal_id ) - invitation = ShamirRecoveryInvitation( created_by=invitation_info.created_by, token=invitation_info.token, created_on=invitation_info.created_on, status=status, - claimer_user_id=shamir_recovery_info.claimer_user_id, - claimer_human_handle=shamir_recovery_info.claimer_human_handle, + claimer_user_id=invitation_info.claimer_user_id, + claimer_human_handle=invitation_info.claimer_human_handle, threshold=shamir_recovery_info.threshold, recipients=shamir_recovery_info.recipients, shamir_recovery_created_on=shamir_recovery_info.created_on, - shamir_recovery_deleted_on=shamir_recovery_info.deleted_on, + shamir_recovery_deleted_on=invitation_info.deleted_on, ) case unknown: assert False, unknown @@ -1457,6 +1451,8 @@ async def _info_as_invited( ) case InvitationType.SHAMIR_RECOVERY: assert invitation_info.shamir_recovery_setup_internal_id is not None + assert invitation_info.claimer_user_id is not None + assert invitation_info.claimer_human_handle is not None shamir_recovery_info = await self._get_shamir_recovery_info( conn, invitation_info.shamir_recovery_setup_internal_id ) @@ -1465,12 +1461,12 @@ async def _info_as_invited( token=token, created_on=invitation_info.created_on, status=InvitationStatus.READY, - claimer_user_id=shamir_recovery_info.claimer_user_id, - claimer_human_handle=shamir_recovery_info.claimer_human_handle, + claimer_user_id=invitation_info.claimer_user_id, + claimer_human_handle=invitation_info.claimer_human_handle, threshold=shamir_recovery_info.threshold, recipients=shamir_recovery_info.recipients, shamir_recovery_created_on=shamir_recovery_info.created_on, - shamir_recovery_deleted_on=shamir_recovery_info.deleted_on, + shamir_recovery_deleted_on=invitation_info.deleted_on, ) case unknown: assert False, unknown @@ -1600,6 +1596,8 @@ async def test_dump_all_invitations( ) case InvitationType.SHAMIR_RECOVERY: assert invitation_info.shamir_recovery_setup_internal_id is not None + assert invitation_info.claimer_user_id is not None + assert invitation_info.claimer_human_handle is not None shamir_recovery_info = await self._get_shamir_recovery_info( conn, invitation_info.shamir_recovery_setup_internal_id ) @@ -1609,12 +1607,12 @@ async def test_dump_all_invitations( status=status, created_by=invitation_info.created_by, token=invitation_info.token, - claimer_human_handle=shamir_recovery_info.claimer_human_handle, - claimer_user_id=shamir_recovery_info.claimer_user_id, + claimer_human_handle=invitation_info.claimer_human_handle, + claimer_user_id=invitation_info.claimer_user_id, threshold=shamir_recovery_info.threshold, recipients=shamir_recovery_info.recipients, shamir_recovery_created_on=shamir_recovery_info.created_on, - shamir_recovery_deleted_on=shamir_recovery_info.deleted_on, + shamir_recovery_deleted_on=invitation_info.deleted_on, ) ) case unknown: @@ -2306,7 +2304,7 @@ async def complete( if not await self.is_greeter_allowed( conn, invitation_info, author_user_id, current_profile ): - if invitation_info.claimer_email != author_info.human_handle.email: + if invitation_info.claimer_user_id != author_info.user_id: return InviteCompleteBadOutcome.AUTHOR_NOT_ALLOWED await conn.execute( diff --git a/server/parsec/components/postgresql/migrations/datamodel.sql b/server/parsec/components/postgresql/migrations/datamodel.sql index c17a8f76bb7..f9f3e735bde 100644 --- a/server/parsec/components/postgresql/migrations/datamodel.sql +++ b/server/parsec/components/postgresql/migrations/datamodel.sql @@ -231,10 +231,30 @@ CREATE TABLE invitation ( token VARCHAR(32) NOT NULL, type INVITATION_TYPE NOT NULL, + -- A more readable ordering of these columns would be: + -- + -- -- Invitation created by either a device or a service + -- created_by_device INTEGER REFERENCES device (_id), + -- created_by_service_label VARCHAR(254), + -- + -- -- Type specific fields + -- -- Required when type=USER + -- claimer_email VARCHAR(255), + -- -- Required when type=DEVICE + -- claimer_user_id INTEGER REFERENCES user_ (_id), + -- -- Required when type=SHAMIR_RECOVERY + -- shamir_recovery INTEGER REFERENCES shamir_recovery_setup (_id), + -- + -- -- Other fields + -- created_on TIMESTAMPTZ NOT NULL, + -- deleted_on TIMESTAMPTZ, + -- deleted_reason INVITATION_DELETED_REASON, + + -- Updated in migration 0009 created_by_device INTEGER REFERENCES device (_id), - -- Required when type=USER or type=SHAMIR_RECOVERY + -- Required when type=USER claimer_email VARCHAR(255), created_on TIMESTAMPTZ NOT NULL, From 13723236fd57e4017b8c621c6ab970f158cde577 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Thu, 23 Jan 2025 15:54:20 +0100 Subject: [PATCH 04/11] Refactor postgresql invitation handling 2 --- server/parsec/components/postgresql/invite.py | 306 +++++++++--------- 1 file changed, 155 insertions(+), 151 deletions(-) diff --git a/server/parsec/components/postgresql/invite.py b/server/parsec/components/postgresql/invite.py index 0fcd0561f54..046578f39dc 100644 --- a/server/parsec/components/postgresql/invite.py +++ b/server/parsec/components/postgresql/invite.py @@ -73,7 +73,7 @@ @dataclass(frozen=True) -class InvitationInfo: +class BaseInvitationInfo: internal_id: int token: InvitationToken type: InvitationType @@ -82,122 +82,146 @@ class InvitationInfo: deleted_on: DateTime | None deleted_reason: InvitationStatus | None - # Available if the invitation is a user invitation - claimer_email: str | None - - # Available if the invitation is a device or a shamir recovery invitation - claimer_user_id: UserID | None - claimer_human_handle: HumanHandle | None - - # Available if the invitation is a shamir recovery invitation - shamir_recovery_setup_internal_id: int | None - def is_finished(self) -> bool: return self.deleted_reason == InvitationStatus.FINISHED def is_cancelled(self) -> bool: return self.deleted_reason == InvitationStatus.CANCELLED - @classmethod - def from_record(cls, record: Record) -> InvitationInfo: - ( - invitation_internal_id, - raw_token, - raw_type, - created_by_user_id_str, - created_by_email, - created_by_label, - created_by_service_label, - claimer_email, - claimer_user_id_str, - claimer_human_email, - claimer_human_label, - shamir_recovery_setup, - shamir_recovery_setup_user_id_str, - shamir_recovery_setup_human_email, - shamir_recovery_setup_human_label, - shamir_recovery_setup_deleted_on, - created_on, - deleted_on, - deleted_reason, - ) = record - type = InvitationType.from_str(raw_type) - token = InvitationToken.from_hex(raw_token) +@dataclass(frozen=True) +class UserInvitationInfo(BaseInvitationInfo): + claimer_email: str - if created_by_user_id_str is None: - assert created_by_service_label is not None - created_by = InvitationCreatedByExternalService(service_label=created_by_service_label) - else: - assert created_by_email is not None - assert created_by_label is not None - created_by_user_id = UserID.from_hex(created_by_user_id_str) - human_handle = HumanHandle(email=created_by_email, label=created_by_label) - created_by = InvitationCreatedByUser( - user_id=created_by_user_id, human_handle=human_handle - ) - if type == InvitationType.USER: - assert claimer_email is not None - assert claimer_user_id_str is None - assert claimer_human_label is None - assert claimer_human_email is None - assert shamir_recovery_setup is None - assert shamir_recovery_setup_user_id_str is None - assert shamir_recovery_setup_human_email is None - assert shamir_recovery_setup_human_label is None - claimer_user_id = None - claimer_human_handle = None - elif type == InvitationType.DEVICE: - assert claimer_email is None - assert claimer_user_id_str is not None - assert claimer_human_label is not None - assert claimer_human_email is not None - assert shamir_recovery_setup is None - assert shamir_recovery_setup_user_id_str is None - assert shamir_recovery_setup_human_email is None - assert shamir_recovery_setup_human_label is None - claimer_user_id = UserID.from_hex(claimer_user_id_str) - claimer_human_handle = HumanHandle(email=claimer_human_email, label=claimer_human_label) - elif type == InvitationType.SHAMIR_RECOVERY: - assert claimer_email is None - assert claimer_user_id_str is None - assert claimer_human_label is None - assert claimer_human_email is None - assert shamir_recovery_setup is not None - assert shamir_recovery_setup_user_id_str is not None - assert shamir_recovery_setup_human_email is not None - assert shamir_recovery_setup_human_label is not None - claimer_user_id = UserID.from_hex(shamir_recovery_setup_user_id_str) - claimer_human_handle = HumanHandle( - email=shamir_recovery_setup_human_email, label=shamir_recovery_setup_human_label - ) - else: - assert False, type +@dataclass(frozen=True) +class DeviceInvitationInfo(BaseInvitationInfo): + claimer_user_id: UserID + claimer_human_handle: HumanHandle + + +@dataclass(frozen=True) +class ShamirRecoveryInvitationInfo(BaseInvitationInfo): + claimer_user_id: UserID + claimer_human_handle: HumanHandle + shamir_recovery_setup_internal_id: int - deleted_reason = ( - InvitationStatus.from_str(deleted_reason) if deleted_reason is not None else None - ) - # Treat active invitation to a deleted shamir recovery as cancelled - if shamir_recovery_setup_deleted_on and not deleted_on: - deleted_on = shamir_recovery_setup_deleted_on - deleted_reason = InvitationStatus.CANCELLED +InvitationInfo: TypeAlias = UserInvitationInfo | DeviceInvitationInfo | ShamirRecoveryInvitationInfo + + +def invitation_info_from_record(record: Record) -> InvitationInfo: + ( + invitation_internal_id, + raw_token, + raw_type, + created_by_user_id_str, + created_by_email, + created_by_label, + created_by_service_label, + claimer_email, + claimer_user_id_str, + claimer_human_email, + claimer_human_label, + shamir_recovery_setup, + shamir_recovery_setup_user_id_str, + shamir_recovery_setup_human_email, + shamir_recovery_setup_human_label, + shamir_recovery_setup_deleted_on, + created_on, + deleted_on, + deleted_reason, + ) = record + + type = InvitationType.from_str(raw_type) + token = InvitationToken.from_hex(raw_token) + + if created_by_user_id_str is None: + assert created_by_service_label is not None + created_by = InvitationCreatedByExternalService(service_label=created_by_service_label) + else: + assert created_by_email is not None + assert created_by_label is not None + created_by_user_id = UserID.from_hex(created_by_user_id_str) + human_handle = HumanHandle(email=created_by_email, label=created_by_label) + created_by = InvitationCreatedByUser(user_id=created_by_user_id, human_handle=human_handle) + + deleted_reason = ( + InvitationStatus.from_str(deleted_reason) if deleted_reason is not None else None + ) - return cls( + # Treat active invitation to a deleted shamir recovery as cancelled + if shamir_recovery_setup_deleted_on and not deleted_on: + deleted_on = shamir_recovery_setup_deleted_on + deleted_reason = InvitationStatus.CANCELLED + + if type == InvitationType.USER: + assert claimer_email is not None + assert claimer_user_id_str is None + assert claimer_human_label is None + assert claimer_human_email is None + assert shamir_recovery_setup is None + assert shamir_recovery_setup_user_id_str is None + assert shamir_recovery_setup_human_email is None + assert shamir_recovery_setup_human_label is None + return UserInvitationInfo( internal_id=invitation_internal_id, - type=type, token=token, + type=type, created_by=created_by, + created_on=created_on, + deleted_on=deleted_on, + deleted_reason=deleted_reason, claimer_email=claimer_email, + ) + + if type == InvitationType.DEVICE: + assert claimer_email is None + assert claimer_user_id_str is not None + assert claimer_human_label is not None + assert claimer_human_email is not None + assert shamir_recovery_setup is None + assert shamir_recovery_setup_user_id_str is None + assert shamir_recovery_setup_human_email is None + assert shamir_recovery_setup_human_label is None + claimer_user_id = UserID.from_hex(claimer_user_id_str) + claimer_human_handle = HumanHandle(email=claimer_human_email, label=claimer_human_label) + return DeviceInvitationInfo( + internal_id=invitation_internal_id, + token=token, + type=type, + created_by=created_by, + created_on=created_on, + deleted_on=deleted_on, + deleted_reason=deleted_reason, claimer_user_id=claimer_user_id, claimer_human_handle=claimer_human_handle, - shamir_recovery_setup_internal_id=shamir_recovery_setup, + ) + + if type == InvitationType.SHAMIR_RECOVERY: + assert shamir_recovery_setup is not None + assert shamir_recovery_setup_user_id_str is not None + assert shamir_recovery_setup_human_email is not None + assert shamir_recovery_setup_human_label is not None + claimer_user_id = UserID.from_hex(shamir_recovery_setup_user_id_str) + claimer_human_handle = HumanHandle( + email=shamir_recovery_setup_human_email, label=shamir_recovery_setup_human_label + ) + return ShamirRecoveryInvitationInfo( + internal_id=invitation_internal_id, + token=token, + type=type, + created_by=created_by, created_on=created_on, deleted_on=deleted_on, deleted_reason=deleted_reason, + claimer_user_id=claimer_user_id, + claimer_human_handle=claimer_human_handle, + shamir_recovery_setup_internal_id=shamir_recovery_setup, ) + assert False, f"Unexpected invitation type: {type}" + @dataclass(frozen=True) class GreetingAttemptInfo: @@ -1345,7 +1369,7 @@ async def list( invitations_with_claimer_online = self._claimers_ready[organization_id] invitations = [] for record in rows: - invitation_info = InvitationInfo.from_record(record) + invitation_info = invitation_info_from_record(record) if invitation_info.deleted_on: assert invitation_info.deleted_reason is not None @@ -1356,9 +1380,8 @@ async def list( status = InvitationStatus.IDLE invitation: Invitation - match invitation_info.type: - case InvitationType.USER: - assert invitation_info.claimer_email is not None + match invitation_info: + case UserInvitationInfo(): # Note that the `administrators` field is actually not used in the context of the `invite_list` command. # Still, we compute it here for consistency. In order to save this unnecessary query to the database, # we could update the invite API to take this difference into account. @@ -1371,9 +1394,7 @@ async def list( administrators=administrators, status=status, ) - case InvitationType.DEVICE: - assert invitation_info.claimer_user_id is not None - assert invitation_info.claimer_human_handle is not None + case DeviceInvitationInfo(): invitation = DeviceInvitation( claimer_user_id=invitation_info.claimer_user_id, claimer_human_handle=invitation_info.claimer_human_handle, @@ -1382,10 +1403,7 @@ async def list( created_on=invitation_info.created_on, status=status, ) - case InvitationType.SHAMIR_RECOVERY: - assert invitation_info.shamir_recovery_setup_internal_id is not None - assert invitation_info.claimer_user_id is not None - assert invitation_info.claimer_human_handle is not None + case ShamirRecoveryInvitationInfo(): shamir_recovery_info = await self._get_shamir_recovery_info( conn, invitation_info.shamir_recovery_setup_internal_id ) @@ -1401,8 +1419,6 @@ async def list( shamir_recovery_created_on=shamir_recovery_info.created_on, shamir_recovery_deleted_on=invitation_info.deleted_on, ) - case unknown: - assert False, unknown invitations.append(invitation) return invitations @@ -1426,9 +1442,8 @@ async def _info_as_invited( if invitation_info.deleted_on: return InviteAsInvitedInfoBadOutcome.INVITATION_DELETED - match invitation_info.type: - case InvitationType.USER: - assert invitation_info.claimer_email is not None + match invitation_info: + case UserInvitationInfo(): administrators = await self._get_administrators(conn, organization_id) return UserInvitation( created_by=invitation_info.created_by, @@ -1438,9 +1453,7 @@ async def _info_as_invited( administrators=administrators, status=InvitationStatus.READY, ) - case InvitationType.DEVICE: - assert invitation_info.claimer_user_id is not None - assert invitation_info.claimer_human_handle is not None + case DeviceInvitationInfo(): return DeviceInvitation( claimer_user_id=invitation_info.claimer_user_id, claimer_human_handle=invitation_info.claimer_human_handle, @@ -1449,10 +1462,7 @@ async def _info_as_invited( created_on=invitation_info.created_on, status=InvitationStatus.READY, ) - case InvitationType.SHAMIR_RECOVERY: - assert invitation_info.shamir_recovery_setup_internal_id is not None - assert invitation_info.claimer_user_id is not None - assert invitation_info.claimer_human_handle is not None + case ShamirRecoveryInvitationInfo(): shamir_recovery_info = await self._get_shamir_recovery_info( conn, invitation_info.shamir_recovery_setup_internal_id ) @@ -1468,8 +1478,6 @@ async def _info_as_invited( shamir_recovery_created_on=shamir_recovery_info.created_on, shamir_recovery_deleted_on=invitation_info.deleted_on, ) - case unknown: - assert False, unknown @override @transaction @@ -1503,7 +1511,7 @@ async def shamir_recovery_reveal( if invitation_info.deleted_on: return InviteShamirRecoveryRevealBadOutcome.INVITATION_DELETED - if invitation_info.type != InvitationType.SHAMIR_RECOVERY: + if not isinstance(invitation_info, ShamirRecoveryInvitationInfo): return InviteShamirRecoveryRevealBadOutcome.BAD_INVITATION_TYPE row = await conn.fetchrow( @@ -1548,7 +1556,7 @@ async def test_dump_all_invitations( # Loop over rows rows = await conn.fetch(*_q_list_all_invitations(organization_id=organization_id.str)) for record in rows: - invitation_info = InvitationInfo.from_record(record) + invitation_info = invitation_info_from_record(record) # TODO: Update method to also return invitation created by external services if not isinstance(invitation_info.created_by, InvitationCreatedByUser): @@ -1567,9 +1575,8 @@ async def test_dump_all_invitations( status = InvitationStatus.IDLE # Append the invite - match invitation_info.type: - case InvitationType.USER: - assert invitation_info.claimer_email is not None + match invitation_info: + case UserInvitationInfo(): administrators = await self._get_administrators(conn, organization_id) current_user_invitations.append( UserInvitation( @@ -1581,9 +1588,7 @@ async def test_dump_all_invitations( token=invitation_info.token, ) ) - case InvitationType.DEVICE: - assert invitation_info.claimer_user_id is not None - assert invitation_info.claimer_human_handle is not None + case DeviceInvitationInfo(): current_user_invitations.append( DeviceInvitation( created_on=invitation_info.created_on, @@ -1594,10 +1599,7 @@ async def test_dump_all_invitations( token=invitation_info.token, ) ) - case InvitationType.SHAMIR_RECOVERY: - assert invitation_info.shamir_recovery_setup_internal_id is not None - assert invitation_info.claimer_user_id is not None - assert invitation_info.claimer_human_handle is not None + case ShamirRecoveryInvitationInfo(): shamir_recovery_info = await self._get_shamir_recovery_info( conn, invitation_info.shamir_recovery_setup_internal_id ) @@ -1615,8 +1617,6 @@ async def test_dump_all_invitations( shamir_recovery_deleted_on=invitation_info.deleted_on, ) ) - case unknown: - assert False, unknown return per_user_invitations @@ -1734,20 +1734,19 @@ async def is_greeter_allowed( greeter_id: UserID, greeter_profile: UserProfile, ) -> bool: - if invitation_info.type == InvitationType.DEVICE: - return invitation_info.claimer_user_id == greeter_id - elif invitation_info.type == InvitationType.USER: - return greeter_profile == UserProfile.ADMIN - elif invitation_info.type == InvitationType.SHAMIR_RECOVERY: - row = await conn.fetchrow( - *_q_is_greeter_in_recipients( - shamir_recovery_setup_internal_id=invitation_info.shamir_recovery_setup_internal_id, - greeter_id=greeter_id, + match invitation_info: + case UserInvitationInfo(): + return greeter_profile == UserProfile.ADMIN + case DeviceInvitationInfo(): + return invitation_info.claimer_user_id == greeter_id + case ShamirRecoveryInvitationInfo(): + row = await conn.fetchrow( + *_q_is_greeter_in_recipients( + shamir_recovery_setup_internal_id=invitation_info.shamir_recovery_setup_internal_id, + greeter_id=greeter_id, + ) ) - ) - return row is not None - else: - assert False, invitation_info.type + return row is not None async def get_invitation( self, conn: AsyncpgConnection, organization_id: OrganizationID, token: InvitationToken @@ -1755,7 +1754,7 @@ async def get_invitation( row = await conn.fetchrow( *_q_info_invitation_for_share(organization_id=organization_id.str, token=token.hex) ) - return None if row is None else InvitationInfo.from_record(row) + return None if row is None else invitation_info_from_record(row) async def lock_invitation( self, @@ -1768,7 +1767,7 @@ async def lock_invitation( organization_id=organization_id.str, token=identifier.hex ) ) - return None if row is None else InvitationInfo.from_record(row) + return None if row is None else invitation_info_from_record(row) async def lock_invitation_from_greeting_attempt_id( self, @@ -1781,7 +1780,7 @@ async def lock_invitation_from_greeting_attempt_id( organization_id=organization_id.str, greeting_attempt_id=greeting_attempt_id ) ) - return None if row is None else InvitationInfo.from_record(row) + return None if row is None else invitation_info_from_record(row) async def get_greeting_attempt_info( self, @@ -2304,8 +2303,13 @@ async def complete( if not await self.is_greeter_allowed( conn, invitation_info, author_user_id, current_profile ): - if invitation_info.claimer_user_id != author_info.user_id: - return InviteCompleteBadOutcome.AUTHOR_NOT_ALLOWED + match invitation_info: + case UserInvitationInfo(): + if author_info.human_handle.email != invitation_info.claimer_email: + return InviteCompleteBadOutcome.AUTHOR_NOT_ALLOWED + case DeviceInvitationInfo() | ShamirRecoveryInvitationInfo(): + if author_user_id != invitation_info.claimer_user_id: + return InviteCompleteBadOutcome.AUTHOR_NOT_ALLOWED await conn.execute( *_q_delete_invitation( From 05dca833c993a7dcc70f45da6517bcf7db79651a Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Thu, 23 Jan 2025 16:10:57 +0100 Subject: [PATCH 05/11] Refactor postgresql invitation handling 3 --- server/parsec/components/memory/datamodel.py | 6 +- server/parsec/components/memory/invite.py | 12 +- server/parsec/components/postgresql/invite.py | 336 ++++++++++-------- 3 files changed, 205 insertions(+), 149 deletions(-) diff --git a/server/parsec/components/memory/datamodel.py b/server/parsec/components/memory/datamodel.py index ca8d4a7e84f..f46681361b4 100644 --- a/server/parsec/components/memory/datamodel.py +++ b/server/parsec/components/memory/datamodel.py @@ -299,13 +299,13 @@ class MemoryInvitation: type: InvitationType created_by: InvitationCreatedBy - # Required for when type=USER or type=SHAMIR_RECOVERY + # Required when type=USER or type=SHAMIR_RECOVERY claimer_email: str | None - # Required for when type=DEVICE or type=SHAMIR_RECOVERY + # Required when type=DEVICE or type=SHAMIR_RECOVERY claimer_user_id: UserID | None - # Required for when type=SHAMIR_RECOVERY + # Required when type=SHAMIR_RECOVERY shamir_recovery_index: int | None created_on: DateTime diff --git a/server/parsec/components/memory/invite.py b/server/parsec/components/memory/invite.py index 418fc89fe02..dc7cd11dbfe 100644 --- a/server/parsec/components/memory/invite.py +++ b/server/parsec/components/memory/invite.py @@ -1042,8 +1042,16 @@ async def complete( # Only the greeter or the claimer can complete the invitation if not self.is_greeter_allowed(org, invitation, author_user): - if invitation.claimer_user_id != author_user.cooked.user_id: - return InviteCompleteBadOutcome.AUTHOR_NOT_ALLOWED + if invitation.type == InvitationType.USER: + assert invitation.claimer_email is not None + if invitation.claimer_email != author_user.cooked.human_handle.email: + return InviteCompleteBadOutcome.AUTHOR_NOT_ALLOWED + elif invitation.type in (InvitationType.DEVICE, InvitationType.SHAMIR_RECOVERY): + assert invitation.claimer_user_id is not None + if invitation.claimer_user_id != author_user.cooked.user_id: + return InviteCompleteBadOutcome.AUTHOR_NOT_ALLOWED + else: + assert False, invitation.type invitation.deleted_on = now invitation.deleted_reason = MemoryInvitationDeletedReason.FINISHED diff --git a/server/parsec/components/postgresql/invite.py b/server/parsec/components/postgresql/invite.py index 046578f39dc..3c7353eb98e 100644 --- a/server/parsec/components/postgresql/invite.py +++ b/server/parsec/components/postgresql/invite.py @@ -105,65 +105,92 @@ class ShamirRecoveryInvitationInfo(BaseInvitationInfo): claimer_user_id: UserID claimer_human_handle: HumanHandle shamir_recovery_setup_internal_id: int + shamir_recovery_threshold: int + shamir_recovery_created_on: DateTime + shamir_recovery_deleted_on: DateTime | None InvitationInfo: TypeAlias = UserInvitationInfo | DeviceInvitationInfo | ShamirRecoveryInvitationInfo def invitation_info_from_record(record: Record) -> InvitationInfo: - ( - invitation_internal_id, - raw_token, - raw_type, - created_by_user_id_str, - created_by_email, - created_by_label, - created_by_service_label, - claimer_email, - claimer_user_id_str, - claimer_human_email, - claimer_human_label, - shamir_recovery_setup, - shamir_recovery_setup_user_id_str, - shamir_recovery_setup_human_email, - shamir_recovery_setup_human_label, - shamir_recovery_setup_deleted_on, - created_on, - deleted_on, - deleted_reason, - ) = record - - type = InvitationType.from_str(raw_type) - token = InvitationToken.from_hex(raw_token) - - if created_by_user_id_str is None: - assert created_by_service_label is not None - created_by = InvitationCreatedByExternalService(service_label=created_by_service_label) - else: - assert created_by_email is not None - assert created_by_label is not None - created_by_user_id = UserID.from_hex(created_by_user_id_str) - human_handle = HumanHandle(email=created_by_email, label=created_by_label) - created_by = InvitationCreatedByUser(user_id=created_by_user_id, human_handle=human_handle) - - deleted_reason = ( - InvitationStatus.from_str(deleted_reason) if deleted_reason is not None else None - ) - - # Treat active invitation to a deleted shamir recovery as cancelled - if shamir_recovery_setup_deleted_on and not deleted_on: - deleted_on = shamir_recovery_setup_deleted_on - deleted_reason = InvitationStatus.CANCELLED + match record["invitation_internal_id"]: + case int() as invitation_internal_id: + pass + case unknown: + assert False, repr(unknown) + + match record["token"]: + case str() as raw_token: + token = InvitationToken.from_hex(raw_token) + case unknown: + assert False, repr(unknown) + + match record["type"]: + case str() as raw_type: + type = InvitationType.from_str(raw_type) + case unknown: + assert False, repr(unknown) + + match record["created_by_user_id"]: + case None: + match record["created_by_service_label"]: + case str() as created_by_service_label: + created_by = InvitationCreatedByExternalService( + service_label=created_by_service_label + ) + case unknown: + assert False, repr(unknown) + case str() as created_by_user_id_str: + match (record["created_by_email"], record["created_by_label"]): + case (str() as created_by_email, str() as created_by_label): + created_by = InvitationCreatedByUser( + user_id=UserID.from_hex(created_by_user_id_str), + human_handle=HumanHandle(email=created_by_email, label=created_by_label), + ) + case unknown: + assert False, repr(unknown) + case unknown: + assert False, repr(unknown) + + match record["created_on"]: + case DateTime() as created_on: + pass + case unknown: + assert False, repr(unknown) + + match record["deleted_on"]: + case DateTime() | None as deleted_on: + pass + case unknown: + assert False, repr(unknown) + + match record["deleted_reason"]: + case None as deleted_reason: + pass + case str() as deleted_reason_str: + deleted_reason = InvitationStatus.from_str(deleted_reason_str) + case unknown: + assert False, repr(unknown) if type == InvitationType.USER: - assert claimer_email is not None - assert claimer_user_id_str is None - assert claimer_human_label is None - assert claimer_human_email is None - assert shamir_recovery_setup is None - assert shamir_recovery_setup_user_id_str is None - assert shamir_recovery_setup_human_email is None - assert shamir_recovery_setup_human_label is None + match record["claimer_email"]: + case str() as claimer_email: + pass + case unknown: + assert False, repr(unknown) + + assert record["claimer_user_id"] is None + assert record["claimer_human_email"] is None + assert record["claimer_human_label"] is None + assert record["shamir_recovery_internal_id"] is None + assert record["shamir_recovery_user_id"] is None + assert record["shamir_recovery_human_email"] is None + assert record["shamir_recovery_human_label"] is None + assert record["shamir_recovery_created_on"] is None + assert record["shamir_recovery_deleted_on"] is None + assert record["shamir_recovery_threshold"] is None + return UserInvitationInfo( internal_id=invitation_internal_id, token=token, @@ -176,16 +203,29 @@ def invitation_info_from_record(record: Record) -> InvitationInfo: ) if type == InvitationType.DEVICE: - assert claimer_email is None - assert claimer_user_id_str is not None - assert claimer_human_label is not None - assert claimer_human_email is not None - assert shamir_recovery_setup is None - assert shamir_recovery_setup_user_id_str is None - assert shamir_recovery_setup_human_email is None - assert shamir_recovery_setup_human_label is None - claimer_user_id = UserID.from_hex(claimer_user_id_str) - claimer_human_handle = HumanHandle(email=claimer_human_email, label=claimer_human_label) + match record["claimer_user_id"]: + case str() as claimer_user_id_str: + claimer_user_id = UserID.from_hex(claimer_user_id_str) + case unknown: + assert False, repr(unknown) + + match (record["claimer_human_email"], record["claimer_human_label"]): + case (str() as claimer_human_email, str() as claimer_human_label): + claimer_human_handle = HumanHandle( + email=claimer_human_email, label=claimer_human_label + ) + case unknown: + assert False, repr(unknown) + + assert record["claimer_email"] is None + assert record["shamir_recovery_internal_id"] is None + assert record["shamir_recovery_user_id"] is None + assert record["shamir_recovery_human_email"] is None + assert record["shamir_recovery_human_label"] is None + assert record["shamir_recovery_created_on"] is None + assert record["shamir_recovery_deleted_on"] is None + assert record["shamir_recovery_threshold"] is None + return DeviceInvitationInfo( internal_id=invitation_internal_id, token=token, @@ -199,14 +239,57 @@ def invitation_info_from_record(record: Record) -> InvitationInfo: ) if type == InvitationType.SHAMIR_RECOVERY: - assert shamir_recovery_setup is not None - assert shamir_recovery_setup_user_id_str is not None - assert shamir_recovery_setup_human_email is not None - assert shamir_recovery_setup_human_label is not None - claimer_user_id = UserID.from_hex(shamir_recovery_setup_user_id_str) - claimer_human_handle = HumanHandle( - email=shamir_recovery_setup_human_email, label=shamir_recovery_setup_human_label - ) + match record["shamir_recovery_internal_id"]: + case int() as shamir_recovery_setup_internal_id: + pass + case unknown: + assert False, repr(unknown) + + match record["shamir_recovery_user_id"]: + case str() as shamir_recovery_setup_user_id_str: + claimer_user_id = UserID.from_hex(shamir_recovery_setup_user_id_str) + case unknown: + assert False, repr(unknown) + + match (record["shamir_recovery_human_email"], record["shamir_recovery_human_label"]): + case ( + str() as shamir_recovery_setup_human_email, + str() as shamir_recovery_setup_human_label, + ): + claimer_human_handle = HumanHandle( + email=shamir_recovery_setup_human_email, label=shamir_recovery_setup_human_label + ) + case unknown: + assert False, repr(unknown) + + match record["shamir_recovery_threshold"]: + case int() as shamir_recovery_threshold: + pass + case unknown: + assert False, repr(unknown) + + match record["shamir_recovery_created_on"]: + case DateTime() as shamir_recovery_created_on: + pass + case unknown: + assert False, repr(unknown) + + match record["shamir_recovery_deleted_on"]: + case DateTime() | None as shamir_recovery_deleted_on: + pass + case unknown: + assert False, repr(unknown) + + assert record["claimer_email"] is None + assert record["claimer_user_id"] is None + assert record["claimer_human_email"] is None + assert record["claimer_human_label"] is None + + # Treat active invitation to a deleted shamir recovery as cancelled + if shamir_recovery_deleted_on is not None and deleted_on is None: + deleted_on = shamir_recovery_deleted_on + deleted_reason = InvitationStatus.CANCELLED + return ShamirRecoveryInvitationInfo( internal_id=invitation_internal_id, token=token, @@ -217,7 +300,10 @@ def invitation_info_from_record(record: Record) -> InvitationInfo: deleted_reason=deleted_reason, claimer_user_id=claimer_user_id, claimer_human_handle=claimer_human_handle, - shamir_recovery_setup_internal_id=shamir_recovery_setup, + shamir_recovery_setup_internal_id=shamir_recovery_setup_internal_id, + shamir_recovery_threshold=shamir_recovery_threshold, + shamir_recovery_created_on=shamir_recovery_created_on, + shamir_recovery_deleted_on=shamir_recovery_deleted_on, ) assert False, f"Unexpected invitation type: {type}" @@ -276,13 +362,6 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: ) -@dataclass -class ShamirRecoverySetupInfo: - threshold: int - recipients: list[ShamirRecoveryRecipient] - created_on: DateTime - - _q_retrieve_shamir_recovery_setup = Q( """ -- We do not lock the shamir topic in read here. @@ -386,17 +465,6 @@ class ShamirRecoverySetupInfo: """ ) -_q_retrieve_shamir_recovery_setup_info = Q( - """ -SELECT - shamir_recovery_setup.threshold, - shamir_recovery_setup.created_on -FROM shamir_recovery_setup -WHERE - shamir_recovery_setup._id = $internal_shamir_recovery_setup_id -""" -) - _q_retrieve_shamir_recovery_recipients = Q( """ SELECT @@ -459,18 +527,20 @@ class ShamirRecoverySetupInfo: invitation.token, invitation.type, user_.user_id AS created_by_user_id, - human.email, - human.label, + human.email AS created_by_email, + human.label AS created_by_label, invitation.created_by_service_label, invitation.claimer_email, claimer_user.user_id as claimer_user_id, claimer_human.email AS claimer_human_email, claimer_human.label AS claimer_human_label, - invitation.shamir_recovery, + invitation.shamir_recovery AS shamir_recovery_internal_id, shamir_recovery_user.user_id AS shamir_recovery_user_id, shamir_recovery_human.email AS shamir_recovery_human_email, shamir_recovery_human.label AS shamir_recovery_human_label, - shamir_recovery_setup.deleted_on, + shamir_recovery_setup.threshold AS shamir_recovery_threshold, + shamir_recovery_setup.created_on AS shamir_recovery_created_on, + shamir_recovery_setup.deleted_on AS shamir_recovery_deleted_on, invitation.created_on, invitation.deleted_on, invitation.deleted_reason @@ -511,11 +581,13 @@ class ShamirRecoverySetupInfo: claimer_user.user_id as claimer_user_id, claimer_human.email AS claimer_human_email, claimer_human.label AS claimer_human_label, - invitation.shamir_recovery, + invitation.shamir_recovery AS shamir_recovery_internal_id, shamir_recovery_user.user_id AS shamir_recovery_user_id, shamir_recovery_human.email AS shamir_recovery_human_email, shamir_recovery_human.label AS shamir_recovery_human_label, - shamir_recovery_setup.deleted_on, + shamir_recovery_setup.threshold AS shamir_recovery_threshold, + shamir_recovery_setup.created_on AS shamir_recovery_created_on, + shamir_recovery_setup.deleted_on AS shamir_recovery_deleted_on, invitation.created_on, invitation.deleted_on, invitation.deleted_reason @@ -568,18 +640,20 @@ def make_q_info_invitation( invitation.token, invitation.type, user_.user_id as created_by_user_id, - human.email, - human.label, + human.email AS created_by_email, + human.label AS created_by_label, invitation.created_by_service_label, invitation.claimer_email, claimer_user.user_id as claimer_user_id, claimer_human.email AS claimer_human_email, claimer_human.label AS claimer_human_label, - invitation.shamir_recovery, + invitation.shamir_recovery AS shamir_recovery_internal_id, shamir_recovery_user.user_id AS shamir_recovery_user_id, shamir_recovery_human.email AS shamir_recovery_human_email, shamir_recovery_human.label AS shamir_recovery_human_label, - shamir_recovery_setup.deleted_on, + shamir_recovery_setup.threshold AS shamir_recovery_threshold, + shamir_recovery_setup.created_on AS shamir_recovery_created_on, + shamir_recovery_setup.deleted_on AS shamir_recovery_deleted_on, invitation.created_on, invitation.deleted_on, invitation.deleted_reason @@ -1170,13 +1244,10 @@ async def new_for_shamir_recovery( case unknown: assert False, repr(unknown) - shamir_recovery_setup_info = await self._get_shamir_recovery_info( + shamir_recovery_recipients = await self._get_shamir_recovery_recipients( conn, internal_shamir_recovery_setup_id=shamir_recovery_setup ) - if not any( - author_user_id == recipient.user_id - for recipient in shamir_recovery_setup_info.recipients - ): + if not any(author_user_id == recipient.user_id for recipient in shamir_recovery_recipients): return InviteNewForShamirRecoveryBadOutcome.AUTHOR_NOT_ALLOWED suggested_token = force_token or InvitationToken.new() @@ -1213,28 +1284,9 @@ async def new_for_shamir_recovery( return token, send_email_outcome - async def _get_shamir_recovery_info( + async def _get_shamir_recovery_recipients( self, conn: AsyncpgConnection, internal_shamir_recovery_setup_id: int - ) -> ShamirRecoverySetupInfo: - row = await conn.fetchrow( - *_q_retrieve_shamir_recovery_setup_info( - internal_shamir_recovery_setup_id=internal_shamir_recovery_setup_id - ) - ) - assert row is not None - - match row["threshold"]: - case int() as threshold: - pass - case unknown: - assert False, repr(unknown) - - match row["created_on"]: - case DateTime() as created_on: - pass - case unknown: - assert False, repr(unknown) - + ) -> list[ShamirRecoveryRecipient]: rows = await conn.fetch( *_q_retrieve_shamir_recovery_recipients( internal_shamir_recovery_setup_id=internal_shamir_recovery_setup_id @@ -1252,11 +1304,7 @@ async def _get_shamir_recovery_info( ] recipients.sort(key=lambda x: x.human_handle.label) - return ShamirRecoverySetupInfo( - threshold=threshold, - recipients=recipients, - created_on=created_on, - ) + return recipients async def _get_administrators( self, conn: AsyncpgConnection, organization_id: OrganizationID @@ -1404,7 +1452,7 @@ async def list( status=status, ) case ShamirRecoveryInvitationInfo(): - shamir_recovery_info = await self._get_shamir_recovery_info( + shamir_recovery_recipients = await self._get_shamir_recovery_recipients( conn, invitation_info.shamir_recovery_setup_internal_id ) invitation = ShamirRecoveryInvitation( @@ -1414,10 +1462,10 @@ async def list( status=status, claimer_user_id=invitation_info.claimer_user_id, claimer_human_handle=invitation_info.claimer_human_handle, - threshold=shamir_recovery_info.threshold, - recipients=shamir_recovery_info.recipients, - shamir_recovery_created_on=shamir_recovery_info.created_on, - shamir_recovery_deleted_on=invitation_info.deleted_on, + threshold=invitation_info.shamir_recovery_threshold, + recipients=shamir_recovery_recipients, + shamir_recovery_created_on=invitation_info.shamir_recovery_created_on, + shamir_recovery_deleted_on=invitation_info.shamir_recovery_deleted_on, ) invitations.append(invitation) @@ -1463,7 +1511,7 @@ async def _info_as_invited( status=InvitationStatus.READY, ) case ShamirRecoveryInvitationInfo(): - shamir_recovery_info = await self._get_shamir_recovery_info( + shamir_recovery_recipients = await self._get_shamir_recovery_recipients( conn, invitation_info.shamir_recovery_setup_internal_id ) return ShamirRecoveryInvitation( @@ -1473,10 +1521,10 @@ async def _info_as_invited( status=InvitationStatus.READY, claimer_user_id=invitation_info.claimer_user_id, claimer_human_handle=invitation_info.claimer_human_handle, - threshold=shamir_recovery_info.threshold, - recipients=shamir_recovery_info.recipients, - shamir_recovery_created_on=shamir_recovery_info.created_on, - shamir_recovery_deleted_on=invitation_info.deleted_on, + threshold=invitation_info.shamir_recovery_threshold, + recipients=shamir_recovery_recipients, + shamir_recovery_created_on=invitation_info.shamir_recovery_created_on, + shamir_recovery_deleted_on=invitation_info.shamir_recovery_deleted_on, ) @override @@ -1600,7 +1648,7 @@ async def test_dump_all_invitations( ) ) case ShamirRecoveryInvitationInfo(): - shamir_recovery_info = await self._get_shamir_recovery_info( + shamir_recovery_recipients = await self._get_shamir_recovery_recipients( conn, invitation_info.shamir_recovery_setup_internal_id ) current_user_invitations.append( @@ -1611,10 +1659,10 @@ async def test_dump_all_invitations( token=invitation_info.token, claimer_human_handle=invitation_info.claimer_human_handle, claimer_user_id=invitation_info.claimer_user_id, - threshold=shamir_recovery_info.threshold, - recipients=shamir_recovery_info.recipients, - shamir_recovery_created_on=shamir_recovery_info.created_on, - shamir_recovery_deleted_on=invitation_info.deleted_on, + threshold=invitation_info.shamir_recovery_threshold, + recipients=shamir_recovery_recipients, + shamir_recovery_created_on=invitation_info.shamir_recovery_created_on, + shamir_recovery_deleted_on=invitation_info.shamir_recovery_deleted_on, ) ) From adb3aa6b465e4cd34856ec08075b2c7916904ce4 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Thu, 23 Jan 2025 19:03:54 +0100 Subject: [PATCH 06/11] Add last_greeting_attempt_joined_on field to UserGreetingAdministrator --- .../client/tests/unit/invite/claimer.rs | 1 + .../schema/invited_cmds/invite_info.json5 | 4 ++ .../tests/invited_cmds/v5/invite_info.rs | 52 +++++++++++++------ .../protocol/invited_cmds/v5/invite_info.pyi | 8 ++- server/parsec/components/memory/invite.py | 19 +++++-- server/parsec/components/postgresql/invite.py | 35 ++++++++++--- .../authenticated/test_invite_new_user.py | 2 + .../tests/api_v5/invited/test_invite_info.py | 37 ++++++++++++- 8 files changed, 127 insertions(+), 31 deletions(-) diff --git a/libparsec/crates/client/tests/unit/invite/claimer.rs b/libparsec/crates/client/tests/unit/invite/claimer.rs index 75afb1009a5..097a9d177e0 100644 --- a/libparsec/crates/client/tests/unit/invite/claimer.rs +++ b/libparsec/crates/client/tests/unit/invite/claimer.rs @@ -52,6 +52,7 @@ async fn claimer(tmp_path: TmpPath, env: &TestbedEnv) { user_id: alice.user_id.to_owned(), human_handle: alice.human_handle.to_owned(), online_status: protocol::invited_cmds::latest::invite_info::UserOnlineStatus::Online, + last_greeting_attempt_joined_on: None, }, ], }, diff --git a/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 b/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 index 136c4587f9a..baf24eec928 100644 --- a/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 +++ b/libparsec/crates/protocol/schema/invited_cmds/invite_info.json5 @@ -147,6 +147,10 @@ { "name": "online_status", "type": "UserOnlineStatus" + }, + { + "name": "last_greeting_attempt_joined_on", + "type": "RequiredOption" } ] }, diff --git a/libparsec/crates/protocol/tests/invited_cmds/v5/invite_info.rs b/libparsec/crates/protocol/tests/invited_cmds/v5/invite_info.rs index 1a3258cdabe..12972fef706 100644 --- a/libparsec/crates/protocol/tests/invited_cmds/v5/invite_info.rs +++ b/libparsec/crates/protocol/tests/invited_cmds/v5/invite_info.rs @@ -49,19 +49,24 @@ pub fn rep_ok() { // Content: // status: 'ok' // type: 'USER' - // administrators: [ { human_handle: [ 'bob@dev1', 'bob', ], online_status: 'UNKNOWN', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), }, { human_handle: [ 'carl@dev1', 'carl', ], online_status: 'ONLINE', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4b), }, ] + // administrators: [ + // human_handle: [ 'bob@dev1', 'bob', ], online_status: 'UNKNOWN', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), last_greeting_attempt_joined_on: None + // human_handle: [ 'carl@dev1', 'carl', ], online_status: 'ONLINE', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4b), last_greeting_attempt_joined_on: ext(1, 946774800000000) i.e. 2000-01-02T02:00:00Z + // ] // claimer_email: 'alice@dev1' // created_by: { type: 'USER', human_handle: [ 'bob@dev1', 'bob', ], user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), } &hex!( "85a6737461747573a26f6ba474797065a455534552ae61646d696e6973747261746f72" - "739283ac68756d616e5f68616e646c6592a8626f624064657631a3626f62ad6f6e6c69" - "6e655f737461747573a7554e4b4e4f574ea7757365725f6964d802109b68ba5cdf428e" - "a0017fc6bcc04d4a83ac68756d616e5f68616e646c6592a96361726c4064657631a463" - "61726cad6f6e6c696e655f737461747573a64f4e4c494e45a7757365725f6964d80210" - "9b68ba5cdf428ea0017fc6bcc04d4bad636c61696d65725f656d61696caa616c696365" - "4064657631aa637265617465645f627983a474797065a455534552ac68756d616e5f68" - "616e646c6592a8626f624064657631a3626f62a7757365725f6964d802109b68ba5cdf" - "428ea0017fc6bcc04d4a" + "739284ac68756d616e5f68616e646c6592a8626f624064657631a3626f62bf6c617374" + "5f6772656574696e675f617474656d70745f6a6f696e65645f6f6ec0ad6f6e6c696e65" + "5f737461747573a7554e4b4e4f574ea7757365725f6964d802109b68ba5cdf428ea001" + "7fc6bcc04d4a84ac68756d616e5f68616e646c6592a96361726c4064657631a4636172" + "6cbf6c6173745f6772656574696e675f617474656d70745f6a6f696e65645f6f6ed701" + "00035d162fa2e400ad6f6e6c696e655f737461747573a64f4e4c494e45a7757365725f" + "6964d802109b68ba5cdf428ea0017fc6bcc04d4bad636c61696d65725f656d61696caa" + "616c6963654064657631aa637265617465645f627983a474797065a455534552ac6875" + "6d616e5f68616e646c6592a8626f624064657631a3626f62a7757365725f6964d80210" + "9b68ba5cdf428ea0017fc6bcc04d4a" )[..], invited_cmds::invite_info::Rep::Ok(invited_cmds::invite_info::InvitationType::User { claimer_email: "alice@dev1".to_owned(), @@ -74,11 +79,15 @@ pub fn rep_ok() { human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), online_status: invited_cmds::invite_info::UserOnlineStatus::Unknown, + last_greeting_attempt_joined_on: None, }, invited_cmds::invite_info::UserGreetingAdministrator { human_handle: HumanHandle::new("carl@dev1", "carl").unwrap(), user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4b").unwrap(), online_status: invited_cmds::invite_info::UserOnlineStatus::Online, + last_greeting_attempt_joined_on: Some( + "2000-1-2T01:00:00Z".parse().unwrap(), + ), }, ], }), @@ -88,18 +97,23 @@ pub fn rep_ok() { // Content: // status: 'ok' // type: 'USER' - // administrators: [ { human_handle: [ 'bob@dev1', 'bob', ], online_status: 'UNKNOWN', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), }, { human_handle: [ 'carl@dev1', 'carl', ], online_status: 'ONLINE', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4b), }, ] + // administrators: [ + // human_handle: [ 'bob@dev1', 'bob', ], online_status: 'UNKNOWN', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4a), last_greeting_attempt_joined_on: None + // human_handle: [ 'carl@dev1', 'carl', ], online_status: 'ONLINE', user_id: ext(2, 0x109b68ba5cdf428ea0017fc6bcc04d4b), last_greeting_attempt_joined_on: ext(1, 946774800000000) i.e. 2000-01-02T02:00:00Z + // ] // claimer_email: 'alice@dev1' // created_by: { type: 'EXTERNAL_SERVICE', service_label: 'LDAP', } &hex!( "85a6737461747573a26f6ba474797065a455534552ae61646d696e6973747261746f72" - "739283ac68756d616e5f68616e646c6592a8626f624064657631a3626f62ad6f6e6c69" - "6e655f737461747573a7554e4b4e4f574ea7757365725f6964d802109b68ba5cdf428e" - "a0017fc6bcc04d4a83ac68756d616e5f68616e646c6592a96361726c4064657631a463" - "61726cad6f6e6c696e655f737461747573a64f4e4c494e45a7757365725f6964d80210" - "9b68ba5cdf428ea0017fc6bcc04d4bad636c61696d65725f656d61696caa616c696365" - "4064657631aa637265617465645f627982a474797065b045585445524e414c5f534552" - "56494345ad736572766963655f6c6162656ca44c444150" + "739284ac68756d616e5f68616e646c6592a8626f624064657631a3626f62bf6c617374" + "5f6772656574696e675f617474656d70745f6a6f696e65645f6f6ec0ad6f6e6c696e65" + "5f737461747573a7554e4b4e4f574ea7757365725f6964d802109b68ba5cdf428ea001" + "7fc6bcc04d4a84ac68756d616e5f68616e646c6592a96361726c4064657631a4636172" + "6cbf6c6173745f6772656574696e675f617474656d70745f6a6f696e65645f6f6ed701" + "00035d162fa2e400ad6f6e6c696e655f737461747573a64f4e4c494e45a7757365725f" + "6964d802109b68ba5cdf428ea0017fc6bcc04d4bad636c61696d65725f656d61696caa" + "616c6963654064657631aa637265617465645f627982a474797065b045585445524e41" + "4c5f53455256494345ad736572766963655f6c6162656ca44c444150" )[..], invited_cmds::invite_info::Rep::Ok(invited_cmds::invite_info::InvitationType::User { claimer_email: "alice@dev1".to_owned(), @@ -111,11 +125,15 @@ pub fn rep_ok() { human_handle: HumanHandle::new("bob@dev1", "bob").unwrap(), user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4a").unwrap(), online_status: invited_cmds::invite_info::UserOnlineStatus::Unknown, + last_greeting_attempt_joined_on: None, }, invited_cmds::invite_info::UserGreetingAdministrator { human_handle: HumanHandle::new("carl@dev1", "carl").unwrap(), user_id: UserID::from_hex("109b68ba5cdf428ea0017fc6bcc04d4b").unwrap(), online_status: invited_cmds::invite_info::UserOnlineStatus::Online, + last_greeting_attempt_joined_on: Some( + "2000-1-2T01:00:00Z".parse().unwrap(), + ), }, ], }), diff --git a/server/parsec/_parsec_pyi/protocol/invited_cmds/v5/invite_info.pyi b/server/parsec/_parsec_pyi/protocol/invited_cmds/v5/invite_info.pyi index 6d3679f05f7..91c4e550834 100644 --- a/server/parsec/_parsec_pyi/protocol/invited_cmds/v5/invite_info.pyi +++ b/server/parsec/_parsec_pyi/protocol/invited_cmds/v5/invite_info.pyi @@ -88,11 +88,17 @@ class InvitationCreatedByExternalService(InvitationCreatedBy): class UserGreetingAdministrator: def __init__( - self, user_id: UserID, human_handle: HumanHandle, online_status: UserOnlineStatus + self, + user_id: UserID, + human_handle: HumanHandle, + online_status: UserOnlineStatus, + last_greeting_attempt_joined_on: DateTime | None, ) -> None: ... @property def human_handle(self) -> HumanHandle: ... @property + def last_greeting_attempt_joined_on(self) -> DateTime | None: ... + @property def online_status(self) -> UserOnlineStatus: ... @property def user_id(self) -> UserID: ... diff --git a/server/parsec/components/memory/invite.py b/server/parsec/components/memory/invite.py index dc7cd11dbfe..f650305d486 100644 --- a/server/parsec/components/memory/invite.py +++ b/server/parsec/components/memory/invite.py @@ -134,12 +134,23 @@ def _get_shamir_recovery_invitation( shamir_recovery_deleted_on=shamir_recovery.deleted_on, ) - def _get_administrators(self, org: MemoryOrganization) -> list[UserGreetingAdministrator]: + def _get_administrators( + self, org: MemoryOrganization, invitation: MemoryInvitation + ) -> list[UserGreetingAdministrator]: + user_id_to_last_greeter_joined = {} + for user_id, session in invitation.greeting_sessions.items(): + for greeting_attempt_id in reversed(session.greeting_attempts): + greeting_attempt = org.greeting_attempts[greeting_attempt_id] + if greeting_attempt.greeter_joined is not None: + user_id_to_last_greeter_joined[user_id] = greeting_attempt.greeter_joined + break + return [ UserGreetingAdministrator( user_id=user_id, human_handle=user.cooked.human_handle, online_status=UserOnlineStatus.UNKNOWN, + last_greeting_attempt_joined_on=user_id_to_last_greeter_joined.get(user_id), ) for user_id, user in org.users.items() if user.current_profile == UserProfile.ADMIN and not user.is_revoked @@ -522,7 +533,7 @@ async def list( token=invitation.token, created_on=invitation.created_on, created_by=invitation.created_by, - administrators=self._get_administrators(org), + administrators=self._get_administrators(org, invitation), status=status, ) case InvitationType.DEVICE: @@ -586,7 +597,7 @@ async def info_as_invited( status=self._get_invitation_status(organization_id, invitation), created_by=invitation.created_by, token=invitation.token, - administrators=self._get_administrators(org), + administrators=self._get_administrators(org, invitation), ) case InvitationType.DEVICE: assert invitation.claimer_user_id is not None @@ -672,7 +683,7 @@ async def test_dump_all_invitations( status=self._get_invitation_status(organization_id, invitation), created_by=invitation.created_by, token=invitation.token, - administrators=self._get_administrators(org), + administrators=self._get_administrators(org, invitation), ) ) case InvitationType.DEVICE: diff --git a/server/parsec/components/postgresql/invite.py b/server/parsec/components/postgresql/invite.py index 3c7353eb98e..6fdf90ad284 100644 --- a/server/parsec/components/postgresql/invite.py +++ b/server/parsec/components/postgresql/invite.py @@ -940,14 +940,20 @@ async def q_take_invitation_create_write_lock( SELECT user_.user_id, human.email, - human.label + human.label, + MAX(greeting_attempt.greeter_joined) AS last_greeter_joined FROM user_ -INNER JOIN human ON user_.human = human._id INNER JOIN organization ON user_.organization = organization._id +INNER JOIN human ON user_.human = human._id +LEFT JOIN greeting_session ON user_._id = greeting_session.greeter +LEFT JOIN invitation ON greeting_session.invitation = invitation._id +LEFT JOIN greeting_attempt ON greeting_session._id = greeting_attempt.greeting_session WHERE organization.organization_id = $organization_id AND user_.current_profile = 'ADMIN' AND user_.revoked_on IS NULL + AND (invitation.token = $token OR invitation.token IS NULL) +GROUP BY user_.user_id, human.email, human.label """ ) @@ -1307,10 +1313,12 @@ async def _get_shamir_recovery_recipients( return recipients async def _get_administrators( - self, conn: AsyncpgConnection, organization_id: OrganizationID + self, conn: AsyncpgConnection, organization_id: OrganizationID, token: InvitationToken ) -> list[UserGreetingAdministrator]: administrators = [] - rows = await conn.fetch(*_q_list_administrators(organization_id=organization_id.str)) + rows = await conn.fetch( + *_q_list_administrators(organization_id=organization_id.str, token=token.hex) + ) for row in rows: match row["user_id"]: case str() as raw_user_id: @@ -1324,11 +1332,18 @@ async def _get_administrators( case unknown: assert False, repr(unknown) + match row["last_greeter_joined"]: + case DateTime() | None as last_greeting_attempt_joined_on: + pass + case unknown: + assert False, repr(unknown) + administrators.append( UserGreetingAdministrator( user_id=user_id, human_handle=human_handle, online_status=UserOnlineStatus.UNKNOWN, + last_greeting_attempt_joined_on=last_greeting_attempt_joined_on, ) ) @@ -1433,7 +1448,9 @@ async def list( # Note that the `administrators` field is actually not used in the context of the `invite_list` command. # Still, we compute it here for consistency. In order to save this unnecessary query to the database, # we could update the invite API to take this difference into account. - administrators = await self._get_administrators(conn, organization_id) + administrators = await self._get_administrators( + conn, organization_id, invitation_info.token + ) invitation = UserInvitation( created_by=invitation_info.created_by, claimer_email=invitation_info.claimer_email, @@ -1492,7 +1509,7 @@ async def _info_as_invited( match invitation_info: case UserInvitationInfo(): - administrators = await self._get_administrators(conn, organization_id) + administrators = await self._get_administrators(conn, organization_id, token) return UserInvitation( created_by=invitation_info.created_by, claimer_email=invitation_info.claimer_email, @@ -1625,7 +1642,9 @@ async def test_dump_all_invitations( # Append the invite match invitation_info: case UserInvitationInfo(): - administrators = await self._get_administrators(conn, organization_id) + administrators = await self._get_administrators( + conn, organization_id, invitation_info.token + ) current_user_invitations.append( UserInvitation( claimer_email=invitation_info.claimer_email, @@ -1723,7 +1742,7 @@ async def join_or_cancel( cancelled_on = await conn.fetchval( *request( greeting_attempt_internal_id=greeting_attempt_internal_id, - now=DateTime.now(), + now=now, ) ) return cancelled_on is None diff --git a/server/tests/api_v5/authenticated/test_invite_new_user.py b/server/tests/api_v5/authenticated/test_invite_new_user.py index 8798a9b01b1..784903d522d 100644 --- a/server/tests/api_v5/authenticated/test_invite_new_user.py +++ b/server/tests/api_v5/authenticated/test_invite_new_user.py @@ -61,6 +61,7 @@ async def test_authenticated_invite_new_user_ok_new( minimalorg.alice.user_id, minimalorg.alice.human_handle, UserOnlineStatus.UNKNOWN, + None, ) ], status=InvitationStatus.IDLE, @@ -218,6 +219,7 @@ async def _mocked_send_email(*args, **kwargs): minimalorg.alice.user_id, minimalorg.alice.human_handle, UserOnlineStatus.UNKNOWN, + None, ) ], status=InvitationStatus.IDLE, diff --git a/server/tests/api_v5/invited/test_invite_info.py b/server/tests/api_v5/invited/test_invite_info.py index ac52322c3d1..cda17a1beb5 100644 --- a/server/tests/api_v5/invited/test_invite_info.py +++ b/server/tests/api_v5/invited/test_invite_info.py @@ -2,7 +2,7 @@ import pytest -from parsec._parsec import DateTime, RevokedUserCertificate, invited_cmds +from parsec._parsec import DateTime, GreetingAttemptID, RevokedUserCertificate, invited_cmds from parsec.components.invite import ( InvitationCreatedByUser, UserGreetingAdministrator, @@ -28,6 +28,7 @@ async def test_invited_invite_info_ok(user_or_device: str, coolorg: CoolorgRpcCl user_id=coolorg.alice.user_id, human_handle=coolorg.alice.human_handle, online_status=UserOnlineStatus.UNKNOWN, + last_greeting_attempt_joined_on=None, ), ], ) @@ -50,6 +51,40 @@ async def test_invited_invite_info_ok(user_or_device: str, coolorg: CoolorgRpcCl assert False, unknown +async def test_invited_invite_info_for_user_with_greeting_attempt( + coolorg: CoolorgRpcClients, + backend: Backend, +) -> None: + now = DateTime.now() + rep = await backend.invite.greeter_start_greeting_attempt( + now, + coolorg.organization_id, + coolorg.alice.device_id, + coolorg.alice.user_id, + coolorg.invited_zack.token, + ) + assert isinstance(rep, GreetingAttemptID) + + rep = await coolorg.invited_zack.invite_info() + assert rep == invited_cmds.latest.invite_info.RepOk( + invited_cmds.latest.invite_info.InvitationTypeUser( + claimer_email=coolorg.invited_zack.claimer_email, + created_by=InvitationCreatedByUser( + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + ).for_invite_info(), + administrators=[ + UserGreetingAdministrator( + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + online_status=UserOnlineStatus.UNKNOWN, + last_greeting_attempt_joined_on=now, + ), + ], + ) + ) + + async def test_invited_invite_info_ok_with_shamir( shamirorg: ShamirOrgRpcClients, backend: Backend ) -> None: From b688d932a951b8a24e624ecf91d776ae7a3be7ee Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Tue, 21 Jan 2025 18:42:14 +0100 Subject: [PATCH 07/11] Bump testbed server --- .github/workflows/ci-rust.yml | 2 +- .github/workflows/ci-web.yml | 2 +- misc/versions.toml | 2 +- server/packaging/testbed-server/README.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 1c52cca7baf..eb348fa80c9 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -63,7 +63,7 @@ jobs: TESTBED_SERVER: http://localhost:6777 services: parsec-testbed-server: - image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.2.4-a.0.dev.20104.7da2559 + image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.2.5-a.0.dev.20111.317cb00 ports: - 6777:6777 steps: diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml index 9e94ecbfea4..0cf63151a86 100644 --- a/.github/workflows/ci-web.yml +++ b/.github/workflows/ci-web.yml @@ -36,7 +36,7 @@ jobs: # https://github.com/Scille/parsec-cloud/pkgs/container/parsec-cloud%2Fparsec-testbed-server services: parsec-testbed-server: - image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.2.4-a.0.dev.20104.7da2559 + image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.2.5-a.0.dev.20111.317cb00 ports: - 6777:6777 steps: diff --git a/misc/versions.toml b/misc/versions.toml index 5aec6ea1f79..21eb224928b 100644 --- a/misc/versions.toml +++ b/misc/versions.toml @@ -8,6 +8,6 @@ nextest = "0.9.54" license = "BUSL-1.1" postgres = "14.10" winfsp = "2.0.23075" -testbed = "3.2.4-a.0.dev.20104.7da2559" +testbed = "3.2.5-a.0.dev.20111.317cb00" pre-commit = "3.7.1" cross = "v0.2.5" diff --git a/server/packaging/testbed-server/README.md b/server/packaging/testbed-server/README.md index aa71bb6d9fb..917c3e05739 100644 --- a/server/packaging/testbed-server/README.md +++ b/server/packaging/testbed-server/README.md @@ -36,7 +36,7 @@ For example, `https://github.com/Scille/parsec-cloud/blob/master/.github/workflo ```yaml services: parsec-testbed-server: - image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.2.4-a.0.dev.20104.7da2559 + image: ghcr.io/scille/parsec-cloud/parsec-testbed-server:3.2.5-a.0.dev.20111.317cb00 ``` ## Build and Publish a new testbed server Docker image From 7f3f52e9069cd28f1171c32ceff6b59b083d2fd5 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Fri, 24 Jan 2025 15:55:08 +0100 Subject: [PATCH 08/11] Address @touilleMan's comments --- server/parsec/components/postgresql/invite.py | 114 +++++++++--------- .../0009_external_service_for_invitation.sql | 11 +- .../postgresql/migrations/datamodel.sql | 8 +- .../components/postgresql/test_queries.py | 8 +- 4 files changed, 74 insertions(+), 67 deletions(-) diff --git a/server/parsec/components/postgresql/invite.py b/server/parsec/components/postgresql/invite.py index 6fdf90ad284..ceb54ef409d 100644 --- a/server/parsec/components/postgresql/invite.py +++ b/server/parsec/components/postgresql/invite.py @@ -174,15 +174,15 @@ def invitation_info_from_record(record: Record) -> InvitationInfo: assert False, repr(unknown) if type == InvitationType.USER: - match record["claimer_email"]: + match record["user_invitation_claimer_email"]: case str() as claimer_email: pass case unknown: assert False, repr(unknown) - assert record["claimer_user_id"] is None - assert record["claimer_human_email"] is None - assert record["claimer_human_label"] is None + assert record["device_invitation_claimer_user_id"] is None + assert record["device_invitation_claimer_human_email"] is None + assert record["device_invitation_claimer_human_label"] is None assert record["shamir_recovery_internal_id"] is None assert record["shamir_recovery_user_id"] is None assert record["shamir_recovery_human_email"] is None @@ -203,13 +203,16 @@ def invitation_info_from_record(record: Record) -> InvitationInfo: ) if type == InvitationType.DEVICE: - match record["claimer_user_id"]: + match record["device_invitation_claimer_user_id"]: case str() as claimer_user_id_str: claimer_user_id = UserID.from_hex(claimer_user_id_str) case unknown: assert False, repr(unknown) - match (record["claimer_human_email"], record["claimer_human_label"]): + match ( + record["device_invitation_claimer_human_email"], + record["device_invitation_claimer_human_label"], + ): case (str() as claimer_human_email, str() as claimer_human_label): claimer_human_handle = HumanHandle( email=claimer_human_email, label=claimer_human_label @@ -217,7 +220,7 @@ def invitation_info_from_record(record: Record) -> InvitationInfo: case unknown: assert False, repr(unknown) - assert record["claimer_email"] is None + assert record["user_invitation_claimer_email"] is None assert record["shamir_recovery_internal_id"] is None assert record["shamir_recovery_user_id"] is None assert record["shamir_recovery_human_email"] is None @@ -280,10 +283,10 @@ def invitation_info_from_record(record: Record) -> InvitationInfo: case unknown: assert False, repr(unknown) - assert record["claimer_email"] is None - assert record["claimer_user_id"] is None - assert record["claimer_human_email"] is None - assert record["claimer_human_label"] is None + assert record["user_invitation_claimer_email"] is None + assert record["device_invitation_claimer_user_id"] is None + assert record["device_invitation_claimer_human_email"] is None + assert record["device_invitation_claimer_human_label"] is None # Treat active invitation to a deleted shamir recovery as cancelled if shamir_recovery_deleted_on is not None and deleted_on is None: @@ -370,7 +373,7 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: -- gets updated, which do not have to remain consistent with other timestamped data. -- Less lock means less ways to mess up the ordering and create a hard-to-debug deadlock, -- so we simply accept that our checks might be slightly outdated when the invitation is written. -SELECT shamir_recovery_setup._id as shamir_recovery_setup_internal_id +SELECT shamir_recovery_setup._id AS shamir_recovery_setup_internal_id FROM shamir_recovery_setup INNER JOIN organization ON shamir_recovery_setup.organization = organization._id INNER JOIN user_ ON shamir_recovery_setup.user_ = user_._id @@ -429,7 +432,7 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: organization.organization_id = $organization_id AND type = 'USER' AND user_.user_id = $user_id - AND claimer_email = $claimer_email + AND user_invitation_claimer_email = $user_invitation_claimer_email AND deleted_on IS NULL LIMIT 1 """ @@ -441,11 +444,11 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: token FROM invitation LEFT JOIN organization ON invitation.organization = organization._id -INNER JOIN user_ ON invitation.claimer_user_id = user_._id +INNER JOIN user_ ON invitation.device_invitation_claimer = user_._id WHERE organization.organization_id = $organization_id AND type = 'DEVICE' - AND user_.user_id = $claimer_user_id + AND user_.user_id = $device_invitation_claimer_user_id AND deleted_on IS NULL LIMIT 1 """ @@ -488,8 +491,8 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: organization, token, type, - claimer_email, - claimer_user_id, + user_invitation_claimer_email, + device_invitation_claimer, shamir_recovery, created_by_device, created_on @@ -498,8 +501,8 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: { q_organization_internal_id("$organization_id") }, $token, $type, - $claimer_email, - { q_user_internal_id(organization_id="$organization_id", user_id="$claimer_user_id") }, + $user_invitation_claimer_email, + { q_user_internal_id(organization_id="$organization_id", user_id="$device_invitation_claimer_user_id") }, $shamir_recovery_setup, { q_device_internal_id(organization_id="$organization_id", device_id="$created_by") }, $created_on @@ -530,10 +533,10 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: human.email AS created_by_email, human.label AS created_by_label, invitation.created_by_service_label, - invitation.claimer_email, - claimer_user.user_id as claimer_user_id, - claimer_human.email AS claimer_human_email, - claimer_human.label AS claimer_human_label, + invitation.user_invitation_claimer_email, + device_invitation_claimer.user_id AS device_invitation_claimer_user_id, + device_invitation_claimer_human.email AS device_invitation_claimer_human_email, + device_invitation_claimer_human.label AS device_invitation_claimer_human_label, invitation.shamir_recovery AS shamir_recovery_internal_id, shamir_recovery_user.user_id AS shamir_recovery_user_id, shamir_recovery_human.email AS shamir_recovery_human_email, @@ -548,8 +551,8 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: LEFT JOIN device ON invitation.created_by_device = device._id LEFT JOIN user_ ON device.user_ = user_._id LEFT JOIN human ON human._id = user_.human -LEFT JOIN user_ AS claimer_user ON invitation.claimer_user_id = claimer_user._id -LEFT JOIN human AS claimer_human ON claimer_user.human = claimer_human._id +LEFT JOIN user_ AS device_invitation_claimer ON invitation.device_invitation_claimer = device_invitation_claimer._id +LEFT JOIN human AS device_invitation_claimer_human ON device_invitation_claimer.human = device_invitation_claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id LEFT JOIN user_ AS shamir_recovery_user ON shamir_recovery_setup.user_ = shamir_recovery_user._id LEFT JOIN human AS shamir_recovery_human ON shamir_recovery_user.human = shamir_recovery_human._id @@ -574,13 +577,13 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: invitation.token, invitation.type, user_.user_id AS created_by_user_id, - human.email as created_by_email, - human.label as created_by_label, + human.email AS created_by_email, + human.label AS created_by_label, invitation.created_by_service_label, - invitation.claimer_email, - claimer_user.user_id as claimer_user_id, - claimer_human.email AS claimer_human_email, - claimer_human.label AS claimer_human_label, + invitation.user_invitation_claimer_email, + device_invitation_claimer.user_id AS device_invitation_claimer_user_id, + device_invitation_claimer_human.email AS device_invitation_claimer_human_email, + device_invitation_claimer_human.label AS device_invitation_claimer_human_label, invitation.shamir_recovery AS shamir_recovery_internal_id, shamir_recovery_user.user_id AS shamir_recovery_user_id, shamir_recovery_human.email AS shamir_recovery_human_email, @@ -595,8 +598,8 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: LEFT JOIN device ON invitation.created_by_device = device._id LEFT JOIN user_ ON device.user_ = user_._id LEFT JOIN human ON human._id = user_.human -LEFT JOIN user_ AS claimer_user ON invitation.claimer_user_id = claimer_user._id -LEFT JOIN human AS claimer_human ON claimer_user.human = claimer_human._id +LEFT JOIN user_ AS device_invitation_claimer ON invitation.device_invitation_claimer = device_invitation_claimer._id +LEFT JOIN human AS device_invitation_claimer_human ON device_invitation_claimer.human = device_invitation_claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id LEFT JOIN user_ AS shamir_recovery_user ON shamir_recovery_setup.user_ = shamir_recovery_user._id LEFT JOIN human AS shamir_recovery_human ON shamir_recovery_user.human = shamir_recovery_human._id @@ -639,14 +642,14 @@ def make_q_info_invitation( invitation._id AS invitation_internal_id, invitation.token, invitation.type, - user_.user_id as created_by_user_id, + user_.user_id AS created_by_user_id, human.email AS created_by_email, human.label AS created_by_label, invitation.created_by_service_label, - invitation.claimer_email, - claimer_user.user_id as claimer_user_id, - claimer_human.email AS claimer_human_email, - claimer_human.label AS claimer_human_label, + invitation.user_invitation_claimer_email, + device_invitation_claimer.user_id AS device_invitation_claimer_user_id, + device_invitation_claimer_human.email AS device_invitation_claimer_human_email, + device_invitation_claimer_human.label AS device_invitation_claimer_human_label, invitation.shamir_recovery AS shamir_recovery_internal_id, shamir_recovery_user.user_id AS shamir_recovery_user_id, shamir_recovery_human.email AS shamir_recovery_human_email, @@ -662,8 +665,8 @@ def make_q_info_invitation( LEFT JOIN device ON invitation.created_by_device = device._id LEFT JOIN user_ ON device.user_ = user_._id LEFT JOIN human ON human._id = user_.human - LEFT JOIN user_ AS claimer_user ON invitation.claimer_user_id = claimer_user._id - LEFT JOIN human AS claimer_human ON claimer_user.human = claimer_human._id + LEFT JOIN user_ AS device_invitation_claimer ON invitation.device_invitation_claimer = device_invitation_claimer._id + LEFT JOIN human AS device_invitation_claimer_human ON device_invitation_claimer.human = device_invitation_claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id LEFT JOIN user_ AS shamir_recovery_user ON shamir_recovery_setup.user_ = shamir_recovery_user._id LEFT JOIN human AS shamir_recovery_human ON shamir_recovery_user.human = shamir_recovery_human._id @@ -682,7 +685,7 @@ def make_q_info_invitation( SELECT greeting_attempt._id, greeting_attempt.greeting_attempt_id, - user_.user_id as greeter, + user_.user_id AS greeter, greeting_attempt.claimer_joined, greeting_attempt.greeter_joined, greeting_attempt.cancelled_by, @@ -952,6 +955,9 @@ async def q_take_invitation_create_write_lock( organization.organization_id = $organization_id AND user_.current_profile = 'ADMIN' AND user_.revoked_on IS NULL + -- Due to the left join, `invitation.token` might be `NULL`. + -- This happens when the administrator has never made any greeting attempt. + -- For this reason, we want to include the user even if `token` is null. AND (invitation.token = $token OR invitation.token IS NULL) GROUP BY user_.user_id, human.email, human.label """ @@ -978,8 +984,8 @@ async def _do_new_invitation( organization_id: OrganizationID, author_user_id: UserID, author_device_id: DeviceID, - claimer_email: str | None, - claimer_user_id: UserID | None, + user_invitation_claimer_email: str | None, + device_invitation_claimer_user_id: UserID | None, shamir_recovery_setup: int | None, created_on: DateTime, invitation_type: InvitationType, @@ -987,18 +993,18 @@ async def _do_new_invitation( ) -> InvitationToken: match invitation_type: case InvitationType.USER: - assert claimer_email is not None + assert user_invitation_claimer_email is not None # This request allows to have multiple invitations for the same email for different administrators # TODO: Update this when implementing https://github.com/Scille/parsec-cloud/issues/9413 q = _q_retrieve_compatible_user_invitation( organization_id=organization_id.str, user_id=author_user_id, - claimer_email=claimer_email, + user_invitation_claimer_email=user_invitation_claimer_email, ) case InvitationType.DEVICE: q = _q_retrieve_compatible_device_invitation( organization_id=organization_id.str, - claimer_user_id=author_user_id, + device_invitation_claimer_user_id=device_invitation_claimer_user_id, ) case InvitationType.SHAMIR_RECOVERY: assert shamir_recovery_setup is not None @@ -1023,8 +1029,8 @@ async def _do_new_invitation( organization_id=organization_id.str, type=invitation_type.str, token=token.hex, - claimer_email=claimer_email, - claimer_user_id=claimer_user_id, + user_invitation_claimer_email=user_invitation_claimer_email, + device_invitation_claimer_user_id=device_invitation_claimer_user_id, shamir_recovery_setup=shamir_recovery_setup, created_by=author_device_id, created_on=created_on, @@ -1111,8 +1117,8 @@ async def new_for_user( organization_id=organization_id, author_user_id=author_user_id, author_device_id=author, - claimer_email=claimer_email, - claimer_user_id=None, + user_invitation_claimer_email=claimer_email, + device_invitation_claimer_user_id=None, shamir_recovery_setup=None, created_on=now, invitation_type=InvitationType.USER, @@ -1173,8 +1179,8 @@ async def new_for_device( organization_id=organization_id, author_user_id=author_user_id, author_device_id=author, - claimer_email=None, - claimer_user_id=author_user_id, + user_invitation_claimer_email=None, + device_invitation_claimer_user_id=author_user_id, shamir_recovery_setup=None, created_on=now, invitation_type=InvitationType.DEVICE, @@ -1262,8 +1268,8 @@ async def new_for_shamir_recovery( organization_id=organization_id, author_user_id=author_user_id, author_device_id=author, - claimer_email=None, - claimer_user_id=None, + user_invitation_claimer_email=None, + device_invitation_claimer_user_id=None, shamir_recovery_setup=shamir_recovery_setup, created_on=now, invitation_type=InvitationType.SHAMIR_RECOVERY, diff --git a/server/parsec/components/postgresql/migrations/0009_external_service_for_invitation.sql b/server/parsec/components/postgresql/migrations/0009_external_service_for_invitation.sql index 8f24b74eff4..89d13c70411 100644 --- a/server/parsec/components/postgresql/migrations/0009_external_service_for_invitation.sql +++ b/server/parsec/components/postgresql/migrations/0009_external_service_for_invitation.sql @@ -6,19 +6,20 @@ -- Rename `created_by` to `created_by_device` in invitation table -- Remove NOT NULL constraint on `created_by_device` column -- Add `created_by_service_label` column to invitation table --- Add `claimer_user_id` column to invitation table --- +-- Add `device_invitation_claimer` column to invitation table +-- Rename `claimer_email` to `user_invitation_claimer_email` in invitation table ------------------------------------------------------- ALTER TABLE invitation RENAME COLUMN created_by TO created_by_device; ALTER TABLE invitation ALTER COLUMN created_by_device DROP NOT NULL; ALTER TABLE invitation RENAME CONSTRAINT "invitation_created_by_fkey" TO "invitation_created_by_device_fkey"; ALTER TABLE invitation ADD COLUMN created_by_service_label VARCHAR(254); -ALTER TABLE invitation ADD COLUMN claimer_user_id INTEGER REFERENCES user_ (_id); +ALTER TABLE invitation ADD COLUMN device_invitation_claimer INTEGER REFERENCES user_ (_id); +ALTER TABLE invitation RENAME COLUMN claimer_email TO user_invitation_claimer_email; --- Update `claimer_user_id` using the `created_by_device` column +-- Update `device_invitation_claimer` using the `created_by_device` column UPDATE invitation -SET claimer_user_id = user_._id +SET device_invitation_claimer = user_._id FROM device INNER JOIN user_ ON device.user_ = user_._id WHERE diff --git a/server/parsec/components/postgresql/migrations/datamodel.sql b/server/parsec/components/postgresql/migrations/datamodel.sql index f9f3e735bde..1e904c40ad4 100644 --- a/server/parsec/components/postgresql/migrations/datamodel.sql +++ b/server/parsec/components/postgresql/migrations/datamodel.sql @@ -239,9 +239,9 @@ CREATE TABLE invitation ( -- -- -- Type specific fields -- -- Required when type=USER - -- claimer_email VARCHAR(255), + -- user_invitation_claimer_email VARCHAR(255), -- -- Required when type=DEVICE - -- claimer_user_id INTEGER REFERENCES user_ (_id), + -- device_invitation_claimer INTEGER REFERENCES user_ (_id), -- -- Required when type=SHAMIR_RECOVERY -- shamir_recovery INTEGER REFERENCES shamir_recovery_setup (_id), -- @@ -255,7 +255,7 @@ CREATE TABLE invitation ( created_by_device INTEGER REFERENCES device (_id), -- Required when type=USER - claimer_email VARCHAR(255), + user_invitation_claimer_email VARCHAR(255), created_on TIMESTAMPTZ NOT NULL, deleted_on TIMESTAMPTZ, @@ -267,7 +267,7 @@ CREATE TABLE invitation ( -- Added in migration 0009 created_by_service_label VARCHAR(254), -- Required when type=DEVICE - claimer_user_id INTEGER REFERENCES user_ (_id), + device_invitation_claimer INTEGER REFERENCES user_ (_id), UNIQUE (organization, token) ); diff --git a/server/parsec/components/postgresql/test_queries.py b/server/parsec/components/postgresql/test_queries.py index fed828c62c9..acca09229b8 100644 --- a/server/parsec/components/postgresql/test_queries.py +++ b/server/parsec/components/postgresql/test_queries.py @@ -406,8 +406,8 @@ type, created_by_device, created_by_service_label, - claimer_email, - claimer_user_id, + user_invitation_claimer_email, + device_invitation_claimer, created_on, deleted_on, deleted_reason, @@ -422,10 +422,10 @@ WHERE device_id = { q_device(_id="invitation.created_by_device", select="device_id") } ), created_by_service_label, - claimer_email, + user_invitation_claimer_email, ( SELECT _id FROM new_users - WHERE user_id = { q_user(_id="invitation.claimer_user_id", select="user_id") } + WHERE user_id = { q_user(_id="invitation.device_invitation_claimer", select="user_id") } ), created_on, deleted_on, From b92e9efcea92a454791b7b8d844d311354957925 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Fri, 24 Jan 2025 16:31:18 +0100 Subject: [PATCH 09/11] Add test_invited_invite_info_for_user_with_multiple_admins and fix _q_list_administrators query --- server/parsec/components/memory/invite.py | 23 +-- server/parsec/components/postgresql/invite.py | 10 +- .../tests/api_v5/invited/test_invite_info.py | 140 ++++++++++++++++++ 3 files changed, 158 insertions(+), 15 deletions(-) diff --git a/server/parsec/components/memory/invite.py b/server/parsec/components/memory/invite.py index f650305d486..937d898153b 100644 --- a/server/parsec/components/memory/invite.py +++ b/server/parsec/components/memory/invite.py @@ -145,16 +145,19 @@ def _get_administrators( user_id_to_last_greeter_joined[user_id] = greeting_attempt.greeter_joined break - return [ - UserGreetingAdministrator( - user_id=user_id, - human_handle=user.cooked.human_handle, - online_status=UserOnlineStatus.UNKNOWN, - last_greeting_attempt_joined_on=user_id_to_last_greeter_joined.get(user_id), - ) - for user_id, user in org.users.items() - if user.current_profile == UserProfile.ADMIN and not user.is_revoked - ] + return sorted( + ( + UserGreetingAdministrator( + user_id=user_id, + human_handle=user.cooked.human_handle, + online_status=UserOnlineStatus.UNKNOWN, + last_greeting_attempt_joined_on=user_id_to_last_greeter_joined.get(user_id), + ) + for user_id, user in org.users.items() + if user.current_profile == UserProfile.ADMIN and not user.is_revoked + ), + key=lambda x: x.human_handle.label, + ) @override async def new_for_user( diff --git a/server/parsec/components/postgresql/invite.py b/server/parsec/components/postgresql/invite.py index ceb54ef409d..81c10420c51 100644 --- a/server/parsec/components/postgresql/invite.py +++ b/server/parsec/components/postgresql/invite.py @@ -948,17 +948,16 @@ async def q_take_invitation_create_write_lock( FROM user_ INNER JOIN organization ON user_.organization = organization._id INNER JOIN human ON user_.human = human._id +-- Use left joins to list all greeting attempts corresponding to a given invitation token +-- for this specific administrator, while ensuring at least one row with NULL values so +-- that the MAX() function returns NULL if no greeting attempt is found. LEFT JOIN greeting_session ON user_._id = greeting_session.greeter LEFT JOIN invitation ON greeting_session.invitation = invitation._id -LEFT JOIN greeting_attempt ON greeting_session._id = greeting_attempt.greeting_session +LEFT JOIN greeting_attempt ON greeting_session._id = greeting_attempt.greeting_session AND invitation.token = $token WHERE organization.organization_id = $organization_id AND user_.current_profile = 'ADMIN' AND user_.revoked_on IS NULL - -- Due to the left join, `invitation.token` might be `NULL`. - -- This happens when the administrator has never made any greeting attempt. - -- For this reason, we want to include the user even if `token` is null. - AND (invitation.token = $token OR invitation.token IS NULL) GROUP BY user_.user_id, human.email, human.label """ ) @@ -1353,6 +1352,7 @@ async def _get_administrators( ) ) + administrators.sort(key=lambda x: x.human_handle.label) return administrators @override diff --git a/server/tests/api_v5/invited/test_invite_info.py b/server/tests/api_v5/invited/test_invite_info.py index cda17a1beb5..b7da6f5cc7a 100644 --- a/server/tests/api_v5/invited/test_invite_info.py +++ b/server/tests/api_v5/invited/test_invite_info.py @@ -9,6 +9,7 @@ UserOnlineStatus, ) from tests.common import Backend, CoolorgRpcClients, HttpCommonErrorsTester, ShamirOrgRpcClients +from tests.common.data import bob_becomes_admin @pytest.mark.parametrize("user_or_device", ("user", "device")) @@ -85,6 +86,145 @@ async def test_invited_invite_info_for_user_with_greeting_attempt( ) +async def test_invited_invite_info_for_user_with_multiple_admins( + coolorg: CoolorgRpcClients, + backend: Backend, +) -> None: + await bob_becomes_admin(coolorg, backend) + + rep = await coolorg.invited_zack.invite_info() + assert rep == invited_cmds.latest.invite_info.RepOk( + invited_cmds.latest.invite_info.InvitationTypeUser( + claimer_email=coolorg.invited_zack.claimer_email, + created_by=InvitationCreatedByUser( + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + ).for_invite_info(), + administrators=[ + UserGreetingAdministrator( + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + online_status=UserOnlineStatus.UNKNOWN, + last_greeting_attempt_joined_on=None, + ), + UserGreetingAdministrator( + user_id=coolorg.bob.user_id, + human_handle=coolorg.bob.human_handle, + online_status=UserOnlineStatus.UNKNOWN, + last_greeting_attempt_joined_on=None, + ), + ], + ) + ) + + # New invitation for non-zack + t0 = DateTime.now() + rep = await backend.invite.new_for_user( + t0, + coolorg.organization_id, + coolorg.alice.device_id, + "non.zack@example.invalid", + False, + ) + assert isinstance(rep, tuple) + non_zack_token, _ = rep + + # Alice starts greeting attempt for zack + t1 = DateTime.now() + rep = await backend.invite.greeter_start_greeting_attempt( + t1, + coolorg.organization_id, + coolorg.alice.device_id, + coolorg.alice.user_id, + coolorg.invited_zack.token, + ) + assert isinstance(rep, GreetingAttemptID) + + # Bob starts greeting attempt for non-zack + t2 = DateTime.now() + rep = await backend.invite.greeter_start_greeting_attempt( + t2, + coolorg.organization_id, + coolorg.bob.device_id, + coolorg.bob.user_id, + non_zack_token, + ) + assert isinstance(rep, GreetingAttemptID) + + # Check the invite info for zack + rep = await coolorg.invited_zack.invite_info() + assert rep == invited_cmds.latest.invite_info.RepOk( + invited_cmds.latest.invite_info.InvitationTypeUser( + claimer_email=coolorg.invited_zack.claimer_email, + created_by=InvitationCreatedByUser( + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + ).for_invite_info(), + administrators=[ + UserGreetingAdministrator( + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + online_status=UserOnlineStatus.UNKNOWN, + last_greeting_attempt_joined_on=t1, + ), + UserGreetingAdministrator( + user_id=coolorg.bob.user_id, + human_handle=coolorg.bob.human_handle, + online_status=UserOnlineStatus.UNKNOWN, + last_greeting_attempt_joined_on=None, + ), + ], + ) + ) + + # Alice starts greeting attempt for zack + t3 = DateTime.now() + rep = await backend.invite.greeter_start_greeting_attempt( + t3, + coolorg.organization_id, + coolorg.alice.device_id, + coolorg.alice.user_id, + coolorg.invited_zack.token, + ) + assert isinstance(rep, GreetingAttemptID) + + # Bob starts greeting attempt for zack + t4 = DateTime.now() + rep = await backend.invite.greeter_start_greeting_attempt( + t4, + coolorg.organization_id, + coolorg.bob.device_id, + coolorg.bob.user_id, + coolorg.invited_zack.token, + ) + + # Check the invite info for zack + rep = await coolorg.invited_zack.invite_info() + assert rep == invited_cmds.latest.invite_info.RepOk( + invited_cmds.latest.invite_info.InvitationTypeUser( + claimer_email=coolorg.invited_zack.claimer_email, + created_by=InvitationCreatedByUser( + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + ).for_invite_info(), + administrators=[ + UserGreetingAdministrator( + user_id=coolorg.alice.user_id, + human_handle=coolorg.alice.human_handle, + online_status=UserOnlineStatus.UNKNOWN, + last_greeting_attempt_joined_on=t3, + ), + UserGreetingAdministrator( + user_id=coolorg.bob.user_id, + human_handle=coolorg.bob.human_handle, + online_status=UserOnlineStatus.UNKNOWN, + last_greeting_attempt_joined_on=t4, + ), + ], + ) + ) + + async def test_invited_invite_info_ok_with_shamir( shamirorg: ShamirOrgRpcClients, backend: Backend ) -> None: From b05c05358429caf2415df34e615cdccc0b65db4b Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Fri, 24 Jan 2025 16:47:27 +0100 Subject: [PATCH 10/11] Add migration test for migration 0009 --- server/tests/migrations/0009_after.sql | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 server/tests/migrations/0009_after.sql diff --git a/server/tests/migrations/0009_after.sql b/server/tests/migrations/0009_after.sql new file mode 100644 index 00000000000..df585cf3a6c --- /dev/null +++ b/server/tests/migrations/0009_after.sql @@ -0,0 +1,27 @@ +-- Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +DO $$ +DECLARE + organization_internal_id integer; + user_current_profile text; + device_invitation_claimer_human_email text; +BEGIN + SELECT _id + INTO organization_internal_id + FROM organization + WHERE organization_id = 'Org1'; + + -- Ensure that the `device_invitation_claimer` column has been correctly updated + SELECT human.email + INTO device_invitation_claimer_human_email + FROM invitation + INNER JOIN user_ + ON user_._id = invitation.device_invitation_claimer + INNER JOIN human + ON user_.human = human._id + WHERE + invitation.organization = organization_internal_id + AND invitation.token = 'e0000000000000000000000000000002'; + + ASSERT device_invitation_claimer_human_email = 'alice@example.com', FORMAT('Bad invitation migration: `%s`', device_invitation_claimer_human_email); +END$$; From 43224d8dc70ff51d3358bd6e03b216e7704abbea Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Mon, 27 Jan 2025 18:40:22 +0100 Subject: [PATCH 11/11] Address @touilleMan's comments --- .../authenticated_cmds/v5/invite_list.rs | 1 - libparsec/src/invite.rs | 3 +- server/parsec/components/invite.py | 8 +-- server/parsec/components/postgresql/invite.py | 60 +++++++++---------- .../tests/api_v5/invited/test_invite_info.py | 11 +++- server/tests/migrations/0009_after.sql | 19 +++++- server/tests/test_sse.py | 2 + 7 files changed, 59 insertions(+), 45 deletions(-) diff --git a/libparsec/crates/protocol/tests/authenticated_cmds/v5/invite_list.rs b/libparsec/crates/protocol/tests/authenticated_cmds/v5/invite_list.rs index f796ea4516b..5f99ceeb7d8 100644 --- a/libparsec/crates/protocol/tests/authenticated_cmds/v5/invite_list.rs +++ b/libparsec/crates/protocol/tests/authenticated_cmds/v5/invite_list.rs @@ -8,7 +8,6 @@ use super::authenticated_cmds; use libparsec_types::prelude::*; use libparsec_tests_lite::{hex, p_assert_eq}; -use libparsec_types::{InvitationStatus, InvitationToken}; // Request diff --git a/libparsec/src/invite.rs b/libparsec/src/invite.rs index ba76e53f1b4..e5f01bf56cd 100644 --- a/libparsec/src/invite.rs +++ b/libparsec/src/invite.rs @@ -12,9 +12,8 @@ pub use libparsec_client::{ ShamirRecoveryClaimRecoverDeviceError, }; pub use libparsec_protocol::authenticated_cmds::latest::invite_list::InvitationCreatedBy as InviteListInvitationCreatedBy; -pub use libparsec_protocol::invited_cmds::latest::invite_info::InvitationCreatedBy as InviteInfoInvitationCreatedBy; pub use libparsec_protocol::invited_cmds::latest::invite_info::{ - ShamirRecoveryRecipient, UserOnlineStatus, + InvitationCreatedBy as InviteInfoInvitationCreatedBy, ShamirRecoveryRecipient, UserOnlineStatus, }; pub use libparsec_types::prelude::*; diff --git a/server/parsec/components/invite.py b/server/parsec/components/invite.py index 1d165c4a709..412e532b3be 100644 --- a/server/parsec/components/invite.py +++ b/server/parsec/components/invite.py @@ -57,7 +57,7 @@ @dataclass(slots=True) -class _InvitationCreatedBy: +class BaseInvitationCreatedBy: def for_invite_info(self) -> InviteInfoInvitationCreatedBy: match self: case InvitationCreatedByUser(user_id, human_handle): @@ -86,17 +86,17 @@ def for_invite_list(self) -> InviteListInvitationCreatedBy: @dataclass(slots=True) -class InvitationCreatedByUser(_InvitationCreatedBy): +class InvitationCreatedByUser(BaseInvitationCreatedBy): user_id: UserID human_handle: HumanHandle @dataclass(slots=True) -class InvitationCreatedByExternalService(_InvitationCreatedBy): +class InvitationCreatedByExternalService(BaseInvitationCreatedBy): service_label: str -InvitationCreatedBy: TypeAlias = InvitationCreatedByUser | InvitationCreatedByExternalService +type InvitationCreatedBy = InvitationCreatedByUser | InvitationCreatedByExternalService @dataclass(slots=True) diff --git a/server/parsec/components/postgresql/invite.py b/server/parsec/components/postgresql/invite.py index 81c10420c51..58849720558 100644 --- a/server/parsec/components/postgresql/invite.py +++ b/server/parsec/components/postgresql/invite.py @@ -110,7 +110,7 @@ class ShamirRecoveryInvitationInfo(BaseInvitationInfo): shamir_recovery_deleted_on: DateTime | None -InvitationInfo: TypeAlias = UserInvitationInfo | DeviceInvitationInfo | ShamirRecoveryInvitationInfo +type InvitationInfo = UserInvitationInfo | DeviceInvitationInfo | ShamirRecoveryInvitationInfo def invitation_info_from_record(record: Record) -> InvitationInfo: @@ -132,16 +132,10 @@ def invitation_info_from_record(record: Record) -> InvitationInfo: case unknown: assert False, repr(unknown) - match record["created_by_user_id"]: - case None: - match record["created_by_service_label"]: - case str() as created_by_service_label: - created_by = InvitationCreatedByExternalService( - service_label=created_by_service_label - ) - case unknown: - assert False, repr(unknown) - case str() as created_by_user_id_str: + match (record["created_by_user_id"], record["created_by_service_label"]): + case (None, str() as created_by_service_label): + created_by = InvitationCreatedByExternalService(service_label=created_by_service_label) + case (str() as created_by_user_id_str, None): match (record["created_by_email"], record["created_by_label"]): case (str() as created_by_email, str() as created_by_label): created_by = InvitationCreatedByUser( @@ -431,7 +425,7 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: WHERE organization.organization_id = $organization_id AND type = 'USER' - AND user_.user_id = $user_id + AND user_.user_id = $invitation_creator_user_id AND user_invitation_claimer_email = $user_invitation_claimer_email AND deleted_on IS NULL LIMIT 1 @@ -529,9 +523,9 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: DISTINCT invitation._id AS invitation_internal_id, invitation.token, invitation.type, - user_.user_id AS created_by_user_id, - human.email AS created_by_email, - human.label AS created_by_label, + created_by_user.user_id AS created_by_user_id, + created_by_human.email AS created_by_email, + created_by_human.label AS created_by_label, invitation.created_by_service_label, invitation.user_invitation_claimer_email, device_invitation_claimer.user_id AS device_invitation_claimer_user_id, @@ -548,9 +542,9 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: invitation.deleted_on, invitation.deleted_reason FROM invitation -LEFT JOIN device ON invitation.created_by_device = device._id -LEFT JOIN user_ ON device.user_ = user_._id -LEFT JOIN human ON human._id = user_.human +LEFT JOIN device AS created_by_device ON invitation.created_by_device = created_by_device._id +LEFT JOIN user_ AS created_by_user ON created_by_device.user_ = created_by_user._id +LEFT JOIN human AS created_by_human ON created_by_human._id = created_by_user.human LEFT JOIN user_ AS device_invitation_claimer ON invitation.device_invitation_claimer = device_invitation_claimer._id LEFT JOIN human AS device_invitation_claimer_human ON device_invitation_claimer.human = device_invitation_claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id @@ -562,8 +556,8 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: invitation.organization = { q_organization_internal_id("$organization_id") } -- Different invitation types have different filtering rules AND ( - (invitation.type = 'USER' AND user_.user_id = $user_id) - OR (invitation.type = 'DEVICE' AND user_.user_id = $user_id) + (invitation.type = 'USER' AND created_by_user.user_id = $user_id) + OR (invitation.type = 'DEVICE' AND device_invitation_claimer.user_id = $user_id) OR (invitation.type = 'SHAMIR_RECOVERY' AND recipient_user_.user_id = $user_id) ) ORDER BY created_on @@ -576,9 +570,9 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: invitation._id AS invitation_internal_id, invitation.token, invitation.type, - user_.user_id AS created_by_user_id, - human.email AS created_by_email, - human.label AS created_by_label, + created_by_user.user_id AS created_by_user_id, + created_by_human.email AS created_by_email, + created_by_human.label AS created_by_label, invitation.created_by_service_label, invitation.user_invitation_claimer_email, device_invitation_claimer.user_id AS device_invitation_claimer_user_id, @@ -595,9 +589,9 @@ def from_record(cls, record: Record) -> GreetingAttemptInfo: invitation.deleted_on, invitation.deleted_reason FROM invitation -LEFT JOIN device ON invitation.created_by_device = device._id -LEFT JOIN user_ ON device.user_ = user_._id -LEFT JOIN human ON human._id = user_.human +LEFT JOIN device AS created_by_device ON invitation.created_by_device = created_by_device._id +LEFT JOIN user_ AS created_by_user ON created_by_device.user_ = created_by_user._id +LEFT JOIN human AS created_by_human ON created_by_human._id = created_by_user.human LEFT JOIN user_ AS device_invitation_claimer ON invitation.device_invitation_claimer = device_invitation_claimer._id LEFT JOIN human AS device_invitation_claimer_human ON device_invitation_claimer.human = device_invitation_claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id @@ -642,9 +636,9 @@ def make_q_info_invitation( invitation._id AS invitation_internal_id, invitation.token, invitation.type, - user_.user_id AS created_by_user_id, - human.email AS created_by_email, - human.label AS created_by_label, + created_by_user.user_id AS created_by_user_id, + created_by_human.email AS created_by_email, + created_by_human.label AS created_by_label, invitation.created_by_service_label, invitation.user_invitation_claimer_email, device_invitation_claimer.user_id AS device_invitation_claimer_user_id, @@ -662,9 +656,9 @@ def make_q_info_invitation( invitation.deleted_reason FROM invitation INNER JOIN selected_invitation ON invitation._id = selected_invitation.invitation_internal_id - LEFT JOIN device ON invitation.created_by_device = device._id - LEFT JOIN user_ ON device.user_ = user_._id - LEFT JOIN human ON human._id = user_.human + LEFT JOIN device AS created_by_device ON invitation.created_by_device = created_by_device._id + LEFT JOIN user_ AS created_by_user ON created_by_device.user_ = created_by_user._id + LEFT JOIN human AS created_by_human ON created_by_human._id = created_by_user.human LEFT JOIN user_ AS device_invitation_claimer ON invitation.device_invitation_claimer = device_invitation_claimer._id LEFT JOIN human AS device_invitation_claimer_human ON device_invitation_claimer.human = device_invitation_claimer_human._id LEFT JOIN shamir_recovery_setup ON invitation.shamir_recovery = shamir_recovery_setup._id @@ -997,7 +991,7 @@ async def _do_new_invitation( # TODO: Update this when implementing https://github.com/Scille/parsec-cloud/issues/9413 q = _q_retrieve_compatible_user_invitation( organization_id=organization_id.str, - user_id=author_user_id, + invitation_creator_user_id=author_user_id, user_invitation_claimer_email=user_invitation_claimer_email, ) case InvitationType.DEVICE: diff --git a/server/tests/api_v5/invited/test_invite_info.py b/server/tests/api_v5/invited/test_invite_info.py index b7da6f5cc7a..fc3e6533208 100644 --- a/server/tests/api_v5/invited/test_invite_info.py +++ b/server/tests/api_v5/invited/test_invite_info.py @@ -8,8 +8,13 @@ UserGreetingAdministrator, UserOnlineStatus, ) -from tests.common import Backend, CoolorgRpcClients, HttpCommonErrorsTester, ShamirOrgRpcClients -from tests.common.data import bob_becomes_admin +from tests.common import ( + Backend, + CoolorgRpcClients, + HttpCommonErrorsTester, + ShamirOrgRpcClients, + bob_becomes_admin, +) @pytest.mark.parametrize("user_or_device", ("user", "device")) @@ -177,7 +182,7 @@ async def test_invited_invite_info_for_user_with_multiple_admins( ) ) - # Alice starts greeting attempt for zack + # Alice re-starts greeting attempt for zack t3 = DateTime.now() rep = await backend.invite.greeter_start_greeting_attempt( t3, diff --git a/server/tests/migrations/0009_after.sql b/server/tests/migrations/0009_after.sql index df585cf3a6c..5abffb94f83 100644 --- a/server/tests/migrations/0009_after.sql +++ b/server/tests/migrations/0009_after.sql @@ -3,8 +3,8 @@ DO $$ DECLARE organization_internal_id integer; - user_current_profile text; device_invitation_claimer_human_email text; + user_invitation_claimer_email text; BEGIN SELECT _id INTO organization_internal_id @@ -21,7 +21,22 @@ BEGIN ON user_.human = human._id WHERE invitation.organization = organization_internal_id - AND invitation.token = 'e0000000000000000000000000000002'; + AND invitation.token = 'e0000000000000000000000000000002' + AND invitation.user_invitation_claimer_email IS NULL; ASSERT device_invitation_claimer_human_email = 'alice@example.com', FORMAT('Bad invitation migration: `%s`', device_invitation_claimer_human_email); + + -- Ensure that the `device_invitation_claimer` column is NULL for the user invitations + SELECT invitation.user_invitation_claimer_email + INTO user_invitation_claimer_email + FROM invitation + WHERE + invitation.organization = organization_internal_id + AND invitation.token = 'e0000000000000000000000000000001' + AND invitation.device_invitation_claimer IS NULL; + + ASSERT user_invitation_claimer_email = 'zack@example.invalid', FORMAT('Bad invitation migration: `%s`', user_invitation_claimer_email); + + + END$$; diff --git a/server/tests/test_sse.py b/server/tests/test_sse.py index 0a2dd84cf3a..650b84eb517 100644 --- a/server/tests/test_sse.py +++ b/server/tests/test_sse.py @@ -208,12 +208,14 @@ def get_local_port(host: str) -> int: "app": app, "host": HOST, "port": PORT, + "proxy_trusted_addresses": None, }, ) proc.start() yield (HOST, PORT) proc.terminate() + proc.join() @pytest.mark.timeout(2)