1use super::policies::{EvalContext, ResourceRef};
26use super::registry::{ConfigRegistry, ConfigRegistryEntry, EvidenceRequirement, Mutability};
27use super::store::AuthStore;
28use super::UserId;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ManagedConfigDecision {
33 PassThrough { key: String },
36 Allow {
40 entry_id: String,
41 entry_version: u64,
42 resource_type: String,
43 managed: bool,
44 mutability: Mutability,
45 matched_action: String,
46 matched_resource: String,
47 evidence: EvidenceRequirement,
48 },
49 Deny {
52 entry_id: String,
53 entry_version: u64,
54 resource_type: String,
55 managed: bool,
56 mutability: Mutability,
57 matched_action: String,
58 matched_resource: String,
59 reason: DenyReason,
60 },
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum DenyReason {
67 PolicyDenied,
70}
71
72impl std::fmt::Display for DenyReason {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 Self::PolicyDenied => write!(f, "managed config required policy permission was denied"),
76 }
77 }
78}
79
80impl ManagedConfigDecision {
81 pub fn permitted(&self) -> bool {
85 matches!(self, Self::PassThrough { .. } | Self::Allow { .. })
86 }
87}
88
89pub const RESOURCE_TYPE_CONFIG_KEY: &str = "config_key";
92pub const RESOURCE_TYPE_CONFIG_NAMESPACE: &str = "config_namespace";
96
97pub struct ManagedConfigGate<'a> {
99 registry: &'a ConfigRegistry,
100}
101
102impl<'a> ManagedConfigGate<'a> {
103 pub fn new(registry: &'a ConfigRegistry) -> Self {
104 Self { registry }
105 }
106
107 pub fn check_write(
111 &self,
112 auth: &AuthStore,
113 actor: &UserId,
114 ctx: &EvalContext,
115 key: &str,
116 ) -> ManagedConfigDecision {
117 let Some(entry) = self.lookup_governing_entry(key) else {
118 return ManagedConfigDecision::PassThrough {
119 key: key.to_string(),
120 };
121 };
122 if !entry.managed {
123 return ManagedConfigDecision::PassThrough {
126 key: key.to_string(),
127 };
128 }
129
130 let (kind, name) = split_required_resource(&entry.required_resource);
131 let matched_resource = format!("{kind}:{name}");
132
133 let resource = ResourceRef::new(kind, name);
134 if !auth.check_policy_authz(actor, &entry.required_action, &resource, ctx) {
135 return ManagedConfigDecision::Deny {
136 entry_id: entry.id.clone(),
137 entry_version: entry.version,
138 resource_type: entry.resource_type.clone(),
139 managed: entry.managed,
140 mutability: entry.mutability,
141 matched_action: entry.required_action.clone(),
142 matched_resource,
143 reason: DenyReason::PolicyDenied,
144 };
145 }
146
147 ManagedConfigDecision::Allow {
148 entry_id: entry.id.clone(),
149 entry_version: entry.version,
150 resource_type: entry.resource_type.clone(),
151 managed: entry.managed,
152 mutability: entry.mutability,
153 matched_action: entry.required_action.clone(),
154 matched_resource,
155 evidence: entry.evidence_requirement,
156 }
157 }
158
159 pub fn check_red_config_write(
164 &self,
165 auth: &AuthStore,
166 actor: &UserId,
167 ctx: &EvalContext,
168 content_key: &str,
169 ) -> ManagedConfigDecision {
170 let candidates = [
171 content_key.to_string(),
172 format!("red.{content_key}"),
173 format!("red.config.{content_key}"),
174 ];
175 let mut allowed = None;
176 for candidate in &candidates {
177 match self.check_write(auth, actor, ctx, candidate) {
178 denied @ ManagedConfigDecision::Deny { .. } => return denied,
179 decision @ ManagedConfigDecision::Allow { .. } => {
180 if allowed.is_none() {
181 allowed = Some(decision);
182 }
183 }
184 ManagedConfigDecision::PassThrough { .. } => {}
185 }
186 }
187 allowed.unwrap_or_else(|| ManagedConfigDecision::PassThrough {
188 key: content_key.to_string(),
189 })
190 }
191
192 fn lookup_governing_entry(&self, key: &str) -> Option<ConfigRegistryEntry> {
201 if let Some(e) = self.registry.get_active(key) {
202 if e.resource_type == RESOURCE_TYPE_CONFIG_KEY {
203 return Some(e);
204 }
205 }
206 let mut cursor = key;
207 while let Some(idx) = cursor.rfind('.') {
208 cursor = &cursor[..idx];
209 if let Some(e) = self.registry.get_active(cursor) {
210 if e.resource_type == RESOURCE_TYPE_CONFIG_NAMESPACE {
211 return Some(e);
212 }
213 }
214 }
215 None
216 }
217}
218
219fn split_required_resource(s: &str) -> (&str, &str) {
224 match s.split_once(':') {
225 Some((k, n)) if !k.is_empty() => (k, n),
226 _ => ("config", s),
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::auth::policies::Policy;
234 use crate::auth::registry::{ConfigRegistryDraft, Mutability, Sensitivity};
235 use crate::auth::store::PrincipalRef;
236 use crate::auth::{AuthConfig, Role};
237 use std::sync::Arc;
238
239 fn store() -> Arc<AuthStore> {
240 Arc::new(AuthStore::new(AuthConfig::default()))
241 }
242
243 fn registry_admin_ctx() -> EvalContext {
244 EvalContext {
249 principal_tenant: None,
250 current_tenant: None,
251 peer_ip: None,
252 mfa_present: false,
253 now_ms: 1_700_000_000_000,
254 principal_is_admin_role: true,
255 principal_is_platform_scoped: true,
256 }
257 }
258
259 fn allow_all_registry(id: &str) -> Policy {
260 Policy::from_json_str(&format!(
261 r#"{{
262 "id": "{id}",
263 "version": 1,
264 "statements": [{{
265 "effect": "allow",
266 "actions": ["red.registry:*"],
267 "resources": ["registry:*"]
268 }}]
269 }}"#
270 ))
271 .unwrap()
272 }
273
274 fn allow_config_write(id: &str, resource_glob: &str) -> Policy {
275 Policy::from_json_str(&format!(
276 r#"{{
277 "id": "{id}",
278 "version": 1,
279 "statements": [{{
280 "effect": "allow",
281 "actions": ["config:write"],
282 "resources": ["{resource_glob}"]
283 }}]
284 }}"#
285 ))
286 .unwrap()
287 }
288
289 fn deny_config_write(id: &str, resource_glob: &str) -> Policy {
290 Policy::from_json_str(&format!(
291 r#"{{
292 "id": "{id}",
293 "version": 1,
294 "statements": [{{
295 "effect": "deny",
296 "actions": ["config:write"],
297 "resources": ["{resource_glob}"]
298 }}]
299 }}"#
300 ))
301 .unwrap()
302 }
303
304 fn allow_all_config(id: &str) -> Policy {
305 Policy::from_json_str(&format!(
306 r#"{{
307 "id": "{id}",
308 "version": 1,
309 "statements": [{{
310 "effect": "allow",
311 "actions": ["config:*"],
312 "resources": ["*"]
313 }}]
314 }}"#
315 ))
316 .unwrap()
317 }
318
319 fn seed_registry_admin(store: &Arc<AuthStore>) -> UserId {
320 store.create_user("seeder", "p", Role::Admin).unwrap();
321 let uid = UserId::platform("seeder");
322 store.put_policy(allow_all_registry("p-reg-allow")).unwrap();
323 store
324 .attach_policy(PrincipalRef::User(uid.clone()), "p-reg-allow")
325 .unwrap();
326 uid
327 }
328
329 fn managed_key_draft(id: &str) -> ConfigRegistryDraft {
330 ConfigRegistryDraft {
331 id: id.to_string(),
332 resource_type: RESOURCE_TYPE_CONFIG_KEY.into(),
333 schema: "string".into(),
334 mutability: Mutability::MutableViaGovernance,
335 sensitivity: Sensitivity::Internal,
336 managed: true,
337 required_action: "config:write".into(),
338 required_resource: format!("config:{id}"),
339 evidence_requirement: EvidenceRequirement::Metadata,
340 }
341 }
342
343 fn managed_namespace_draft(id: &str) -> ConfigRegistryDraft {
344 ConfigRegistryDraft {
345 id: id.to_string(),
346 resource_type: RESOURCE_TYPE_CONFIG_NAMESPACE.into(),
347 schema: "namespace".into(),
348 mutability: Mutability::MutableViaGovernance,
349 sensitivity: Sensitivity::Internal,
350 managed: true,
351 required_action: "config:write".into(),
352 required_resource: format!("config:{id}.*"),
353 evidence_requirement: EvidenceRequirement::Metadata,
354 }
355 }
356
357 fn unmanaged_key_draft(id: &str) -> ConfigRegistryDraft {
358 let mut d = managed_key_draft(id);
359 d.managed = false;
360 d
361 }
362
363 #[test]
366 fn registry_entry_can_mark_a_config_key_as_managed() {
367 let store = store();
370 let seeder = seed_registry_admin(&store);
371 let reg = ConfigRegistry::new();
372 let entry = reg
373 .register(
374 &store,
375 &seeder,
376 ®istry_admin_ctx(),
377 managed_key_draft("red.config.audit.enabled"),
378 1_000,
379 )
380 .unwrap();
381 assert!(entry.managed, "draft.managed must propagate to entry");
382
383 let unmanaged = reg
385 .register(
386 &store,
387 &seeder,
388 ®istry_admin_ctx(),
389 unmanaged_key_draft("app.feature_flag"),
390 1_000,
391 )
392 .unwrap();
393 assert!(!unmanaged.managed);
394 }
395
396 #[test]
397 fn canonical_content_keys_match_both_legacy_registry_prefixes() {
398 for (registry_id, content_key) in [
399 ("red.ai", "ai.default.provider"),
400 ("red.config.audit", "audit.enabled"),
401 ] {
402 let store = store();
403 let seeder = seed_registry_admin(&store);
404 store.create_user("alice", "p", Role::Read).unwrap();
405 let alice = UserId::platform("alice");
406 let registry = ConfigRegistry::new();
407 registry
408 .register(
409 &store,
410 &seeder,
411 ®istry_admin_ctx(),
412 managed_namespace_draft(registry_id),
413 1_000,
414 )
415 .unwrap();
416 let ctx = EvalContext {
417 principal_tenant: None,
418 current_tenant: None,
419 peer_ip: None,
420 mfa_present: false,
421 now_ms: 1_700_000_000_001,
422 principal_is_admin_role: false,
423 principal_is_platform_scoped: true,
424 };
425
426 let decision = ManagedConfigGate::new(®istry).check_red_config_write(
427 &store,
428 &alice,
429 &ctx,
430 content_key,
431 );
432 assert!(
433 matches!(
434 decision,
435 ManagedConfigDecision::Deny { ref entry_id, .. }
436 if entry_id == registry_id
437 ),
438 "canonical key {content_key} bypassed managed registry {registry_id}: {decision:?}"
439 );
440 }
441 }
442
443 #[test]
446 fn ordinary_allow_all_user_is_allowed_on_managed_key() {
447 let store = store();
451 let seeder = seed_registry_admin(&store);
452 store.create_user("alice", "p", Role::Admin).unwrap();
453 let alice = UserId::platform("alice");
454 store.put_policy(allow_all_config("p-allow-cfg")).unwrap();
455 store
456 .attach_policy(PrincipalRef::User(alice.clone()), "p-allow-cfg")
457 .unwrap();
458
459 let reg = ConfigRegistry::new();
460 reg.register(
461 &store,
462 &seeder,
463 ®istry_admin_ctx(),
464 managed_key_draft("red.config.audit.enabled"),
465 1_000,
466 )
467 .unwrap();
468
469 let gate = ManagedConfigGate::new(®);
470 let ctx = EvalContext {
471 principal_tenant: None,
472 current_tenant: None,
473 peer_ip: None,
474 mfa_present: false,
475 now_ms: 1_700_000_000_001,
476 principal_is_admin_role: true,
477 principal_is_platform_scoped: true,
478 };
479 let decision = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
480 assert!(
481 matches!(decision, ManagedConfigDecision::Allow { .. }),
482 "got {decision:?}"
483 );
484 }
485
486 #[test]
489 fn caller_with_matching_policy_is_allowed() {
490 let store = store();
491 let seeder = seed_registry_admin(&store);
492 store
493 .create_admin_user("ops", "p", Role::Admin, None)
494 .expect("create user");
495 let ops = UserId::platform("ops");
496 store
497 .put_policy(allow_config_write(
498 "p-cfg-write",
499 "config:red.config.audit.*",
500 ))
501 .unwrap();
502 store
503 .attach_policy(PrincipalRef::User(ops.clone()), "p-cfg-write")
504 .unwrap();
505
506 let reg = ConfigRegistry::new();
507 reg.register(
508 &store,
509 &seeder,
510 ®istry_admin_ctx(),
511 managed_key_draft("red.config.audit.enabled"),
512 1_000,
513 )
514 .unwrap();
515
516 let gate = ManagedConfigGate::new(®);
517 let ctx = EvalContext {
518 principal_tenant: None,
519 current_tenant: None,
520 peer_ip: None,
521 mfa_present: false,
522 now_ms: 1_700_000_000_002,
523 principal_is_admin_role: true,
524 principal_is_platform_scoped: true,
525 };
526 let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
527 assert!(
528 matches!(decision, ManagedConfigDecision::Allow { .. }),
529 "got {decision:?}"
530 );
531 assert!(decision.permitted());
532 }
533
534 #[test]
535 fn caller_without_matching_policy_is_policy_denied() {
536 let store = store();
539 let seeder = seed_registry_admin(&store);
540 store
541 .create_admin_user("ops", "p", Role::Write, None)
542 .unwrap();
543 let ops = UserId::platform("ops");
544 store
547 .put_policy(
548 Policy::from_json_str(
549 r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.x"]}]}"#,
550 )
551 .unwrap(),
552 )
553 .unwrap();
554 store
555 .attach_policy(PrincipalRef::User(ops.clone()), "p-unrelated")
556 .unwrap();
557
558 let reg = ConfigRegistry::new();
559 reg.register(
560 &store,
561 &seeder,
562 ®istry_admin_ctx(),
563 managed_key_draft("red.config.audit.enabled"),
564 1_000,
565 )
566 .unwrap();
567
568 let gate = ManagedConfigGate::new(®);
569 let ctx = EvalContext {
570 principal_tenant: None,
571 current_tenant: None,
572 peer_ip: None,
573 mfa_present: false,
574 now_ms: 1_700_000_000_003,
575 principal_is_admin_role: false,
576 principal_is_platform_scoped: true,
577 };
578 let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
579 match decision {
580 ManagedConfigDecision::Deny { reason, .. } => {
581 assert!(matches!(reason, DenyReason::PolicyDenied), "got {reason:?}");
582 }
583 other => panic!("expected Deny(PolicyDenied), got {other:?}"),
584 }
585 }
586
587 #[test]
588 fn explicit_deny_overrides_allow() {
589 let store = store();
592 let seeder = seed_registry_admin(&store);
593 store
594 .create_admin_user("ops", "p", Role::Admin, None)
595 .unwrap();
596 let ops = UserId::platform("ops");
597 store.put_policy(allow_all_config("p-allow")).unwrap();
598 store
599 .put_policy(deny_config_write("p-deny", "config:red.config.audit.*"))
600 .unwrap();
601 store
602 .attach_policy(PrincipalRef::User(ops.clone()), "p-allow")
603 .unwrap();
604 store
605 .attach_policy(PrincipalRef::User(ops.clone()), "p-deny")
606 .unwrap();
607
608 let reg = ConfigRegistry::new();
609 reg.register(
610 &store,
611 &seeder,
612 ®istry_admin_ctx(),
613 managed_key_draft("red.config.audit.enabled"),
614 1_000,
615 )
616 .unwrap();
617
618 let gate = ManagedConfigGate::new(®);
619 let ctx = EvalContext {
620 principal_tenant: None,
621 current_tenant: None,
622 peer_ip: None,
623 mfa_present: false,
624 now_ms: 1_700_000_000_004,
625 principal_is_admin_role: true,
626 principal_is_platform_scoped: true,
627 };
628 let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
629 match decision {
630 ManagedConfigDecision::Deny { reason, .. } => {
631 assert!(matches!(reason, DenyReason::PolicyDenied), "got {reason:?}");
632 }
633 other => panic!("expected Deny(PolicyDenied), got {other:?}"),
634 }
635 }
636
637 #[test]
640 fn deny_carries_resource_and_reason_for_control_event() {
641 let store = store();
644 let seeder = seed_registry_admin(&store);
645 store.create_user("alice", "p", Role::Admin).unwrap();
646 let alice = UserId::platform("alice");
647
648 let reg = ConfigRegistry::new();
649 let mut draft = managed_key_draft("red.config.backup.retention_days");
650 draft.evidence_requirement = EvidenceRequirement::Full;
651 reg.register(&store, &seeder, ®istry_admin_ctx(), draft, 1_000)
652 .unwrap();
653
654 let gate = ManagedConfigGate::new(®);
655 let ctx = EvalContext {
656 principal_tenant: None,
657 current_tenant: None,
658 peer_ip: None,
659 mfa_present: false,
660 now_ms: 1_700_000_000_005,
661 principal_is_admin_role: true,
662 principal_is_platform_scoped: true,
663 };
664 let decision = gate.check_write(&store, &alice, &ctx, "red.config.backup.retention_days");
665 match decision {
666 ManagedConfigDecision::Deny {
667 entry_id,
668 entry_version,
669 matched_action,
670 matched_resource,
671 reason,
672 ..
673 } => {
674 assert_eq!(entry_id, "red.config.backup.retention_days");
675 assert_eq!(entry_version, 1);
676 assert_eq!(matched_action, "config:write");
677 assert_eq!(matched_resource, "config:red.config.backup.retention_days");
678 let rendered = reason.to_string();
680 assert!(
681 rendered.contains("policy permission"),
682 "reason should be Control-Event-renderable: {rendered}"
683 );
684 }
685 other => panic!("expected Deny, got {other:?}"),
686 }
687 }
688
689 #[test]
692 fn non_managed_application_config_passes_through() {
693 let store = store();
695 let _ = seed_registry_admin(&store);
696 let reg = ConfigRegistry::new();
697 let gate = ManagedConfigGate::new(®);
698 let ctx = EvalContext::default();
699 let alice = UserId::platform("alice");
700 let d = gate.check_write(&store, &alice, &ctx, "app.feature_flag");
701 assert!(matches!(d, ManagedConfigDecision::PassThrough { .. }));
702 assert!(d.permitted());
703 }
704
705 #[test]
706 fn unmanaged_registry_entry_also_passes_through() {
707 let store = store();
711 let seeder = seed_registry_admin(&store);
712 let reg = ConfigRegistry::new();
713 reg.register(
714 &store,
715 &seeder,
716 ®istry_admin_ctx(),
717 unmanaged_key_draft("app.feature_flag"),
718 1_000,
719 )
720 .unwrap();
721
722 let gate = ManagedConfigGate::new(®);
723 let ctx = EvalContext::default();
724 let alice = UserId::platform("alice");
725 let d = gate.check_write(&store, &alice, &ctx, "app.feature_flag");
726 assert!(matches!(d, ManagedConfigDecision::PassThrough { .. }));
727 }
728
729 #[test]
732 fn namespace_entry_governs_descendant_keys() {
733 let store = store();
736 let seeder = seed_registry_admin(&store);
737 let reg = ConfigRegistry::new();
738 reg.register(
739 &store,
740 &seeder,
741 ®istry_admin_ctx(),
742 managed_namespace_draft("red.config.audit"),
743 1_000,
744 )
745 .unwrap();
746
747 let gate = ManagedConfigGate::new(®);
748 let alice = UserId::platform("alice");
749 let ctx = EvalContext {
750 principal_is_platform_scoped: true,
751 ..EvalContext::default()
752 };
753
754 for key in [
755 "red.config.audit.enabled",
756 "red.config.audit.sink.kafka.brokers",
757 ] {
758 let d = gate.check_write(&store, &alice, &ctx, key);
759 match d {
760 ManagedConfigDecision::Deny {
761 entry_id,
762 matched_resource,
763 ..
764 } => {
765 assert_eq!(entry_id, "red.config.audit");
766 assert_eq!(matched_resource, "config:red.config.audit.*");
767 }
768 other => panic!("expected Deny for {key}, got {other:?}"),
769 }
770 }
771
772 let d = gate.check_write(&store, &alice, &ctx, "red.config.storage.tier");
774 assert!(
775 matches!(d, ManagedConfigDecision::PassThrough { .. }),
776 "got {d:?}"
777 );
778 }
779
780 #[test]
781 fn exact_key_entry_wins_over_namespace_entry() {
782 let store = store();
785 let seeder = seed_registry_admin(&store);
786 let reg = ConfigRegistry::new();
787 let mut ns = managed_namespace_draft("red.config.audit");
789 ns.managed = false;
790 reg.register(&store, &seeder, ®istry_admin_ctx(), ns, 1_000)
791 .unwrap();
792 reg.register(
794 &store,
795 &seeder,
796 ®istry_admin_ctx(),
797 managed_key_draft("red.config.audit.enabled"),
798 1_000,
799 )
800 .unwrap();
801
802 let gate = ManagedConfigGate::new(®);
803 let alice = UserId::platform("alice");
804 let ctx = EvalContext {
805 principal_is_platform_scoped: true,
806 ..EvalContext::default()
807 };
808 let d = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
810 assert!(matches!(d, ManagedConfigDecision::Deny { .. }), "got {d:?}");
811 let d = gate.check_write(&store, &alice, &ctx, "red.config.audit.sink");
814 assert!(
815 matches!(d, ManagedConfigDecision::PassThrough { .. }),
816 "got {d:?}"
817 );
818 }
819
820 #[test]
821 fn entry_with_unrelated_resource_type_does_not_gate_config_writes() {
822 let store = store();
826 let seeder = seed_registry_admin(&store);
827 let reg = ConfigRegistry::new();
828 let mut d = managed_key_draft("red.config.audit.enabled");
829 d.resource_type = "vault_path".into();
830 reg.register(&store, &seeder, ®istry_admin_ctx(), d, 1_000)
831 .unwrap();
832
833 let gate = ManagedConfigGate::new(®);
834 let alice = UserId::platform("alice");
835 let ctx = EvalContext::default();
836 let dec = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
837 assert!(
838 matches!(dec, ManagedConfigDecision::PassThrough { .. }),
839 "got {dec:?}"
840 );
841 }
842
843 #[test]
844 fn split_required_resource_handles_bare_string() {
845 assert_eq!(
846 split_required_resource("config:red.config.audit.enabled"),
847 ("config", "red.config.audit.enabled")
848 );
849 assert_eq!(
850 split_required_resource("red.config.audit.enabled"),
851 ("config", "red.config.audit.enabled")
852 );
853 assert_eq!(
854 split_required_resource(":only-name"),
855 ("config", ":only-name")
856 );
857 }
858}