Skip to content

Commit 613131f

Browse files
committed
feat(security): add clear_and_install_from for in-place registry repair
All in-memory security and event registries (permissions, roles, API keys, blacklist, credentials, RLS policies, change streams, consumer groups, scheduler, streaming MVs, alerts, triggers, retention policies) gain a clear_and_install_from method used by the catalog recovery sanity checker. When the checker detects divergence between the in-memory registry and the redb system catalog, it loads a fresh store from redb and calls clear_and_install_from to repair in place, keeping all existing Arc references stable so listeners need not be restarted.
1 parent dd9ed9b commit 613131f

16 files changed

Lines changed: 428 additions & 23 deletions

File tree

nodedb/src/control/security/apikey.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,19 @@ impl ApiKeyStore {
137137
Ok(())
138138
}
139139

140+
/// Clear the in-memory key map and re-run `load_from`.
141+
/// Used by the catalog recovery sanity checker to repair
142+
/// a divergent registry.
143+
pub(crate) fn clear_and_reload(&self, catalog: &SystemCatalog) -> crate::Result<()> {
144+
{
145+
let mut keys = self.keys.write().map_err(|e| crate::Error::Internal {
146+
detail: format!("api key lock poisoned during repair: {e}"),
147+
})?;
148+
keys.clear();
149+
}
150+
self.load_from(catalog)
151+
}
152+
140153
/// Persist a single key record to the catalog.
141154
fn persist_to(&self, catalog: &SystemCatalog, record: &ApiKeyRecord) -> crate::Result<()> {
142155
catalog.put_api_key(&record.to_stored())

nodedb/src/control/security/blacklist/store.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,30 @@ impl BlacklistStore {
281281
.collect()
282282
}
283283

284+
/// All in-memory entries (including potentially expired ones that
285+
/// haven't been lazily evicted yet). Used by the recovery verifier
286+
/// for exact redb↔memory comparison.
287+
pub fn list_all_entries(&self) -> Vec<BlacklistEntry> {
288+
let entries = self.entries.read().unwrap_or_else(|p| p.into_inner());
289+
entries.values().cloned().collect()
290+
}
291+
292+
/// Clear all in-memory entries and reload from catalog.
293+
/// Used by the recovery verifier repair path.
294+
pub fn clear_and_reload(&self, catalog: &SystemCatalog) -> crate::Result<()> {
295+
// Reload by clearing first then re-applying — load_from only appends.
296+
let stored = catalog.load_all_blacklist_entries()?;
297+
let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
298+
entries.clear();
299+
for s in stored {
300+
let entry = BlacklistEntry::from_stored(&s);
301+
if !entry.is_expired() {
302+
entries.insert(entry.key.clone(), entry);
303+
}
304+
}
305+
Ok(())
306+
}
307+
284308
/// Total active entries.
285309
pub fn count(&self) -> usize {
286310
let entries = self.entries.read().unwrap_or_else(|p| p.into_inner());

nodedb/src/control/security/catalog/collection_constraints.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,23 +88,23 @@ pub struct LegalHold {
8888
}
8989

9090
/// State transition constraint: column value can only change along declared paths.
91-
#[derive(Serialize, Deserialize, ToMessagePack, FromMessagePack, Debug, Clone)]
91+
#[derive(Serialize, Deserialize, ToMessagePack, FromMessagePack, Debug, Clone, PartialEq)]
9292
pub struct StateTransitionDef {
9393
pub name: String,
9494
pub column: String,
9595
pub transitions: Vec<TransitionRule>,
9696
}
9797

9898
/// A single allowed state transition, optionally guarded by a role.
99-
#[derive(Serialize, Deserialize, ToMessagePack, FromMessagePack, Debug, Clone)]
99+
#[derive(Serialize, Deserialize, ToMessagePack, FromMessagePack, Debug, Clone, PartialEq)]
100100
pub struct TransitionRule {
101101
pub from: String,
102102
pub to: String,
103103
pub required_role: Option<String>,
104104
}
105105

106106
/// Transition check predicate: evaluated on UPDATE with OLD and NEW access.
107-
#[derive(Serialize, Deserialize, ToMessagePack, FromMessagePack, Debug, Clone)]
107+
#[derive(Serialize, Deserialize, ToMessagePack, FromMessagePack, Debug, Clone, PartialEq)]
108108
pub struct TransitionCheckDef {
109109
pub name: String,
110110
pub predicate: SqlExpr,

nodedb/src/control/security/credential/store/list.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,37 @@ impl CredentialStore {
1515
users.values().filter(|u| u.is_active).cloned().collect()
1616
}
1717

18+
/// List ALL user records (active and inactive). Used by the
19+
/// recovery verifier for a complete redb↔memory comparison.
20+
pub fn list_all_user_details(&self) -> Vec<UserRecord> {
21+
let users = match read_lock(&self.users) {
22+
Ok(u) => u,
23+
Err(_) => return Vec::new(),
24+
};
25+
users.values().cloned().collect()
26+
}
27+
28+
/// Reload all users from the given catalog into the in-memory cache.
29+
/// Used by the recovery verifier repair path.
30+
pub fn reload_from_catalog(&self, catalog: &SystemCatalog) -> crate::Result<()> {
31+
use super::super::record::UserRecord;
32+
let stored_users = catalog.load_all_users()?;
33+
let mut users = match self.users.write() {
34+
Ok(u) => u,
35+
Err(_) => {
36+
return Err(crate::Error::Internal {
37+
detail: "credential store write lock poisoned in reload_from_catalog".into(),
38+
});
39+
}
40+
};
41+
users.clear();
42+
for stored in stored_users {
43+
let record = UserRecord::from_stored(stored);
44+
users.insert(record.username.clone(), record);
45+
}
46+
Ok(())
47+
}
48+
1849
/// List all active usernames.
1950
pub fn list_users(&self) -> Vec<String> {
2051
let users = match read_lock(&self.users) {

nodedb/src/control/security/permission/store.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,83 @@ impl PermissionStore {
157157
.collect()
158158
}
159159

160+
/// Replace the entire in-memory grants + owners state
161+
/// with the contents of `other`. Used by the catalog
162+
/// recovery sanity checker to repair a divergent registry
163+
/// by loading a fresh `PermissionStore` from redb and then
164+
/// swapping its contents into `self`. Callers keep their
165+
/// existing `Arc<PermissionStore>` reference stable.
166+
pub(crate) fn clear_and_install_from(&self, other: &Self) {
167+
let fresh_grants = other.snapshot_grants();
168+
let fresh_owners = other.snapshot_owners();
169+
let mut grants = match self.grants.write() {
170+
Ok(g) => g,
171+
Err(p) => {
172+
tracing::error!("permission grants lock poisoned during repair — recovering");
173+
p.into_inner()
174+
}
175+
};
176+
grants.clear();
177+
for g in fresh_grants {
178+
grants.insert(g);
179+
}
180+
drop(grants);
181+
let mut owners = match self.owners.write() {
182+
Ok(o) => o,
183+
Err(p) => {
184+
tracing::error!("owner store lock poisoned during repair — recovering");
185+
p.into_inner()
186+
}
187+
};
188+
owners.clear();
189+
for (k, v) in fresh_owners {
190+
owners.insert(k, v);
191+
}
192+
}
193+
194+
/// Deterministic snapshot of every grant held in memory,
195+
/// sorted by `(target, grantee, permission)` so diff-based
196+
/// callers (the recovery sanity checker) can compare
197+
/// against a catalog load without caring about HashSet
198+
/// iteration order.
199+
pub fn snapshot_grants(&self) -> Vec<Grant> {
200+
let grants = match self.grants.read() {
201+
Ok(g) => g,
202+
Err(p) => p.into_inner(),
203+
};
204+
let mut out: Vec<Grant> = grants.iter().cloned().collect();
205+
out.sort_by(|a, b| {
206+
let a_key = (
207+
a.target.clone(),
208+
a.grantee.clone(),
209+
format_permission(a.permission),
210+
);
211+
let b_key = (
212+
b.target.clone(),
213+
b.grantee.clone(),
214+
format_permission(b.permission),
215+
);
216+
a_key.cmp(&b_key)
217+
});
218+
out
219+
}
220+
221+
/// Deterministic snapshot of every owner held in memory as
222+
/// `(owner_key, username)` pairs, sorted by key.
223+
/// `owner_key` is the internal `"collection:{tenant}:{name}"`
224+
/// composite — used by the sanity checker to cross-check
225+
/// against `catalog.load_all_owners()`.
226+
pub fn snapshot_owners(&self) -> Vec<(String, String)> {
227+
let owners = match self.owners.read() {
228+
Ok(o) => o,
229+
Err(p) => p.into_inner(),
230+
};
231+
let mut out: Vec<(String, String)> =
232+
owners.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
233+
out.sort_by(|a, b| a.0.cmp(&b.0));
234+
out
235+
}
236+
160237
/// List all grants on a target.
161238
pub fn grants_on(&self, target: &str) -> Vec<Grant> {
162239
let grants = match self.grants.read() {

nodedb/src/control/security/rls/store.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,36 @@ impl RlsPolicyStore {
101101
.unwrap_or_default()
102102
}
103103

104+
/// Flat list of all policies (all tenants, all collections).
105+
/// Used by the recovery verifier.
106+
pub fn list_all_flat(&self) -> Vec<RlsPolicy> {
107+
let policies = self.lock_read();
108+
policies.values().flat_map(|v| v.iter().cloned()).collect()
109+
}
110+
111+
/// Clear all in-memory policies and reload from the catalog.
112+
/// Used by the recovery verifier repair path.
113+
pub fn clear_and_reload(
114+
&self,
115+
catalog: &crate::control::security::catalog::SystemCatalog,
116+
) -> crate::Result<()> {
117+
let stored = catalog.load_all_rls_policies()?;
118+
let mut policies = self.lock_write();
119+
policies.clear();
120+
for s in stored {
121+
match s.to_runtime() {
122+
Ok(rp) => {
123+
let key = super::types::policy_key(rp.tenant_id, &rp.collection);
124+
policies.entry(key).or_default().push(rp);
125+
}
126+
Err(e) => {
127+
tracing::warn!(error = %e, "rls_store.clear_and_reload: skipping unparseable policy");
128+
}
129+
}
130+
}
131+
Ok(())
132+
}
133+
104134
/// Total policies across all collections.
105135
pub fn policy_count(&self) -> usize {
106136
self.policies

nodedb/src/control/security/role.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ impl RoleStore {
6464
Ok(())
6565
}
6666

67+
/// Clear the in-memory role map and re-run `load_from`.
68+
/// Used by the catalog recovery sanity checker to repair
69+
/// a divergent registry. Callers keep their existing
70+
/// `&RoleStore` reference.
71+
pub(crate) fn clear_and_reload(&self, catalog: &SystemCatalog) -> crate::Result<()> {
72+
{
73+
let mut roles = self.roles.write().map_err(|e| crate::Error::Internal {
74+
detail: format!("role store lock poisoned during repair: {e}"),
75+
})?;
76+
roles.clear();
77+
}
78+
self.load_from(catalog)
79+
}
80+
6781
// ── Cluster replication hooks ──────────────────────────────
6882
//
6983
// Symmetric partners to `CredentialStore::install_replicated_user`:

nodedb/src/control/trigger/registry.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,51 @@ impl TriggerRegistry {
152152
}
153153
}
154154

155+
/// Replace the entire in-memory trigger map with `rows`.
156+
/// Used by the catalog recovery sanity checker to repair
157+
/// a divergent registry by re-loading from redb. Callers
158+
/// keep their existing `&TriggerRegistry` reference.
159+
pub(crate) fn clear_and_install_all(&self, rows: Vec<StoredTrigger>) {
160+
let mut map = match self.by_collection.write() {
161+
Ok(m) => m,
162+
Err(p) => p.into_inner(),
163+
};
164+
map.clear();
165+
for trigger in rows {
166+
let key = (trigger.tenant_id, trigger.collection.clone());
167+
map.entry(key).or_default().push(trigger);
168+
}
169+
for list in map.values_mut() {
170+
list.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
171+
}
172+
}
173+
174+
/// Deterministic snapshot of every trigger across every
175+
/// tenant, sorted by `(tenant_id, collection, name)` so the
176+
/// recovery sanity checker can diff against
177+
/// `catalog.load_all_triggers()` without caring about
178+
/// HashMap iteration order.
179+
pub fn snapshot_all(&self) -> Vec<StoredTrigger> {
180+
let map = match self.by_collection.read() {
181+
Ok(m) => m,
182+
Err(p) => p.into_inner(),
183+
};
184+
let mut result: Vec<StoredTrigger> = Vec::new();
185+
for list in map.values() {
186+
for t in list {
187+
result.push(t.clone());
188+
}
189+
}
190+
result.sort_by(|a, b| {
191+
(a.tenant_id, a.collection.clone(), a.name.clone()).cmp(&(
192+
b.tenant_id,
193+
b.collection.clone(),
194+
b.name.clone(),
195+
))
196+
});
197+
result
198+
}
199+
155200
/// List all triggers for a tenant (for SHOW TRIGGERS).
156201
pub fn list_for_tenant(&self, tenant_id: u32) -> Vec<StoredTrigger> {
157202
let map = match self.by_collection.read() {

nodedb/src/engine/timeseries/retention_policy/registry.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,32 @@ impl RetentionPolicyRegistry {
8484
.collect()
8585
}
8686

87+
/// List all policies (all tenants, enabled and disabled).
88+
/// Used by the recovery verifier.
89+
pub fn list_all(&self) -> Vec<RetentionPolicyDef> {
90+
self.policies
91+
.read()
92+
.expect("registry lock poisoned")
93+
.values()
94+
.cloned()
95+
.collect()
96+
}
97+
98+
/// Clear and reload from catalog. Used by the recovery verifier repair path.
99+
pub fn clear_and_reload(
100+
&self,
101+
catalog: &crate::control::security::catalog::types::SystemCatalog,
102+
) -> crate::Result<()> {
103+
let fresh = catalog.load_all_retention_policies()?;
104+
let mut map = self.policies.write().expect("registry lock poisoned");
105+
map.clear();
106+
for p in fresh {
107+
let key = (p.tenant_id, p.name.clone());
108+
map.insert(key, p);
109+
}
110+
Ok(())
111+
}
112+
87113
/// List all policies for a tenant.
88114
pub fn list_for_tenant(&self, tenant_id: u32) -> Vec<RetentionPolicyDef> {
89115
self.policies

nodedb/src/event/alert/registry.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,27 @@ impl AlertRegistry {
5757
.collect()
5858
}
5959

60+
/// List all alerts (all tenants, enabled and disabled).
61+
/// Used by the recovery verifier.
62+
pub fn list_all(&self) -> Vec<AlertDef> {
63+
self.read_map().values().cloned().collect()
64+
}
65+
66+
/// Clear and reload from catalog. Used by the recovery verifier repair path.
67+
pub fn clear_and_reload(
68+
&self,
69+
catalog: &crate::control::security::catalog::types::SystemCatalog,
70+
) -> crate::Result<()> {
71+
let fresh = catalog.load_all_alert_rules()?;
72+
let mut map = self.write_map();
73+
map.clear();
74+
for alert in fresh {
75+
let key = (alert.tenant_id, alert.name.clone());
76+
map.insert(key, alert);
77+
}
78+
Ok(())
79+
}
80+
6081
/// List all alerts for a tenant.
6182
pub fn list_for_tenant(&self, tenant_id: u32) -> Vec<AlertDef> {
6283
self.read_map()

0 commit comments

Comments
 (0)