Undelete: deleted accounts are kept for their period, hold their addresses, and are restored or destroyed through inbuxa:DeletedAccount (UD-15 to UD-17a)
With archiveDeletedAccountsFor set, a destroyed account's record is kept in the fork subspace with its id, its DestroyAccount task is due at the end of the period, and its shares are suspended both ways. Its addresses can't be taken by new accounts, aliases, lists or masks. inbuxa:DeletedAccount/get lists kept accounts to server and tenant administrators; /set restores one with a new password (same id, task cancelled, shares reinstated) or destroys it now. The destroy task also clears undelete's own records. Acceptance test 14; test 16 written as the ignored undelete_compat.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Deleted accounts kept for their period (UD-15 to UD-17a). The account's
|
||||
//! record is removed as upstream removes it; a copy waits here with its
|
||||
//! shares, both ways, until it's restored or its `DestroyAccount` task runs.
|
||||
|
||||
use crate::undelete::{
|
||||
data::{self, Share},
|
||||
records,
|
||||
};
|
||||
use store::{
|
||||
Deserialize, IterateParams, RegistryStore, SerializeInfallible, Store, U32_LEN, ValueKey,
|
||||
write::{BatchBuilder, ValueClass, key::DeserializeBigEndian},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::collection::Collection;
|
||||
|
||||
/// Every share `account_id` is part of, either way.
|
||||
async fn shares_of(data: &Store, account_id: u32) -> trc::Result<Vec<Share>> {
|
||||
let mut shares = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(
|
||||
ValueKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
document_id: 0,
|
||||
class: ValueClass::Acl(0),
|
||||
},
|
||||
ValueKey {
|
||||
account_id: u32::MAX,
|
||||
collection: u8::MAX,
|
||||
document_id: u32::MAX,
|
||||
class: ValueClass::Acl(u32::MAX),
|
||||
},
|
||||
)
|
||||
.ascending(),
|
||||
|key, value| {
|
||||
// grantee, owner, collection, document
|
||||
let grantee = key.deserialize_be_u32(0)?;
|
||||
let owner = key.deserialize_be_u32(U32_LEN)?;
|
||||
if grantee == account_id || owner == account_id {
|
||||
shares.push(Share {
|
||||
grantee,
|
||||
owner,
|
||||
collection: *key.get(U32_LEN * 2).ok_or_else(|| {
|
||||
trc::StoreEvent::DataCorruption.caused_by(trc::location!())
|
||||
})?,
|
||||
document_id: key.deserialize_be_u32(U32_LEN * 2 + 1)?,
|
||||
permissions: u64::deserialize(value)?,
|
||||
});
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(shares)
|
||||
}
|
||||
|
||||
fn write_shares<'x>(
|
||||
batch: &mut BatchBuilder,
|
||||
shares: impl Iterator<Item = &'x Share>,
|
||||
grant: bool,
|
||||
) {
|
||||
for share in shares {
|
||||
batch
|
||||
.with_account_id(share.owner)
|
||||
.with_collection(Collection::from(share.collection))
|
||||
.with_document(share.document_id);
|
||||
if grant {
|
||||
batch.acl_grant(share.grantee, share.permissions.serialize());
|
||||
} else {
|
||||
batch.acl_revoke(share.grantee);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Revokes every share an account is part of and returns them (UD-17a).
|
||||
pub async fn suspend_shares(data: &Store, account_id: u32) -> trc::Result<Vec<Share>> {
|
||||
let shares = shares_of(data, account_id).await?;
|
||||
for chunk in shares.chunks(1000) {
|
||||
let mut batch = BatchBuilder::new();
|
||||
write_shares(&mut batch, chunk.iter(), false);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
Ok(shares)
|
||||
}
|
||||
|
||||
/// Grants back the suspended shares whose other account still exists
|
||||
/// (UD-17a). Returns the other accounts, whose access changes.
|
||||
pub async fn reinstate_shares(
|
||||
data: &Store,
|
||||
account_id: u32,
|
||||
shares: &[Share],
|
||||
exists: impl Fn(u32) -> bool,
|
||||
) -> trc::Result<Vec<u32>> {
|
||||
let shares = shares
|
||||
.iter()
|
||||
.filter(|share| {
|
||||
let other = if share.owner == account_id {
|
||||
share.grantee
|
||||
} else {
|
||||
share.owner
|
||||
};
|
||||
other == account_id || exists(other)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for chunk in shares.chunks(1000) {
|
||||
let mut batch = BatchBuilder::new();
|
||||
write_shares(&mut batch, chunk.iter().copied(), true);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
let mut others = shares
|
||||
.iter()
|
||||
.flat_map(|share| [share.owner, share.grantee])
|
||||
.filter(|id| *id != account_id)
|
||||
.collect::<Vec<_>>();
|
||||
others.sort_unstable();
|
||||
others.dedup();
|
||||
Ok(others)
|
||||
}
|
||||
|
||||
/// When the account is finally destroyed: its hold, and everything undelete
|
||||
/// kept for it, go too.
|
||||
pub async fn forget(data: &Store, registry: &RegistryStore, account_id: u32) -> trc::Result<()> {
|
||||
if let Some(kept) = data::kept_account(data, account_id).await? {
|
||||
let mut batch = BatchBuilder::new();
|
||||
data::clear_kept_account(&mut batch, account_id, &kept);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
for (id, item) in records::of_account(data, registry, account_id).await? {
|
||||
records::remove(data, registry, id, &item).await?;
|
||||
}
|
||||
data::clear_account(data, account_id).await
|
||||
}
|
||||
@@ -163,6 +163,22 @@ pub struct KeptAccount {
|
||||
pub member_tenant_id: Option<u64>,
|
||||
pub deleted_at: u64,
|
||||
pub kept_until: u64,
|
||||
/// The id of its `DestroyAccount` task, due at `kept_until`.
|
||||
#[serde(default)]
|
||||
pub task_id: u64,
|
||||
/// Its shares, both ways, suspended while it's kept (UD-17a).
|
||||
#[serde(default)]
|
||||
pub shares: Vec<Share>,
|
||||
}
|
||||
|
||||
/// One share: `grantee` may reach `owner`'s document with `permissions`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
pub struct Share {
|
||||
pub grantee: u32,
|
||||
pub owner: u32,
|
||||
pub collection: u8,
|
||||
pub document_id: u32,
|
||||
pub permissions: u64,
|
||||
}
|
||||
|
||||
pub fn note_email(
|
||||
@@ -382,6 +398,44 @@ pub async fn kept_accounts(data: &Store) -> trc::Result<Vec<(u32, KeptAccount)>>
|
||||
Ok(kept)
|
||||
}
|
||||
|
||||
/// Clears what's kept under an account's id: its notes, archive links and
|
||||
/// change log. The records themselves go with `records::remove`.
|
||||
pub async fn clear_account(data: &Store, account_id: u32) -> trc::Result<()> {
|
||||
// Account ids stop short of u32::MAX, the store's sentinel
|
||||
let (from, to) = (account_id.to_be_bytes(), (account_id + 1).to_be_bytes());
|
||||
let mut ranges = vec![
|
||||
(vec![KIND_NOTE], vec![KIND_NOTE]),
|
||||
(vec![KIND_BLOB], vec![KIND_BLOB]),
|
||||
(vec![KIND_CHANGE], vec![KIND_CHANGE]),
|
||||
];
|
||||
// The groupware notes, `Ug` + kind + account + document
|
||||
for kind in 0u8..3 {
|
||||
ranges.push((vec![b'g', kind], vec![b'g', kind]));
|
||||
}
|
||||
for (mut start, mut end) in ranges {
|
||||
start.extend_from_slice(&from);
|
||||
end.extend_from_slice(&to);
|
||||
data.delete_range(
|
||||
ValueKey::from(class_raw(&start)),
|
||||
ValueKey::from(class_raw(&end)),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A key under `U` spelled out in full (the groupware notes, `Ug`).
|
||||
fn class_raw(rest: &[u8]) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(1 + rest.len());
|
||||
key.push(FEATURE);
|
||||
key.extend_from_slice(rest);
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
/// The kept account an address is reserved for (UD-16).
|
||||
pub async fn reserved_by(data: &Store, address: &str) -> trc::Result<Option<u32>> {
|
||||
data.get_value::<u32>(key(KIND_RESERVED, address.to_lowercase().as_bytes()))
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
//! beyond them, and the fork's bookkeeping, live in the fork's own subspace
|
||||
//! (`data`). Requirements are named `UD-n`, after the spec.
|
||||
|
||||
pub mod accounts;
|
||||
pub mod data;
|
||||
pub mod email;
|
||||
pub mod groupware;
|
||||
|
||||
Reference in New Issue
Block a user