1use std::collections::HashMap;
23use std::sync::RwLock;
24
25use super::policies::{EvalContext, ResourceRef};
26use super::store::AuthStore;
27use super::UserId;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Mutability {
32 Immutable,
34 MutableViaGovernance,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Sensitivity {
41 Public,
42 Internal,
43 Confidential,
44 Secret,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum EvidenceRequirement {
52 None,
53 Metadata,
54 Full,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ConfigRegistryEntry {
61 pub id: String,
63 pub version: u64,
66 pub resource_type: String,
69 pub schema: String,
72 pub mutability: Mutability,
73 pub sensitivity: Sensitivity,
74 pub managed: bool,
77 pub required_action: String,
80 pub required_resource: String,
82 pub evidence_requirement: EvidenceRequirement,
83 pub updated_by: String,
85 pub updated_at_ms: u128,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ConfigRegistryHistoryRecord {
93 pub entry: ConfigRegistryEntry,
94 pub superseded_at_ms: u128,
97 pub superseded_by: String,
99 pub change_reason: String,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum RegistryError {
105 Unauthorized { action: String, resource: String },
107 NotFound(String),
109 Immutable(String),
111 AlreadyRegistered(String),
113}
114
115impl std::fmt::Display for RegistryError {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 match self {
118 Self::Unauthorized { action, resource } => write!(
119 f,
120 "registry mutation denied by policy: action={action} resource={resource}"
121 ),
122 Self::NotFound(id) => write!(f, "registry entry not found: {id}"),
123 Self::Immutable(id) => write!(f, "registry entry is immutable: {id}"),
124 Self::AlreadyRegistered(id) => write!(f, "registry entry already exists: {id}"),
125 }
126 }
127}
128
129impl std::error::Error for RegistryError {}
130
131#[derive(Debug, Clone)]
135pub struct ConfigRegistryDraft {
136 pub id: String,
137 pub resource_type: String,
138 pub schema: String,
139 pub mutability: Mutability,
140 pub sensitivity: Sensitivity,
141 pub managed: bool,
142 pub required_action: String,
143 pub required_resource: String,
144 pub evidence_requirement: EvidenceRequirement,
145}
146
147#[derive(Default)]
150pub struct ConfigRegistry {
151 active: RwLock<HashMap<String, ConfigRegistryEntry>>,
152 history: RwLock<HashMap<String, Vec<ConfigRegistryHistoryRecord>>>,
153}
154
155pub const ACTION_REGISTER: &str = "red.registry:register";
157pub const ACTION_SUPERSEDE: &str = "red.registry:supersede";
159pub const RESOURCE_KIND: &str = "registry";
162
163impl ConfigRegistry {
164 pub fn new() -> Self {
165 Self::default()
166 }
167
168 pub fn register(
174 &self,
175 auth: &AuthStore,
176 actor: &UserId,
177 ctx: &EvalContext,
178 draft: ConfigRegistryDraft,
179 now_ms: u128,
180 ) -> Result<ConfigRegistryEntry, RegistryError> {
181 let resource = ResourceRef::new(RESOURCE_KIND, draft.id.clone());
182 if !auth.check_policy_authz(actor, ACTION_REGISTER, &resource, ctx) {
183 return Err(RegistryError::Unauthorized {
184 action: ACTION_REGISTER.to_string(),
185 resource: format!("{}:{}", RESOURCE_KIND, draft.id),
186 });
187 }
188
189 let mut active = self.active.write().unwrap_or_else(|e| e.into_inner());
190 if active.contains_key(&draft.id) {
191 return Err(RegistryError::AlreadyRegistered(draft.id));
192 }
193 let entry = ConfigRegistryEntry {
194 id: draft.id.clone(),
195 version: 1,
196 resource_type: draft.resource_type,
197 schema: draft.schema,
198 mutability: draft.mutability,
199 sensitivity: draft.sensitivity,
200 managed: draft.managed,
201 required_action: draft.required_action,
202 required_resource: draft.required_resource,
203 evidence_requirement: draft.evidence_requirement,
204 updated_by: actor.to_string(),
205 updated_at_ms: now_ms,
206 };
207 active.insert(draft.id, entry.clone());
208 Ok(entry)
209 }
210
211 pub fn supersede(
219 &self,
220 auth: &AuthStore,
221 actor: &UserId,
222 ctx: &EvalContext,
223 draft: ConfigRegistryDraft,
224 change_reason: impl Into<String>,
225 now_ms: u128,
226 ) -> Result<ConfigRegistryEntry, RegistryError> {
227 let resource = ResourceRef::new(RESOURCE_KIND, draft.id.clone());
228 if !auth.check_policy_authz(actor, ACTION_SUPERSEDE, &resource, ctx) {
229 return Err(RegistryError::Unauthorized {
230 action: ACTION_SUPERSEDE.to_string(),
231 resource: format!("{}:{}", RESOURCE_KIND, draft.id),
232 });
233 }
234
235 let mut active = self.active.write().unwrap_or_else(|e| e.into_inner());
236 let prev = active
237 .get(&draft.id)
238 .cloned()
239 .ok_or_else(|| RegistryError::NotFound(draft.id.clone()))?;
240 if prev.mutability == Mutability::Immutable {
241 return Err(RegistryError::Immutable(draft.id));
242 }
243
244 let next = ConfigRegistryEntry {
245 id: draft.id.clone(),
246 version: prev.version + 1,
247 resource_type: draft.resource_type,
248 schema: draft.schema,
249 mutability: draft.mutability,
250 sensitivity: draft.sensitivity,
251 managed: draft.managed,
252 required_action: draft.required_action,
253 required_resource: draft.required_resource,
254 evidence_requirement: draft.evidence_requirement,
255 updated_by: actor.to_string(),
256 updated_at_ms: now_ms,
257 };
258 active.insert(draft.id.clone(), next.clone());
259
260 let record = ConfigRegistryHistoryRecord {
261 entry: prev,
262 superseded_at_ms: now_ms,
263 superseded_by: actor.to_string(),
264 change_reason: change_reason.into(),
265 };
266 self.history
267 .write()
268 .unwrap_or_else(|e| e.into_inner())
269 .entry(draft.id)
270 .or_default()
271 .push(record);
272 Ok(next)
273 }
274
275 pub fn get_active(&self, id: &str) -> Option<ConfigRegistryEntry> {
277 self.active.read().ok().and_then(|m| m.get(id).cloned())
278 }
279
280 pub fn list_active(&self) -> Vec<ConfigRegistryEntry> {
282 let map = match self.active.read() {
283 Ok(g) => g,
284 Err(_) => return Vec::new(),
285 };
286 let mut out: Vec<ConfigRegistryEntry> = map.values().cloned().collect();
287 out.sort_by(|a, b| a.id.cmp(&b.id));
288 out
289 }
290
291 pub fn history(&self, id: &str) -> Vec<ConfigRegistryHistoryRecord> {
294 self.history
295 .read()
296 .ok()
297 .and_then(|m| m.get(id).cloned())
298 .unwrap_or_default()
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::auth::policies::Policy;
306 use crate::auth::{AuthConfig, Role};
307
308 fn store_with_admin() -> (std::sync::Arc<AuthStore>, UserId) {
309 let store = std::sync::Arc::new(AuthStore::new(AuthConfig::default()));
310 store.create_user("ops", "p", Role::Admin).unwrap();
311 let uid = UserId::platform("ops");
312 (store, uid)
313 }
314
315 fn ctx() -> EvalContext {
316 EvalContext {
317 principal_tenant: None,
318 current_tenant: None,
319 peer_ip: None,
320 mfa_present: false,
321 now_ms: 1_700_000_000_000,
322 principal_is_admin_role: true,
323 principal_is_system_owned: false,
324 principal_is_platform_scoped: true,
325 }
326 }
327
328 fn allow_all_registry(id: &str) -> Policy {
329 Policy::from_json_str(&format!(
330 r#"{{
331 "id": "{id}",
332 "version": 1,
333 "statements": [{{
334 "effect": "allow",
335 "actions": ["red.registry:*"],
336 "resources": ["registry:*"]
337 }}]
338 }}"#
339 ))
340 .unwrap()
341 }
342
343 fn deny_all_registry(id: &str) -> Policy {
344 Policy::from_json_str(&format!(
345 r#"{{
346 "id": "{id}",
347 "version": 1,
348 "statements": [{{
349 "effect": "deny",
350 "actions": ["red.registry:*"],
351 "resources": ["registry:*"]
352 }}]
353 }}"#
354 ))
355 .unwrap()
356 }
357
358 fn sample_draft(id: &str) -> ConfigRegistryDraft {
359 ConfigRegistryDraft {
360 id: id.to_string(),
361 resource_type: "config_key".into(),
362 schema: "string".into(),
363 mutability: Mutability::MutableViaGovernance,
364 sensitivity: Sensitivity::Internal,
365 managed: true,
366 required_action: "config:write".into(),
367 required_resource: format!("config:{id}"),
368 evidence_requirement: EvidenceRequirement::Metadata,
369 }
370 }
371
372 #[test]
373 fn register_then_get_active_returns_v1() {
374 let (store, uid) = store_with_admin();
375 store.put_policy(allow_all_registry("p-allow")).unwrap();
376 store
377 .attach_policy(
378 super::super::store::PrincipalRef::User(uid.clone()),
379 "p-allow",
380 )
381 .unwrap();
382 let reg = ConfigRegistry::new();
383
384 let entry = reg
385 .register(
386 &store,
387 &uid,
388 &ctx(),
389 sample_draft("red.config.audit.enabled"),
390 1_000,
391 )
392 .expect("register");
393 assert_eq!(entry.version, 1);
394
395 let got = reg.get_active("red.config.audit.enabled").unwrap();
396 assert_eq!(got, entry);
397 assert!(reg.history("red.config.audit.enabled").is_empty());
398 }
399
400 #[test]
401 fn supersede_promotes_v2_and_records_history() {
402 let (store, uid) = store_with_admin();
403 store.put_policy(allow_all_registry("p-allow")).unwrap();
404 store
405 .attach_policy(
406 super::super::store::PrincipalRef::User(uid.clone()),
407 "p-allow",
408 )
409 .unwrap();
410 let reg = ConfigRegistry::new();
411
412 let v1 = reg
413 .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
414 .unwrap();
415 let mut next = sample_draft("k");
416 next.schema = "string-v2".into();
417 let v2 = reg
418 .supersede(&store, &uid, &ctx(), next, "tightened schema", 2_000)
419 .unwrap();
420 assert_eq!(v2.version, 2);
421 assert_eq!(reg.get_active("k").unwrap(), v2);
422
423 let hist = reg.history("k");
424 assert_eq!(hist.len(), 1);
425 assert_eq!(hist[0].entry, v1);
426 assert_eq!(hist[0].superseded_at_ms, 2_000);
427 assert_eq!(hist[0].superseded_by, uid.to_string());
428 assert_eq!(hist[0].change_reason, "tightened schema");
429 }
430
431 #[test]
432 fn explicit_deny_blocks_mutation_even_for_admin() {
433 let (store, uid) = store_with_admin();
434 store.put_policy(allow_all_registry("p-allow")).unwrap();
435 store.put_policy(deny_all_registry("p-deny")).unwrap();
436 store
437 .attach_policy(
438 super::super::store::PrincipalRef::User(uid.clone()),
439 "p-allow",
440 )
441 .unwrap();
442 store
443 .attach_policy(
444 super::super::store::PrincipalRef::User(uid.clone()),
445 "p-deny",
446 )
447 .unwrap();
448 let reg = ConfigRegistry::new();
449
450 let err = reg
451 .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
452 .unwrap_err();
453 assert!(
454 matches!(err, RegistryError::Unauthorized { .. }),
455 "got {err:?}"
456 );
457 assert!(reg.get_active("k").is_none());
458 }
459
460 #[test]
461 fn ordinary_user_without_registry_policy_is_denied() {
462 let store = std::sync::Arc::new(AuthStore::new(AuthConfig::default()));
465 store.create_user("alice", "p", Role::Write).unwrap();
466 let uid = UserId::platform("alice");
467 store
469 .put_policy(
470 Policy::from_json_str(
471 r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.x"]}]}"#,
472 )
473 .unwrap(),
474 )
475 .unwrap();
476 let mut c = ctx();
477 c.principal_is_admin_role = false;
478 let reg = ConfigRegistry::new();
479 let err = reg
480 .register(&store, &uid, &c, sample_draft("k"), 1_000)
481 .unwrap_err();
482 assert!(
483 matches!(err, RegistryError::Unauthorized { .. }),
484 "got {err:?}"
485 );
486 }
487
488 #[test]
489 fn immutable_entries_reject_supersede() {
490 let (store, uid) = store_with_admin();
491 store.put_policy(allow_all_registry("p-allow")).unwrap();
492 store
493 .attach_policy(
494 super::super::store::PrincipalRef::User(uid.clone()),
495 "p-allow",
496 )
497 .unwrap();
498 let reg = ConfigRegistry::new();
499
500 let mut draft = sample_draft("k");
501 draft.mutability = Mutability::Immutable;
502 reg.register(&store, &uid, &ctx(), draft, 1_000).unwrap();
503
504 let err = reg
505 .supersede(
506 &store,
507 &uid,
508 &ctx(),
509 sample_draft("k"),
510 "should fail",
511 2_000,
512 )
513 .unwrap_err();
514 assert!(matches!(err, RegistryError::Immutable(_)), "got {err:?}");
515 assert_eq!(reg.get_active("k").unwrap().version, 1);
516 assert!(reg.history("k").is_empty());
517 }
518
519 #[test]
520 fn register_twice_is_already_registered() {
521 let (store, uid) = store_with_admin();
522 store.put_policy(allow_all_registry("p-allow")).unwrap();
523 store
524 .attach_policy(
525 super::super::store::PrincipalRef::User(uid.clone()),
526 "p-allow",
527 )
528 .unwrap();
529 let reg = ConfigRegistry::new();
530 reg.register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
531 .unwrap();
532 let err = reg
533 .register(&store, &uid, &ctx(), sample_draft("k"), 1_500)
534 .unwrap_err();
535 assert!(
536 matches!(err, RegistryError::AlreadyRegistered(_)),
537 "got {err:?}"
538 );
539 }
540
541 #[test]
542 fn registry_is_not_exposed_as_sql_collection() {
543 let reg = ConfigRegistry::new();
549 let _ = reg.list_active();
551 let _ = reg.history("k");
552 }
556}