use std::collections::HashSet;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde_json::{Value, json};
use trust_tasks_rs::Payload;
use trust_tasks_rs::specs::rooms::owner::group::remove::v0_1 as owner_remove;
use trust_tasks_rs::specs::rooms::owner::group::remove::v0_2 as owner_remove_v0_2;
use trust_tasks_rs::specs::rooms::owner::group::remove::v0_3 as owner_remove_v0_3;
use trust_tasks_rs::specs::rooms::owner::revoke::v0_1 as owner_revoke;
use vti_common::audit::{AuditEvent, CommunityRoomChangedData};
use vti_common::error::AppError;
use super::community::{
ADVANCE_TIMEOUT, CommunityRooms, DepartureCause, HolderError, KeyHolder, MemberRecord,
OwnedRoom,
};
use crate::server::AppState;
pub const ACTOR: &str = "vtc:member-departure";
pub const GRACE: chrono::Duration = chrono::Duration::seconds(60);
pub const SWEEP_INTERVAL: Duration = Duration::from_secs(30);
const BACKOFF_BASE_SECS: i64 = 30;
const BACKOFF_CAP_SECS: i64 = 60 * 60;
pub const REMOVAL_NOTICE: &str = "Removal ends access to what is written from now on. It recalls \
nothing the member already read, and their authority credential is not revoked: a host \
honours it until it expires, though on a sealed room it opens nothing written after this.";
const OWNER_GROUP_REMOVE: &str = <owner_remove::Payload as Payload>::TYPE_URI;
const OWNER_GROUP_REMOVE_V0_2: &str = <owner_remove_v0_2::Payload as Payload>::TYPE_URI;
const OWNER_GROUP_REMOVE_V0_3: &str = <owner_remove_v0_3::Payload as Payload>::TYPE_URI;
const OWNER_REVOKE: &str = <owner_revoke::Payload as Payload>::TYPE_URI;
pub fn removal_notice(withdrawn_at_host: bool) -> &'static str {
if withdrawn_at_host {
"Removal ends the member's access at once: the room's host refuses every credential \
the room issued them, and anything they delegated, from `revokedAt`. It recalls \
nothing they already read or saved, and re-seals nothing written before."
} else {
"On a private room a member cannot be withdrawn one at a time: they leave the room's \
group, so nothing written from the new epoch on opens for them, but the host honours \
their credentials until `validUntil`. It recalls nothing they already read."
}
}
#[derive(Debug, Default)]
pub struct Removed {
pub epoch: Option<u64>,
pub revocation: Option<(Value, String)>,
}
fn request<P: serde::de::DeserializeOwned + serde::Serialize>(
payload: Value,
) -> Result<Value, HolderError> {
let p: P = serde_json::from_value(payload)
.map_err(|e| HolderError::Unavailable(format!("a request to the community's VTA: {e}")))?;
serde_json::to_value(p)
.map_err(|e| HolderError::Unavailable(format!("a request to the community's VTA: {e}")))
}
fn shape<R: serde::de::DeserializeOwned>(task: &str, answer: &Value) -> Result<R, HolderError> {
serde_json::from_value(answer.clone()).map_err(|e| {
HolderError::Unavailable(format!(
"the community's VTA answered {task} in a shape its specification does not define: {e}"
))
})
}
pub async fn remove_member(
holder: &KeyHolder,
room: &OwnedRoom,
host: &str,
member: &str,
offered: bool,
reason: &str,
) -> Result<Removed, HolderError> {
let room_id = room.room_id.as_str();
if room.visibility == "private" {
return remove_from_group(holder, room_id, host, member, reason)
.await
.map(|epoch| Removed {
epoch,
revocation: None,
});
}
if room.visibility != "open" && !offered {
let req = request::<owner_remove_v0_3::Payload>(json!({
"roomId": room_id,
"host": host,
"member": member,
"signingKeyId": room.signing_key_id,
"reason": reason,
}))?;
let mut asked = holder
.ask(OWNER_GROUP_REMOVE_V0_3, req.clone(), ADVANCE_TIMEOUT)
.await;
if matches!(&asked, Err(HolderError::Refused { code, .. })
if code == "unsupportedType" || code == "unsupportedVersion")
{
asked = holder
.ask(OWNER_GROUP_REMOVE_V0_2, req, ADVANCE_TIMEOUT)
.await;
}
match asked {
Ok(answer) => {
let r: owner_remove_v0_3::Response = shape("rooms/owner/group/remove", &answer)?;
return Ok(Removed {
epoch: u64::try_from(r.epoch).ok(),
revocation: Some((
answer["revocation"].clone(),
answer["revokedAt"].as_str().unwrap_or_default().to_string(),
)),
});
}
Err(HolderError::Refused { code, .. })
if code == owner_remove_v0_3::error_codes::NOT_MEMBER.code => {}
Err(HolderError::Refused { code, message })
if code == owner_remove_v0_3::error_codes::HOST_UNREACHABLE.code =>
{
return Err(HolderError::Unavailable(message));
}
Err(e) => return Err(e),
}
}
let req = request::<owner_revoke::Payload>(json!({
"roomId": room_id,
"signingKeyId": room.signing_key_id,
"hosts": [host],
"withdraw": [{ "kind": "subject", "subject": member }],
"reason": reason,
}))?;
let answer = holder.ask(OWNER_REVOKE, req, ADVANCE_TIMEOUT).await?;
shape::<owner_revoke::Response>("rooms/owner/revoke", &answer)?;
let delivery = answer["deliveries"]
.as_array()
.and_then(|d| d.first())
.cloned()
.unwrap_or(Value::Null);
match delivery["outcome"].as_str() {
Some("accepted") => {}
Some("refused") => {
return Err(HolderError::Refused {
code: delivery["code"].as_str().unwrap_or_default().to_string(),
message: "this community, the room's host, refused the room's revocation list"
.into(),
});
}
_ => {
return Err(HolderError::Unavailable(
"the room's revocation list was signed, but this community did not confirm it \
as the room's host; it is delivered again on the next attempt"
.into(),
));
}
}
let revoked_at = answer["list"]["entries"]
.as_array()
.into_iter()
.flatten()
.filter(|e| e["kind"] == "subject" && e["subject"] == member)
.filter_map(|e| e["revokedAt"].as_str())
.max()
.unwrap_or_default()
.to_string();
Ok(Removed {
epoch: None,
revocation: Some((answer["head"].clone(), revoked_at)),
})
}
pub fn backoff(attempts: u32) -> chrono::Duration {
let shift = attempts.saturating_sub(1).min(16);
chrono::Duration::seconds((BACKOFF_BASE_SECS << shift).min(BACKOFF_CAP_SECS))
}
fn rfc3339(t: DateTime<Utc>) -> String {
t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
pub async fn remove_from_group(
holder: &KeyHolder,
room_id: &str,
host: &str,
member: &str,
reason: &str,
) -> Result<Option<u64>, HolderError> {
let request: owner_remove::Payload = serde_json::from_value(json!({
"roomId": room_id,
"host": host,
"member": member,
"reason": reason,
}))
.map_err(|e| HolderError::Unavailable(format!("a request to the community's VTA: {e}")))?;
let request = serde_json::to_value(request)
.map_err(|e| HolderError::Unavailable(format!("a request to the community's VTA: {e}")))?;
match holder
.ask(OWNER_GROUP_REMOVE, request, ADVANCE_TIMEOUT)
.await
{
Ok(answer) => serde_json::from_value::<owner_remove::Response>(answer)
.map(|r| u64::try_from(r.epoch).ok())
.map_err(|e| {
HolderError::Unavailable(format!(
"the community's VTA answered rooms/owner/group/remove in a shape its \
specification does not define: {e}"
))
}),
Err(HolderError::Refused { code, .. })
if code == owner_remove::error_codes::NOT_MEMBER.code =>
{
Ok(None)
}
Err(HolderError::Refused { code, message })
if code == owner_remove::error_codes::HOST_UNREACHABLE.code =>
{
Err(HolderError::Unavailable(message))
}
Err(e) => Err(e),
}
}
pub async fn on_departure(
state: &AppState,
member: &str,
cause: DepartureCause,
) -> Result<(), AppError> {
state
.community_rooms
.record_departure(member, cause, Utc::now())
.await
.map(|_| ())
}
async fn departure_of(
state: &AppState,
did: &str,
now: DateTime<Utc>,
) -> Result<Option<DepartureCause>, AppError> {
let entry = crate::acl::get_acl_entry(&state.acl_ks, did).await?;
let member = crate::members::storage::get_member(&state.members_ks, did).await?;
if member.as_ref().is_some_and(|m| m.is_removed()) {
return Ok(Some(DepartureCause::Removed));
}
Ok(match entry {
Some(e) if e.is_expired(now.timestamp().max(0) as u64) => Some(DepartureCause::Expired),
Some(e) if e.is_suspended() => Some(DepartureCause::Suspended),
Some(_) => None,
None if member.is_some() => Some(DepartureCause::AclRemoved),
None => None,
})
}
pub async fn reconcile(state: &AppState, now: DateTime<Utc>) -> Result<usize, AppError> {
let cr = &state.community_rooms;
let mut holding = HashSet::new();
for room in cr.rooms().await? {
for m in cr.members(&room.room_id).await? {
if m.state != MemberRecord::REMOVED {
holding.insert(m.member);
}
}
}
let mut due = Vec::new();
{
let mut departed = Vec::new();
for did in &holding {
if let Some(cause) = departure_of(state, did, now).await? {
departed.push((did.clone(), cause));
}
}
let mut seen = cr.seen_inactive.lock().expect("seen lock");
seen.retain(|did, _| departed.iter().any(|(d, _)| d == did));
for (did, cause) in departed {
let first = *seen.entry(did.clone()).or_insert(now);
if now - first >= GRACE {
seen.remove(&did);
due.push((did, cause));
}
}
}
let mut recorded = 0;
for (did, cause) in due {
recorded += cr.record_departure(&did, cause, now).await?;
}
Ok(recorded)
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DriveReport {
pub removed: usize,
pub settled: usize,
pub retrying: usize,
}
pub async fn drive(state: &AppState, now: DateTime<Utc>) -> Result<DriveReport, AppError> {
let mut report = DriveReport::default();
let mut vta_down = false;
for p in state.community_rooms.pending_removals().await? {
let due = DateTime::parse_from_rfc3339(&p.next_attempt_at)
.map(|t| t.with_timezone(&Utc) <= now)
.unwrap_or(true);
if due {
drive_one(
state,
&p.room_id,
&p.member,
now,
&mut vta_down,
&mut report,
)
.await?;
}
}
Ok(report)
}
async fn drive_one(
state: &AppState,
room_id: &str,
member: &str,
now: DateTime<Utc>,
vta_down: &mut bool,
report: &mut DriveReport,
) -> Result<(), AppError> {
let cr: &CommunityRooms = &state.community_rooms;
let guard = cr.lock().await;
let Some(pending) = cr.pending_removal(room_id, member).await? else {
return Ok(());
};
let Some(room) = cr.room(room_id).await? else {
cr.drop_pending_removal(room_id, member).await?;
report.settled += 1;
return Ok(());
};
let mut record = match cr.member(room_id, member).await? {
Some(r) if r.state != MemberRecord::REMOVED => r,
_ => {
cr.drop_pending_removal(room_id, member).await?;
report.settled += 1;
return Ok(());
}
};
let withdrawn = record.state == MemberRecord::OFFERED;
let mut epoch = None;
let mut withdrawn_at_host = false;
if !(withdrawn && room.visibility == "private") {
if *vta_down {
return Ok(());
}
let failed = match cr.key_holder() {
None => {
Some("this community has no link to the VTA that holds the room's keys".to_string())
}
Some(holder) => {
let host = state
.config
.read()
.await
.vtc_did
.clone()
.unwrap_or_default();
match remove_member(
&holder,
&room,
&host,
member,
withdrawn,
pending.cause.reason(),
)
.await
{
Ok(r) => {
epoch = r.epoch;
withdrawn_at_host = r.revocation.is_some();
None
}
Err(HolderError::Unavailable(why)) => {
*vta_down = true;
Some(why)
}
Err(HolderError::Refused { code, message }) => {
Some(format!("the community's VTA refused: {code} {message}"))
}
}
}
};
if let Some(why) = failed {
let mut next = pending;
next.attempts = next.attempts.saturating_add(1);
next.last_attempt_at = Some(rfc3339(now));
next.next_attempt_at = rfc3339(now + backoff(next.attempts));
next.last_error = Some(why.clone());
cr.put_pending_removal(&next).await?;
tracing::warn!(
room = %room_id,
member,
attempts = next.attempts,
next = %next.next_attempt_at,
error = %why,
"a departing member's removal from a community room did not complete; retrying"
);
report.retrying += 1;
return Ok(());
}
}
record.state = MemberRecord::REMOVED.into();
record.removed_at = Some(rfc3339(now));
record.credentials = None;
record.welcome = None;
cr.settle_removal(&record).await?;
drop(guard);
report.removed += 1;
let row = CommunityRoomChangedData {
event: if withdrawn {
"offerWithdrawn"
} else {
"memberRemoved"
}
.to_string(),
room_id: room.room_id.clone(),
visibility: room.visibility.clone(),
member: Some(member.to_string()),
actions: Vec::new(),
epoch,
note: (!withdrawn || withdrawn_at_host)
.then(|| removal_notice(withdrawn_at_host).to_string()),
reason: Some(pending.cause.reason().to_string()),
details: Vec::new(),
};
if let Some(writer) = state.audit_writer.as_ref()
&& let Err(e) = writer
.write(ACTOR, Some(member), AuditEvent::CommunityRoomChanged(row))
.await
{
tracing::warn!(error = %e, room = %room_id, "could not audit a departing member's removal");
}
tracing::info!(room = %room_id, member, ?epoch, withdrawn, "departing member removed from a community room");
Ok(())
}
pub async fn run_once(state: &AppState, now: DateTime<Utc>) -> Result<DriveReport, AppError> {
reconcile(state, now).await?;
drive(state, now).await
}
pub async fn ext_for(state: &AppState, rooms: &[&str]) -> Result<Option<Value>, AppError> {
let mut owed = Vec::new();
for room in rooms {
for p in state.community_rooms.pending_removals_in(room).await? {
owed.push(p.wire());
}
}
Ok((!owed.is_empty()).then_some(Value::Array(owed)))
}
pub fn spawn(
state: AppState,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
if let Err(e) = run_once(&state, Utc::now()).await {
tracing::warn!(error = %e, "community-room departure sweep failed");
}
tokio::select! {
_ = shutdown_rx.changed() => return,
_ = super::community::departure_hinted() => {}
_ = tokio::time::sleep(SWEEP_INTERVAL) => {}
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_doubles_to_an_hour() {
assert_eq!(backoff(1), chrono::Duration::seconds(30));
assert_eq!(backoff(2), chrono::Duration::seconds(60));
assert_eq!(backoff(3), chrono::Duration::seconds(120));
assert_eq!(backoff(8), chrono::Duration::seconds(3600));
assert_eq!(backoff(u32::MAX), chrono::Duration::seconds(3600));
}
}