Skip to main content

reddb_server/auth/
managed_config.rs

1//! Managed config namespace enforcement (#649).
2//!
3//! Guards mutations of config keys that the operator has marked
4//! `managed=true` in [`super::registry::ConfigRegistry`]. The gate sits
5//! in front of the ordinary config write path: given the config key the
6//! caller is about to set, it looks the key up in the registry (by
7//! exact id, then by parent-namespace fallback), and:
8//!
9//! * If no entry matches, or the matched entry is **not** managed,
10//!   returns [`ManagedConfigDecision::PassThrough`] — ordinary policy
11//!   rules govern the write (criterion #5: non-managed configs remain
12//!   writable per ordinary policy).
13//!
14//! * If the matched entry is managed, caller must satisfy
15//!   [`AuthStore::check_policy_authz`] against the entry's
16//!   `required_action` / `required_resource`. Otherwise
17//!   [`ManagedConfigDecision::Deny`] with [`DenyReason::PolicyDenied`].
18//!
19//! Every Deny carries the matched entry id, version, required action,
20//! and required resource so the Control Event Ledger can persist the
21//! evidence (criterion #4). The matched action/resource string mirrors
22//! what `check_policy_authz` would have evaluated, so an investigator
23//! can replay the decision.
24
25use super::policies::{EvalContext, ResourceRef};
26use super::registry::{ConfigRegistry, ConfigRegistryEntry, EvidenceRequirement, Mutability};
27use super::store::AuthStore;
28use super::UserId;
29
30/// Outcome of a managed-config write check.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ManagedConfigDecision {
33    /// Key is not governed by a managed registry entry. Caller should
34    /// proceed with ordinary policy checks against `config:write` (etc.).
35    PassThrough { key: String },
36    /// Key is managed and the caller satisfied the policy gate. Caller
37    /// may proceed; the returned evidence requirement tells the Control
38    /// Event Ledger how much detail to persist.
39    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    /// Key is managed and the caller failed one of the gates. Carries
50    /// enough metadata for Control Event emission.
51    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/// Why a managed config write was denied. Designed for Control Event
64/// payloads: the variant tells operators what *kind* of guard tripped.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum DenyReason {
67    /// The policy evaluator rejected the action/resource pair (either
68    /// explicit Deny or DefaultDeny).
69    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    /// Convenience: did this decision permit the write (PassThrough or
82    /// Allow)? Callers that just want a boolean gate use this; callers
83    /// that need to emit Control Events match on the full variant.
84    pub fn permitted(&self) -> bool {
85        matches!(self, Self::PassThrough { .. } | Self::Allow { .. })
86    }
87}
88
89/// Resource-type tag for entries that govern a single config key (e.g.
90/// `red.config.audit.enabled`). The gate matches by exact id.
91pub const RESOURCE_TYPE_CONFIG_KEY: &str = "config_key";
92/// Resource-type tag for entries that govern an entire dotted namespace
93/// (e.g. `red.config.audit`). The gate matches any descendant key under
94/// the namespace.
95pub const RESOURCE_TYPE_CONFIG_NAMESPACE: &str = "config_namespace";
96
97/// Stateless guard wrapping a [`ConfigRegistry`] reference.
98pub 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    /// Evaluate a write to `key` for `actor`. Returns one of the three
108    /// [`ManagedConfigDecision`] variants — see the module docs for the
109    /// decision rules.
110    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            // The registry knows about this key but the operator has not
124            // marked it managed. Ordinary policy rules apply.
125            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    /// Most-specific registry entry governing `key`: exact match first,
160    /// then dotted-namespace ancestors (`a.b.c` → `a.b` → `a`). Only
161    /// entries whose `resource_type` is `config_key` (for exact match)
162    /// or `config_namespace` (for ancestor match) qualify; an entry of
163    /// any other resource_type is ignored even if its id collides with
164    /// the key — those entries describe other governance surfaces
165    /// (vault paths, policies, audit) and must not silently gate config
166    /// writes.
167    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
186/// Split `"kind:name"` from a registry entry's `required_resource`.
187/// Falls back to `("config", whole_string)` when the colon is absent so
188/// older entries that just stored a bare config key still produce a
189/// well-formed [`ResourceRef`].
190fn 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        // Admin shape used only for *seeding* the registry — managed
212        // entries register via the governance path, which requires
213        // `red.registry:*` allow. The fact that the seeder is admin
214        // here is incidental.
215        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    // ---- acceptance #1 ---------------------------------------------------
331
332    #[test]
333    fn registry_entry_can_mark_a_config_key_as_managed() {
334        // The registry already supports the `managed` flag; this test
335        // pins that the gate reads it as the trigger for enforcement.
336        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                &registry_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        // Sanity: an unmanaged sibling registers with managed=false.
351        let unmanaged = reg
352            .register(
353                &store,
354                &seeder,
355                &registry_admin_ctx(),
356                unmanaged_key_draft("app.feature_flag"),
357                1_000,
358            )
359            .unwrap();
360        assert!(!unmanaged.managed);
361    }
362
363    // ---- acceptance #2 ---------------------------------------------------
364
365    #[test]
366    fn ordinary_allow_all_user_is_allowed_on_managed_key() {
367        // Managed config is policy-first: an ordinary principal with a
368        // matching policy can write a managed key. Protection comes from
369        // explicit Deny or missing permission, not a structural flag.
370        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            &registry_admin_ctx(),
384            managed_key_draft("red.config.audit.enabled"),
385            1_000,
386        )
387        .unwrap();
388
389        let gate = ManagedConfigGate::new(&reg);
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    // ---- acceptance #3 ---------------------------------------------------
407
408    #[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            &registry_admin_ctx(),
431            managed_key_draft("red.config.audit.enabled"),
432            1_000,
433        )
434        .unwrap();
435
436        let gate = ManagedConfigGate::new(&reg);
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        // No allow grants `config:write` on the managed key —
457        // DefaultDeny falls out of the evaluator.
458        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        // Attach an unrelated policy so the evaluator runs through IAM
465        // rather than short-circuiting on "no policies".
466        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            &registry_admin_ctx(),
483            managed_key_draft("red.config.audit.enabled"),
484            1_000,
485        )
486        .unwrap();
487
488        let gate = ManagedConfigGate::new(&reg);
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        // Principal with a broad config-write allow *and* an explicit
510        // deny on the managed key — deny wins.
511        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            &registry_admin_ctx(),
533            managed_key_draft("red.config.audit.enabled"),
534            1_000,
535        )
536        .unwrap();
537
538        let gate = ManagedConfigGate::new(&reg);
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    // ---- acceptance #4 ---------------------------------------------------
558
559    #[test]
560    fn deny_carries_resource_and_reason_for_control_event() {
561        // The Deny payload must give Control Event integration enough
562        // detail to reconstruct what was attempted and why it failed.
563        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, &registry_admin_ctx(), draft, 1_000)
572            .unwrap();
573
574        let gate = ManagedConfigGate::new(&reg);
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                // The reason is human-presentable and identifies the policy gate.
599                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    // ---- acceptance #5 ---------------------------------------------------
610
611    #[test]
612    fn non_managed_application_config_passes_through() {
613        // No registry entry at all → PassThrough.
614        let store = store();
615        let _ = seed_registry_admin(&store);
616        let reg = ConfigRegistry::new();
617        let gate = ManagedConfigGate::new(&reg);
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        // Entry exists but `managed=false` — the registry knows about
628        // the key (so schema/sensitivity metadata is available) but the
629        // gate must not interfere with ordinary policy rules.
630        let store = store();
631        let seeder = seed_registry_admin(&store);
632        let reg = ConfigRegistry::new();
633        reg.register(
634            &store,
635            &seeder,
636            &registry_admin_ctx(),
637            unmanaged_key_draft("app.feature_flag"),
638            1_000,
639        )
640        .unwrap();
641
642        let gate = ManagedConfigGate::new(&reg);
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    // ---- namespace matching contract ------------------------------------
650
651    #[test]
652    fn namespace_entry_governs_descendant_keys() {
653        // A `config_namespace` entry at `red.config.audit` must gate
654        // writes to `red.config.audit.enabled` and any deeper key.
655        let store = store();
656        let seeder = seed_registry_admin(&store);
657        let reg = ConfigRegistry::new();
658        reg.register(
659            &store,
660            &seeder,
661            &registry_admin_ctx(),
662            managed_namespace_draft("red.config.audit"),
663            1_000,
664        )
665        .unwrap();
666
667        let gate = ManagedConfigGate::new(&reg);
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        // A sibling outside the namespace stays a PassThrough.
693        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        // When both a namespace and a more specific key entry exist,
703        // the key entry is the one that gates the decision.
704        let store = store();
705        let seeder = seed_registry_admin(&store);
706        let reg = ConfigRegistry::new();
707        // Namespace: non-managed.
708        let mut ns = managed_namespace_draft("red.config.audit");
709        ns.managed = false;
710        reg.register(&store, &seeder, &registry_admin_ctx(), ns, 1_000)
711            .unwrap();
712        // Specific key: managed.
713        reg.register(
714            &store,
715            &seeder,
716            &registry_admin_ctx(),
717            managed_key_draft("red.config.audit.enabled"),
718            1_000,
719        )
720        .unwrap();
721
722        let gate = ManagedConfigGate::new(&reg);
723        let alice = UserId::platform("alice");
724        let ctx = EvalContext {
725            principal_is_platform_scoped: true,
726            ..EvalContext::default()
727        };
728        // Specific key wins → managed → Deny.
729        let d = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
730        assert!(matches!(d, ManagedConfigDecision::Deny { .. }), "got {d:?}");
731        // Sibling under the namespace falls through (namespace was
732        // marked unmanaged).
733        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        // Pin the contract: a registry entry whose `resource_type` is
743        // not config_key / config_namespace (e.g. `vault_path` reusing a
744        // dotted id) must not silently fire the config gate.
745        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, &registry_admin_ctx(), d, 1_000)
751            .unwrap();
752
753        let gate = ManagedConfigGate::new(&reg);
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}