1use super::policies::{EvalContext, ResourceRef};
32use super::registry::{ConfigRegistry, ConfigRegistryEntry, EvidenceRequirement};
33use super::store::AuthStore;
34use super::UserId;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ManagedConfigDecision {
39 PassThrough { key: String },
42 Allow {
47 entry_id: String,
48 entry_version: u64,
49 matched_action: String,
50 matched_resource: String,
51 evidence: EvidenceRequirement,
52 },
53 Deny {
56 entry_id: String,
57 entry_version: u64,
58 matched_action: String,
59 matched_resource: String,
60 reason: DenyReason,
61 },
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum DenyReason {
70 NotStructurallyEligible {
73 is_system_owned: bool,
74 is_platform_scoped: bool,
75 },
76 PolicyDenied,
79}
80
81impl std::fmt::Display for DenyReason {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 Self::NotStructurallyEligible {
85 is_system_owned,
86 is_platform_scoped,
87 } => write!(
88 f,
89 "caller is not structurally eligible for managed config \
90 (system_owned={is_system_owned}, platform_scoped={is_platform_scoped})"
91 ),
92 Self::PolicyDenied => write!(f, "managed config required policy permission was denied"),
93 }
94 }
95}
96
97impl ManagedConfigDecision {
98 pub fn permitted(&self) -> bool {
102 matches!(self, Self::PassThrough { .. } | Self::Allow { .. })
103 }
104}
105
106pub const RESOURCE_TYPE_CONFIG_KEY: &str = "config_key";
109pub const RESOURCE_TYPE_CONFIG_NAMESPACE: &str = "config_namespace";
113
114pub struct ManagedConfigGate<'a> {
116 registry: &'a ConfigRegistry,
117}
118
119impl<'a> ManagedConfigGate<'a> {
120 pub fn new(registry: &'a ConfigRegistry) -> Self {
121 Self { registry }
122 }
123
124 pub fn check_write(
128 &self,
129 auth: &AuthStore,
130 actor: &UserId,
131 ctx: &EvalContext,
132 key: &str,
133 ) -> ManagedConfigDecision {
134 let Some(entry) = self.lookup_governing_entry(key) else {
135 return ManagedConfigDecision::PassThrough {
136 key: key.to_string(),
137 };
138 };
139 if !entry.managed {
140 return ManagedConfigDecision::PassThrough {
143 key: key.to_string(),
144 };
145 }
146
147 let (kind, name) = split_required_resource(&entry.required_resource);
148 let matched_resource = format!("{kind}:{name}");
149
150 if !(ctx.principal_is_system_owned && ctx.principal_is_platform_scoped) {
151 return ManagedConfigDecision::Deny {
152 entry_id: entry.id.clone(),
153 entry_version: entry.version,
154 matched_action: entry.required_action.clone(),
155 matched_resource,
156 reason: DenyReason::NotStructurallyEligible {
157 is_system_owned: ctx.principal_is_system_owned,
158 is_platform_scoped: ctx.principal_is_platform_scoped,
159 },
160 };
161 }
162
163 let resource = ResourceRef::new(kind, name);
164 if !auth.check_policy_authz(actor, &entry.required_action, &resource, ctx) {
165 return ManagedConfigDecision::Deny {
166 entry_id: entry.id.clone(),
167 entry_version: entry.version,
168 matched_action: entry.required_action.clone(),
169 matched_resource,
170 reason: DenyReason::PolicyDenied,
171 };
172 }
173
174 ManagedConfigDecision::Allow {
175 entry_id: entry.id,
176 entry_version: entry.version,
177 matched_action: entry.required_action.clone(),
178 matched_resource,
179 evidence: entry.evidence_requirement,
180 }
181 }
182
183 fn lookup_governing_entry(&self, key: &str) -> Option<ConfigRegistryEntry> {
192 if let Some(e) = self.registry.get_active(key) {
193 if e.resource_type == RESOURCE_TYPE_CONFIG_KEY {
194 return Some(e);
195 }
196 }
197 let mut cursor = key;
198 while let Some(idx) = cursor.rfind('.') {
199 cursor = &cursor[..idx];
200 if let Some(e) = self.registry.get_active(cursor) {
201 if e.resource_type == RESOURCE_TYPE_CONFIG_NAMESPACE {
202 return Some(e);
203 }
204 }
205 }
206 None
207 }
208}
209
210fn split_required_resource(s: &str) -> (&str, &str) {
215 match s.split_once(':') {
216 Some((k, n)) if !k.is_empty() => (k, n),
217 _ => ("config", s),
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::auth::policies::Policy;
225 use crate::auth::registry::{ConfigRegistryDraft, Mutability, Sensitivity};
226 use crate::auth::store::PrincipalRef;
227 use crate::auth::{AuthConfig, Role};
228 use std::sync::Arc;
229
230 fn store() -> Arc<AuthStore> {
231 Arc::new(AuthStore::new(AuthConfig::default()))
232 }
233
234 fn registry_admin_ctx() -> EvalContext {
235 EvalContext {
240 principal_tenant: None,
241 current_tenant: None,
242 peer_ip: None,
243 mfa_present: false,
244 now_ms: 1_700_000_000_000,
245 principal_is_admin_role: true,
246 principal_is_system_owned: false,
247 principal_is_platform_scoped: true,
248 }
249 }
250
251 fn allow_all_registry(id: &str) -> Policy {
252 Policy::from_json_str(&format!(
253 r#"{{
254 "id": "{id}",
255 "version": 1,
256 "statements": [{{
257 "effect": "allow",
258 "actions": ["red.registry:*"],
259 "resources": ["registry:*"]
260 }}]
261 }}"#
262 ))
263 .unwrap()
264 }
265
266 fn allow_config_write(id: &str, resource_glob: &str) -> Policy {
267 Policy::from_json_str(&format!(
268 r#"{{
269 "id": "{id}",
270 "version": 1,
271 "statements": [{{
272 "effect": "allow",
273 "actions": ["config:write"],
274 "resources": ["{resource_glob}"]
275 }}]
276 }}"#
277 ))
278 .unwrap()
279 }
280
281 fn deny_config_write(id: &str, resource_glob: &str) -> Policy {
282 Policy::from_json_str(&format!(
283 r#"{{
284 "id": "{id}",
285 "version": 1,
286 "statements": [{{
287 "effect": "deny",
288 "actions": ["config:write"],
289 "resources": ["{resource_glob}"]
290 }}]
291 }}"#
292 ))
293 .unwrap()
294 }
295
296 fn allow_all_config(id: &str) -> Policy {
297 Policy::from_json_str(&format!(
298 r#"{{
299 "id": "{id}",
300 "version": 1,
301 "statements": [{{
302 "effect": "allow",
303 "actions": ["config:*"],
304 "resources": ["*"]
305 }}]
306 }}"#
307 ))
308 .unwrap()
309 }
310
311 fn seed_registry_admin(store: &Arc<AuthStore>) -> UserId {
312 store.create_user("seeder", "p", Role::Admin).unwrap();
313 let uid = UserId::platform("seeder");
314 store.put_policy(allow_all_registry("p-reg-allow")).unwrap();
315 store
316 .attach_policy(PrincipalRef::User(uid.clone()), "p-reg-allow")
317 .unwrap();
318 uid
319 }
320
321 fn managed_key_draft(id: &str) -> ConfigRegistryDraft {
322 ConfigRegistryDraft {
323 id: id.to_string(),
324 resource_type: RESOURCE_TYPE_CONFIG_KEY.into(),
325 schema: "string".into(),
326 mutability: Mutability::MutableViaGovernance,
327 sensitivity: Sensitivity::Internal,
328 managed: true,
329 required_action: "config:write".into(),
330 required_resource: format!("config:{id}"),
331 evidence_requirement: EvidenceRequirement::Metadata,
332 }
333 }
334
335 fn managed_namespace_draft(id: &str) -> ConfigRegistryDraft {
336 ConfigRegistryDraft {
337 id: id.to_string(),
338 resource_type: RESOURCE_TYPE_CONFIG_NAMESPACE.into(),
339 schema: "namespace".into(),
340 mutability: Mutability::MutableViaGovernance,
341 sensitivity: Sensitivity::Internal,
342 managed: true,
343 required_action: "config:write".into(),
344 required_resource: format!("config:{id}.*"),
345 evidence_requirement: EvidenceRequirement::Metadata,
346 }
347 }
348
349 fn unmanaged_key_draft(id: &str) -> ConfigRegistryDraft {
350 let mut d = managed_key_draft(id);
351 d.managed = false;
352 d
353 }
354
355 #[test]
358 fn registry_entry_can_mark_a_config_key_as_managed() {
359 let store = store();
362 let seeder = seed_registry_admin(&store);
363 let reg = ConfigRegistry::new();
364 let entry = reg
365 .register(
366 &store,
367 &seeder,
368 ®istry_admin_ctx(),
369 managed_key_draft("red.config.audit.enabled"),
370 1_000,
371 )
372 .unwrap();
373 assert!(entry.managed, "draft.managed must propagate to entry");
374
375 let unmanaged = reg
377 .register(
378 &store,
379 &seeder,
380 ®istry_admin_ctx(),
381 unmanaged_key_draft("app.feature_flag"),
382 1_000,
383 )
384 .unwrap();
385 assert!(!unmanaged.managed);
386 }
387
388 #[test]
391 fn ordinary_allow_all_user_is_denied_on_managed_key() {
392 let store = store();
396 let seeder = seed_registry_admin(&store);
397 store.create_user("alice", "p", Role::Admin).unwrap();
398 let alice = UserId::platform("alice");
399 store.put_policy(allow_all_config("p-allow-cfg")).unwrap();
400 store
401 .attach_policy(PrincipalRef::User(alice.clone()), "p-allow-cfg")
402 .unwrap();
403
404 let reg = ConfigRegistry::new();
405 reg.register(
406 &store,
407 &seeder,
408 ®istry_admin_ctx(),
409 managed_key_draft("red.config.audit.enabled"),
410 1_000,
411 )
412 .unwrap();
413
414 let gate = ManagedConfigGate::new(®);
415 let ctx = EvalContext {
416 principal_tenant: None,
417 current_tenant: None,
418 peer_ip: None,
419 mfa_present: false,
420 now_ms: 1_700_000_000_001,
421 principal_is_admin_role: true,
422 principal_is_system_owned: false, principal_is_platform_scoped: true,
424 };
425 let decision = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
426 match decision {
427 ManagedConfigDecision::Deny {
428 entry_id,
429 matched_action,
430 matched_resource,
431 reason,
432 ..
433 } => {
434 assert_eq!(entry_id, "red.config.audit.enabled");
435 assert_eq!(matched_action, "config:write");
436 assert_eq!(matched_resource, "config:red.config.audit.enabled");
437 assert!(
438 matches!(
439 reason,
440 DenyReason::NotStructurallyEligible {
441 is_system_owned: false,
442 is_platform_scoped: true
443 }
444 ),
445 "got {reason:?}"
446 );
447 }
448 other => panic!("expected Deny, got {other:?}"),
449 }
450 }
451
452 #[test]
455 fn structurally_eligible_caller_with_matching_policy_is_allowed() {
456 let store = store();
457 let seeder = seed_registry_admin(&store);
458 store
460 .create_system_user("ops", "p", Role::Admin, None)
461 .expect("create system-owned user");
462 let ops = UserId::platform("ops");
463 store
464 .put_policy(allow_config_write(
465 "p-cfg-write",
466 "config:red.config.audit.*",
467 ))
468 .unwrap();
469 store
470 .attach_policy(PrincipalRef::User(ops.clone()), "p-cfg-write")
471 .unwrap();
472
473 let reg = ConfigRegistry::new();
474 reg.register(
475 &store,
476 &seeder,
477 ®istry_admin_ctx(),
478 managed_key_draft("red.config.audit.enabled"),
479 1_000,
480 )
481 .unwrap();
482
483 let gate = ManagedConfigGate::new(®);
484 let ctx = EvalContext {
485 principal_tenant: None,
486 current_tenant: None,
487 peer_ip: None,
488 mfa_present: false,
489 now_ms: 1_700_000_000_002,
490 principal_is_admin_role: true,
491 principal_is_system_owned: true,
492 principal_is_platform_scoped: true,
493 };
494 let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
495 assert!(
496 matches!(decision, ManagedConfigDecision::Allow { .. }),
497 "got {decision:?}"
498 );
499 assert!(decision.permitted());
500 }
501
502 #[test]
503 fn structurally_eligible_caller_without_matching_policy_is_policy_denied() {
504 let store = store();
507 let seeder = seed_registry_admin(&store);
508 store
509 .create_system_user("ops", "p", Role::Write, None)
510 .unwrap();
511 let ops = UserId::platform("ops");
512 store
515 .put_policy(
516 Policy::from_json_str(
517 r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.x"]}]}"#,
518 )
519 .unwrap(),
520 )
521 .unwrap();
522 store
523 .attach_policy(PrincipalRef::User(ops.clone()), "p-unrelated")
524 .unwrap();
525
526 let reg = ConfigRegistry::new();
527 reg.register(
528 &store,
529 &seeder,
530 ®istry_admin_ctx(),
531 managed_key_draft("red.config.audit.enabled"),
532 1_000,
533 )
534 .unwrap();
535
536 let gate = ManagedConfigGate::new(®);
537 let ctx = EvalContext {
538 principal_tenant: None,
539 current_tenant: None,
540 peer_ip: None,
541 mfa_present: false,
542 now_ms: 1_700_000_000_003,
543 principal_is_admin_role: false,
544 principal_is_system_owned: true,
545 principal_is_platform_scoped: true,
546 };
547 let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
548 match decision {
549 ManagedConfigDecision::Deny { reason, .. } => {
550 assert!(matches!(reason, DenyReason::PolicyDenied), "got {reason:?}");
551 }
552 other => panic!("expected Deny(PolicyDenied), got {other:?}"),
553 }
554 }
555
556 #[test]
557 fn explicit_deny_overrides_structural_allow() {
558 let store = store();
562 let seeder = seed_registry_admin(&store);
563 store
564 .create_system_user("ops", "p", Role::Admin, None)
565 .unwrap();
566 let ops = UserId::platform("ops");
567 store.put_policy(allow_all_config("p-allow")).unwrap();
568 store
569 .put_policy(deny_config_write("p-deny", "config:red.config.audit.*"))
570 .unwrap();
571 store
572 .attach_policy(PrincipalRef::User(ops.clone()), "p-allow")
573 .unwrap();
574 store
575 .attach_policy(PrincipalRef::User(ops.clone()), "p-deny")
576 .unwrap();
577
578 let reg = ConfigRegistry::new();
579 reg.register(
580 &store,
581 &seeder,
582 ®istry_admin_ctx(),
583 managed_key_draft("red.config.audit.enabled"),
584 1_000,
585 )
586 .unwrap();
587
588 let gate = ManagedConfigGate::new(®);
589 let ctx = EvalContext {
590 principal_tenant: None,
591 current_tenant: None,
592 peer_ip: None,
593 mfa_present: false,
594 now_ms: 1_700_000_000_004,
595 principal_is_admin_role: true,
596 principal_is_system_owned: true,
597 principal_is_platform_scoped: true,
598 };
599 let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
600 match decision {
601 ManagedConfigDecision::Deny { reason, .. } => {
602 assert!(matches!(reason, DenyReason::PolicyDenied), "got {reason:?}");
603 }
604 other => panic!("expected Deny(PolicyDenied), got {other:?}"),
605 }
606 }
607
608 #[test]
611 fn deny_carries_resource_and_reason_for_control_event() {
612 let store = store();
615 let seeder = seed_registry_admin(&store);
616 store.create_user("alice", "p", Role::Admin).unwrap();
617 let alice = UserId::platform("alice");
618
619 let reg = ConfigRegistry::new();
620 let mut draft = managed_key_draft("red.config.backup.retention_days");
621 draft.evidence_requirement = EvidenceRequirement::Full;
622 reg.register(&store, &seeder, ®istry_admin_ctx(), draft, 1_000)
623 .unwrap();
624
625 let gate = ManagedConfigGate::new(®);
626 let ctx = EvalContext {
627 principal_tenant: None,
628 current_tenant: None,
629 peer_ip: None,
630 mfa_present: false,
631 now_ms: 1_700_000_000_005,
632 principal_is_admin_role: true,
633 principal_is_system_owned: false,
634 principal_is_platform_scoped: true,
635 };
636 let decision = gate.check_write(&store, &alice, &ctx, "red.config.backup.retention_days");
637 match decision {
638 ManagedConfigDecision::Deny {
639 entry_id,
640 entry_version,
641 matched_action,
642 matched_resource,
643 reason,
644 } => {
645 assert_eq!(entry_id, "red.config.backup.retention_days");
646 assert_eq!(entry_version, 1);
647 assert_eq!(matched_action, "config:write");
648 assert_eq!(matched_resource, "config:red.config.backup.retention_days");
649 let rendered = reason.to_string();
651 assert!(
652 rendered.contains("structurally eligible"),
653 "reason should be Control-Event-renderable: {rendered}"
654 );
655 }
656 other => panic!("expected Deny, got {other:?}"),
657 }
658 }
659
660 #[test]
663 fn non_managed_application_config_passes_through() {
664 let store = store();
666 let _ = seed_registry_admin(&store);
667 let reg = ConfigRegistry::new();
668 let gate = ManagedConfigGate::new(®);
669 let ctx = EvalContext::default();
670 let alice = UserId::platform("alice");
671 let d = gate.check_write(&store, &alice, &ctx, "app.feature_flag");
672 assert!(matches!(d, ManagedConfigDecision::PassThrough { .. }));
673 assert!(d.permitted());
674 }
675
676 #[test]
677 fn unmanaged_registry_entry_also_passes_through() {
678 let store = store();
682 let seeder = seed_registry_admin(&store);
683 let reg = ConfigRegistry::new();
684 reg.register(
685 &store,
686 &seeder,
687 ®istry_admin_ctx(),
688 unmanaged_key_draft("app.feature_flag"),
689 1_000,
690 )
691 .unwrap();
692
693 let gate = ManagedConfigGate::new(®);
694 let ctx = EvalContext::default();
695 let alice = UserId::platform("alice");
696 let d = gate.check_write(&store, &alice, &ctx, "app.feature_flag");
697 assert!(matches!(d, ManagedConfigDecision::PassThrough { .. }));
698 }
699
700 #[test]
703 fn namespace_entry_governs_descendant_keys() {
704 let store = store();
707 let seeder = seed_registry_admin(&store);
708 let reg = ConfigRegistry::new();
709 reg.register(
710 &store,
711 &seeder,
712 ®istry_admin_ctx(),
713 managed_namespace_draft("red.config.audit"),
714 1_000,
715 )
716 .unwrap();
717
718 let gate = ManagedConfigGate::new(®);
719 let alice = UserId::platform("alice");
720 let ctx = EvalContext {
721 principal_is_system_owned: false,
722 principal_is_platform_scoped: true,
723 ..EvalContext::default()
724 };
725
726 for key in [
727 "red.config.audit.enabled",
728 "red.config.audit.sink.kafka.brokers",
729 ] {
730 let d = gate.check_write(&store, &alice, &ctx, key);
731 match d {
732 ManagedConfigDecision::Deny {
733 entry_id,
734 matched_resource,
735 ..
736 } => {
737 assert_eq!(entry_id, "red.config.audit");
738 assert_eq!(matched_resource, "config:red.config.audit.*");
739 }
740 other => panic!("expected Deny for {key}, got {other:?}"),
741 }
742 }
743
744 let d = gate.check_write(&store, &alice, &ctx, "red.config.storage.tier");
746 assert!(
747 matches!(d, ManagedConfigDecision::PassThrough { .. }),
748 "got {d:?}"
749 );
750 }
751
752 #[test]
753 fn exact_key_entry_wins_over_namespace_entry() {
754 let store = store();
757 let seeder = seed_registry_admin(&store);
758 let reg = ConfigRegistry::new();
759 let mut ns = managed_namespace_draft("red.config.audit");
761 ns.managed = false;
762 reg.register(&store, &seeder, ®istry_admin_ctx(), ns, 1_000)
763 .unwrap();
764 reg.register(
766 &store,
767 &seeder,
768 ®istry_admin_ctx(),
769 managed_key_draft("red.config.audit.enabled"),
770 1_000,
771 )
772 .unwrap();
773
774 let gate = ManagedConfigGate::new(®);
775 let alice = UserId::platform("alice");
776 let ctx = EvalContext {
777 principal_is_system_owned: false,
778 principal_is_platform_scoped: true,
779 ..EvalContext::default()
780 };
781 let d = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
783 assert!(matches!(d, ManagedConfigDecision::Deny { .. }), "got {d:?}");
784 let d = gate.check_write(&store, &alice, &ctx, "red.config.audit.sink");
787 assert!(
788 matches!(d, ManagedConfigDecision::PassThrough { .. }),
789 "got {d:?}"
790 );
791 }
792
793 #[test]
794 fn entry_with_unrelated_resource_type_does_not_gate_config_writes() {
795 let store = store();
799 let seeder = seed_registry_admin(&store);
800 let reg = ConfigRegistry::new();
801 let mut d = managed_key_draft("red.config.audit.enabled");
802 d.resource_type = "vault_path".into();
803 reg.register(&store, &seeder, ®istry_admin_ctx(), d, 1_000)
804 .unwrap();
805
806 let gate = ManagedConfigGate::new(®);
807 let alice = UserId::platform("alice");
808 let ctx = EvalContext::default();
809 let dec = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
810 assert!(
811 matches!(dec, ManagedConfigDecision::PassThrough { .. }),
812 "got {dec:?}"
813 );
814 }
815
816 #[test]
817 fn split_required_resource_handles_bare_string() {
818 assert_eq!(
819 split_required_resource("config:red.config.audit.enabled"),
820 ("config", "red.config.audit.enabled")
821 );
822 assert_eq!(
823 split_required_resource("red.config.audit.enabled"),
824 ("config", "red.config.audit.enabled")
825 );
826 assert_eq!(
827 split_required_resource(":only-name"),
828 ("config", ":only-name")
829 );
830 }
831}