1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
//! Background pruning of expired ACL rows.
//!
//! Called from the storage thread's interval loop. Walks the ACL keyspace
//! once and deletes any rows whose `expires_at` has passed.
//!
//! Every deletion is logged at `info!` AND recorded in the audit log as
//! an `acl.expire` event so operators can correlate "DID not in ACL"
//! errors with the deletion that caused them. Without this trail, the
//! sweeper's removals are invisible — there's nothing for the operator
//! to grep / SIEM-query when an entry mysteriously stops working.
use tracing::{debug, info, warn};
use crate::acl::{AclEntry, delete_acl_entry};
use crate::error::AppError;
use crate::store::KeyspaceHandle;
fn now_epoch() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Sweep the ACL keyspace and delete any row whose `expires_at` has
/// passed. Each deletion produces:
///
/// - one `info!` line with `did`, `role`, `expired_at`, and a
/// `reason = "expired"` field so operators can grep
/// `acl_sweeper` lines and see WHICH DIDs were pruned, not just
/// the aggregate count;
/// - one `audit::record(acl.expire, system:sweeper, <did>, ...)`
/// entry so the removal is queryable from the audit log alongside
/// `acl.create` / `acl.swap` / `acl.revoke`.
///
/// Audit-record failures are warn-logged but don't abort the sweep
/// — the deletion has already happened; losing the audit row is
/// strictly worse if it would cause the keyspace to keep an
/// expired entry around.
pub async fn sweep_expired(
acl_ks: &KeyspaceHandle,
audit_ks: &KeyspaceHandle,
) -> Result<(), AppError> {
let now = now_epoch();
let mut pruned = 0usize;
let rows = acl_ks.prefix_iter_raw("acl:").await?;
for (key, value) in rows {
let entry: AclEntry = match serde_json::from_slice(&value) {
Ok(e) => e,
Err(e) => {
debug!(
key = %String::from_utf8_lossy(&key),
error = %e,
"sweeper: skipping unreadable acl row",
);
continue;
}
};
if entry.is_expired(now) {
let did = entry.did.clone();
let role = entry.role.to_string();
let expired_at = entry.expires_at;
delete_acl_entry(acl_ks, &did).await?;
pruned += 1;
info!(
did = %did,
role = %role,
expired_at = ?expired_at,
now_epoch = now,
reason = "expired",
"acl sweeper deleted expired entry"
);
// Audit-log the removal so operators can correlate
// "DID not in ACL" errors with the sweeper's prior
// deletion. The actor is `system:sweeper` (synthetic,
// matches the format `cli:vault-seed` uses elsewhere)
// because no human / consumer triggered this — it's a
// background timer firing on a previously-set TTL.
if let Err(e) = crate::audit::record(
audit_ks,
"acl.expire",
"system:sweeper",
Some(&did),
"success",
None,
None,
)
.await
{
warn!(
did = %did,
error = %e,
"acl sweeper: deletion succeeded but audit::record failed; audit log will be missing this removal"
);
}
}
}
if pruned > 0 {
info!(acl_pruned = pruned, "acl sweeper pruned expired rows");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acl::{Role, store_acl_entry};
use crate::store::Store;
use vti_common::config::StoreConfig;
async fn fresh_store() -> (Store, KeyspaceHandle, KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = Store::open(&config).unwrap();
let acl_ks = store.keyspace(crate::keyspaces::ACL).unwrap();
let audit_ks = store.keyspace(crate::keyspaces::AUDIT).unwrap();
(store, acl_ks, audit_ks, dir)
}
fn entry(did: &str, expires_at: Option<u64>) -> AclEntry {
AclEntry::new(did, Role::Admin, "test").with_expires_at(expires_at)
}
/// Expired entries get deleted; permanent (no `expires_at`)
/// entries are untouched. This is the core invariant — without
/// it, either the wallet's long-term DID would be permanently
/// orphaned by stale entries, or every entry would be at risk.
#[tokio::test]
async fn sweeper_deletes_expired_and_preserves_permanent() {
let (_store, acl_ks, audit_ks, _dir) = fresh_store().await;
let now = now_epoch();
let expired = entry("did:key:zExpired", Some(now - 1));
let live_ttl = entry("did:key:zLiveTtl", Some(now + 3600));
let permanent = entry("did:key:zPermanent", None);
store_acl_entry(&acl_ks, &expired).await.unwrap();
store_acl_entry(&acl_ks, &live_ttl).await.unwrap();
store_acl_entry(&acl_ks, &permanent).await.unwrap();
sweep_expired(&acl_ks, &audit_ks).await.unwrap();
// Expired entry: gone.
assert!(
crate::acl::get_acl_entry(&acl_ks, &expired.did)
.await
.unwrap()
.is_none(),
"expired entry must be pruned"
);
// Live-TTL + permanent: still there.
assert!(
crate::acl::get_acl_entry(&acl_ks, &live_ttl.did)
.await
.unwrap()
.is_some(),
"live-TTL entry must NOT be pruned"
);
assert!(
crate::acl::get_acl_entry(&acl_ks, &permanent.did)
.await
.unwrap()
.is_some(),
"permanent entry must NOT be pruned"
);
// Audit-log entry for the deletion exists under
// `log:<timestamp>:<uuid>`. Without this, the sweeper's
// removals would be invisible to forensic queries.
let audit_rows = audit_ks.prefix_iter_raw("log:").await.unwrap();
let mut found_expire_row_for_did = false;
for (_, value) in audit_rows {
let s = String::from_utf8_lossy(&value);
if s.contains("acl.expire") && s.contains(&expired.did) {
found_expire_row_for_did = true;
break;
}
}
assert!(
found_expire_row_for_did,
"audit log must contain an acl.expire entry for the pruned DID"
);
}
/// Sweep over an empty keyspace is a no-op + does not panic.
#[tokio::test]
async fn sweeper_handles_empty_keyspace() {
let (_store, acl_ks, audit_ks, _dir) = fresh_store().await;
sweep_expired(&acl_ks, &audit_ks).await.unwrap();
}
}