1use std::borrow::Cow;
23use std::collections::HashMap;
24use std::sync::RwLock;
25
26use super::policies::{EvalContext, ResourceRef};
27use super::store::AuthStore;
28use super::UserId;
29use crate::runtime::control_events::{
30 ControlEvent, ControlEventConfig, ControlEventCtx, ControlEventLedger, EventKind, Outcome,
31 Sensitivity as ControlSensitivity,
32};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Mutability {
37 Immutable,
39 MutableViaGovernance,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Sensitivity {
46 Public,
47 Internal,
48 Confidential,
49 Secret,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum EvidenceRequirement {
57 None,
58 Metadata,
59 Full,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ConfigRegistryEntry {
66 pub id: String,
68 pub version: u64,
71 pub resource_type: String,
74 pub schema: String,
77 pub mutability: Mutability,
78 pub sensitivity: Sensitivity,
79 pub managed: bool,
82 pub required_action: String,
85 pub required_resource: String,
87 pub evidence_requirement: EvidenceRequirement,
88 pub updated_by: String,
90 pub updated_at_ms: u128,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ConfigRegistryHistoryRecord {
98 pub entry: ConfigRegistryEntry,
99 pub superseded_at_ms: u128,
102 pub superseded_by: String,
104 pub change_reason: String,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum RegistryError {
110 Unauthorized { action: String, resource: String },
112 NotFound(String),
114 Immutable(String),
116 AlreadyRegistered(String),
118 ControlEvent(String),
121}
122
123impl std::fmt::Display for RegistryError {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 match self {
126 Self::Unauthorized { action, resource } => write!(
127 f,
128 "registry mutation denied by policy: action={action} resource={resource}"
129 ),
130 Self::NotFound(id) => write!(f, "registry entry not found: {id}"),
131 Self::Immutable(id) => write!(f, "registry entry is immutable: {id}"),
132 Self::AlreadyRegistered(id) => write!(f, "registry entry already exists: {id}"),
133 Self::ControlEvent(msg) => write!(f, "{msg}"),
134 }
135 }
136}
137
138impl std::error::Error for RegistryError {}
139
140#[derive(Debug, Clone)]
144pub struct ConfigRegistryDraft {
145 pub id: String,
146 pub resource_type: String,
147 pub schema: String,
148 pub mutability: Mutability,
149 pub sensitivity: Sensitivity,
150 pub managed: bool,
151 pub required_action: String,
152 pub required_resource: String,
153 pub evidence_requirement: EvidenceRequirement,
154}
155
156pub struct ConfigRegistryControl<'a> {
158 pub ctx: &'a ControlEventCtx<'a>,
159 pub ledger: &'a dyn ControlEventLedger,
160 pub config: ControlEventConfig,
161}
162
163#[derive(Default)]
166pub struct ConfigRegistry {
167 active: RwLock<HashMap<String, ConfigRegistryEntry>>,
168 history: RwLock<HashMap<String, Vec<ConfigRegistryHistoryRecord>>>,
169}
170
171pub const ACTION_REGISTER: &str = "red.registry:register";
173pub const ACTION_SUPERSEDE: &str = "red.registry:supersede";
175pub const RESOURCE_KIND: &str = "registry";
178
179impl ConfigRegistry {
180 pub fn new() -> Self {
181 Self::default()
182 }
183
184 pub fn register(
190 &self,
191 auth: &AuthStore,
192 actor: &UserId,
193 ctx: &EvalContext,
194 draft: ConfigRegistryDraft,
195 now_ms: u128,
196 ) -> Result<ConfigRegistryEntry, RegistryError> {
197 let resource = ResourceRef::new(RESOURCE_KIND, draft.id.clone());
198 if !auth.check_policy_authz(actor, ACTION_REGISTER, &resource, ctx) {
199 return Err(RegistryError::Unauthorized {
200 action: ACTION_REGISTER.to_string(),
201 resource: format!("{}:{}", RESOURCE_KIND, draft.id),
202 });
203 }
204
205 let mut active = self.active.write().unwrap_or_else(|e| e.into_inner());
206 if active.contains_key(&draft.id) {
207 return Err(RegistryError::AlreadyRegistered(draft.id));
208 }
209 let entry = ConfigRegistryEntry {
210 id: draft.id.clone(),
211 version: 1,
212 resource_type: draft.resource_type,
213 schema: draft.schema,
214 mutability: draft.mutability,
215 sensitivity: draft.sensitivity,
216 managed: draft.managed,
217 required_action: draft.required_action,
218 required_resource: draft.required_resource,
219 evidence_requirement: draft.evidence_requirement,
220 updated_by: actor.to_string(),
221 updated_at_ms: now_ms,
222 };
223 active.insert(draft.id, entry.clone());
224 Ok(entry)
225 }
226
227 pub fn register_with_control_events(
228 &self,
229 auth: &AuthStore,
230 actor: &UserId,
231 ctx: &EvalContext,
232 draft: ConfigRegistryDraft,
233 now_ms: u128,
234 control: &ConfigRegistryControl<'_>,
235 ) -> Result<ConfigRegistryEntry, RegistryError> {
236 let id = draft.id.clone();
237 let draft_for_event = draft.clone();
238 match self.register(auth, actor, ctx, draft, now_ms) {
239 Ok(entry) => match emit_registry_event(
240 control,
241 Outcome::Allowed,
242 ACTION_REGISTER,
243 &entry.id,
244 None,
245 registry_fields_for_entry(&entry),
246 ) {
247 Ok(()) => Ok(entry),
248 Err(err) => {
249 self.rollback_register(&id);
250 Err(err)
251 }
252 },
253 Err(err @ RegistryError::Unauthorized { .. }) => {
254 emit_registry_denied(control, ACTION_REGISTER, &id, &err, &draft_for_event);
255 Err(err)
256 }
257 Err(err) => {
258 emit_registry_error(control, ACTION_REGISTER, &id, &err, &draft_for_event);
259 Err(err)
260 }
261 }
262 }
263
264 pub fn supersede(
272 &self,
273 auth: &AuthStore,
274 actor: &UserId,
275 ctx: &EvalContext,
276 draft: ConfigRegistryDraft,
277 change_reason: impl Into<String>,
278 now_ms: u128,
279 ) -> Result<ConfigRegistryEntry, RegistryError> {
280 let resource = ResourceRef::new(RESOURCE_KIND, draft.id.clone());
281 if !auth.check_policy_authz(actor, ACTION_SUPERSEDE, &resource, ctx) {
282 return Err(RegistryError::Unauthorized {
283 action: ACTION_SUPERSEDE.to_string(),
284 resource: format!("{}:{}", RESOURCE_KIND, draft.id),
285 });
286 }
287
288 let mut active = self.active.write().unwrap_or_else(|e| e.into_inner());
289 let prev = active
290 .get(&draft.id)
291 .cloned()
292 .ok_or_else(|| RegistryError::NotFound(draft.id.clone()))?;
293 if prev.mutability == Mutability::Immutable {
294 return Err(RegistryError::Immutable(draft.id));
295 }
296
297 let next = ConfigRegistryEntry {
298 id: draft.id.clone(),
299 version: prev.version + 1,
300 resource_type: draft.resource_type,
301 schema: draft.schema,
302 mutability: draft.mutability,
303 sensitivity: draft.sensitivity,
304 managed: draft.managed,
305 required_action: draft.required_action,
306 required_resource: draft.required_resource,
307 evidence_requirement: draft.evidence_requirement,
308 updated_by: actor.to_string(),
309 updated_at_ms: now_ms,
310 };
311 active.insert(draft.id.clone(), next.clone());
312
313 let record = ConfigRegistryHistoryRecord {
314 entry: prev,
315 superseded_at_ms: now_ms,
316 superseded_by: actor.to_string(),
317 change_reason: change_reason.into(),
318 };
319 self.history
320 .write()
321 .unwrap_or_else(|e| e.into_inner())
322 .entry(draft.id)
323 .or_default()
324 .push(record);
325 Ok(next)
326 }
327
328 pub fn supersede_with_control_events(
329 &self,
330 auth: &AuthStore,
331 actor: &UserId,
332 ctx: &EvalContext,
333 draft: ConfigRegistryDraft,
334 change_reason: impl Into<String>,
335 now_ms: u128,
336 control: &ConfigRegistryControl<'_>,
337 ) -> Result<ConfigRegistryEntry, RegistryError> {
338 let id = draft.id.clone();
339 let draft_for_event = draft.clone();
340 let previous = self.get_active(&id);
341 let history_len = self.history(&id).len();
342 match self.supersede(auth, actor, ctx, draft, change_reason, now_ms) {
343 Ok(entry) => match emit_registry_event(
344 control,
345 Outcome::Allowed,
346 ACTION_SUPERSEDE,
347 &entry.id,
348 None,
349 registry_fields_for_entry(&entry),
350 ) {
351 Ok(()) => Ok(entry),
352 Err(err) => {
353 if let Some(previous) = previous {
354 self.rollback_supersede(previous, history_len);
355 }
356 Err(err)
357 }
358 },
359 Err(err @ RegistryError::Unauthorized { .. }) => {
360 emit_registry_denied(control, ACTION_SUPERSEDE, &id, &err, &draft_for_event);
361 Err(err)
362 }
363 Err(err) => {
364 emit_registry_error(control, ACTION_SUPERSEDE, &id, &err, &draft_for_event);
365 Err(err)
366 }
367 }
368 }
369
370 pub fn get_active(&self, id: &str) -> Option<ConfigRegistryEntry> {
372 self.active.read().ok().and_then(|m| m.get(id).cloned())
373 }
374
375 pub fn list_active(&self) -> Vec<ConfigRegistryEntry> {
377 let map = match self.active.read() {
378 Ok(g) => g,
379 Err(_) => return Vec::new(),
380 };
381 let mut out: Vec<ConfigRegistryEntry> = map.values().cloned().collect();
382 out.sort_by(|a, b| a.id.cmp(&b.id));
383 out
384 }
385
386 pub fn history(&self, id: &str) -> Vec<ConfigRegistryHistoryRecord> {
389 self.history
390 .read()
391 .ok()
392 .and_then(|m| m.get(id).cloned())
393 .unwrap_or_default()
394 }
395
396 pub(crate) fn restore_bootstrap_entry(
401 &self,
402 entry: ConfigRegistryEntry,
403 ) -> Result<(), RegistryError> {
404 let mut active = self.active.write().unwrap_or_else(|e| e.into_inner());
405 if let Some(existing) = active.get(&entry.id) {
406 if existing == &entry {
407 return Ok(());
408 }
409 return Err(RegistryError::AlreadyRegistered(entry.id));
410 }
411 active.insert(entry.id.clone(), entry);
412 Ok(())
413 }
414
415 fn rollback_register(&self, id: &str) {
416 self.active
417 .write()
418 .unwrap_or_else(|e| e.into_inner())
419 .remove(id);
420 }
421
422 fn rollback_supersede(&self, previous: ConfigRegistryEntry, history_len: usize) {
423 let id = previous.id.clone();
424 self.active
425 .write()
426 .unwrap_or_else(|e| e.into_inner())
427 .insert(id.clone(), previous);
428 if let Some(records) = self
429 .history
430 .write()
431 .unwrap_or_else(|e| e.into_inner())
432 .get_mut(&id)
433 {
434 records.truncate(history_len);
435 }
436 }
437}
438
439fn emit_registry_denied(
440 control: &ConfigRegistryControl<'_>,
441 action: &'static str,
442 id: &str,
443 err: &RegistryError,
444 draft: &ConfigRegistryDraft,
445) {
446 let _ = emit_registry_event(
447 control,
448 Outcome::Denied,
449 action,
450 id,
451 Some(err.to_string()),
452 registry_fields_for_draft(draft),
453 );
454}
455
456fn emit_registry_error(
457 control: &ConfigRegistryControl<'_>,
458 action: &'static str,
459 id: &str,
460 err: &RegistryError,
461 draft: &ConfigRegistryDraft,
462) {
463 let _ = emit_registry_event(
464 control,
465 Outcome::Error,
466 action,
467 id,
468 Some(err.to_string()),
469 registry_fields_for_draft(draft),
470 );
471}
472
473fn emit_registry_event(
474 control: &ConfigRegistryControl<'_>,
475 outcome: Outcome,
476 action: &'static str,
477 id: &str,
478 reason: Option<String>,
479 fields: HashMap<String, ControlSensitivity>,
480) -> Result<(), RegistryError> {
481 let event = ControlEvent {
482 kind: EventKind::ConfigWrite,
483 outcome,
484 action: Cow::Borrowed(action),
485 resource: Some(format!("{RESOURCE_KIND}:{id}")),
486 reason,
487 matched_policy_id: None,
488 fields,
489 };
490 match control.ledger.emit(control.ctx, event) {
491 Ok(_) => Ok(()),
492 Err(err) if control.config.require_persistence() => {
493 Err(RegistryError::ControlEvent(err.to_string()))
494 }
495 Err(_) => Ok(()),
496 }
497}
498
499fn registry_fields_for_entry(entry: &ConfigRegistryEntry) -> HashMap<String, ControlSensitivity> {
500 let mut fields = registry_common_fields(
501 &entry.id,
502 &entry.resource_type,
503 entry.managed,
504 entry.mutability,
505 );
506 fields.insert(
507 "payload".to_string(),
508 registry_payload_sensitivity(&entry.resource_type, entry.schema.as_bytes()),
509 );
510 fields.insert(
511 "version".to_string(),
512 ControlSensitivity::raw(entry.version.to_string()),
513 );
514 fields
515}
516
517fn registry_fields_for_draft(draft: &ConfigRegistryDraft) -> HashMap<String, ControlSensitivity> {
518 let mut fields = registry_common_fields(
519 &draft.id,
520 &draft.resource_type,
521 draft.managed,
522 draft.mutability,
523 );
524 fields.insert(
525 "payload".to_string(),
526 registry_payload_sensitivity(&draft.resource_type, draft.schema.as_bytes()),
527 );
528 fields
529}
530
531fn registry_common_fields(
532 id: &str,
533 resource_type: &str,
534 managed: bool,
535 mutability: Mutability,
536) -> HashMap<String, ControlSensitivity> {
537 let mut fields = HashMap::new();
538 fields.insert("id".to_string(), ControlSensitivity::raw(id));
539 fields.insert(
540 "resource_type".to_string(),
541 ControlSensitivity::raw(resource_type),
542 );
543 fields.insert(
544 "managed".to_string(),
545 ControlSensitivity::raw(managed.to_string()),
546 );
547 fields.insert(
548 "mutability".to_string(),
549 ControlSensitivity::raw(mutability_label(mutability)),
550 );
551 fields
552}
553
554fn registry_payload_sensitivity(resource_type: &str, payload: &[u8]) -> ControlSensitivity {
555 if registry_payload_raw_allowed(resource_type) {
556 ControlSensitivity::raw(String::from_utf8_lossy(payload).into_owned())
557 } else {
558 ControlSensitivity::hashed(payload)
559 }
560}
561
562fn registry_payload_raw_allowed(resource_type: &str) -> bool {
563 matches!(resource_type, "audit_surface")
564}
565
566fn mutability_label(mutability: Mutability) -> &'static str {
567 match mutability {
568 Mutability::Immutable => "immutable",
569 Mutability::MutableViaGovernance => "mutable_via_governance",
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::auth::policies::Policy;
577 use crate::auth::{AuthConfig, Role};
578
579 fn store_with_admin() -> (std::sync::Arc<AuthStore>, UserId) {
580 let store = std::sync::Arc::new(AuthStore::new(AuthConfig::default()));
581 store.create_user("ops", "p", Role::Admin).unwrap();
582 let uid = UserId::platform("ops");
583 (store, uid)
584 }
585
586 fn ctx() -> EvalContext {
587 EvalContext {
588 principal_tenant: None,
589 current_tenant: None,
590 peer_ip: None,
591 mfa_present: false,
592 now_ms: 1_700_000_000_000,
593 principal_is_admin_role: true,
594 principal_is_system_owned: false,
595 principal_is_platform_scoped: true,
596 }
597 }
598
599 fn allow_all_registry(id: &str) -> Policy {
600 Policy::from_json_str(&format!(
601 r#"{{
602 "id": "{id}",
603 "version": 1,
604 "statements": [{{
605 "effect": "allow",
606 "actions": ["red.registry:*"],
607 "resources": ["registry:*"]
608 }}]
609 }}"#
610 ))
611 .unwrap()
612 }
613
614 fn deny_all_registry(id: &str) -> Policy {
615 Policy::from_json_str(&format!(
616 r#"{{
617 "id": "{id}",
618 "version": 1,
619 "statements": [{{
620 "effect": "deny",
621 "actions": ["red.registry:*"],
622 "resources": ["registry:*"]
623 }}]
624 }}"#
625 ))
626 .unwrap()
627 }
628
629 fn sample_draft(id: &str) -> ConfigRegistryDraft {
630 ConfigRegistryDraft {
631 id: id.to_string(),
632 resource_type: "config_key".into(),
633 schema: "string".into(),
634 mutability: Mutability::MutableViaGovernance,
635 sensitivity: Sensitivity::Internal,
636 managed: true,
637 required_action: "config:write".into(),
638 required_resource: format!("config:{id}"),
639 evidence_requirement: EvidenceRequirement::Metadata,
640 }
641 }
642
643 #[test]
644 fn register_then_get_active_returns_v1() {
645 let (store, uid) = store_with_admin();
646 store.put_policy(allow_all_registry("p-allow")).unwrap();
647 store
648 .attach_policy(
649 super::super::store::PrincipalRef::User(uid.clone()),
650 "p-allow",
651 )
652 .unwrap();
653 let reg = ConfigRegistry::new();
654
655 let entry = reg
656 .register(
657 &store,
658 &uid,
659 &ctx(),
660 sample_draft("red.config.audit.enabled"),
661 1_000,
662 )
663 .expect("register");
664 assert_eq!(entry.version, 1);
665
666 let got = reg.get_active("red.config.audit.enabled").unwrap();
667 assert_eq!(got, entry);
668 assert!(reg.history("red.config.audit.enabled").is_empty());
669 }
670
671 #[test]
672 fn supersede_promotes_v2_and_records_history() {
673 let (store, uid) = store_with_admin();
674 store.put_policy(allow_all_registry("p-allow")).unwrap();
675 store
676 .attach_policy(
677 super::super::store::PrincipalRef::User(uid.clone()),
678 "p-allow",
679 )
680 .unwrap();
681 let reg = ConfigRegistry::new();
682
683 let v1 = reg
684 .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
685 .unwrap();
686 let mut next = sample_draft("k");
687 next.schema = "string-v2".into();
688 let v2 = reg
689 .supersede(&store, &uid, &ctx(), next, "tightened schema", 2_000)
690 .unwrap();
691 assert_eq!(v2.version, 2);
692 assert_eq!(reg.get_active("k").unwrap(), v2);
693
694 let hist = reg.history("k");
695 assert_eq!(hist.len(), 1);
696 assert_eq!(hist[0].entry, v1);
697 assert_eq!(hist[0].superseded_at_ms, 2_000);
698 assert_eq!(hist[0].superseded_by, uid.to_string());
699 assert_eq!(hist[0].change_reason, "tightened schema");
700 }
701
702 #[test]
703 fn explicit_deny_blocks_mutation_even_for_admin() {
704 let (store, uid) = store_with_admin();
705 store.put_policy(allow_all_registry("p-allow")).unwrap();
706 store.put_policy(deny_all_registry("p-deny")).unwrap();
707 store
708 .attach_policy(
709 super::super::store::PrincipalRef::User(uid.clone()),
710 "p-allow",
711 )
712 .unwrap();
713 store
714 .attach_policy(
715 super::super::store::PrincipalRef::User(uid.clone()),
716 "p-deny",
717 )
718 .unwrap();
719 let reg = ConfigRegistry::new();
720
721 let err = reg
722 .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
723 .unwrap_err();
724 assert!(
725 matches!(err, RegistryError::Unauthorized { .. }),
726 "got {err:?}"
727 );
728 assert!(reg.get_active("k").is_none());
729 }
730
731 #[test]
732 fn ordinary_user_without_registry_policy_is_denied() {
733 let store = std::sync::Arc::new(AuthStore::new(AuthConfig::default()));
736 store.create_user("alice", "p", Role::Write).unwrap();
737 let uid = UserId::platform("alice");
738 store
740 .put_policy(
741 Policy::from_json_str(
742 r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.x"]}]}"#,
743 )
744 .unwrap(),
745 )
746 .unwrap();
747 let mut c = ctx();
748 c.principal_is_admin_role = false;
749 let reg = ConfigRegistry::new();
750 let err = reg
751 .register(&store, &uid, &c, sample_draft("k"), 1_000)
752 .unwrap_err();
753 assert!(
754 matches!(err, RegistryError::Unauthorized { .. }),
755 "got {err:?}"
756 );
757 }
758
759 #[test]
760 fn immutable_entries_reject_supersede() {
761 let (store, uid) = store_with_admin();
762 store.put_policy(allow_all_registry("p-allow")).unwrap();
763 store
764 .attach_policy(
765 super::super::store::PrincipalRef::User(uid.clone()),
766 "p-allow",
767 )
768 .unwrap();
769 let reg = ConfigRegistry::new();
770
771 let mut draft = sample_draft("k");
772 draft.mutability = Mutability::Immutable;
773 reg.register(&store, &uid, &ctx(), draft, 1_000).unwrap();
774
775 let err = reg
776 .supersede(
777 &store,
778 &uid,
779 &ctx(),
780 sample_draft("k"),
781 "should fail",
782 2_000,
783 )
784 .unwrap_err();
785 assert!(matches!(err, RegistryError::Immutable(_)), "got {err:?}");
786 assert_eq!(reg.get_active("k").unwrap().version, 1);
787 assert!(reg.history("k").is_empty());
788 }
789
790 #[test]
791 fn register_twice_is_already_registered() {
792 let (store, uid) = store_with_admin();
793 store.put_policy(allow_all_registry("p-allow")).unwrap();
794 store
795 .attach_policy(
796 super::super::store::PrincipalRef::User(uid.clone()),
797 "p-allow",
798 )
799 .unwrap();
800 let reg = ConfigRegistry::new();
801 reg.register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
802 .unwrap();
803 let err = reg
804 .register(&store, &uid, &ctx(), sample_draft("k"), 1_500)
805 .unwrap_err();
806 assert!(
807 matches!(err, RegistryError::AlreadyRegistered(_)),
808 "got {err:?}"
809 );
810 }
811
812 #[test]
813 fn registry_is_not_exposed_as_sql_collection() {
814 let reg = ConfigRegistry::new();
820 let _ = reg.list_active();
822 let _ = reg.history("k");
823 }
827}