[Add] Proper User managment
This commit is contained in:
parent
430c12e139
commit
b38b68ad96
38 changed files with 4331 additions and 1065 deletions
|
|
@ -1,5 +1,5 @@
|
|||
use async_trait::async_trait;
|
||||
use mtp::codec::CommunicationValue;
|
||||
use mtp::codec::{CommunicationType, CommunicationValue};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -7,6 +7,7 @@ pub enum OmikronError {
|
|||
Disconnected(String),
|
||||
Timeout(String),
|
||||
Authentication(String),
|
||||
Rejected(CommunicationType, String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ impl std::fmt::Display for OmikronError {
|
|||
Self::Disconnected(v)
|
||||
| Self::Timeout(v)
|
||||
| Self::Authentication(v)
|
||||
| Self::Rejected(_, v)
|
||||
| Self::Internal(v) => f.write_str(v),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ use iota_util::route_target::RouteTarget;
|
|||
|
||||
const IOTA_KEYRING_PATH: &str = "iota.mk";
|
||||
static IDENTITY_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
static OMIKRON_PUBLIC_KEY_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
static OMIKRON_TRUST_DIRECTORY: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
fn record_origin_delivery_failure(signer_id: u64, relay_message_id: &str, failure: &str) {
|
||||
let Ok(storage_owner) = i64::try_from(signer_id) else {
|
||||
|
|
@ -60,10 +60,10 @@ fn record_origin_delivery_failure(signer_id: u64, relay_message_id: &str, failur
|
|||
* working directory, so restarts use the same trusted material.
|
||||
*/
|
||||
pub fn configure_identity_path(path: PathBuf) {
|
||||
let key_path = path.parent().map(|parent| parent.join("omikron.mpkb"));
|
||||
let trust_directory = path.parent().map(|parent| parent.join("omikrons"));
|
||||
let _ = IDENTITY_PATH.set(path);
|
||||
if let Some(key_path) = key_path {
|
||||
let _ = OMIKRON_PUBLIC_KEY_PATH.set(key_path);
|
||||
if let Some(trust_directory) = trust_directory {
|
||||
let _ = OMIKRON_TRUST_DIRECTORY.set(trust_directory);
|
||||
}
|
||||
}
|
||||
fn identity_path() -> &'static Path {
|
||||
|
|
@ -72,11 +72,22 @@ fn identity_path() -> &'static Path {
|
|||
.map(PathBuf::as_path)
|
||||
.unwrap_or_else(|| Path::new(IOTA_KEYRING_PATH))
|
||||
}
|
||||
fn omikron_public_key_path() -> &'static Path {
|
||||
OMIKRON_PUBLIC_KEY_PATH
|
||||
fn omikron_trust_directory() -> &'static Path {
|
||||
OMIKRON_TRUST_DIRECTORY
|
||||
.get()
|
||||
.map(PathBuf::as_path)
|
||||
.unwrap_or_else(|| Path::new("omikron.mpkb"))
|
||||
.unwrap_or_else(|| Path::new("omikrons"))
|
||||
}
|
||||
|
||||
fn omikron_public_key_path(id: i64) -> PathBuf {
|
||||
omikron_trust_directory().join(format!("{id}.mpkb"))
|
||||
}
|
||||
|
||||
fn legacy_omikron_public_key_path() -> PathBuf {
|
||||
identity_path()
|
||||
.parent()
|
||||
.map(|parent| parent.join("omikron.mpkb"))
|
||||
.unwrap_or_else(|| PathBuf::from("omikron.mpkb"))
|
||||
}
|
||||
|
||||
fn save_omikron_public_key(key: &PublicKeyBundle, path: &Path) -> Result<(), String> {
|
||||
|
|
@ -107,13 +118,20 @@ fn serialization_path(path: &Path) -> Result<PathBuf, String> {
|
|||
|
||||
const RECONNECT_DELAY: Duration = Duration::from_secs(5);
|
||||
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300);
|
||||
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const TASK_MAX_AGE: Duration = Duration::from_secs(60);
|
||||
const MAX_CONCURRENT_HANDLERS: usize = 20;
|
||||
const RELAY_RETENTION_MILLIS: i64 = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
struct ResolvedOmikronEndpoint {
|
||||
id: Option<i64>,
|
||||
host: String,
|
||||
port: u16,
|
||||
public_key: PublicKeyBundle,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum IdentityError {
|
||||
Storage(mtp::files::FileError),
|
||||
|
|
@ -159,6 +177,18 @@ fn jittered_reconnect_delay(delay: Duration) -> Duration {
|
|||
Duration::from_millis(u64::try_from(jittered).expect("bounded jitter must be non-negative"))
|
||||
}
|
||||
|
||||
fn map_await_response_error(error: String) -> OmikronError {
|
||||
if error.contains("timed out") {
|
||||
OmikronError::Timeout(error)
|
||||
} else if error.contains("kind=not_found") {
|
||||
OmikronError::Rejected(CommunicationType::ErrorNotFound, error)
|
||||
} else if error.starts_with("Request rejected") {
|
||||
OmikronError::Rejected(CommunicationType::Error, error)
|
||||
} else {
|
||||
OmikronError::Disconnected(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_user_id(user_id: i64) -> u64 {
|
||||
u64::try_from(user_id).expect("validated user ID is non-negative")
|
||||
}
|
||||
|
|
@ -443,10 +473,9 @@ impl OmikronConnection {
|
|||
|
||||
let existing_iota_id = CONFIG.load().iota_id;
|
||||
|
||||
let (host, port, omikron_public_key) =
|
||||
self.resolve_omikron_endpoint(existing_iota_id).await?;
|
||||
let endpoint = self.resolve_omikron_endpoint(existing_iota_id).await?;
|
||||
|
||||
let addr_str = format!("https://{}:{}", host, port);
|
||||
let addr_str = format!("https://{}:{}", endpoint.host, endpoint.port);
|
||||
|
||||
log!("Connecting to Omikron at {}", addr_str);
|
||||
|
||||
|
|
@ -470,26 +499,24 @@ impl OmikronConnection {
|
|||
client_config,
|
||||
existing_iota_id,
|
||||
&keyring,
|
||||
&omikron_public_key,
|
||||
&endpoint.public_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connection) => connection,
|
||||
Err(mtp::common::CommunicationError::AuthenticationFailed(reason)) => {
|
||||
let reason = format!(
|
||||
"Authentication failed: {}. Your Iota keys may be invalid or the private key has changed on the server.",
|
||||
reason
|
||||
);
|
||||
*self.reconnect_on_close.write().await = false;
|
||||
*self.auth_failure.write().await = Some(reason.clone());
|
||||
self.set_state(ConnectionState::Disconnected).await;
|
||||
return Err(reason);
|
||||
/* MTP currently combines invalid proofs with timeouts and backend
|
||||
* failures in this variant. Retrying is safe; stopping here can
|
||||
* strand an Iota during a temporary Omega outage. */
|
||||
return Err(format!("Authentication attempt failed: {reason}"));
|
||||
}
|
||||
Err(e) => return Err(format!("Connection failed: {}", e)),
|
||||
};
|
||||
|
||||
log_t!("omikron_connection_success");
|
||||
|
||||
self.persist_authenticated_omikron(&endpoint)?;
|
||||
|
||||
if existing_iota_id.is_none() {
|
||||
modify_config(|cfg| cfg.iota_id = Some(connection.client_id));
|
||||
log!("Registered with Iota-ID: {}", connection.client_id);
|
||||
|
|
@ -564,22 +591,20 @@ impl OmikronConnection {
|
|||
* override for local dev/testing against a hand-run Omikron without a
|
||||
* live Omega.
|
||||
*
|
||||
* The fetched Omikron public key is pinned to `omikron.mpkb` (trust on
|
||||
* first use): if a cached key exists and a fresh discovery response
|
||||
* disagrees with it, the mismatch is logged loudly and the cached key is
|
||||
* kept rather than silently trusting whatever Omega's HTTP API returned
|
||||
* this time - the same trust boundary the previous manual-file-drop
|
||||
* model had, just automated for the common case.
|
||||
* Keys are pinned per Omikron ID. A different relay can therefore be used
|
||||
* after failover, while an unexpected key change for one relay remains a
|
||||
* security error. Discovery data becomes durable only after MTP
|
||||
* authentication has completed.
|
||||
*/
|
||||
async fn resolve_omikron_endpoint(
|
||||
&self,
|
||||
existing_iota_id: Option<u64>,
|
||||
) -> Result<(String, u16, PublicKeyBundle), String> {
|
||||
) -> Result<ResolvedOmikronEndpoint, String> {
|
||||
if let (Ok(host), Ok(port_str)) = (env::var("OMIKRON_HOST"), env::var("OMIKRON_PORT")) {
|
||||
let port: u16 = port_str
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid OMIKRON_PORT: {}", port_str))?;
|
||||
let key_path = omikron_public_key_path();
|
||||
let key_path = Path::new("omikron.mpkb");
|
||||
let public_key = mtp::files::load_public_key_bundle(key_path)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
|
|
@ -587,21 +612,41 @@ impl OmikronConnection {
|
|||
key_path.display(), e, key_path.display()
|
||||
)
|
||||
})?;
|
||||
return Ok((host, port, public_key));
|
||||
return Ok(ResolvedOmikronEndpoint {
|
||||
id: None,
|
||||
host,
|
||||
port,
|
||||
public_key,
|
||||
});
|
||||
}
|
||||
|
||||
let key_path = omikron_public_key_path();
|
||||
let cached_key = mtp::files::load_public_key_bundle(key_path).ok();
|
||||
let cached_host_port = {
|
||||
let cached_endpoint = {
|
||||
let conf = CONFIG.load();
|
||||
match (&conf.omikron_host, conf.omikron_port) {
|
||||
(Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => {
|
||||
Some((host.clone(), port))
|
||||
match (&conf.omikron_id, &conf.omikron_host, conf.omikron_port) {
|
||||
(Some(id), Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => {
|
||||
mtp::files::load_public_key_bundle(&omikron_public_key_path(*id))
|
||||
.ok()
|
||||
.map(|public_key| ResolvedOmikronEndpoint {
|
||||
id: Some(*id),
|
||||
host: host.clone(),
|
||||
port,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
(Some(_), Some(_), Some(_)) => {
|
||||
log!("Ignoring invalid cached Omikron endpoint in Iota configuration");
|
||||
None
|
||||
}
|
||||
(None, Some(host), Some(port)) if !host.trim().is_empty() && port != 0 => {
|
||||
mtp::files::load_public_key_bundle(legacy_omikron_public_key_path())
|
||||
.ok()
|
||||
.map(|public_key| ResolvedOmikronEndpoint {
|
||||
id: None,
|
||||
host: host.clone(),
|
||||
port,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
|
@ -620,8 +665,9 @@ impl OmikronConnection {
|
|||
None => omega_discovery::discover_random().await.ok(),
|
||||
};
|
||||
|
||||
let (host, port, public_key) = if let Some(endpoint) = discovered {
|
||||
match &cached_key {
|
||||
let endpoint = if let Some(endpoint) = discovered {
|
||||
let key_path = omikron_public_key_path(endpoint.id);
|
||||
match mtp::files::load_public_key_bundle(&key_path).ok() {
|
||||
Some(cached) => {
|
||||
let keys_match =
|
||||
match (cached.try_as_bytes(), endpoint.public_key.try_as_bytes()) {
|
||||
|
|
@ -632,49 +678,63 @@ impl OmikronConnection {
|
|||
};
|
||||
if !keys_match {
|
||||
log!(
|
||||
"Fetched Omikron public key differs from the cached {} - keeping the \
|
||||
cached key. Delete {} manually if this is an expected key rotation.",
|
||||
"Fetched Omikron public key differs from the trusted {}. \
|
||||
Omikron key rotation requires an explicit trust refresh.",
|
||||
key_path.display(),
|
||||
key_path.display()
|
||||
);
|
||||
if let Some((cached_host, cached_port)) = &cached_host_port {
|
||||
(cached_host.clone(), *cached_port, cached.clone())
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Omega returned an Omikron key that differs from {} and no validated cached endpoint is available",
|
||||
key_path.display()
|
||||
));
|
||||
}
|
||||
return Err(format!(
|
||||
"Omega returned a changed public key for Omikron {}",
|
||||
endpoint.id
|
||||
));
|
||||
} else {
|
||||
(endpoint.host, endpoint.port, cached.clone())
|
||||
ResolvedOmikronEndpoint {
|
||||
id: Some(endpoint.id),
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
public_key: cached,
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if let Err(e) = save_omikron_public_key(&endpoint.public_key, key_path) {
|
||||
log!("Failed to cache Omikron public key: {}", e);
|
||||
}
|
||||
(endpoint.host, endpoint.port, endpoint.public_key)
|
||||
}
|
||||
None => ResolvedOmikronEndpoint {
|
||||
id: Some(endpoint.id),
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
public_key: endpoint.public_key,
|
||||
},
|
||||
}
|
||||
} else if let (Some(cached), Some((host, port))) = (&cached_key, &cached_host_port) {
|
||||
} else if let Some(cached) = cached_endpoint {
|
||||
log!(
|
||||
"Omega discovery unreachable, falling back to last-known Omikron {}:{}",
|
||||
host,
|
||||
port
|
||||
cached.host,
|
||||
cached.port
|
||||
);
|
||||
(host.clone(), *port, cached.clone())
|
||||
cached
|
||||
} else {
|
||||
return Err(
|
||||
"Omega discovery failed and no cached Omikron address/key is available".to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
modify_config(|cfg| {
|
||||
cfg.omikron_host = Some(host.clone());
|
||||
cfg.omikron_port = Some(port);
|
||||
});
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
Ok((host, port, public_key))
|
||||
fn persist_authenticated_omikron(
|
||||
&self,
|
||||
endpoint: &ResolvedOmikronEndpoint,
|
||||
) -> Result<(), String> {
|
||||
let Some(id) = endpoint.id else {
|
||||
return Ok(());
|
||||
};
|
||||
let key_path = omikron_public_key_path(id);
|
||||
std::fs::create_dir_all(omikron_trust_directory())
|
||||
.map_err(|error| format!("create Omikron trust directory: {error}"))?;
|
||||
save_omikron_public_key(&endpoint.public_key, &key_path)?;
|
||||
modify_config(|cfg| {
|
||||
cfg.omikron_id = Some(id);
|
||||
cfg.omikron_host = Some(endpoint.host.clone());
|
||||
cfg.omikron_port = Some(endpoint.port);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -1946,6 +2006,7 @@ impl OmikronConnection {
|
|||
dispatch!(MessageStoragePolicySet, handle_message_storage_policy_set);
|
||||
dispatch!(UserBlockCheck, handle_user_block_check);
|
||||
dispatch!(EraseHostedUserData, handle_erase_hosted_user_data);
|
||||
dispatch!(ProvisionIotaUser, handle_iota_user_provisioning);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -1980,6 +2041,54 @@ impl OmikronConnection {
|
|||
let _ = self.send_message(&acknowledgement).await;
|
||||
}
|
||||
|
||||
/* Omega sends only public account metadata here. The locally created
|
||||
* profile records an external credential origin and never receives a TU. */
|
||||
async fn handle_iota_user_provisioning(self: Arc<Self>, cv: &CommunicationValue) {
|
||||
let user_id = cv
|
||||
.get_data(DataType::UserId)
|
||||
.as_signed_number()
|
||||
.and_then(|id| i64::try_from(id).ok())
|
||||
.filter(|id| *id > 0);
|
||||
let username = cv.get_data(DataType::Username).as_str().map(str::to_owned);
|
||||
let public_key = cv.get_data(DataType::PublicKey).as_str().map(str::to_owned);
|
||||
let invitation_id = cv
|
||||
.get_data(DataType::InvitationId)
|
||||
.as_str()
|
||||
.filter(|value| uuid::Uuid::parse_str(value).is_ok())
|
||||
.map(str::to_owned);
|
||||
let Some((user_id, username, public_key, invitation_id)) = user_id
|
||||
.zip(username)
|
||||
.zip(public_key)
|
||||
.zip(invitation_id)
|
||||
.map(|(((id, username), key), invitation_id)| (id, username, key, invitation_id))
|
||||
else {
|
||||
let _ = self
|
||||
.send_message(&error_response(cv, CommunicationType::ErrorInvalidData))
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
let profile = iota_storage::users::user_profile::UserProfile::new(
|
||||
user_id, username, None, public_key, None, None,
|
||||
);
|
||||
if iota_storage::users::user_manager::try_add_user_with_credential_origin(
|
||||
profile,
|
||||
iota_storage::users::user_manager::CredentialOrigin::External,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
let _ = self
|
||||
.send_message(&error_response(cv, CommunicationType::ErrorInternal))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let acknowledgement =
|
||||
CommunicationValue::new(CommunicationType::AcknowledgeIotaUserProvision)
|
||||
.with_request_id(cv)
|
||||
.add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into()))
|
||||
.add_typed_default(DataType::InvitationId, DataValue::Str(invitation_id));
|
||||
let _ = self.send_message(&acknowledgement).await;
|
||||
}
|
||||
|
||||
async fn handle_get_chat_secret(self: Arc<Self>, cv: &CommunicationValue) {
|
||||
let _ = self
|
||||
.send_message(&message_handlers::handle_get_chat_secret(cv))
|
||||
|
|
@ -2781,9 +2890,13 @@ impl OmikronConnection {
|
|||
.or_else(|| response_cv.get_data(DataType::ErrorType).as_str())
|
||||
.unwrap_or("connection error")
|
||||
.to_string();
|
||||
let rejection_kind = if response_cv.is_type(CommunicationType::ErrorNotFound) {
|
||||
"not_found"
|
||||
} else {
|
||||
"other"
|
||||
};
|
||||
Err(format!(
|
||||
"Request rejected (msg_id={}, reason={})",
|
||||
msg_id, reason
|
||||
"Request rejected (kind={rejection_kind}, msg_id={msg_id}, reason={reason})"
|
||||
))
|
||||
} else {
|
||||
Ok(response_cv)
|
||||
|
|
@ -3029,15 +3142,7 @@ impl OmikronClient for OmikronConnection {
|
|||
) -> Result<CommunicationValue, OmikronError> {
|
||||
Self::await_response(self, value, Some(timeout))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.contains("timed out") {
|
||||
OmikronError::Timeout(error)
|
||||
} else if error.starts_with("Request rejected") {
|
||||
OmikronError::Internal(error)
|
||||
} else {
|
||||
OmikronError::Disconnected(error)
|
||||
}
|
||||
})
|
||||
.map_err(map_await_response_error)
|
||||
}
|
||||
|
||||
async fn reconnect(&self) -> Result<(), OmikronError> {
|
||||
|
|
@ -3165,4 +3270,21 @@ mod tests {
|
|||
|
||||
assert!(jittered_reconnect_delay(Duration::from_secs(600)) <= MAX_RECONNECT_DELAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_omikron_trust_paths_do_not_collide() {
|
||||
assert_ne!(omikron_public_key_path(1), omikron_public_key_path(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_response_is_preserved_for_callers() {
|
||||
let error = map_await_response_error(
|
||||
"Request rejected (kind=not_found, msg_id=1, reason=connection error)".into(),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
OmikronError::Rejected(CommunicationType::ErrorNotFound, _)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ pub enum LifecycleUserError {
|
|||
LocalPersistence(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InspectedTuCredential {
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub assigned_iota_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<crate::OmikronError> for LifecycleUserError {
|
||||
fn from(value: crate::OmikronError) -> Self {
|
||||
Self::Transport(value)
|
||||
|
|
@ -120,6 +127,58 @@ async fn inspect_credential_account(
|
|||
Ok((username, public_key, created_at))
|
||||
}
|
||||
|
||||
/*
|
||||
* Inspection verifies the credential against Omega but deliberately stops
|
||||
* before proof, assignment, pending-operation, credential, or profile writes.
|
||||
*/
|
||||
pub async fn inspect_tu_credential(
|
||||
connection: &dyn OmikronClient,
|
||||
contents: &str,
|
||||
) -> Result<InspectedTuCredential, LifecycleUserError> {
|
||||
let credential = TuCredential::parse(contents)
|
||||
.map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?;
|
||||
let (username, _, _) = inspect_credential_account(connection, &credential).await?;
|
||||
let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default(
|
||||
DataType::UserId,
|
||||
DataValue::SignedNumber(credential.user_id.into()),
|
||||
);
|
||||
let response = connection
|
||||
.await_response(&request, Duration::from_secs(20))
|
||||
.await?;
|
||||
if !response.is_type(CommunicationType::GetUserData) {
|
||||
return Err(LifecycleUserError::RemoteRejected);
|
||||
}
|
||||
let assigned_iota_id = response
|
||||
.get_data(DataType::IotaId)
|
||||
.as_signed_number()
|
||||
.and_then(|value| i64::try_from(value).ok())
|
||||
.filter(|value| *value > 0);
|
||||
Ok(InspectedTuCredential {
|
||||
user_id: credential.user_id,
|
||||
username,
|
||||
assigned_iota_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_remote_user_assignment(
|
||||
connection: &dyn OmikronClient,
|
||||
user_id: i64,
|
||||
) -> Result<Option<i64>, LifecycleUserError> {
|
||||
let request = CommunicationValue::new(CommunicationType::GetUserData)
|
||||
.add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into()));
|
||||
let response = connection
|
||||
.await_response(&request, Duration::from_secs(20))
|
||||
.await?;
|
||||
if !response.is_type(CommunicationType::GetUserData) {
|
||||
return Err(LifecycleUserError::RemoteRejected);
|
||||
}
|
||||
Ok(response
|
||||
.get_data(DataType::IotaId)
|
||||
.as_signed_number()
|
||||
.and_then(|value| i64::try_from(value).ok())
|
||||
.filter(|value| *value > 0))
|
||||
}
|
||||
|
||||
async fn credential_proof(
|
||||
connection: &dyn OmikronClient,
|
||||
credential: &TuCredential,
|
||||
|
|
@ -181,8 +240,8 @@ pub async fn attach_user_from_tu(
|
|||
username,
|
||||
None,
|
||||
public_key,
|
||||
hex_hash(contents),
|
||||
String::new(),
|
||||
Some(hex_hash(contents)),
|
||||
Some(String::new()),
|
||||
created_at,
|
||||
);
|
||||
pending_operations::upsert(&PendingUserOperation {
|
||||
|
|
@ -190,8 +249,8 @@ pub async fn attach_user_from_tu(
|
|||
operation: PendingUserOperationKind::Attach,
|
||||
username: profile.username.clone(),
|
||||
public_key: Some(profile.public_key.clone()),
|
||||
private_key_hash: Some(profile.private_key_hash.clone()),
|
||||
reset_token: Some(profile.reset_token.clone()),
|
||||
private_key_hash: profile.private_key_hash.clone(),
|
||||
reset_token: profile.reset_token.clone(),
|
||||
registration_token: None,
|
||||
phase: PendingUserOperationPhase::Prepared,
|
||||
created_at: now_millis(),
|
||||
|
|
@ -254,16 +313,17 @@ pub async fn complete_delete_user_with_tu(
|
|||
/// Repair local management state after a release or migration committed in
|
||||
/// Omega but local cleanup was interrupted. Hosted data is retained.
|
||||
pub async fn reconcile_managed_users(connection: &dyn OmikronClient) {
|
||||
let Ok(local_iota_id) = configured_iota_id() else {
|
||||
return;
|
||||
};
|
||||
let pending = match pending_operations::get_all() {
|
||||
let mut pending = match pending_operations::get_all() {
|
||||
Ok(pending) => pending,
|
||||
Err(error) => {
|
||||
log!("Pending user operation reconciliation could not read storage: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
pending.retain(|operation| operation.operation != PendingUserOperationKind::Purge);
|
||||
let Ok(local_iota_id) = configured_iota_id() else {
|
||||
return;
|
||||
};
|
||||
for operation in pending {
|
||||
let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default(
|
||||
DataType::UserId,
|
||||
|
|
@ -319,15 +379,20 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) {
|
|||
operation.username,
|
||||
None,
|
||||
public_key,
|
||||
operation.private_key_hash.unwrap_or_default(),
|
||||
operation.reset_token.unwrap_or_default(),
|
||||
operation.private_key_hash,
|
||||
operation.reset_token,
|
||||
);
|
||||
if try_add_user(profile).is_ok() {
|
||||
let _ = pending_operations::remove(operation.user_id);
|
||||
}
|
||||
}
|
||||
PendingUserOperationKind::Release if remote_iota_id != Some(local_iota_id) => {
|
||||
if iota_storage::users::user_manager::release_user(operation.user_id).is_ok() {
|
||||
if iota_storage::users::user_manager::finalize_local_release(
|
||||
operation.user_id,
|
||||
Some(&operation.username),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
let _ = pending_operations::remove(operation.user_id);
|
||||
}
|
||||
}
|
||||
|
|
@ -339,19 +404,47 @@ pub async fn reconcile_managed_users(connection: &dyn OmikronClient) {
|
|||
DataType::UserId,
|
||||
DataValue::SignedNumber(user.user_id.into()),
|
||||
);
|
||||
let Ok(response) = connection
|
||||
let response = connection
|
||||
.await_response(&request, Duration::from_secs(10))
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
.await;
|
||||
let should_release = match should_release_reconciled_user(response, local_iota_id) {
|
||||
Ok(should_release) => should_release,
|
||||
Err(error) => {
|
||||
log!(
|
||||
"Could not reconcile local user {} with Omega: {}",
|
||||
user.user_id,
|
||||
error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let remote_iota_id = response
|
||||
if should_release {
|
||||
if let Err(error) = iota_storage::users::user_manager::finalize_local_release(
|
||||
user.user_id,
|
||||
Some(&user.username),
|
||||
) {
|
||||
log!(
|
||||
"Could not release local user {} after Omega reconciliation: {}",
|
||||
user.user_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_release_reconciled_user(
|
||||
response: Result<CommunicationValue, crate::OmikronError>,
|
||||
local_iota_id: i64,
|
||||
) -> Result<bool, crate::OmikronError> {
|
||||
match response {
|
||||
Ok(response) => Ok(response
|
||||
.get_data(DataType::IotaId)
|
||||
.as_signed_number()
|
||||
.and_then(|value| i64::try_from(value).ok());
|
||||
if remote_iota_id != Some(local_iota_id) {
|
||||
let _ = iota_storage::users::user_manager::release_user(user.user_id);
|
||||
}
|
||||
.and_then(|value| i64::try_from(value).ok())
|
||||
!= Some(local_iota_id)),
|
||||
Err(crate::OmikronError::Rejected(CommunicationType::ErrorNotFound, _)) => Ok(true),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -505,8 +598,8 @@ pub async fn create_user(
|
|||
username.to_string(),
|
||||
None,
|
||||
public_key_bundle_to_base64(&pub_key_bundle),
|
||||
private_key_hash,
|
||||
reset_token.clone(),
|
||||
Some(private_key_hash),
|
||||
Some(reset_token.clone()),
|
||||
);
|
||||
let credential = format!(
|
||||
"{}@{}::{}",
|
||||
|
|
@ -519,8 +612,8 @@ pub async fn create_user(
|
|||
operation: PendingUserOperationKind::Create,
|
||||
username: user_profile.username.clone(),
|
||||
public_key: Some(user_profile.public_key.clone()),
|
||||
private_key_hash: Some(user_profile.private_key_hash.clone()),
|
||||
reset_token: Some(user_profile.reset_token.clone()),
|
||||
private_key_hash: user_profile.private_key_hash.clone(),
|
||||
reset_token: user_profile.reset_token.clone(),
|
||||
registration_token: Some(registration_token.clone()),
|
||||
phase: PendingUserOperationPhase::Prepared,
|
||||
created_at: now_millis(),
|
||||
|
|
@ -563,7 +656,9 @@ pub async fn create_user(
|
|||
} else {
|
||||
log_t!("User creation: {}", error.to_string());
|
||||
return Err(match error {
|
||||
crate::OmikronError::Internal(_) => CreateUserError::RemoteRejected,
|
||||
crate::OmikronError::Rejected(_, _) | crate::OmikronError::Internal(_) => {
|
||||
CreateUserError::RemoteRejected
|
||||
}
|
||||
error => CreateUserError::Transport(error),
|
||||
});
|
||||
}
|
||||
|
|
@ -583,10 +678,15 @@ pub async fn create_user(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CreateUserError, request_user_id, valid_username};
|
||||
use super::{
|
||||
CreateUserError, LifecycleUserError, inspect_tu_credential, request_user_id,
|
||||
should_release_reconciled_user, valid_username,
|
||||
};
|
||||
use crate::{OmikronClient, OmikronError};
|
||||
use async_trait::async_trait;
|
||||
use iota_connection::message_common::CommunicationResponseExt;
|
||||
use iota_util::crypto_helper::{generate_keyring, public_key_bundle_to_base64};
|
||||
use iota_util::tu::TuCredential;
|
||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -594,6 +694,34 @@ mod tests {
|
|||
response: CommunicationValue,
|
||||
}
|
||||
|
||||
struct InspectionClient {
|
||||
response: CommunicationValue,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OmikronClient for InspectionClient {
|
||||
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn await_response(
|
||||
&self,
|
||||
request: &CommunicationValue,
|
||||
_: Duration,
|
||||
) -> Result<CommunicationValue, OmikronError> {
|
||||
assert!(request.is_type(CommunicationType::GetUserData));
|
||||
Ok(self.response.clone().with_request_id(request))
|
||||
}
|
||||
async fn reconnect(&self) -> Result<(), OmikronError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn rotate_identity(&self) -> Result<(), OmikronError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn is_connected(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OmikronClient for RegistrationClient {
|
||||
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
|
||||
|
|
@ -634,6 +762,42 @@ mod tests {
|
|||
assert!(!valid_username("line\nbreak"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inspection_returns_verified_identity_without_lifecycle_requests() {
|
||||
let credential = TuCredential {
|
||||
user_id: 42,
|
||||
omega_host: crate::omega_discovery::omega_host(),
|
||||
keyring: generate_keyring(),
|
||||
};
|
||||
let client = InspectionClient {
|
||||
response: CommunicationValue::new(CommunicationType::GetUserData)
|
||||
.add_typed_default(DataType::Username, DataValue::Str("alice".into()))
|
||||
.add_typed_default(
|
||||
DataType::PublicKey,
|
||||
DataValue::Str(public_key_bundle_to_base64(&credential.public_key_bundle())),
|
||||
)
|
||||
.add_typed_default(DataType::CreatedAt, DataValue::SignedNumber(1))
|
||||
.add_typed_default(DataType::IotaId, DataValue::SignedNumber(7)),
|
||||
};
|
||||
let preview = inspect_tu_credential(&client, &credential.to_canonical_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(preview.user_id, 42);
|
||||
assert_eq!(preview.username, "alice");
|
||||
assert_eq!(preview.assigned_iota_id, Some(7));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inspection_rejects_malformed_credentials_before_remote_access() {
|
||||
let client = InspectionClient {
|
||||
response: CommunicationValue::new(CommunicationType::GetUserData),
|
||||
};
|
||||
assert!(matches!(
|
||||
inspect_tu_credential(&client, "malformed").await,
|
||||
Err(LifecycleUserError::InvalidCredential(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uses_user_id_allocated_by_omega() {
|
||||
let client = RegistrationClient {
|
||||
|
|
@ -663,4 +827,24 @@ mod tests {
|
|||
Err(CreateUserError::InvalidResponse)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releases_a_user_that_omega_no_longer_has() {
|
||||
let response = Err(OmikronError::Rejected(
|
||||
CommunicationType::ErrorNotFound,
|
||||
"user does not exist".into(),
|
||||
));
|
||||
|
||||
assert_eq!(should_release_reconciled_user(response, 7), Ok(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_a_user_when_omega_cannot_be_reached() {
|
||||
let response = Err(OmikronError::Timeout("timed out".into()));
|
||||
|
||||
assert!(matches!(
|
||||
should_release_reconciled_user(response, 7),
|
||||
Err(OmikronError::Timeout(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue