diff --git a/bindings/generator/api/invite.py b/bindings/generator/api/invite.py index e078d7dc953..c77a26a5f24 100644 --- a/bindings/generator/api/invite.py +++ b/bindings/generator/api/invite.py @@ -365,6 +365,9 @@ class Offline: class NotAllowed: pass + class UserNotFound: + pass + class Internal: pass diff --git a/cli/src/commands/invite/mod.rs b/cli/src/commands/invite/mod.rs index 45df4618025..75c999198e8 100644 --- a/cli/src/commands/invite/mod.rs +++ b/cli/src/commands/invite/mod.rs @@ -3,6 +3,7 @@ mod claim; mod device; mod greet; mod list; +mod shared_recovery; mod user; #[derive(clap::Subcommand)] @@ -19,6 +20,8 @@ pub enum Group { User(user::Args), /// Create device invitation Device(device::Args), + /// Create shared recovery invitation + SharedRecovery(shared_recovery::Args), } pub async fn dispatch_command(command: Group) -> anyhow::Result<()> { @@ -29,5 +32,6 @@ pub async fn dispatch_command(command: Group) -> anyhow::Result<()> { Group::List(args) => list::main(args).await, Group::User(args) => user::main(args).await, Group::Device(args) => device::main(args).await, + Group::SharedRecovery(args) => shared_recovery::main(args).await, } } diff --git a/cli/src/commands/invite/shared_recovery.rs b/cli/src/commands/invite/shared_recovery.rs new file mode 100644 index 00000000000..209aa51a5db --- /dev/null +++ b/cli/src/commands/invite/shared_recovery.rs @@ -0,0 +1,90 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +use libparsec::{InvitationEmailSentStatus, InvitationType, ParsecInvitationAddr}; + +use crate::utils::*; + +crate::clap_parser_with_shared_opts_builder!( + #[with = config_dir, device, password_stdin] + pub struct Args { + /// Claimer email (i.e.: The invitee) + #[arg(short, long)] + email: String, + /// Send email to the invitee + #[arg(short, long, default_value_t)] + send_email: bool, + } +); + +pub async fn main(args: Args) -> anyhow::Result<()> { + let Args { + email, + send_email, + device, + config_dir, + password_stdin, + } = args; + log::trace!( + "Inviting an user to perform a shared recovery (confdir={}, device={})", + config_dir.display(), + device.as_deref().unwrap_or("N/A") + ); + + let client = load_client(&config_dir, device, password_stdin).await?; + + let mut handle = start_spinner("Poll server for new certificates".into()); + client.poll_server_for_new_certificates().await?; + handle.stop(); + + let users = client.list_users(true, None, None).await?; + let user_info = users + .iter() + .find(|u| u.human_handle.email() == email) + .ok_or_else(|| anyhow::anyhow!("User with email {} not found", email))?; + + let mut handle = start_spinner("Creating a shared recovery invitation".into()); + let (url, email_sent_status) = match client + .new_shamir_recovery_invitation(user_info.id, send_email) + .await + { + Ok((token, email_sent_status)) => ( + ParsecInvitationAddr::new( + client.organization_addr().clone(), + client.organization_id().clone(), + InvitationType::ShamirRecovery, + token, + ) + .to_url(), + email_sent_status, + ), + Err(e) => { + return Err(anyhow::anyhow!( + "Server refused to create shared recovery invitation: {e}" + )); + } + }; + + handle.stop_with_message(format!("Invitation URL: {YELLOW}{url}{RESET}")); + + if send_email { + match email_sent_status { + InvitationEmailSentStatus::Success => { + println!("Invitation email sent to {}", email); + } + InvitationEmailSentStatus::RecipientRefused => { + println!( + "Invitation email not sent to {} because the recipient was refused", + email + ); + } + InvitationEmailSentStatus::ServerUnavailable => { + println!( + "Invitation email not sent to {} because the server is unavailable", + email + ); + } + } + } + + Ok(()) +} diff --git a/libparsec/crates/client/src/invite/greeter.rs b/libparsec/crates/client/src/invite/greeter.rs index 8791abc8849..9d2c928e594 100644 --- a/libparsec/crates/client/src/invite/greeter.rs +++ b/libparsec/crates/client/src/invite/greeter.rs @@ -141,6 +141,8 @@ pub enum NewShamirRecoveryInvitationError { Offline, #[error("Not part of the user's current recipients")] NotAllowed, + #[error("Provided user not found")] + UserNotFound, #[error(transparent)] Internal(#[from] anyhow::Error), } @@ -183,6 +185,7 @@ pub async fn new_shamir_recovery_invitation( Ok((token, email_sent)) } Rep::AuthorNotAllowed => Err(NewShamirRecoveryInvitationError::NotAllowed), + Rep::UserNotFound => Err(NewShamirRecoveryInvitationError::UserNotFound), rep @ Rep::UnknownStatus { .. } => { Err(anyhow::anyhow!("Unexpected server response: {:?}", rep).into()) } diff --git a/libparsec/crates/protocol/schema/authenticated_cmds/invite_new_shamir_recovery.json5 b/libparsec/crates/protocol/schema/authenticated_cmds/invite_new_shamir_recovery.json5 index 072ee9d13aa..87fb234637d 100644 --- a/libparsec/crates/protocol/schema/authenticated_cmds/invite_new_shamir_recovery.json5 +++ b/libparsec/crates/protocol/schema/authenticated_cmds/invite_new_shamir_recovery.json5 @@ -33,6 +33,9 @@ }, { "status": "author_not_allowed" + }, + { + "status": "user_not_found" } ], "nested_types": [ diff --git a/libparsec/crates/protocol/tests/authenticated_cmds/v4/invite_new_shamir_recovery.rs b/libparsec/crates/protocol/tests/authenticated_cmds/v4/invite_new_shamir_recovery.rs index 4858a226ec3..fb8c326935b 100644 --- a/libparsec/crates/protocol/tests/authenticated_cmds/v4/invite_new_shamir_recovery.rs +++ b/libparsec/crates/protocol/tests/authenticated_cmds/v4/invite_new_shamir_recovery.rs @@ -94,6 +94,18 @@ pub fn rep_author_not_allowed() { rep_helper(raw, expected); } +pub fn rep_user_not_found() { + // Generated from Parsec 3.1.1-a.0+dev + // Content: + // status: 'user_not_found' + let raw: &[u8] = hex!("81a6737461747573ae757365725f6e6f745f666f756e64").as_ref(); + + let expected = authenticated_cmds::invite_new_shamir_recovery::Rep::UserNotFound; + println!("***expected: {:?}", expected.dump().unwrap()); + + rep_helper(raw, expected); +} + fn rep_helper(raw: &[u8], expected: authenticated_cmds::invite_new_shamir_recovery::Rep) { let data = authenticated_cmds::invite_new_shamir_recovery::Rep::load(raw).unwrap(); diff --git a/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/invite_new_shamir_recovery.pyi b/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/invite_new_shamir_recovery.pyi index abc87a58b10..78561b2b739 100644 --- a/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/invite_new_shamir_recovery.pyi +++ b/server/parsec/_parsec_pyi/protocol/authenticated_cmds/v4/invite_new_shamir_recovery.pyi @@ -48,3 +48,8 @@ class RepAuthorNotAllowed(Rep): def __init__( self, ) -> None: ... + +class RepUserNotFound(Rep): + def __init__( + self, + ) -> None: ... diff --git a/server/parsec/components/invite.py b/server/parsec/components/invite.py index 30d79b74d7a..129647fb896 100644 --- a/server/parsec/components/invite.py +++ b/server/parsec/components/invite.py @@ -249,6 +249,7 @@ class InviteNewForShamirBadOutcome(BadOutcomeEnum): AUTHOR_NOT_FOUND = auto() AUTHOR_REVOKED = auto() AUTHOR_NOT_ALLOWED = auto() + USER_NOT_FOUND = auto() class InviteCancelBadOutcome(BadOutcomeEnum): @@ -840,6 +841,8 @@ async def api_invite_new_shamir_recovery( email_sent = authenticated_cmds.latest.invite_new_shamir_recovery.InvitationEmailSentStatus.RECIPIENT_REFUSED case InviteNewForShamirBadOutcome.AUTHOR_NOT_ALLOWED: return authenticated_cmds.latest.invite_new_shamir_recovery.RepAuthorNotAllowed() + case InviteNewForShamirBadOutcome.USER_NOT_FOUND: + return authenticated_cmds.latest.invite_new_shamir_recovery.RepUserNotFound() case InviteNewForShamirBadOutcome.ORGANIZATION_NOT_FOUND: client_ctx.organization_not_found_abort() case InviteNewForShamirBadOutcome.ORGANIZATION_EXPIRED: diff --git a/server/parsec/components/memory/invite.py b/server/parsec/components/memory/invite.py index ef31c458e66..d2fc4f24013 100644 --- a/server/parsec/components/memory/invite.py +++ b/server/parsec/components/memory/invite.py @@ -278,6 +278,12 @@ async def new_for_shamir_recovery( if author_user.is_revoked: return InviteNewForShamirBadOutcome.AUTHOR_REVOKED + # Check that the claimer exists + claimer = org.users.get(claimer_user_id) + if claimer is None: + return InviteNewForShamirBadOutcome.USER_NOT_FOUND + claimer_human_handle = claimer.cooked.human_handle + # Check that a shamir setup exists shamir_setup = org.shamir_setup.get(claimer_user_id) if shamir_setup is None: @@ -309,7 +315,7 @@ async def new_for_shamir_recovery( type=InvitationType.SHAMIR_RECOVERY, created_by_user_id=author_user_id, created_by_device_id=author, - claimer_email=None, + claimer_email=claimer_human_handle.email, created_on=now, claimer_user_id=claimer_user_id, ) @@ -326,7 +332,7 @@ async def new_for_shamir_recovery( if send_email: send_email_outcome = await self._send_shamir_recovery_invitation_email( organization_id=organization_id, - email=author_user.cooked.human_handle.email, + email=claimer_human_handle.email, token=token, greeter_human_handle=author_user.cooked.human_handle, ) diff --git a/server/tests/api_v4/authenticated/test_invite_new_shamir_recovery.py b/server/tests/api_v4/authenticated/test_invite_new_shamir_recovery.py index 53ae66d5847..79ead8a7afd 100644 --- a/server/tests/api_v4/authenticated/test_invite_new_shamir_recovery.py +++ b/server/tests/api_v4/authenticated/test_invite_new_shamir_recovery.py @@ -10,6 +10,7 @@ InvitationToken, ShamirRecoveryBriefCertificate, ShamirRecoveryShareCertificate, + UserID, authenticated_cmds, ) from parsec.components.invite import ( @@ -137,6 +138,18 @@ async def test_authenticated_invite_new_shamir_recovery_author_not_allowed( assert isinstance(rep, authenticated_cmds.v4.invite_new_shamir_recovery.RepAuthorNotAllowed) +@pytest.mark.parametrize("send_email", (False, True)) +async def test_authenticated_invite_new_shamir_recovery_user_not_found( + send_email: bool, coolorg: CoolorgRpcClients, backend: Backend, alice_shamir: None +) -> None: + # Shamir setup exists but author is not part of the recipients + rep = await coolorg.alice.invite_new_shamir_recovery( + send_email=send_email, + claimer_user_id=UserID.new(), + ) + assert isinstance(rep, authenticated_cmds.v4.invite_new_shamir_recovery.RepUserNotFound) + + async def test_authenticated_invite_new_shamir_recovery_ok_already_exist( coolorg: CoolorgRpcClients, backend: Backend, alice_shamir: None ) -> None: