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 fn lookup_governing_entry(&self, key: &str) -> Option<ConfigRegistryEntry> {
168 if let Some(e) = self.registry.get_active(key) {
169 if e.resource_type == RESOURCE_TYPE_CONFIG_KEY {
170 return Some(e);
171 }
172 }
173 let mut cursor = key;
174 while let Some(idx) = cursor.rfind('.') {
175 cursor = &cursor[..idx];
176 if let Some(e) = self.registry.get_active(cursor) {
177 if e.resource_type == RESOURCE_TYPE_CONFIG_NAMESPACE {
178 return Some(e);
179 }
180 }
181 }
182 None
183 }
184}
185
186fn split_required_resource(s: &str) -> (&str, &str) {
191 match s.split_once(':') {
192 Some((k, n)) if !k.is_empty() => (k, n),
193 _ => ("config", s),
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::auth::policies::Policy;
201 use crate::auth::registry::{ConfigRegistryDraft, Mutability, Sensitivity};
202 use crate::auth::store::PrincipalRef;
203 use crate::auth::{AuthConfig, Role};
204 use std::sync::Arc;
205
206 fn store() -> Arc<AuthStore> {
207 Arc::new(AuthStore::new(AuthConfig::default()))
208 }
209
210 fn registry_admin_ctx() -> EvalContext {
211 EvalContext {
216 principal_tenant: None,
217 current_tenant: None,
218 peer_ip: None,
219 mfa_present: false,
220 now_ms: 1_700_000_000_000,
221 principal_is_admin_role: true,
222 principal_is_platform_scoped: true,
223 }
224 }
225
226 fn allow_all_registry(id: &str) -> Policy {
227 Policy::from_json_str(&format!(
228 r#"{{
229 "id": "{id}",
230 "version": 1,
231 "statements": [{{
232 "effect": "allow",
233 "actions": ["red.registry:*"],
234 "resources": ["registry:*"]
235 }}]
236 }}"#
237 ))
238 .unwrap()
239 }
240
241 fn allow_config_write(id: &str, resource_glob: &str) -> Policy {
242 Policy::from_json_str(&format!(
243 r#"{{
244 "id": "{id}",
245 "version": 1,
246 "statements": [{{
247 "effect": "allow",
248 "actions": ["config:write"],
249 "resources": ["{resource_glob}"]
250 }}]
251 }}"#
252 ))
253 .unwrap()
254 }
255
256 fn deny_config_write(id: &str, resource_glob: &str) -> Policy {
257 Policy::from_json_str(&format!(
258 r#"{{
259 "id": "{id}",
260 "version": 1,
261 "statements": [{{
262 "effect": "deny",
263 "actions": ["config:write"],
264 "resources": ["{resource_glob}"]
265 }}]
266 }}"#
267 ))
268 .unwrap()
269 }
270
271 fn allow_all_config(id: &str) -> Policy {
272 Policy::from_json_str(&format!(
273 r#"{{
274 "id": "{id}",
275 "version": 1,
276 "statements": [{{
277 "effect": "allow",
278 "actions": ["config:*"],
279 "resources": ["*"]
280 }}]
281 }}"#
282 ))
283 .unwrap()
284 }
285
286 fn seed_registry_admin(store: &Arc<AuthStore>) -> UserId {
287 store.create_user("seeder", "p", Role::Admin).unwrap();
288 let uid = UserId::platform("seeder");
289 store.put_policy(allow_all_registry("p-reg-allow")).unwrap();
290 store
291 .attach_policy(PrincipalRef::User(uid.clone()), "p-reg-allow")
292 .unwrap();
293 uid
294 }
295
296 fn managed_key_draft(id: &str) -> ConfigRegistryDraft {
297 ConfigRegistryDraft {
298 id: id.to_string(),
299 resource_type: RESOURCE_TYPE_CONFIG_KEY.into(),
300 schema: "string".into(),
301 mutability: Mutability::MutableViaGovernance,
302 sensitivity: Sensitivity::Internal,
303 managed: true,
304 required_action: "config:write".into(),
305 required_resource: format!("config:{id}"),
306 evidence_requirement: EvidenceRequirement::Metadata,
307 }
308 }
309
310 fn managed_namespace_draft(id: &str) -> ConfigRegistryDraft {
311 ConfigRegistryDraft {
312 id: id.to_string(),
313 resource_type: RESOURCE_TYPE_CONFIG_NAMESPACE.into(),
314 schema: "namespace".into(),
315 mutability: Mutability::MutableViaGovernance,
316 sensitivity: Sensitivity::Internal,
317 managed: true,
318 required_action: "config:write".into(),
319 required_resource: format!("config:{id}.*"),
320 evidence_requirement: EvidenceRequirement::Metadata,
321 }
322 }
323
324 fn unmanaged_key_draft(id: &str) -> ConfigRegistryDraft {
325 let mut d = managed_key_draft(id);
326 d.managed = false;
327 d
328 }
329
330 #[test]
333 fn registry_entry_can_mark_a_config_key_as_managed() {
334 let store = store();
337 let seeder = seed_registry_admin(&store);
338 let reg = ConfigRegistry::new();
339 let entry = reg
340 .register(
341 &store,
342 &seeder,
343 ®istry_admin_ctx(),
344 managed_key_draft("red.config.audit.enabled"),
345 1_000,
346 )
347 .unwrap();
348 assert!(entry.managed, "draft.managed must propagate to entry");
349
350 let unmanaged = reg
352 .register(
353 &store,
354 &seeder,
355 ®istry_admin_ctx(),
356 unmanaged_key_draft("app.feature_flag"),
357 1_000,
358 )
359 .unwrap();
360 assert!(!unmanaged.managed);
361 }
362
363 #[test]
366 fn ordinary_allow_all_user_is_allowed_on_managed_key() {
367 let store = store();
371 let seeder = seed_registry_admin(&store);
372 store.create_user("alice", "p", Role::Admin).unwrap();
373 let alice = UserId::platform("alice");
374 store.put_policy(allow_all_config("p-allow-cfg")).unwrap();
375 store
376 .attach_policy(PrincipalRef::User(alice.clone()), "p-allow-cfg")
377 .unwrap();
378
379 let reg = ConfigRegistry::new();
380 reg.register(
381 &store,
382 &seeder,
383 ®istry_admin_ctx(),
384 managed_key_draft("red.config.audit.enabled"),
385 1_000,
386 )
387 .unwrap();
388
389 let gate = ManagedConfigGate::new(®);
390 let ctx = EvalContext {
391 principal_tenant: None,
392 current_tenant: None,
393 peer_ip: None,
394 mfa_present: false,
395 now_ms: 1_700_000_000_001,
396 principal_is_admin_role: true,
397 principal_is_platform_scoped: true,
398 };
399 let decision = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
400 assert!(
401 matches!(decision, ManagedConfigDecision::Allow { .. }),
402 "got {decision:?}"
403 );
404 }
405
406 #[test]
409 fn caller_with_matching_policy_is_allowed() {
410 let store = store();
411 let seeder = seed_registry_admin(&store);
412 store
413 .create_admin_user("ops", "p", Role::Admin, None)
414 .expect("create user");
415 let ops = UserId::platform("ops");
416 store
417 .put_policy(allow_config_write(
418 "p-cfg-write",
419 "config:red.config.audit.*",
420 ))
421 .unwrap();
422 store
423 .attach_policy(PrincipalRef::User(ops.clone()), "p-cfg-write")
424 .unwrap();
425
426 let reg = ConfigRegistry::new();
427 reg.register(
428 &store,
429 &seeder,
430 ®istry_admin_ctx(),
431 managed_key_draft("red.config.audit.enabled"),
432 1_000,
433 )
434 .unwrap();
435
436 let gate = ManagedConfigGate::new(®);
437 let ctx = EvalContext {
438 principal_tenant: None,
439 current_tenant: None,
440 peer_ip: None,
441 mfa_present: false,
442 now_ms: 1_700_000_000_002,
443 principal_is_admin_role: true,
444 principal_is_platform_scoped: true,
445 };
446 let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
447 assert!(
448 matches!(decision, ManagedConfigDecision::Allow { .. }),
449 "got {decision:?}"
450 );
451 assert!(decision.permitted());
452 }
453
454 #[test]
455 fn caller_without_matching_policy_is_policy_denied() {
456 let store = store();
459 let seeder = seed_registry_admin(&store);
460 store
461 .create_admin_user("ops", "p", Role::Write, None)
462 .unwrap();
463 let ops = UserId::platform("ops");
464 store
467 .put_policy(
468 Policy::from_json_str(
469 r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.x"]}]}"#,
470 )
471 .unwrap(),
472 )
473 .unwrap();
474 store
475 .attach_policy(PrincipalRef::User(ops.clone()), "p-unrelated")
476 .unwrap();
477
478 let reg = ConfigRegistry::new();
479 reg.register(
480 &store,
481 &seeder,
482 ®istry_admin_ctx(),
483 managed_key_draft("red.config.audit.enabled"),
484 1_000,
485 )
486 .unwrap();
487
488 let gate = ManagedConfigGate::new(®);
489 let ctx = EvalContext {
490 principal_tenant: None,
491 current_tenant: None,
492 peer_ip: None,
493 mfa_present: false,
494 now_ms: 1_700_000_000_003,
495 principal_is_admin_role: false,
496 principal_is_platform_scoped: true,
497 };
498 let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
499 match decision {
500 ManagedConfigDecision::Deny { reason, .. } => {
501 assert!(matches!(reason, DenyReason::PolicyDenied), "got {reason:?}");
502 }
503 other => panic!("expected Deny(PolicyDenied), got {other:?}"),
504 }
505 }
506
507 #[test]
508 fn explicit_deny_overrides_allow() {
509 let store = store();
512 let seeder = seed_registry_admin(&store);
513 store
514 .create_admin_user("ops", "p", Role::Admin, None)
515 .unwrap();
516 let ops = UserId::platform("ops");
517 store.put_policy(allow_all_config("p-allow")).unwrap();
518 store
519 .put_policy(deny_config_write("p-deny", "config:red.config.audit.*"))
520 .unwrap();
521 store
522 .attach_policy(PrincipalRef::User(ops.clone()), "p-allow")
523 .unwrap();
524 store
525 .attach_policy(PrincipalRef::User(ops.clone()), "p-deny")
526 .unwrap();
527
528 let reg = ConfigRegistry::new();
529 reg.register(
530 &store,
531 &seeder,
532 ®istry_admin_ctx(),
533 managed_key_draft("red.config.audit.enabled"),
534 1_000,
535 )
536 .unwrap();
537
538 let gate = ManagedConfigGate::new(®);
539 let ctx = EvalContext {
540 principal_tenant: None,
541 current_tenant: None,
542 peer_ip: None,
543 mfa_present: false,
544 now_ms: 1_700_000_000_004,
545 principal_is_admin_role: true,
546 principal_is_platform_scoped: true,
547 };
548 let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
549 match decision {
550 ManagedConfigDecision::Deny { reason, .. } => {
551 assert!(matches!(reason, DenyReason::PolicyDenied), "got {reason:?}");
552 }
553 other => panic!("expected Deny(PolicyDenied), got {other:?}"),
554 }
555 }
556
557 #[test]
560 fn deny_carries_resource_and_reason_for_control_event() {
561 let store = store();
564 let seeder = seed_registry_admin(&store);
565 store.create_user("alice", "p", Role::Admin).unwrap();
566 let alice = UserId::platform("alice");
567
568 let reg = ConfigRegistry::new();
569 let mut draft = managed_key_draft("red.config.backup.retention_days");
570 draft.evidence_requirement = EvidenceRequirement::Full;
571 reg.register(&store, &seeder, ®istry_admin_ctx(), draft, 1_000)
572 .unwrap();
573
574 let gate = ManagedConfigGate::new(®);
575 let ctx = EvalContext {
576 principal_tenant: None,
577 current_tenant: None,
578 peer_ip: None,
579 mfa_present: false,
580 now_ms: 1_700_000_000_005,
581 principal_is_admin_role: true,
582 principal_is_platform_scoped: true,
583 };
584 let decision = gate.check_write(&store, &alice, &ctx, "red.config.backup.retention_days");
585 match decision {
586 ManagedConfigDecision::Deny {
587 entry_id,
588 entry_version,
589 matched_action,
590 matched_resource,
591 reason,
592 ..
593 } => {
594 assert_eq!(entry_id, "red.config.backup.retention_days");
595 assert_eq!(entry_version, 1);
596 assert_eq!(matched_action, "config:write");
597 assert_eq!(matched_resource, "config:red.config.backup.retention_days");
598 let rendered = reason.to_string();
600 assert!(
601 rendered.contains("policy permission"),
602 "reason should be Control-Event-renderable: {rendered}"
603 );
604 }
605 other => panic!("expected Deny, got {other:?}"),
606 }
607 }
608
609 #[test]
612 fn non_managed_application_config_passes_through() {
613 let store = store();
615 let _ = seed_registry_admin(&store);
616 let reg = ConfigRegistry::new();
617 let gate = ManagedConfigGate::new(®);
618 let ctx = EvalContext::default();
619 let alice = UserId::platform("alice");
620 let d = gate.check_write(&store, &alice, &ctx, "app.feature_flag");
621 assert!(matches!(d, ManagedConfigDecision::PassThrough { .. }));
622 assert!(d.permitted());
623 }
624
625 #[test]
626 fn unmanaged_registry_entry_also_passes_through() {
627 let store = store();
631 let seeder = seed_registry_admin(&store);
632 let reg = ConfigRegistry::new();
633 reg.register(
634 &store,
635 &seeder,
636 ®istry_admin_ctx(),
637 unmanaged_key_draft("app.feature_flag"),
638 1_000,
639 )
640 .unwrap();
641
642 let gate = ManagedConfigGate::new(®);
643 let ctx = EvalContext::default();
644 let alice = UserId::platform("alice");
645 let d = gate.check_write(&store, &alice, &ctx, "app.feature_flag");
646 assert!(matches!(d, ManagedConfigDecision::PassThrough { .. }));
647 }
648
649 #[test]
652 fn namespace_entry_governs_descendant_keys() {
653 let store = store();
656 let seeder = seed_registry_admin(&store);
657 let reg = ConfigRegistry::new();
658 reg.register(
659 &store,
660 &seeder,
661 ®istry_admin_ctx(),
662 managed_namespace_draft("red.config.audit"),
663 1_000,
664 )
665 .unwrap();
666
667 let gate = ManagedConfigGate::new(®);
668 let alice = UserId::platform("alice");
669 let ctx = EvalContext {
670 principal_is_platform_scoped: true,
671 ..EvalContext::default()
672 };
673
674 for key in [
675 "red.config.audit.enabled",
676 "red.config.audit.sink.kafka.brokers",
677 ] {
678 let d = gate.check_write(&store, &alice, &ctx, key);
679 match d {
680 ManagedConfigDecision::Deny {
681 entry_id,
682 matched_resource,
683 ..
684 } => {
685 assert_eq!(entry_id, "red.config.audit");
686 assert_eq!(matched_resource, "config:red.config.audit.*");
687 }
688 other => panic!("expected Deny for {key}, got {other:?}"),
689 }
690 }
691
692 let d = gate.check_write(&store, &alice, &ctx, "red.config.storage.tier");
694 assert!(
695 matches!(d, ManagedConfigDecision::PassThrough { .. }),
696 "got {d:?}"
697 );
698 }
699
700 #[test]
701 fn exact_key_entry_wins_over_namespace_entry() {
702 let store = store();
705 let seeder = seed_registry_admin(&store);
706 let reg = ConfigRegistry::new();
707 let mut ns = managed_namespace_draft("red.config.audit");
709 ns.managed = false;
710 reg.register(&store, &seeder, ®istry_admin_ctx(), ns, 1_000)
711 .unwrap();
712 reg.register(
714 &store,
715 &seeder,
716 ®istry_admin_ctx(),
717 managed_key_draft("red.config.audit.enabled"),
718 1_000,
719 )
720 .unwrap();
721
722 let gate = ManagedConfigGate::new(®);
723 let alice = UserId::platform("alice");
724 let ctx = EvalContext {
725 principal_is_platform_scoped: true,
726 ..EvalContext::default()
727 };
728 let d = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
730 assert!(matches!(d, ManagedConfigDecision::Deny { .. }), "got {d:?}");
731 let d = gate.check_write(&store, &alice, &ctx, "red.config.audit.sink");
734 assert!(
735 matches!(d, ManagedConfigDecision::PassThrough { .. }),
736 "got {d:?}"
737 );
738 }
739
740 #[test]
741 fn entry_with_unrelated_resource_type_does_not_gate_config_writes() {
742 let store = store();
746 let seeder = seed_registry_admin(&store);
747 let reg = ConfigRegistry::new();
748 let mut d = managed_key_draft("red.config.audit.enabled");
749 d.resource_type = "vault_path".into();
750 reg.register(&store, &seeder, ®istry_admin_ctx(), d, 1_000)
751 .unwrap();
752
753 let gate = ManagedConfigGate::new(®);
754 let alice = UserId::platform("alice");
755 let ctx = EvalContext::default();
756 let dec = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
757 assert!(
758 matches!(dec, ManagedConfigDecision::PassThrough { .. }),
759 "got {dec:?}"
760 );
761 }
762
763 #[test]
764 fn split_required_resource_handles_bare_string() {
765 assert_eq!(
766 split_required_resource("config:red.config.audit.enabled"),
767 ("config", "red.config.audit.enabled")
768 );
769 assert_eq!(
770 split_required_resource("red.config.audit.enabled"),
771 ("config", "red.config.audit.enabled")
772 );
773 assert_eq!(
774 split_required_resource(":only-name"),
775 ("config", ":only-name")
776 );
777 }
778}