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_platform_scoped: true,
595 }
596 }
597
598 fn allow_all_registry(id: &str) -> Policy {
599 Policy::from_json_str(&format!(
600 r#"{{
601 "id": "{id}",
602 "version": 1,
603 "statements": [{{
604 "effect": "allow",
605 "actions": ["red.registry:*"],
606 "resources": ["registry:*"]
607 }}]
608 }}"#
609 ))
610 .unwrap()
611 }
612
613 fn deny_all_registry(id: &str) -> Policy {
614 Policy::from_json_str(&format!(
615 r#"{{
616 "id": "{id}",
617 "version": 1,
618 "statements": [{{
619 "effect": "deny",
620 "actions": ["red.registry:*"],
621 "resources": ["registry:*"]
622 }}]
623 }}"#
624 ))
625 .unwrap()
626 }
627
628 fn sample_draft(id: &str) -> ConfigRegistryDraft {
629 ConfigRegistryDraft {
630 id: id.to_string(),
631 resource_type: "config_key".into(),
632 schema: "string".into(),
633 mutability: Mutability::MutableViaGovernance,
634 sensitivity: Sensitivity::Internal,
635 managed: true,
636 required_action: "config:write".into(),
637 required_resource: format!("config:{id}"),
638 evidence_requirement: EvidenceRequirement::Metadata,
639 }
640 }
641
642 #[test]
643 fn register_then_get_active_returns_v1() {
644 let (store, uid) = store_with_admin();
645 store.put_policy(allow_all_registry("p-allow")).unwrap();
646 store
647 .attach_policy(
648 super::super::store::PrincipalRef::User(uid.clone()),
649 "p-allow",
650 )
651 .unwrap();
652 let reg = ConfigRegistry::new();
653
654 let entry = reg
655 .register(
656 &store,
657 &uid,
658 &ctx(),
659 sample_draft("red.config.audit.enabled"),
660 1_000,
661 )
662 .expect("register");
663 assert_eq!(entry.version, 1);
664
665 let got = reg.get_active("red.config.audit.enabled").unwrap();
666 assert_eq!(got, entry);
667 assert!(reg.history("red.config.audit.enabled").is_empty());
668 }
669
670 #[test]
671 fn supersede_promotes_v2_and_records_history() {
672 let (store, uid) = store_with_admin();
673 store.put_policy(allow_all_registry("p-allow")).unwrap();
674 store
675 .attach_policy(
676 super::super::store::PrincipalRef::User(uid.clone()),
677 "p-allow",
678 )
679 .unwrap();
680 let reg = ConfigRegistry::new();
681
682 let v1 = reg
683 .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
684 .unwrap();
685 let mut next = sample_draft("k");
686 next.schema = "string-v2".into();
687 let v2 = reg
688 .supersede(&store, &uid, &ctx(), next, "tightened schema", 2_000)
689 .unwrap();
690 assert_eq!(v2.version, 2);
691 assert_eq!(reg.get_active("k").unwrap(), v2);
692
693 let hist = reg.history("k");
694 assert_eq!(hist.len(), 1);
695 assert_eq!(hist[0].entry, v1);
696 assert_eq!(hist[0].superseded_at_ms, 2_000);
697 assert_eq!(hist[0].superseded_by, uid.to_string());
698 assert_eq!(hist[0].change_reason, "tightened schema");
699 }
700
701 #[test]
702 fn explicit_deny_blocks_mutation_even_for_admin() {
703 let (store, uid) = store_with_admin();
704 store.put_policy(allow_all_registry("p-allow")).unwrap();
705 store.put_policy(deny_all_registry("p-deny")).unwrap();
706 store
707 .attach_policy(
708 super::super::store::PrincipalRef::User(uid.clone()),
709 "p-allow",
710 )
711 .unwrap();
712 store
713 .attach_policy(
714 super::super::store::PrincipalRef::User(uid.clone()),
715 "p-deny",
716 )
717 .unwrap();
718 let reg = ConfigRegistry::new();
719
720 let err = reg
721 .register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
722 .unwrap_err();
723 assert!(
724 matches!(err, RegistryError::Unauthorized { .. }),
725 "got {err:?}"
726 );
727 assert!(reg.get_active("k").is_none());
728 }
729
730 #[test]
731 fn ordinary_user_without_registry_policy_is_denied() {
732 let store = std::sync::Arc::new(AuthStore::new(AuthConfig::default()));
735 store.create_user("alice", "p", Role::Write).unwrap();
736 let uid = UserId::platform("alice");
737 store
739 .put_policy(
740 Policy::from_json_str(
741 r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.x"]}]}"#,
742 )
743 .unwrap(),
744 )
745 .unwrap();
746 let mut c = ctx();
747 c.principal_is_admin_role = false;
748 let reg = ConfigRegistry::new();
749 let err = reg
750 .register(&store, &uid, &c, sample_draft("k"), 1_000)
751 .unwrap_err();
752 assert!(
753 matches!(err, RegistryError::Unauthorized { .. }),
754 "got {err:?}"
755 );
756 }
757
758 #[test]
759 fn immutable_entries_reject_supersede() {
760 let (store, uid) = store_with_admin();
761 store.put_policy(allow_all_registry("p-allow")).unwrap();
762 store
763 .attach_policy(
764 super::super::store::PrincipalRef::User(uid.clone()),
765 "p-allow",
766 )
767 .unwrap();
768 let reg = ConfigRegistry::new();
769
770 let mut draft = sample_draft("k");
771 draft.mutability = Mutability::Immutable;
772 reg.register(&store, &uid, &ctx(), draft, 1_000).unwrap();
773
774 let err = reg
775 .supersede(
776 &store,
777 &uid,
778 &ctx(),
779 sample_draft("k"),
780 "should fail",
781 2_000,
782 )
783 .unwrap_err();
784 assert!(matches!(err, RegistryError::Immutable(_)), "got {err:?}");
785 assert_eq!(reg.get_active("k").unwrap().version, 1);
786 assert!(reg.history("k").is_empty());
787 }
788
789 #[test]
790 fn register_twice_is_already_registered() {
791 let (store, uid) = store_with_admin();
792 store.put_policy(allow_all_registry("p-allow")).unwrap();
793 store
794 .attach_policy(
795 super::super::store::PrincipalRef::User(uid.clone()),
796 "p-allow",
797 )
798 .unwrap();
799 let reg = ConfigRegistry::new();
800 reg.register(&store, &uid, &ctx(), sample_draft("k"), 1_000)
801 .unwrap();
802 let err = reg
803 .register(&store, &uid, &ctx(), sample_draft("k"), 1_500)
804 .unwrap_err();
805 assert!(
806 matches!(err, RegistryError::AlreadyRegistered(_)),
807 "got {err:?}"
808 );
809 }
810
811 #[test]
812 fn registry_is_not_exposed_as_sql_collection() {
813 let reg = ConfigRegistry::new();
819 let _ = reg.list_active();
821 let _ = reg.history("k");
822 }
826}