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    /// Evaluate a canonical content key from the physical `red.config`
160    /// collection against registry IDs used before content-key normalization.
161    /// A deny from any matching managed entry wins; otherwise the first allow
162    /// wins and a fully unmatched key passes through.
163    pub fn check_red_config_write(
164        &self,
165        auth: &AuthStore,
166        actor: &UserId,
167        ctx: &EvalContext,
168        content_key: &str,
169    ) -> ManagedConfigDecision {
170        let candidates = [
171            content_key.to_string(),
172            format!("red.{content_key}"),
173            format!("red.config.{content_key}"),
174        ];
175        let mut allowed = None;
176        for candidate in &candidates {
177            match self.check_write(auth, actor, ctx, candidate) {
178                denied @ ManagedConfigDecision::Deny { .. } => return denied,
179                decision @ ManagedConfigDecision::Allow { .. } => {
180                    if allowed.is_none() {
181                        allowed = Some(decision);
182                    }
183                }
184                ManagedConfigDecision::PassThrough { .. } => {}
185            }
186        }
187        allowed.unwrap_or_else(|| ManagedConfigDecision::PassThrough {
188            key: content_key.to_string(),
189        })
190    }
191
192    /// Most-specific registry entry governing `key`: exact match first,
193    /// then dotted-namespace ancestors (`a.b.c` → `a.b` → `a`). Only
194    /// entries whose `resource_type` is `config_key` (for exact match)
195    /// or `config_namespace` (for ancestor match) qualify; an entry of
196    /// any other resource_type is ignored even if its id collides with
197    /// the key — those entries describe other governance surfaces
198    /// (vault paths, policies, audit) and must not silently gate config
199    /// writes.
200    fn lookup_governing_entry(&self, key: &str) -> Option<ConfigRegistryEntry> {
201        if let Some(e) = self.registry.get_active(key) {
202            if e.resource_type == RESOURCE_TYPE_CONFIG_KEY {
203                return Some(e);
204            }
205        }
206        let mut cursor = key;
207        while let Some(idx) = cursor.rfind('.') {
208            cursor = &cursor[..idx];
209            if let Some(e) = self.registry.get_active(cursor) {
210                if e.resource_type == RESOURCE_TYPE_CONFIG_NAMESPACE {
211                    return Some(e);
212                }
213            }
214        }
215        None
216    }
217}
218
219/// Split `"kind:name"` from a registry entry's `required_resource`.
220/// Falls back to `("config", whole_string)` when the colon is absent so
221/// older entries that just stored a bare config key still produce a
222/// well-formed [`ResourceRef`].
223fn split_required_resource(s: &str) -> (&str, &str) {
224    match s.split_once(':') {
225        Some((k, n)) if !k.is_empty() => (k, n),
226        _ => ("config", s),
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::auth::policies::Policy;
234    use crate::auth::registry::{ConfigRegistryDraft, Mutability, Sensitivity};
235    use crate::auth::store::PrincipalRef;
236    use crate::auth::{AuthConfig, Role};
237    use std::sync::Arc;
238
239    fn store() -> Arc<AuthStore> {
240        Arc::new(AuthStore::new(AuthConfig::default()))
241    }
242
243    fn registry_admin_ctx() -> EvalContext {
244        // Admin shape used only for *seeding* the registry — managed
245        // entries register via the governance path, which requires
246        // `red.registry:*` allow. The fact that the seeder is admin
247        // here is incidental.
248        EvalContext {
249            principal_tenant: None,
250            current_tenant: None,
251            peer_ip: None,
252            mfa_present: false,
253            now_ms: 1_700_000_000_000,
254            principal_is_admin_role: true,
255            principal_is_platform_scoped: true,
256        }
257    }
258
259    fn allow_all_registry(id: &str) -> Policy {
260        Policy::from_json_str(&format!(
261            r#"{{
262                "id": "{id}",
263                "version": 1,
264                "statements": [{{
265                    "effect": "allow",
266                    "actions": ["red.registry:*"],
267                    "resources": ["registry:*"]
268                }}]
269            }}"#
270        ))
271        .unwrap()
272    }
273
274    fn allow_config_write(id: &str, resource_glob: &str) -> Policy {
275        Policy::from_json_str(&format!(
276            r#"{{
277                "id": "{id}",
278                "version": 1,
279                "statements": [{{
280                    "effect": "allow",
281                    "actions": ["config:write"],
282                    "resources": ["{resource_glob}"]
283                }}]
284            }}"#
285        ))
286        .unwrap()
287    }
288
289    fn deny_config_write(id: &str, resource_glob: &str) -> Policy {
290        Policy::from_json_str(&format!(
291            r#"{{
292                "id": "{id}",
293                "version": 1,
294                "statements": [{{
295                    "effect": "deny",
296                    "actions": ["config:write"],
297                    "resources": ["{resource_glob}"]
298                }}]
299            }}"#
300        ))
301        .unwrap()
302    }
303
304    fn allow_all_config(id: &str) -> Policy {
305        Policy::from_json_str(&format!(
306            r#"{{
307                "id": "{id}",
308                "version": 1,
309                "statements": [{{
310                    "effect": "allow",
311                    "actions": ["config:*"],
312                    "resources": ["*"]
313                }}]
314            }}"#
315        ))
316        .unwrap()
317    }
318
319    fn seed_registry_admin(store: &Arc<AuthStore>) -> UserId {
320        store.create_user("seeder", "p", Role::Admin).unwrap();
321        let uid = UserId::platform("seeder");
322        store.put_policy(allow_all_registry("p-reg-allow")).unwrap();
323        store
324            .attach_policy(PrincipalRef::User(uid.clone()), "p-reg-allow")
325            .unwrap();
326        uid
327    }
328
329    fn managed_key_draft(id: &str) -> ConfigRegistryDraft {
330        ConfigRegistryDraft {
331            id: id.to_string(),
332            resource_type: RESOURCE_TYPE_CONFIG_KEY.into(),
333            schema: "string".into(),
334            mutability: Mutability::MutableViaGovernance,
335            sensitivity: Sensitivity::Internal,
336            managed: true,
337            required_action: "config:write".into(),
338            required_resource: format!("config:{id}"),
339            evidence_requirement: EvidenceRequirement::Metadata,
340        }
341    }
342
343    fn managed_namespace_draft(id: &str) -> ConfigRegistryDraft {
344        ConfigRegistryDraft {
345            id: id.to_string(),
346            resource_type: RESOURCE_TYPE_CONFIG_NAMESPACE.into(),
347            schema: "namespace".into(),
348            mutability: Mutability::MutableViaGovernance,
349            sensitivity: Sensitivity::Internal,
350            managed: true,
351            required_action: "config:write".into(),
352            required_resource: format!("config:{id}.*"),
353            evidence_requirement: EvidenceRequirement::Metadata,
354        }
355    }
356
357    fn unmanaged_key_draft(id: &str) -> ConfigRegistryDraft {
358        let mut d = managed_key_draft(id);
359        d.managed = false;
360        d
361    }
362
363    // ---- acceptance #1 ---------------------------------------------------
364
365    #[test]
366    fn registry_entry_can_mark_a_config_key_as_managed() {
367        // The registry already supports the `managed` flag; this test
368        // pins that the gate reads it as the trigger for enforcement.
369        let store = store();
370        let seeder = seed_registry_admin(&store);
371        let reg = ConfigRegistry::new();
372        let entry = reg
373            .register(
374                &store,
375                &seeder,
376                &registry_admin_ctx(),
377                managed_key_draft("red.config.audit.enabled"),
378                1_000,
379            )
380            .unwrap();
381        assert!(entry.managed, "draft.managed must propagate to entry");
382
383        // Sanity: an unmanaged sibling registers with managed=false.
384        let unmanaged = reg
385            .register(
386                &store,
387                &seeder,
388                &registry_admin_ctx(),
389                unmanaged_key_draft("app.feature_flag"),
390                1_000,
391            )
392            .unwrap();
393        assert!(!unmanaged.managed);
394    }
395
396    #[test]
397    fn canonical_content_keys_match_both_legacy_registry_prefixes() {
398        for (registry_id, content_key) in [
399            ("red.ai", "ai.default.provider"),
400            ("red.config.audit", "audit.enabled"),
401        ] {
402            let store = store();
403            let seeder = seed_registry_admin(&store);
404            store.create_user("alice", "p", Role::Read).unwrap();
405            let alice = UserId::platform("alice");
406            let registry = ConfigRegistry::new();
407            registry
408                .register(
409                    &store,
410                    &seeder,
411                    &registry_admin_ctx(),
412                    managed_namespace_draft(registry_id),
413                    1_000,
414                )
415                .unwrap();
416            let ctx = EvalContext {
417                principal_tenant: None,
418                current_tenant: None,
419                peer_ip: None,
420                mfa_present: false,
421                now_ms: 1_700_000_000_001,
422                principal_is_admin_role: false,
423                principal_is_platform_scoped: true,
424            };
425
426            let decision = ManagedConfigGate::new(&registry).check_red_config_write(
427                &store,
428                &alice,
429                &ctx,
430                content_key,
431            );
432            assert!(
433                matches!(
434                    decision,
435                    ManagedConfigDecision::Deny { ref entry_id, .. }
436                        if entry_id == registry_id
437                ),
438                "canonical key {content_key} bypassed managed registry {registry_id}: {decision:?}"
439            );
440        }
441    }
442
443    // ---- acceptance #2 ---------------------------------------------------
444
445    #[test]
446    fn ordinary_allow_all_user_is_allowed_on_managed_key() {
447        // Managed config is policy-first: an ordinary principal with a
448        // matching policy can write a managed key. Protection comes from
449        // explicit Deny or missing permission, not a structural flag.
450        let store = store();
451        let seeder = seed_registry_admin(&store);
452        store.create_user("alice", "p", Role::Admin).unwrap();
453        let alice = UserId::platform("alice");
454        store.put_policy(allow_all_config("p-allow-cfg")).unwrap();
455        store
456            .attach_policy(PrincipalRef::User(alice.clone()), "p-allow-cfg")
457            .unwrap();
458
459        let reg = ConfigRegistry::new();
460        reg.register(
461            &store,
462            &seeder,
463            &registry_admin_ctx(),
464            managed_key_draft("red.config.audit.enabled"),
465            1_000,
466        )
467        .unwrap();
468
469        let gate = ManagedConfigGate::new(&reg);
470        let ctx = EvalContext {
471            principal_tenant: None,
472            current_tenant: None,
473            peer_ip: None,
474            mfa_present: false,
475            now_ms: 1_700_000_000_001,
476            principal_is_admin_role: true,
477            principal_is_platform_scoped: true,
478        };
479        let decision = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
480        assert!(
481            matches!(decision, ManagedConfigDecision::Allow { .. }),
482            "got {decision:?}"
483        );
484    }
485
486    // ---- acceptance #3 ---------------------------------------------------
487
488    #[test]
489    fn caller_with_matching_policy_is_allowed() {
490        let store = store();
491        let seeder = seed_registry_admin(&store);
492        store
493            .create_admin_user("ops", "p", Role::Admin, None)
494            .expect("create user");
495        let ops = UserId::platform("ops");
496        store
497            .put_policy(allow_config_write(
498                "p-cfg-write",
499                "config:red.config.audit.*",
500            ))
501            .unwrap();
502        store
503            .attach_policy(PrincipalRef::User(ops.clone()), "p-cfg-write")
504            .unwrap();
505
506        let reg = ConfigRegistry::new();
507        reg.register(
508            &store,
509            &seeder,
510            &registry_admin_ctx(),
511            managed_key_draft("red.config.audit.enabled"),
512            1_000,
513        )
514        .unwrap();
515
516        let gate = ManagedConfigGate::new(&reg);
517        let ctx = EvalContext {
518            principal_tenant: None,
519            current_tenant: None,
520            peer_ip: None,
521            mfa_present: false,
522            now_ms: 1_700_000_000_002,
523            principal_is_admin_role: true,
524            principal_is_platform_scoped: true,
525        };
526        let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
527        assert!(
528            matches!(decision, ManagedConfigDecision::Allow { .. }),
529            "got {decision:?}"
530        );
531        assert!(decision.permitted());
532    }
533
534    #[test]
535    fn caller_without_matching_policy_is_policy_denied() {
536        // No allow grants `config:write` on the managed key —
537        // DefaultDeny falls out of the evaluator.
538        let store = store();
539        let seeder = seed_registry_admin(&store);
540        store
541            .create_admin_user("ops", "p", Role::Write, None)
542            .unwrap();
543        let ops = UserId::platform("ops");
544        // Attach an unrelated policy so the evaluator runs through IAM
545        // rather than short-circuiting on "no policies".
546        store
547            .put_policy(
548                Policy::from_json_str(
549                    r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.x"]}]}"#,
550                )
551                .unwrap(),
552            )
553            .unwrap();
554        store
555            .attach_policy(PrincipalRef::User(ops.clone()), "p-unrelated")
556            .unwrap();
557
558        let reg = ConfigRegistry::new();
559        reg.register(
560            &store,
561            &seeder,
562            &registry_admin_ctx(),
563            managed_key_draft("red.config.audit.enabled"),
564            1_000,
565        )
566        .unwrap();
567
568        let gate = ManagedConfigGate::new(&reg);
569        let ctx = EvalContext {
570            principal_tenant: None,
571            current_tenant: None,
572            peer_ip: None,
573            mfa_present: false,
574            now_ms: 1_700_000_000_003,
575            principal_is_admin_role: false,
576            principal_is_platform_scoped: true,
577        };
578        let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
579        match decision {
580            ManagedConfigDecision::Deny { reason, .. } => {
581                assert!(matches!(reason, DenyReason::PolicyDenied), "got {reason:?}");
582            }
583            other => panic!("expected Deny(PolicyDenied), got {other:?}"),
584        }
585    }
586
587    #[test]
588    fn explicit_deny_overrides_allow() {
589        // Principal with a broad config-write allow *and* an explicit
590        // deny on the managed key — deny wins.
591        let store = store();
592        let seeder = seed_registry_admin(&store);
593        store
594            .create_admin_user("ops", "p", Role::Admin, None)
595            .unwrap();
596        let ops = UserId::platform("ops");
597        store.put_policy(allow_all_config("p-allow")).unwrap();
598        store
599            .put_policy(deny_config_write("p-deny", "config:red.config.audit.*"))
600            .unwrap();
601        store
602            .attach_policy(PrincipalRef::User(ops.clone()), "p-allow")
603            .unwrap();
604        store
605            .attach_policy(PrincipalRef::User(ops.clone()), "p-deny")
606            .unwrap();
607
608        let reg = ConfigRegistry::new();
609        reg.register(
610            &store,
611            &seeder,
612            &registry_admin_ctx(),
613            managed_key_draft("red.config.audit.enabled"),
614            1_000,
615        )
616        .unwrap();
617
618        let gate = ManagedConfigGate::new(&reg);
619        let ctx = EvalContext {
620            principal_tenant: None,
621            current_tenant: None,
622            peer_ip: None,
623            mfa_present: false,
624            now_ms: 1_700_000_000_004,
625            principal_is_admin_role: true,
626            principal_is_platform_scoped: true,
627        };
628        let decision = gate.check_write(&store, &ops, &ctx, "red.config.audit.enabled");
629        match decision {
630            ManagedConfigDecision::Deny { reason, .. } => {
631                assert!(matches!(reason, DenyReason::PolicyDenied), "got {reason:?}");
632            }
633            other => panic!("expected Deny(PolicyDenied), got {other:?}"),
634        }
635    }
636
637    // ---- acceptance #4 ---------------------------------------------------
638
639    #[test]
640    fn deny_carries_resource_and_reason_for_control_event() {
641        // The Deny payload must give Control Event integration enough
642        // detail to reconstruct what was attempted and why it failed.
643        let store = store();
644        let seeder = seed_registry_admin(&store);
645        store.create_user("alice", "p", Role::Admin).unwrap();
646        let alice = UserId::platform("alice");
647
648        let reg = ConfigRegistry::new();
649        let mut draft = managed_key_draft("red.config.backup.retention_days");
650        draft.evidence_requirement = EvidenceRequirement::Full;
651        reg.register(&store, &seeder, &registry_admin_ctx(), draft, 1_000)
652            .unwrap();
653
654        let gate = ManagedConfigGate::new(&reg);
655        let ctx = EvalContext {
656            principal_tenant: None,
657            current_tenant: None,
658            peer_ip: None,
659            mfa_present: false,
660            now_ms: 1_700_000_000_005,
661            principal_is_admin_role: true,
662            principal_is_platform_scoped: true,
663        };
664        let decision = gate.check_write(&store, &alice, &ctx, "red.config.backup.retention_days");
665        match decision {
666            ManagedConfigDecision::Deny {
667                entry_id,
668                entry_version,
669                matched_action,
670                matched_resource,
671                reason,
672                ..
673            } => {
674                assert_eq!(entry_id, "red.config.backup.retention_days");
675                assert_eq!(entry_version, 1);
676                assert_eq!(matched_action, "config:write");
677                assert_eq!(matched_resource, "config:red.config.backup.retention_days");
678                // The reason is human-presentable and identifies the policy gate.
679                let rendered = reason.to_string();
680                assert!(
681                    rendered.contains("policy permission"),
682                    "reason should be Control-Event-renderable: {rendered}"
683                );
684            }
685            other => panic!("expected Deny, got {other:?}"),
686        }
687    }
688
689    // ---- acceptance #5 ---------------------------------------------------
690
691    #[test]
692    fn non_managed_application_config_passes_through() {
693        // No registry entry at all → PassThrough.
694        let store = store();
695        let _ = seed_registry_admin(&store);
696        let reg = ConfigRegistry::new();
697        let gate = ManagedConfigGate::new(&reg);
698        let ctx = EvalContext::default();
699        let alice = UserId::platform("alice");
700        let d = gate.check_write(&store, &alice, &ctx, "app.feature_flag");
701        assert!(matches!(d, ManagedConfigDecision::PassThrough { .. }));
702        assert!(d.permitted());
703    }
704
705    #[test]
706    fn unmanaged_registry_entry_also_passes_through() {
707        // Entry exists but `managed=false` — the registry knows about
708        // the key (so schema/sensitivity metadata is available) but the
709        // gate must not interfere with ordinary policy rules.
710        let store = store();
711        let seeder = seed_registry_admin(&store);
712        let reg = ConfigRegistry::new();
713        reg.register(
714            &store,
715            &seeder,
716            &registry_admin_ctx(),
717            unmanaged_key_draft("app.feature_flag"),
718            1_000,
719        )
720        .unwrap();
721
722        let gate = ManagedConfigGate::new(&reg);
723        let ctx = EvalContext::default();
724        let alice = UserId::platform("alice");
725        let d = gate.check_write(&store, &alice, &ctx, "app.feature_flag");
726        assert!(matches!(d, ManagedConfigDecision::PassThrough { .. }));
727    }
728
729    // ---- namespace matching contract ------------------------------------
730
731    #[test]
732    fn namespace_entry_governs_descendant_keys() {
733        // A `config_namespace` entry at `red.config.audit` must gate
734        // writes to `red.config.audit.enabled` and any deeper key.
735        let store = store();
736        let seeder = seed_registry_admin(&store);
737        let reg = ConfigRegistry::new();
738        reg.register(
739            &store,
740            &seeder,
741            &registry_admin_ctx(),
742            managed_namespace_draft("red.config.audit"),
743            1_000,
744        )
745        .unwrap();
746
747        let gate = ManagedConfigGate::new(&reg);
748        let alice = UserId::platform("alice");
749        let ctx = EvalContext {
750            principal_is_platform_scoped: true,
751            ..EvalContext::default()
752        };
753
754        for key in [
755            "red.config.audit.enabled",
756            "red.config.audit.sink.kafka.brokers",
757        ] {
758            let d = gate.check_write(&store, &alice, &ctx, key);
759            match d {
760                ManagedConfigDecision::Deny {
761                    entry_id,
762                    matched_resource,
763                    ..
764                } => {
765                    assert_eq!(entry_id, "red.config.audit");
766                    assert_eq!(matched_resource, "config:red.config.audit.*");
767                }
768                other => panic!("expected Deny for {key}, got {other:?}"),
769            }
770        }
771
772        // A sibling outside the namespace stays a PassThrough.
773        let d = gate.check_write(&store, &alice, &ctx, "red.config.storage.tier");
774        assert!(
775            matches!(d, ManagedConfigDecision::PassThrough { .. }),
776            "got {d:?}"
777        );
778    }
779
780    #[test]
781    fn exact_key_entry_wins_over_namespace_entry() {
782        // When both a namespace and a more specific key entry exist,
783        // the key entry is the one that gates the decision.
784        let store = store();
785        let seeder = seed_registry_admin(&store);
786        let reg = ConfigRegistry::new();
787        // Namespace: non-managed.
788        let mut ns = managed_namespace_draft("red.config.audit");
789        ns.managed = false;
790        reg.register(&store, &seeder, &registry_admin_ctx(), ns, 1_000)
791            .unwrap();
792        // Specific key: managed.
793        reg.register(
794            &store,
795            &seeder,
796            &registry_admin_ctx(),
797            managed_key_draft("red.config.audit.enabled"),
798            1_000,
799        )
800        .unwrap();
801
802        let gate = ManagedConfigGate::new(&reg);
803        let alice = UserId::platform("alice");
804        let ctx = EvalContext {
805            principal_is_platform_scoped: true,
806            ..EvalContext::default()
807        };
808        // Specific key wins → managed → Deny.
809        let d = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
810        assert!(matches!(d, ManagedConfigDecision::Deny { .. }), "got {d:?}");
811        // Sibling under the namespace falls through (namespace was
812        // marked unmanaged).
813        let d = gate.check_write(&store, &alice, &ctx, "red.config.audit.sink");
814        assert!(
815            matches!(d, ManagedConfigDecision::PassThrough { .. }),
816            "got {d:?}"
817        );
818    }
819
820    #[test]
821    fn entry_with_unrelated_resource_type_does_not_gate_config_writes() {
822        // Pin the contract: a registry entry whose `resource_type` is
823        // not config_key / config_namespace (e.g. `vault_path` reusing a
824        // dotted id) must not silently fire the config gate.
825        let store = store();
826        let seeder = seed_registry_admin(&store);
827        let reg = ConfigRegistry::new();
828        let mut d = managed_key_draft("red.config.audit.enabled");
829        d.resource_type = "vault_path".into();
830        reg.register(&store, &seeder, &registry_admin_ctx(), d, 1_000)
831            .unwrap();
832
833        let gate = ManagedConfigGate::new(&reg);
834        let alice = UserId::platform("alice");
835        let ctx = EvalContext::default();
836        let dec = gate.check_write(&store, &alice, &ctx, "red.config.audit.enabled");
837        assert!(
838            matches!(dec, ManagedConfigDecision::PassThrough { .. }),
839            "got {dec:?}"
840        );
841    }
842
843    #[test]
844    fn split_required_resource_handles_bare_string() {
845        assert_eq!(
846            split_required_resource("config:red.config.audit.enabled"),
847            ("config", "red.config.audit.enabled")
848        );
849        assert_eq!(
850            split_required_resource("red.config.audit.enabled"),
851            ("config", "red.config.audit.enabled")
852        );
853        assert_eq!(
854            split_required_resource(":only-name"),
855            ("config", ":only-name")
856        );
857    }
858}