[Fix] User deletion & migration
This commit is contained in:
parent
326ebf3b37
commit
7dc98ef29b
20 changed files with 742 additions and 129 deletions
|
|
@ -1,10 +1,26 @@
|
|||
use crate::users::user_profile::UserProfile;
|
||||
use crate::util::db;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use iota_util::crypto_helper::{self, hex_hash, keyring_from_base64, public_key_bundle_to_base64};
|
||||
use iota_util::file_util::{load_file, save_file};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file};
|
||||
use rusqlite::params;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LocalUserState {
|
||||
Managed,
|
||||
Released,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserResidency {
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub state: LocalUserState,
|
||||
pub data_present: bool,
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64
|
||||
}
|
||||
|
||||
pub fn add_user(user: UserProfile) {
|
||||
if let Err(e) = try_add_user(user) {
|
||||
|
|
@ -45,6 +61,12 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora
|
|||
params![user.user_id, app_id, app_secret],
|
||||
)?;
|
||||
}
|
||||
conn.execute(
|
||||
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at)
|
||||
VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3)
|
||||
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', updated_at = excluded.updated_at"#,
|
||||
params![user.user_id, user.username, now_millis()],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
|
@ -205,42 +227,92 @@ pub fn remove_user(user_id: i64) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Remove only local management authority. Hosted content is intentionally
|
||||
/// retained and is indexed as released for a later purge operation.
|
||||
pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
let username = get_user(user_id).map(|user| user.username).ok_or_else(|| {
|
||||
crate::storage_error::StorageError::Other("managed user was not found".into())
|
||||
})?;
|
||||
db::with_db(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute(
|
||||
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at)
|
||||
VALUES (?1, ?2, 'released', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3)
|
||||
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'released', updated_at = excluded.updated_at"#,
|
||||
params![user_id, username, now_millis()],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
|
||||
}
|
||||
|
||||
/// Authoritative hosted-data erasure used by local purge and future Omega
|
||||
/// erasure delivery. Management metadata and credentials are left intact.
|
||||
pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
db::with_db(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute("DELETE FROM message_edits WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?;
|
||||
tx.execute("DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?;
|
||||
tx.execute("DELETE FROM messages WHERE storage_owner = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM contacts WHERE storage_owner = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM communities WHERE storage_owner = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM settings WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM sync_events WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM sync_heads WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM client_sync_state WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute(
|
||||
"UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1",
|
||||
params![user_id, now_millis()],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})?;
|
||||
crate::util::e2ee_storage::purge_user(user_id)
|
||||
.map_err(crate::storage_error::StorageError::Other)?;
|
||||
delete_user_directory(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
|
||||
}
|
||||
|
||||
/// Complete local erasure is idempotent and is the target for a durable
|
||||
/// Omega-hosted erasure request after account deletion.
|
||||
pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
purge_user_data(user_id)?;
|
||||
db::with_db(|conn| {
|
||||
conn.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?;
|
||||
conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?;
|
||||
conn.execute("DELETE FROM user_residency WHERE user_id = ?1", params![user_id])?;
|
||||
Ok(())
|
||||
})?;
|
||||
remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn get_residency() -> Vec<UserResidency> {
|
||||
db::with_db(|conn| {
|
||||
let mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state FROM user_residency ORDER BY username")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let lifecycle: String = row.get(2)?;
|
||||
Ok(UserResidency {
|
||||
user_id: row.get(0)?, username: row.get(1)?,
|
||||
state: if lifecycle == "managed" { LocalUserState::Managed } else { LocalUserState::Released },
|
||||
data_present: row.get::<_, String>(3)? == "present",
|
||||
})
|
||||
})?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn clear() {
|
||||
if let Err(e) = db::with_db(|conn| {
|
||||
conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users;")?;
|
||||
conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;")?;
|
||||
Ok(())
|
||||
}) {
|
||||
eprintln!("Failed to clear users: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn load_from_tu(username: &str) -> Result<(), ()> {
|
||||
let file_content = load_file("", &format!("{}.tu", username));
|
||||
let segments = file_content.split("::").collect::<Vec<&str>>();
|
||||
let (uuid_str, _omega_host) = segments[0].split_once('@').unwrap_or((segments[0], ""));
|
||||
let uuid = uuid_str.parse::<i64>().unwrap_or(0);
|
||||
let b64_private_key = segments[1];
|
||||
|
||||
let keyring = keyring_from_base64(b64_private_key).unwrap();
|
||||
let pub_key_bundle = keyring.public_key_bundle();
|
||||
let keyring_b64 = crypto_helper::keyring_to_base64(&keyring);
|
||||
|
||||
let mut bytes = [0u8; 192];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let reset_token = STANDARD.encode(&bytes);
|
||||
|
||||
let user_profile = UserProfile::new(
|
||||
uuid,
|
||||
username.to_string(),
|
||||
Some(username.to_string()),
|
||||
public_key_bundle_to_base64(&pub_key_bundle),
|
||||
hex_hash(&keyring_b64),
|
||||
reset_token,
|
||||
);
|
||||
add_user(user_profile);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_users() {
|
||||
// No-op: users are auto-saved via SQLite.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
use iota_util::file_util::{has_file, load_file, used_dir_space};
|
||||
use iota_util::file_util::{read_user_credential_with_legacy, used_dir_space};
|
||||
use json::{JsonValue, object};
|
||||
use rand::Rng;
|
||||
use rand::rngs::OsRng;
|
||||
|
|
@ -55,9 +55,11 @@ impl UserProfile {
|
|||
if let Some(d) = &self.display_name {
|
||||
obj["display_name"] = d.clone().into();
|
||||
}
|
||||
if has_file("", &format!("{}.tu", self.username.clone())) {
|
||||
obj["tu"] = load_file("", &format!("{}.tu", self.username.clone())).into();
|
||||
}
|
||||
// Frontend consumers must never receive private credential material.
|
||||
obj["has_tu"] = read_user_credential_with_legacy(self.user_id, &self.username)
|
||||
.map(|credential| credential.is_some())
|
||||
.unwrap_or(false)
|
||||
.into();
|
||||
obj
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -251,6 +251,21 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
|
|||
)?;
|
||||
}
|
||||
|
||||
if current_version < 7 {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS user_residency (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('managed', 'released')),
|
||||
data_state TEXT NOT NULL CHECK (data_state IN ('present', 'empty')),
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 7;
|
||||
"#,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -320,7 +335,7 @@ mod tests {
|
|||
run_migrations_on_connection(&conn)?;
|
||||
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
assert_eq!(version, 6);
|
||||
assert_eq!(version, 7);
|
||||
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
|
||||
let mut statement =
|
||||
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
|
||||
|
|
@ -337,8 +352,8 @@ mod tests {
|
|||
run_migrations_on_connection(&conn)?;
|
||||
run_migrations_on_connection(&conn)?;
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
assert_eq!(version, 6);
|
||||
for table in ["sync_heads", "sync_events", "client_sync_state"] {
|
||||
assert_eq!(version, 7);
|
||||
for table in ["sync_heads", "sync_events", "client_sync_state", "user_residency"] {
|
||||
let exists: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||
[table],
|
||||
|
|
|
|||
|
|
@ -188,6 +188,23 @@ pub fn delete_pending_chat_secret_forward(
|
|||
})
|
||||
}
|
||||
|
||||
/// Erase every E2EE record owned by, or queued for, a user. The operation is
|
||||
/// intentionally idempotent so it can be retried after an interrupted remote
|
||||
/// erasure request.
|
||||
pub fn purge_user(user_id: i64) -> Result<(), StorageError> {
|
||||
let user_id = user_id.to_string();
|
||||
db::with_conn(&E2EE_DB, |conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute("DELETE FROM chat_secrets WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute(
|
||||
"DELETE FROM pending_chat_secret_forwards WHERE recipient_user_id = ?1 OR sender_user_id = ?1",
|
||||
params![user_id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_chat_secret(query: ChatSecretQuery) -> Result<Option<StoredChatSecret>, StorageError> {
|
||||
if query.user_id.is_empty() || query.chat_id.is_empty() {
|
||||
return Ok(None);
|
||||
|
|
|
|||
Loading…
Reference in a new issue