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:
2026-09-18 21:35:48 -07:00
parent 0a2c29e8fd
commit ecbdfd533b
21 changed files with 1357 additions and 6 deletions
+145
View File
@@ -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
}