[Add] Proper User managment
This commit is contained in:
parent
430c12e139
commit
b38b68ad96
38 changed files with 4331 additions and 1065 deletions
83
iota-storage/src/users/invitations.rs
Normal file
83
iota-storage/src/users/invitations.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use crate::{storage_error::StorageError, util::db};
|
||||
use rusqlite::params;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum InvitationState {
|
||||
Pending,
|
||||
Redeemed,
|
||||
Revoked,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl InvitationState {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Redeemed => "redeemed",
|
||||
Self::Revoked => "revoked",
|
||||
Self::Expired => "expired",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(value: &str) -> Result<Self, StorageError> {
|
||||
match value {
|
||||
"pending" => Ok(Self::Pending),
|
||||
"redeemed" => Ok(Self::Redeemed),
|
||||
"revoked" => Ok(Self::Revoked),
|
||||
"expired" => Ok(Self::Expired),
|
||||
_ => Err(StorageError::Other("unknown invitation state".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InvitationSummary {
|
||||
pub invitation_id: String,
|
||||
pub created_at: i64,
|
||||
pub expires_at: Option<i64>,
|
||||
pub state: InvitationState,
|
||||
pub redeemed_user_id: Option<i64>,
|
||||
pub redeemed_at: Option<i64>,
|
||||
pub revoked_at: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn insert(summary: &InvitationSummary, token_hash: &[u8]) -> Result<(), StorageError> {
|
||||
db::with_immediate_transaction(|tx| {
|
||||
tx.execute(
|
||||
"INSERT INTO user_invitations (invitation_id, token_hash, created_at, expires_at, state, redeemed_user_id, redeemed_at, revoked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![summary.invitation_id, token_hash, summary.created_at, summary.expires_at, summary.state.as_str(), summary.redeemed_user_id, summary.redeemed_at, summary.revoked_at],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list() -> Result<Vec<InvitationSummary>, StorageError> {
|
||||
db::with_db(|conn| {
|
||||
let mut statement = conn.prepare("SELECT invitation_id, created_at, expires_at, state, redeemed_user_id, redeemed_at, revoked_at FROM user_invitations ORDER BY created_at DESC")?;
|
||||
Ok(statement
|
||||
.query_map([], |row| {
|
||||
Ok(InvitationSummary {
|
||||
invitation_id: row.get(0)?,
|
||||
created_at: row.get(1)?,
|
||||
expires_at: row.get(2)?,
|
||||
state: InvitationState::parse(&row.get::<_, String>(3)?).map_err(|error| {
|
||||
rusqlite::Error::ToSqlConversionFailure(Box::new(error))
|
||||
})?,
|
||||
redeemed_user_id: row.get(4)?,
|
||||
redeemed_at: row.get(5)?,
|
||||
revoked_at: row.get(6)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn revoke(invitation_id: &str, revoked_at: i64) -> Result<bool, StorageError> {
|
||||
db::with_immediate_transaction(|tx| {
|
||||
let changed = tx.execute(
|
||||
"UPDATE user_invitations SET state = 'revoked', revoked_at = ?2 WHERE invitation_id = ?1 AND state = 'pending'",
|
||||
params![invitation_id, revoked_at],
|
||||
)?;
|
||||
Ok(changed == 1)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod contact;
|
||||
pub mod invitations;
|
||||
pub mod pending_operations;
|
||||
pub mod user_manager;
|
||||
pub mod user_profile;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pub enum PendingUserOperationKind {
|
|||
Create,
|
||||
Attach,
|
||||
Release,
|
||||
Purge,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
|
@ -15,15 +16,21 @@ pub enum PendingUserOperationPhase {
|
|||
CredentialWritten,
|
||||
RemoteCommitted,
|
||||
LocalCommitted,
|
||||
DatabasePurged,
|
||||
E2eePurged,
|
||||
FilesystemPurged,
|
||||
}
|
||||
|
||||
impl PendingUserOperationPhase {
|
||||
fn as_str(self) -> &'static str {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Prepared => "prepared",
|
||||
Self::CredentialWritten => "credential_written",
|
||||
Self::RemoteCommitted => "remote_committed",
|
||||
Self::LocalCommitted => "local_committed",
|
||||
Self::DatabasePurged => "database_purged",
|
||||
Self::E2eePurged => "e2ee_purged",
|
||||
Self::FilesystemPurged => "filesystem_purged",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +40,9 @@ impl PendingUserOperationPhase {
|
|||
"credential_written" => Ok(Self::CredentialWritten),
|
||||
"remote_committed" => Ok(Self::RemoteCommitted),
|
||||
"local_committed" => Ok(Self::LocalCommitted),
|
||||
"database_purged" => Ok(Self::DatabasePurged),
|
||||
"e2ee_purged" => Ok(Self::E2eePurged),
|
||||
"filesystem_purged" => Ok(Self::FilesystemPurged),
|
||||
_ => Err(StorageError::Other(
|
||||
"unknown pending user operation phase".into(),
|
||||
)),
|
||||
|
|
@ -41,11 +51,12 @@ impl PendingUserOperationPhase {
|
|||
}
|
||||
|
||||
impl PendingUserOperationKind {
|
||||
fn as_str(self) -> &'static str {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Create => "create",
|
||||
Self::Attach => "attach",
|
||||
Self::Release => "release",
|
||||
Self::Purge => "purge",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +65,7 @@ impl PendingUserOperationKind {
|
|||
"create" => Ok(Self::Create),
|
||||
"attach" => Ok(Self::Attach),
|
||||
"release" => Ok(Self::Release),
|
||||
"purge" => Ok(Self::Purge),
|
||||
_ => Err(StorageError::Other("unknown pending user operation".into())),
|
||||
}
|
||||
}
|
||||
|
|
@ -171,3 +183,20 @@ pub fn remove(user_id: i64) -> Result<(), StorageError> {
|
|||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn complete_purge(user_id: i64, updated_at: i64) -> Result<(), StorageError> {
|
||||
db::with_immediate_transaction(|tx| {
|
||||
let changed = tx.execute(
|
||||
"UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1",
|
||||
params![user_id, updated_at],
|
||||
)?;
|
||||
if changed == 0 {
|
||||
return Err(StorageError::Other("user residency was not found".into()));
|
||||
}
|
||||
tx.execute(
|
||||
"DELETE FROM pending_user_operations WHERE user_id = ?1 AND operation = 'purge'",
|
||||
[user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::users::pending_operations;
|
||||
use crate::users::user_profile::UserProfile;
|
||||
use crate::util::db;
|
||||
use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file};
|
||||
|
|
@ -10,12 +11,19 @@ pub enum LocalUserState {
|
|||
Released,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CredentialOrigin {
|
||||
Local,
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserResidency {
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub state: LocalUserState,
|
||||
pub data_present: bool,
|
||||
pub credential_origin: CredentialOrigin,
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
|
|
@ -32,6 +40,13 @@ pub fn add_user(user: UserProfile) {
|
|||
}
|
||||
|
||||
pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::StorageError> {
|
||||
try_add_user_with_credential_origin(user, CredentialOrigin::Local)
|
||||
}
|
||||
|
||||
pub fn try_add_user_with_credential_origin(
|
||||
user: UserProfile,
|
||||
credential_origin: CredentialOrigin,
|
||||
) -> Result<(), crate::storage_error::StorageError> {
|
||||
db::with_immediate_transaction(|tx| {
|
||||
tx.execute(
|
||||
r#"
|
||||
|
|
@ -65,10 +80,10 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora
|
|||
)?;
|
||||
}
|
||||
tx.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()],
|
||||
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, credential_origin, updated_at)
|
||||
VALUES (?1, ?2, 'managed', COALESCE((SELECT data_state FROM user_residency WHERE user_id = ?1), 'present'), ?3, ?4)
|
||||
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, lifecycle_state = 'managed', credential_origin = excluded.credential_origin, updated_at = excluded.updated_at"#,
|
||||
params![user.user_id, user.username, match credential_origin { CredentialOrigin::Local => "local", CredentialOrigin::External => "external" }, now_millis()],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
|
|
@ -155,8 +170,8 @@ pub fn get_users() -> Vec<UserProfile> {
|
|||
let user_id: i64 = r.get(0)?;
|
||||
let username: String = r.get(1)?;
|
||||
let public_key: String = r.get(2)?;
|
||||
let private_key_hash: String = r.get(3)?;
|
||||
let reset_token: String = r.get(4)?;
|
||||
let private_key_hash: Option<String> = r.get(3)?;
|
||||
let reset_token: Option<String> = r.get(4)?;
|
||||
let created_at: i64 = r.get(5)?;
|
||||
let display_name: Option<String> = r.get(6)?;
|
||||
|
||||
|
|
@ -211,6 +226,29 @@ fn load_trusted_apps(
|
|||
})
|
||||
}
|
||||
|
||||
pub fn revoke_trusted_app(
|
||||
user_id: i64,
|
||||
app_id: &str,
|
||||
) -> Result<bool, crate::storage_error::StorageError> {
|
||||
db::with_immediate_transaction(|tx| {
|
||||
let removed = tx.execute(
|
||||
"DELETE FROM trusted_apps WHERE user_id = ?1 AND app_id = ?2",
|
||||
params![user_id, app_id],
|
||||
)?;
|
||||
Ok(removed > 0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn revoke_all_trusted_apps(user_id: i64) -> Result<usize, crate::storage_error::StorageError> {
|
||||
db::with_immediate_transaction(|tx| {
|
||||
let removed = tx.execute(
|
||||
"DELETE FROM trusted_apps WHERE user_id = ?1",
|
||||
params![user_id],
|
||||
)?;
|
||||
Ok(removed)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove_user(user_id: i64) {
|
||||
if let Err(e) = db::with_db(|conn| {
|
||||
conn.execute(
|
||||
|
|
@ -232,6 +270,31 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr
|
|||
.ok_or_else(|| {
|
||||
crate::storage_error::StorageError::Other("managed user was not found".into())
|
||||
})?;
|
||||
finalize_local_release(user_id, Some(&username))
|
||||
}
|
||||
|
||||
/*
|
||||
* Finish local release after Omega has changed assignment. Every step accepts
|
||||
* an earlier attempt completing it, so reconciliation can safely retry it.
|
||||
*/
|
||||
pub fn finalize_local_release(
|
||||
user_id: i64,
|
||||
username_hint: Option<&str>,
|
||||
) -> Result<(), crate::storage_error::StorageError> {
|
||||
let username = get_user(user_id)?
|
||||
.map(|user| user.username)
|
||||
.or_else(|| {
|
||||
get_residency_by_id(user_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|residency| residency.username)
|
||||
})
|
||||
.or_else(|| username_hint.map(str::to_owned));
|
||||
let Some(username) = username else {
|
||||
return Err(crate::storage_error::StorageError::Other(
|
||||
"user residency was not found".into(),
|
||||
));
|
||||
};
|
||||
db::with_db(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute(
|
||||
|
|
@ -252,12 +315,84 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr
|
|||
.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> {
|
||||
if crate::util::relay_queue::has_unclassified_relays()? {
|
||||
return Err(crate::storage_error::StorageError::PendingRelayOwnershipUnknown);
|
||||
pub fn force_local_detach(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
finalize_local_release(user_id, None)
|
||||
}
|
||||
|
||||
pub fn forget_released_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
let residency = get_residency_by_id(user_id)?.ok_or_else(|| {
|
||||
crate::storage_error::StorageError::Other("user residency was not found".into())
|
||||
})?;
|
||||
if residency.state != LocalUserState::Released || residency.data_present {
|
||||
return Err(crate::storage_error::StorageError::Other(
|
||||
"only released users with empty hosted data can be forgotten".into(),
|
||||
));
|
||||
}
|
||||
if pending_operations::get_all()?
|
||||
.iter()
|
||||
.any(|operation| operation.user_id == user_id)
|
||||
{
|
||||
return Err(crate::storage_error::StorageError::Other(
|
||||
"user has a pending lifecycle operation".into(),
|
||||
));
|
||||
}
|
||||
db::with_db(|conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM user_residency WHERE user_id = ?1",
|
||||
params![user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum PurgeStage {
|
||||
Database,
|
||||
E2ee,
|
||||
Filesystem,
|
||||
Complete,
|
||||
}
|
||||
|
||||
fn remaining_purge_stages(
|
||||
phase: pending_operations::PendingUserOperationPhase,
|
||||
) -> Result<Vec<PurgeStage>, crate::storage_error::StorageError> {
|
||||
use pending_operations::PendingUserOperationPhase;
|
||||
let stages = match phase {
|
||||
PendingUserOperationPhase::Prepared => vec![
|
||||
PurgeStage::Database,
|
||||
PurgeStage::E2ee,
|
||||
PurgeStage::Filesystem,
|
||||
PurgeStage::Complete,
|
||||
],
|
||||
PendingUserOperationPhase::DatabasePurged => vec![
|
||||
PurgeStage::E2ee,
|
||||
PurgeStage::Filesystem,
|
||||
PurgeStage::Complete,
|
||||
],
|
||||
PendingUserOperationPhase::E2eePurged => {
|
||||
vec![PurgeStage::Filesystem, PurgeStage::Complete]
|
||||
}
|
||||
PendingUserOperationPhase::FilesystemPurged => vec![PurgeStage::Complete],
|
||||
_ => {
|
||||
return Err(crate::storage_error::StorageError::Other(
|
||||
"pending purge has an invalid phase".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(stages)
|
||||
}
|
||||
|
||||
fn run_purge_stages(
|
||||
phase: pending_operations::PendingUserOperationPhase,
|
||||
mut run: impl FnMut(PurgeStage) -> Result<(), crate::storage_error::StorageError>,
|
||||
) -> Result<(), crate::storage_error::StorageError> {
|
||||
for stage in remaining_purge_stages(phase)? {
|
||||
run(stage)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn purge_database_rows(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
db::with_db(|conn| {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute(
|
||||
|
|
@ -315,10 +450,6 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage
|
|||
"DELETE FROM client_message_deliveries WHERE user_id = ?1",
|
||||
params![user_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM trusted_apps WHERE user_id = ?1",
|
||||
params![user_id],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM pending_relays WHERE relay_signer_id = ?1 OR relay_destination_user_id = ?1",
|
||||
params![user_id],
|
||||
|
|
@ -331,17 +462,84 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage
|
|||
"DELETE FROM relay_inbox WHERE signer_id = ?1 OR destination_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(())
|
||||
})
|
||||
}
|
||||
|
||||
/* Keep the residency authoritative until every hosted-data store has been
|
||||
* erased. Each completed phase is durable, and every cleanup stage is safe to
|
||||
* repeat after a crash between cleanup and phase persistence. */
|
||||
pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
|
||||
use pending_operations::{
|
||||
PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase,
|
||||
};
|
||||
|
||||
let residency = get_residency_by_id(user_id)?.ok_or_else(|| {
|
||||
crate::storage_error::StorageError::Other("user residency was not found".into())
|
||||
})?;
|
||||
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()))
|
||||
let existing = pending_operations::get_all()?
|
||||
.into_iter()
|
||||
.find(|operation| operation.user_id == user_id);
|
||||
let phase = match existing {
|
||||
Some(operation) if operation.operation == PendingUserOperationKind::Purge => {
|
||||
operation.phase
|
||||
}
|
||||
Some(_) => {
|
||||
return Err(crate::storage_error::StorageError::Other(
|
||||
"user has another pending lifecycle operation".into(),
|
||||
));
|
||||
}
|
||||
None if !residency.data_present => return Ok(()),
|
||||
None => {
|
||||
let operation = PendingUserOperation {
|
||||
user_id,
|
||||
operation: PendingUserOperationKind::Purge,
|
||||
username: residency.username,
|
||||
public_key: None,
|
||||
private_key_hash: None,
|
||||
reset_token: None,
|
||||
registration_token: None,
|
||||
phase: PendingUserOperationPhase::Prepared,
|
||||
created_at: now_millis(),
|
||||
};
|
||||
pending_operations::upsert(&operation)?;
|
||||
PendingUserOperationPhase::Prepared
|
||||
}
|
||||
};
|
||||
|
||||
run_purge_stages(phase, |stage| {
|
||||
match stage {
|
||||
PurgeStage::Database => {
|
||||
if crate::util::relay_queue::has_unclassified_relays()? {
|
||||
return Err(crate::storage_error::StorageError::PendingRelayOwnershipUnknown);
|
||||
}
|
||||
purge_database_rows(user_id)?;
|
||||
pending_operations::update_phase(
|
||||
user_id,
|
||||
PendingUserOperationPhase::DatabasePurged,
|
||||
)?;
|
||||
}
|
||||
PurgeStage::E2ee => {
|
||||
crate::util::e2ee_storage::purge_user(user_id)
|
||||
.map_err(crate::storage_error::StorageError::Other)?;
|
||||
pending_operations::update_phase(user_id, PendingUserOperationPhase::E2eePurged)?;
|
||||
}
|
||||
PurgeStage::Filesystem => {
|
||||
delete_user_directory(user_id).map_err(|error| {
|
||||
crate::storage_error::StorageError::Other(error.to_string())
|
||||
})?;
|
||||
pending_operations::update_phase(
|
||||
user_id,
|
||||
PendingUserOperationPhase::FilesystemPurged,
|
||||
)?;
|
||||
}
|
||||
PurgeStage::Complete => {
|
||||
pending_operations::complete_purge(user_id, now_millis())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Complete local erasure is idempotent and is the target for a durable
|
||||
|
|
@ -372,19 +570,50 @@ pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::Stor
|
|||
|
||||
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 mut stmt = conn.prepare("SELECT user_id, username, lifecycle_state, data_state, credential_origin 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",
|
||||
credential_origin: if row.get::<_, String>(4)? == "external" { CredentialOrigin::External } else { CredentialOrigin::Local },
|
||||
})
|
||||
})?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn get_residency_by_id(
|
||||
user_id: i64,
|
||||
) -> Result<Option<UserResidency>, crate::storage_error::StorageError> {
|
||||
db::with_db(|conn| {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT user_id, username, lifecycle_state, data_state, credential_origin FROM user_residency WHERE user_id = ?1",
|
||||
)?;
|
||||
let mut rows = statement.query(params![user_id])?;
|
||||
let Some(row) = rows.next()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let lifecycle: String = row.get(2)?;
|
||||
Ok(Some(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",
|
||||
credential_origin: if row.get::<_, String>(4)? == "external" {
|
||||
CredentialOrigin::External
|
||||
} else {
|
||||
CredentialOrigin::Local
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear() {
|
||||
if let Err(e) = db::with_db(|conn| {
|
||||
conn.execute_batch(
|
||||
|
|
@ -424,6 +653,75 @@ pub fn load_users_sync() -> std::io::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod purge_tests {
|
||||
use super::{PurgeStage, remaining_purge_stages, run_purge_stages};
|
||||
use crate::users::pending_operations::PendingUserOperationPhase;
|
||||
|
||||
#[test]
|
||||
fn prepared_purge_runs_every_stage_before_completion() {
|
||||
assert_eq!(
|
||||
remaining_purge_stages(PendingUserOperationPhase::Prepared).unwrap(),
|
||||
vec![
|
||||
PurgeStage::Database,
|
||||
PurgeStage::E2ee,
|
||||
PurgeStage::Filesystem,
|
||||
PurgeStage::Complete,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_resumes_after_each_persisted_phase() {
|
||||
assert_eq!(
|
||||
remaining_purge_stages(PendingUserOperationPhase::DatabasePurged).unwrap(),
|
||||
vec![
|
||||
PurgeStage::E2ee,
|
||||
PurgeStage::Filesystem,
|
||||
PurgeStage::Complete,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
remaining_purge_stages(PendingUserOperationPhase::E2eePurged).unwrap(),
|
||||
vec![PurgeStage::Filesystem, PurgeStage::Complete]
|
||||
);
|
||||
assert_eq!(
|
||||
remaining_purge_stages(PendingUserOperationPhase::FilesystemPurged).unwrap(),
|
||||
vec![PurgeStage::Complete]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_phases_are_rejected_for_pending_purge() {
|
||||
assert!(remaining_purge_stages(PendingUserOperationPhase::RemoteCommitted).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_does_not_complete_after_an_interrupted_cleanup_stage() {
|
||||
for failed_stage in [
|
||||
PurgeStage::Database,
|
||||
PurgeStage::E2ee,
|
||||
PurgeStage::Filesystem,
|
||||
] {
|
||||
let mut data_empty = false;
|
||||
let result = run_purge_stages(PendingUserOperationPhase::Prepared, |stage| {
|
||||
if stage == failed_stage {
|
||||
return Err(crate::storage_error::StorageError::Other(
|
||||
"interrupted".into(),
|
||||
));
|
||||
}
|
||||
if stage == PurgeStage::Complete {
|
||||
data_empty = true;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(!data_empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) {
|
||||
let path = format!("users/{}/apps", user_id);
|
||||
let name = format!("{}.json", app_identifier);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ pub struct UserProfile {
|
|||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub public_key: String,
|
||||
pub private_key_hash: String,
|
||||
pub reset_token: String,
|
||||
pub private_key_hash: Option<String>,
|
||||
pub reset_token: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub display_name: Option<String>,
|
||||
pub trusted_apps: std::collections::HashMap<String, String>,
|
||||
|
|
@ -25,8 +25,8 @@ impl UserProfile {
|
|||
username: String,
|
||||
display_name: Option<String>,
|
||||
public_key: String,
|
||||
private_key_hash: String,
|
||||
reset_token: String,
|
||||
private_key_hash: Option<String>,
|
||||
reset_token: Option<String>,
|
||||
) -> Self {
|
||||
Self::new_with_created_at(
|
||||
user_id,
|
||||
|
|
@ -47,8 +47,8 @@ impl UserProfile {
|
|||
username: String,
|
||||
display_name: Option<String>,
|
||||
public_key: String,
|
||||
private_key_hash: String,
|
||||
reset_token: String,
|
||||
private_key_hash: Option<String>,
|
||||
reset_token: Option<String>,
|
||||
created_at: i64,
|
||||
) -> Self {
|
||||
Self {
|
||||
|
|
@ -68,7 +68,6 @@ impl UserProfile {
|
|||
"uuid" => self.user_id,
|
||||
"username" => self.username.clone(),
|
||||
"public_key" => self.public_key.clone(),
|
||||
"private_key_hash" => self.private_key_hash.clone(),
|
||||
"created_at" => self.created_at,
|
||||
"storage" => used_dir_space(&format!("users/{}", self.user_id.to_string())),
|
||||
};
|
||||
|
|
@ -88,8 +87,8 @@ impl UserProfile {
|
|||
let user_id = j["uuid"].as_i64()?;
|
||||
let username = j["username"].as_str()?.to_string();
|
||||
let public_key = j["public_key"].as_str()?.to_string();
|
||||
let private_key_hash = j["private_key_hash"].as_str()?.to_string();
|
||||
let reset_token = j["reset_token"].as_str()?.to_string();
|
||||
let private_key_hash = j["private_key_hash"].as_str().map(str::to_owned);
|
||||
let reset_token = j["reset_token"].as_str().map(str::to_owned);
|
||||
let created_at = j["created_at"].as_i64()?;
|
||||
let display_name = j["display_name"].as_str().map(|s| s.to_string());
|
||||
|
||||
|
|
@ -127,7 +126,7 @@ impl UserProfile {
|
|||
let mut bytes = [0u8; 192];
|
||||
OsRng.fill(bytes.as_mut());
|
||||
let new_token = general_purpose::STANDARD.encode(&bytes);
|
||||
self.reset_token = new_token.clone();
|
||||
self.reset_token = Some(new_token.clone());
|
||||
new_token
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ pub struct IotaConfig {
|
|||
pub omikron_host: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub omikron_port: Option<u16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub omikron_id: Option<i64>,
|
||||
#[serde(skip_serializing)]
|
||||
pub keyring: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
|
|
@ -101,6 +103,7 @@ impl Default for IotaConfig {
|
|||
web: WebSettings::default(),
|
||||
omikron_host: None,
|
||||
omikron_port: None,
|
||||
omikron_id: None,
|
||||
keyring: None,
|
||||
public_key: None,
|
||||
private_key: None,
|
||||
|
|
|
|||
|
|
@ -710,6 +710,110 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
|
|||
)?;
|
||||
}
|
||||
|
||||
if current_version < 21 {
|
||||
let users_exist: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if users_exist {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
ALTER TABLE users RENAME TO users_before_credential_origin;
|
||||
CREATE TABLE users (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_hash TEXT,
|
||||
reset_token TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
display_name TEXT
|
||||
);
|
||||
INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name)
|
||||
SELECT user_id, username, public_key, private_key_hash, reset_token, created_at, display_name
|
||||
FROM users_before_credential_origin;
|
||||
DROP TABLE users_before_credential_origin;
|
||||
"#,
|
||||
)?;
|
||||
}
|
||||
let residency_exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'user_residency')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if residency_exists {
|
||||
add_table_column_if_missing(
|
||||
conn,
|
||||
"user_residency",
|
||||
"credential_origin",
|
||||
"credential_origin TEXT NOT NULL DEFAULT 'local' CHECK (credential_origin IN ('local', 'external'))",
|
||||
)?;
|
||||
}
|
||||
conn.pragma_update(None, "user_version", 21)?;
|
||||
}
|
||||
|
||||
if current_version < 22 {
|
||||
let pending_exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pending_user_operations')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if pending_exists {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
ALTER TABLE pending_user_operations RENAME TO pending_user_operations_before_purge;
|
||||
CREATE TABLE pending_user_operations (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
operation TEXT NOT NULL
|
||||
CHECK (operation IN ('create', 'attach', 'release', 'purge')),
|
||||
username TEXT NOT NULL,
|
||||
public_key TEXT,
|
||||
private_key_hash TEXT,
|
||||
reset_token TEXT,
|
||||
registration_token TEXT,
|
||||
phase TEXT NOT NULL DEFAULT 'prepared'
|
||||
CHECK (phase IN (
|
||||
'prepared', 'credential_written', 'remote_committed', 'local_committed',
|
||||
'database_purged', 'e2ee_purged', 'filesystem_purged'
|
||||
)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO pending_user_operations (
|
||||
user_id, operation, username, public_key, private_key_hash,
|
||||
reset_token, registration_token, phase, created_at
|
||||
)
|
||||
SELECT user_id, operation, username, public_key, private_key_hash,
|
||||
reset_token, registration_token, phase, created_at
|
||||
FROM pending_user_operations_before_purge;
|
||||
DROP TABLE pending_user_operations_before_purge;
|
||||
CREATE INDEX idx_pending_user_operations_operation
|
||||
ON pending_user_operations (operation, created_at);
|
||||
"#,
|
||||
)?;
|
||||
}
|
||||
conn.pragma_update(None, "user_version", 22)?;
|
||||
}
|
||||
|
||||
if current_version < 23 {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS user_invitations (
|
||||
invitation_id TEXT PRIMARY KEY,
|
||||
token_hash BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'redeemed', 'revoked', 'expired')),
|
||||
redeemed_user_id INTEGER,
|
||||
redeemed_at INTEGER,
|
||||
revoked_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_invitations_state_created
|
||||
ON user_invitations (state, created_at);
|
||||
PRAGMA user_version = 23;
|
||||
"#,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -779,7 +883,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, 20);
|
||||
assert_eq!(version, 23);
|
||||
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")?;
|
||||
|
|
@ -798,7 +902,7 @@ 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, 20);
|
||||
assert_eq!(version, 23);
|
||||
for table in [
|
||||
"sync_heads",
|
||||
"sync_events",
|
||||
|
|
@ -813,6 +917,7 @@ mod tests {
|
|||
"blocked_users",
|
||||
"user_receipt_policy",
|
||||
"user_message_storage_policy",
|
||||
"user_invitations",
|
||||
] {
|
||||
let exists: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||
|
|
@ -837,7 +942,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, 20);
|
||||
assert_eq!(version, 23);
|
||||
for column in [
|
||||
"id",
|
||||
"user_id",
|
||||
|
|
@ -855,4 +960,84 @@ mod tests {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_origin_migration_allows_external_profiles() -> Result<(), StorageError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE users (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_hash TEXT NOT NULL,
|
||||
reset_token TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
display_name TEXT
|
||||
);
|
||||
CREATE TABLE user_residency (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
lifecycle_state TEXT NOT NULL,
|
||||
data_state TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 20;
|
||||
"#,
|
||||
)?;
|
||||
run_migrations_on_connection(&conn)?;
|
||||
conn.execute(
|
||||
"INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at) VALUES (1, 'alice', 'key', NULL, NULL, 1)",
|
||||
[],
|
||||
)?;
|
||||
let origin_exists: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('user_residency') WHERE name = 'credential_origin'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(origin_exists, 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_purge_migration_preserves_lifecycle_operations() -> Result<(), StorageError> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE pending_user_operations (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
operation TEXT NOT NULL
|
||||
CHECK (operation IN ('create', 'attach', 'release')),
|
||||
username TEXT NOT NULL,
|
||||
public_key TEXT,
|
||||
private_key_hash TEXT,
|
||||
reset_token TEXT,
|
||||
registration_token TEXT,
|
||||
phase TEXT NOT NULL DEFAULT 'prepared'
|
||||
CHECK (phase IN ('prepared', 'credential_written', 'remote_committed', 'local_committed')),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO pending_user_operations (
|
||||
user_id, operation, username, phase, created_at
|
||||
) VALUES (1, 'release', 'alice', 'remote_committed', 1);
|
||||
PRAGMA user_version = 21;
|
||||
"#,
|
||||
)?;
|
||||
|
||||
run_migrations_on_connection(&conn)?;
|
||||
conn.execute(
|
||||
"INSERT INTO pending_user_operations (user_id, operation, username, phase, created_at) VALUES (2, 'purge', 'bob', 'filesystem_purged', 2)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
let preserved: String = conn.query_row(
|
||||
"SELECT phase FROM pending_user_operations WHERE user_id = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
|
||||
assert_eq!(preserved, "remote_committed");
|
||||
assert_eq!(version, 23);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue