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