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