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, enforces two layers in order:
15//!     1. **Structural eligibility** — caller must be both system-owned
16//!        and platform-scoped (the operator-principal shape). Otherwise
17//!        [`ManagedConfigDecision::Deny`] with
18//!        [`DenyReason::NotStructurallyEligible`].
19//!     2. **Policy permission** — caller must satisfy
20//!        [`AuthStore::check_policy_authz`] against the entry's
21//!        `required_action` / `required_resource`. Otherwise
22//!        [`ManagedConfigDecision::Deny`] with
23//!        [`DenyReason::PolicyDenied`].
24//!
25//! Every Deny carries the matched entry id, version, required action,
26//! and required resource so the Control Event Ledger can persist the
27//! evidence (criterion #4). The matched action/resource string mirrors
28//! what `check_policy_authz` would have evaluated, so an investigator
29//! can replay the decision.
30
31use super::policies::{EvalContext, ResourceRef};
32use super::registry::{ConfigRegistry, ConfigRegistryEntry, EvidenceRequirement};
33use super::store::AuthStore;
34use super::UserId;
35
36/// Outcome of a managed-config write check.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ManagedConfigDecision {
39    /// Key is not governed by a managed registry entry. Caller should
40    /// proceed with ordinary policy checks against `config:write` (etc.).
41    PassThrough { key: String },
42    /// Key is managed and the caller satisfied both the structural and
43    /// policy gates. Caller may proceed; the returned evidence
44    /// requirement tells the Control Event Ledger how much detail to
45    /// persist.
46    Allow {
47        entry_id: String,
48        entry_version: u64,
49        matched_action: String,
50        matched_resource: String,
51        evidence: EvidenceRequirement,
52    },
53    /// Key is managed and the caller failed one of the gates. Carries
54    /// enough metadata for Control Event emission.
55    Deny {
56        entry_id: String,
57        entry_version: u64,
58        matched_action: String,
59        matched_resource: String,
60        reason: DenyReason,
61    },
62}
63
64/// Why a managed config write was denied. Designed for Control Event
65/// payloads: the variant tells operators what *kind* of guard tripped,
66/// and the structured fields let them see which eligibility flag was
67/// false.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum DenyReason {
70    /// Caller is not the operator-principal shape required by managed
71    /// guardrails (must be both system-owned and platform-scoped).
72    NotStructurallyEligible {
73        is_system_owned: bool,
74        is_platform_scoped: bool,
75    },
76    /// Caller is structurally eligible but the policy evaluator rejected
77    /// the action/resource pair (either explicit Deny or DefaultDeny).
78    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    /// Convenience: did this decision permit the write (PassThrough or
99    /// Allow)? Callers that just want a boolean gate use this; callers
100    /// that need to emit Control Events match on the full variant.
101    pub fn permitted(&self) -> bool {
102        matches!(self, Self::PassThrough { .. } | Self::Allow { .. })
103    }
104}
105
106/// Resource-type tag for entries that govern a single config key (e.g.
107/// `red.config.audit.enabled`). The gate matches by exact id.
108pub const RESOURCE_TYPE_CONFIG_KEY: &str = "config_key";
109/// Resource-type tag for entries that govern an entire dotted namespace
110/// (e.g. `red.config.audit`). The gate matches any descendant key under
111/// the namespace.
112pub const RESOURCE_TYPE_CONFIG_NAMESPACE: &str = "config_namespace";
113
114/// Stateless guard wrapping a [`ConfigRegistry`] reference.
115pub 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    /// Evaluate a write to `key` for `actor`. Returns one of the three
125    /// [`ManagedConfigDecision`] variants — see the module docs for the
126    /// decision rules.
127    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            // The registry knows about this key but the operator has not
141            // marked it managed. Ordinary policy rules apply.
142            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    /// Most-specific registry entry governing `key`: exact match first,
184    /// then dotted-namespace ancestors (`a.b.c` → `a.b` → `a`). Only
185    /// entries whose `resource_type` is `config_key` (for exact match)
186    /// or `config_namespace` (for ancestor match) qualify; an entry of
187    /// any other resource_type is ignored even if its id collides with
188    /// the key — those entries describe other governance surfaces
189    /// (vault paths, policies, audit) and must not silently gate config
190    /// writes.
191    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
210/// Split `"kind:name"` from a registry entry's `required_resource`.
211/// Falls back to `("config", whole_string)` when the colon is absent so
212/// older entries that just stored a bare config key still produce a
213/// well-formed [`ResourceRef`].
214fn 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        // Admin shape used only for *seeding* the registry — managed
236        // entries register via the governance path, which requires
237        // `red.registry:*` allow. The fact that the seeder is admin
238        // here is incidental.
239        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    // ---- acceptance #1 ---------------------------------------------------
356
357    #[test]
358    fn registry_entry_can_mark_a_config_key_as_managed() {
359        // The registry already supports the `managed` flag; this test
360        // pins that the gate reads it as the trigger for enforcement.
361        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                &registry_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        // Sanity: an unmanaged sibling registers with managed=false.
376        let unmanaged = reg
377            .register(
378                &store,
379                &seeder,
380                &registry_admin_ctx(),
381                unmanaged_key_draft("app.feature_flag"),
382                1_000,
383            )
384            .unwrap();
385        assert!(!unmanaged.managed);
386    }
387
388    // ---- acceptance #2 ---------------------------------------------------
389
390    #[test]
391    fn ordinary_allow_all_user_is_denied_on_managed_key() {
392        // Allow-all policy ("config:*" on "*") would normally let
393        // alice@platform write any config. The managed gate must block
394        // her because she lacks the structural shape (not system-owned).
395        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            &registry_admin_ctx(),
409            managed_key_draft("red.config.audit.enabled"),
410            1_000,
411        )
412        .unwrap();
413
414        let gate = ManagedConfigGate::new(&reg);
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, // <-- the key gate
423            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    // ---- acceptance #3 ---------------------------------------------------
453
454    #[test]
455    fn structurally_eligible_caller_with_matching_policy_is_allowed() {
456        let store = store();
457        let seeder = seed_registry_admin(&store);
458        // System-owned, platform-scoped operator.
459        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            &registry_admin_ctx(),
478            managed_key_draft("red.config.audit.enabled"),
479            1_000,
480        )
481        .unwrap();
482
483        let gate = ManagedConfigGate::new(&reg);
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        // Operator shape is right, but no allow grants `config:write`
505        // on the managed key — DefaultDeny falls out of the evaluator.
506        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        // Attach an unrelated policy so the evaluator runs through IAM
513        // rather than short-circuiting on "no policies".
514        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            &registry_admin_ctx(),
531            managed_key_draft("red.config.audit.enabled"),
532            1_000,
533        )
534        .unwrap();
535
536        let gate = ManagedConfigGate::new(&reg);
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        // System-owned operator with a broad config-write allow *and* an
559        // explicit deny on the managed key — deny wins (policy-first,
560        // per #644).
561        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            &registry_admin_ctx(),
583            managed_key_draft("red.config.audit.enabled"),
584            1_000,
585        )
586        .unwrap();
587
588        let gate = ManagedConfigGate::new(&reg);
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    // ---- acceptance #4 ---------------------------------------------------
609
610    #[test]
611    fn deny_carries_resource_and_reason_for_control_event() {
612        // The Deny payload must give Control Event integration enough
613        // detail to reconstruct what was attempted and why it failed.
614        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, &registry_admin_ctx(), draft, 1_000)
623            .unwrap();
624
625        let gate = ManagedConfigGate::new(&reg);
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                // The reason is human-presentable and identifies the gate.
650                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    // ---- acceptance #5 ---------------------------------------------------
661
662    #[test]
663    fn non_managed_application_config_passes_through() {
664        // No registry entry at all → PassThrough.
665        let store = store();
666        let _ = seed_registry_admin(&store);
667        let reg = ConfigRegistry::new();
668        let gate = ManagedConfigGate::new(&reg);
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        // Entry exists but `managed=false` — the registry knows about
679        // the key (so schema/sensitivity metadata is available) but the
680        // gate must not interfere with ordinary policy rules.
681        let store = store();
682        let seeder = seed_registry_admin(&store);
683        let reg = ConfigRegistry::new();
684        reg.register(
685            &store,
686            &seeder,
687            &registry_admin_ctx(),
688            unmanaged_key_draft("app.feature_flag"),
689            1_000,
690        )
691        .unwrap();
692
693        let gate = ManagedConfigGate::new(&reg);
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    // ---- namespace matching contract ------------------------------------
701
702    #[test]
703    fn namespace_entry_governs_descendant_keys() {
704        // A `config_namespace` entry at `red.config.audit` must gate
705        // writes to `red.config.audit.enabled` and any deeper key.
706        let store = store();
707        let seeder = seed_registry_admin(&store);
708        let reg = ConfigRegistry::new();
709        reg.register(
710            &store,
711            &seeder,
712            &registry_admin_ctx(),
713            managed_namespace_draft("red.config.audit"),
714            1_000,
715        )
716        .unwrap();
717
718        let gate = ManagedConfigGate::new(&reg);
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        // A sibling outside the namespace stays a PassThrough.
745        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        // When both a namespace and a more specific key entry exist,
755        // the key entry is the one that gates the decision.
756        let store = store();
757        let seeder = seed_registry_admin(&store);
758        let reg = ConfigRegistry::new();
759        // Namespace: non-managed.
760        let mut ns = managed_namespace_draft("red.config.audit");
761        ns.managed = false;
762        reg.register(&store, &seeder, &registry_admin_ctx(), ns, 1_000)
763            .unwrap();
764        // Specific key: managed.
765        reg.register(
766            &store,
767            &seeder,
768            &registry_admin_ctx(),
769            managed_key_draft("red.config.audit.enabled"),
770            1_000,
771        )
772        .unwrap();
773
774        let gate = ManagedConfigGate::new(&reg);
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        // Specific key wins → managed → Deny.
782        let d = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
783        assert!(matches!(d, ManagedConfigDecision::Deny { .. }), "got {d:?}");
784        // Sibling under the namespace falls through (namespace was
785        // marked unmanaged).
786        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        // Pin the contract: a registry entry whose `resource_type` is
796        // not config_key / config_namespace (e.g. `vault_path` reusing a
797        // dotted id) must not silently fire the config gate.
798        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, &registry_admin_ctx(), d, 1_000)
804            .unwrap();
805
806        let gate = ManagedConfigGate::new(&reg);
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}