Skip to main content

reddb_server/auth/
managed_policy.rs

1//! Managed policy guardrail (#646).
2//!
3//! Companion to [`super::managed_config::ManagedConfigGate`]. Guards
4//! mutations of IAM policy documents that the operator has marked
5//! `managed=true` in [`super::registry::ConfigRegistry`] under a
6//! `resource_type` of [`RESOURCE_TYPE_POLICY`].
7//!
8//! The gate sits in front of the four ordinary policy-mutation paths
9//! (`put_policy`, `delete_policy`, `attach_policy`, `detach_policy`).
10//! Given the policy id the caller is about to mutate, plus the
11//! operation kind, it looks the id up in the registry and:
12//!
13//! * If no entry matches, or the matched entry is **not** managed,
14//!   returns [`ManagedPolicyDecision::PassThrough`] — ordinary IAM
15//!   rules govern the mutation (criterion #1 baseline: ordinary
16//!   policies remain mutable per ordinary policy).
17//!
18//! * If the matched entry is managed, enforces two layers in order:
19//!     1. **Structural eligibility** — caller must be both system-owned
20//!        and platform-scoped (the operator-principal shape). Otherwise
21//!        [`ManagedPolicyDecision::Deny`] with
22//!        [`DenyReason::NotStructurallyEligible`]. This is the layer
23//!        that blocks ordinary allow-all admins from rewriting,
24//!        dropping, or re-attaching a managed policy (criterion #2).
25//!     2. **Policy permission** — caller must satisfy
26//!        [`AuthStore::check_policy_authz`] against the operation's
27//!        action and the entry's `required_resource`. Otherwise
28//!        [`ManagedPolicyDecision::Deny`] with
29//!        [`DenyReason::PolicyDenied`] (criterion #4: structurally
30//!        eligible caller still needs policy permission).
31//!
32//! Because the `managed=true` bit lives in the registry — not in the
33//! submitted Policy document — re-submitting a Policy JSON that omits
34//! any "managed" hint cannot flip the entry off; the gate consults the
35//! registry per call (criterion #3).
36//!
37//! Every Deny carries the matched entry id, version, op-derived action,
38//! and required resource so the Control Event Ledger / audit hook can
39//! persist the evidence (criterion #5). The matched action/resource
40//! string mirrors what `check_policy_authz` would have evaluated, so an
41//! investigator can replay the decision.
42
43use super::policies::{EvalContext, ResourceRef};
44use super::registry::{ConfigRegistry, ConfigRegistryEntry, EvidenceRequirement};
45use super::store::AuthStore;
46use super::UserId;
47
48/// Resource-type tag a registry entry must carry to govern a policy id.
49/// Entries of any other `resource_type` are ignored by this gate even if
50/// their id collides with a policy id — those entries describe other
51/// governance surfaces (config keys, vault paths, audit) and must not
52/// silently gate policy mutations.
53pub const RESOURCE_TYPE_POLICY: &str = "policy";
54
55/// Which mutation the caller is attempting on a (possibly-managed)
56/// policy id. The gate uses this to derive the policy action it asks
57/// the IAM evaluator about.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum PolicyOp {
60    /// `put_policy` — install or replace the document.
61    Put,
62    /// `delete_policy` — remove the document and its attachments.
63    Drop,
64    /// `attach_policy` — bind the policy to a user or group.
65    Attach,
66    /// `detach_policy` — unbind the policy from a user or group.
67    Detach,
68}
69
70impl PolicyOp {
71    /// IAM action verb the gate evaluates for this op against the
72    /// managed policy's `required_resource`. Matches the verbs already
73    /// in `ACTION_ALLOWLIST` (see `policies.rs`).
74    pub fn action(self) -> &'static str {
75        match self {
76            Self::Put => "policy:put",
77            Self::Drop => "policy:drop",
78            Self::Attach => "policy:attach",
79            Self::Detach => "policy:detach",
80        }
81    }
82}
83
84/// Outcome of a managed-policy mutation check.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum ManagedPolicyDecision {
87    /// Policy id is not governed by a managed registry entry. Caller
88    /// should proceed with ordinary IAM checks.
89    PassThrough { policy_id: String, op: PolicyOp },
90    /// Policy is managed and the caller satisfied both the structural
91    /// and policy gates. Caller may proceed; the returned evidence
92    /// requirement tells the Control Event Ledger how much detail to
93    /// persist.
94    Allow {
95        entry_id: String,
96        entry_version: u64,
97        op: PolicyOp,
98        matched_action: String,
99        matched_resource: String,
100        evidence: EvidenceRequirement,
101    },
102    /// Policy is managed and the caller failed one of the gates.
103    /// Carries enough metadata for Control Event / audit emission.
104    Deny {
105        entry_id: String,
106        entry_version: u64,
107        op: PolicyOp,
108        matched_action: String,
109        matched_resource: String,
110        reason: DenyReason,
111    },
112}
113
114/// Why a managed policy mutation was denied. Designed for Control Event
115/// payloads: the variant tells operators what *kind* of guard tripped,
116/// and the structured fields let them see which eligibility flag was
117/// false.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum DenyReason {
120    /// Caller is not the operator-principal shape required by managed
121    /// guardrails (must be both system-owned and platform-scoped).
122    NotStructurallyEligible {
123        is_system_owned: bool,
124        is_platform_scoped: bool,
125    },
126    /// Caller is structurally eligible but the policy evaluator rejected
127    /// the op-derived action / required-resource pair (either explicit
128    /// Deny or DefaultDeny).
129    PolicyDenied,
130}
131
132impl std::fmt::Display for DenyReason {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            Self::NotStructurallyEligible {
136                is_system_owned,
137                is_platform_scoped,
138            } => write!(
139                f,
140                "caller is not structurally eligible for managed policy \
141                 (system_owned={is_system_owned}, platform_scoped={is_platform_scoped})"
142            ),
143            Self::PolicyDenied => {
144                write!(f, "managed policy required IAM permission was denied")
145            }
146        }
147    }
148}
149
150impl ManagedPolicyDecision {
151    /// Convenience: did this decision permit the mutation (PassThrough
152    /// or Allow)?
153    pub fn permitted(&self) -> bool {
154        matches!(self, Self::PassThrough { .. } | Self::Allow { .. })
155    }
156}
157
158/// Stateless guard wrapping a [`ConfigRegistry`] reference.
159pub struct ManagedPolicyGate<'a> {
160    registry: &'a ConfigRegistry,
161}
162
163impl<'a> ManagedPolicyGate<'a> {
164    pub fn new(registry: &'a ConfigRegistry) -> Self {
165        Self { registry }
166    }
167
168    /// Evaluate `op` on `policy_id` for `actor`. Returns one of the
169    /// three [`ManagedPolicyDecision`] variants — see the module docs
170    /// for the decision rules.
171    pub fn check_mutation(
172        &self,
173        auth: &AuthStore,
174        actor: &UserId,
175        ctx: &EvalContext,
176        policy_id: &str,
177        op: PolicyOp,
178    ) -> ManagedPolicyDecision {
179        let Some(entry) = self.lookup_governing_entry(policy_id) else {
180            return ManagedPolicyDecision::PassThrough {
181                policy_id: policy_id.to_string(),
182                op,
183            };
184        };
185        if !entry.managed {
186            return ManagedPolicyDecision::PassThrough {
187                policy_id: policy_id.to_string(),
188                op,
189            };
190        }
191
192        let (kind, name) = split_required_resource(&entry.required_resource, policy_id);
193        let matched_resource = format!("{kind}:{name}");
194        let matched_action = op.action().to_string();
195
196        if !(ctx.principal_is_system_owned && ctx.principal_is_platform_scoped) {
197            return ManagedPolicyDecision::Deny {
198                entry_id: entry.id.clone(),
199                entry_version: entry.version,
200                op,
201                matched_action,
202                matched_resource,
203                reason: DenyReason::NotStructurallyEligible {
204                    is_system_owned: ctx.principal_is_system_owned,
205                    is_platform_scoped: ctx.principal_is_platform_scoped,
206                },
207            };
208        }
209
210        let resource = ResourceRef::new(kind, name);
211        if !auth.check_policy_authz(actor, op.action(), &resource, ctx) {
212            return ManagedPolicyDecision::Deny {
213                entry_id: entry.id.clone(),
214                entry_version: entry.version,
215                op,
216                matched_action,
217                matched_resource,
218                reason: DenyReason::PolicyDenied,
219            };
220        }
221
222        ManagedPolicyDecision::Allow {
223            entry_id: entry.id,
224            entry_version: entry.version,
225            op,
226            matched_action,
227            matched_resource,
228            evidence: entry.evidence_requirement,
229        }
230    }
231
232    /// Registry entry governing `policy_id` — exact id match against an
233    /// entry whose `resource_type` is [`RESOURCE_TYPE_POLICY`]. There is
234    /// no namespace fallback for policies (unlike config keys): policy
235    /// ids are flat, not dotted, so a "most-specific" walk has nothing
236    /// to climb.
237    fn lookup_governing_entry(&self, policy_id: &str) -> Option<ConfigRegistryEntry> {
238        let e = self.registry.get_active(policy_id)?;
239        if e.resource_type == RESOURCE_TYPE_POLICY {
240            Some(e)
241        } else {
242            None
243        }
244    }
245}
246
247/// Split `"kind:name"` from a registry entry's `required_resource`.
248/// Falls back to `("policy", policy_id)` when the colon is absent so
249/// older entries that just stored a bare policy id still produce a
250/// well-formed [`ResourceRef`] aligned with the policy id under
251/// evaluation.
252fn split_required_resource<'a>(s: &'a str, policy_id: &'a str) -> (&'a str, &'a str) {
253    match s.split_once(':') {
254        Some((k, n)) if !k.is_empty() => (k, n),
255        _ => ("policy", policy_id),
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::auth::policies::Policy;
263    use crate::auth::registry::{ConfigRegistryDraft, Mutability, Sensitivity};
264    use crate::auth::store::PrincipalRef;
265    use crate::auth::{AuthConfig, Role};
266    use std::sync::Arc;
267
268    fn store() -> Arc<AuthStore> {
269        Arc::new(AuthStore::new(AuthConfig::default()))
270    }
271
272    fn registry_admin_ctx() -> EvalContext {
273        EvalContext {
274            principal_tenant: None,
275            current_tenant: None,
276            peer_ip: None,
277            mfa_present: false,
278            now_ms: 1_700_000_000_000,
279            principal_is_admin_role: true,
280            principal_is_system_owned: false,
281            principal_is_platform_scoped: true,
282        }
283    }
284
285    fn allow_all_registry(id: &str) -> Policy {
286        Policy::from_json_str(&format!(
287            r#"{{
288                "id": "{id}",
289                "version": 1,
290                "statements": [{{
291                    "effect": "allow",
292                    "actions": ["red.registry:*"],
293                    "resources": ["registry:*"]
294                }}]
295            }}"#
296        ))
297        .unwrap()
298    }
299
300    fn allow_all_policies(id: &str) -> Policy {
301        Policy::from_json_str(&format!(
302            r#"{{
303                "id": "{id}",
304                "version": 1,
305                "statements": [{{
306                    "effect": "allow",
307                    "actions": ["policy:*"],
308                    "resources": ["*"]
309                }}]
310            }}"#
311        ))
312        .unwrap()
313    }
314
315    fn allow_policy_action(id: &str, action: &str, resource_glob: &str) -> Policy {
316        Policy::from_json_str(&format!(
317            r#"{{
318                "id": "{id}",
319                "version": 1,
320                "statements": [{{
321                    "effect": "allow",
322                    "actions": ["{action}"],
323                    "resources": ["{resource_glob}"]
324                }}]
325            }}"#
326        ))
327        .unwrap()
328    }
329
330    fn deny_policy_action(id: &str, action: &str, resource_glob: &str) -> Policy {
331        Policy::from_json_str(&format!(
332            r#"{{
333                "id": "{id}",
334                "version": 1,
335                "statements": [{{
336                    "effect": "deny",
337                    "actions": ["{action}"],
338                    "resources": ["{resource_glob}"]
339                }}]
340            }}"#
341        ))
342        .unwrap()
343    }
344
345    fn seed_registry_admin(store: &Arc<AuthStore>) -> UserId {
346        store.create_user("seeder", "p", Role::Admin).unwrap();
347        let uid = UserId::platform("seeder");
348        store.put_policy(allow_all_registry("p-reg-allow")).unwrap();
349        store
350            .attach_policy(PrincipalRef::User(uid.clone()), "p-reg-allow")
351            .unwrap();
352        uid
353    }
354
355    fn managed_policy_draft(id: &str) -> ConfigRegistryDraft {
356        ConfigRegistryDraft {
357            id: id.to_string(),
358            resource_type: RESOURCE_TYPE_POLICY.into(),
359            schema: "iam-policy/v1".into(),
360            mutability: Mutability::MutableViaGovernance,
361            sensitivity: Sensitivity::Internal,
362            managed: true,
363            // For policy ops the gate derives the action from PolicyOp;
364            // `required_action` is recorded for completeness but is not
365            // what the evaluator is asked about.
366            required_action: "policy:*".into(),
367            required_resource: format!("policy:{id}"),
368            evidence_requirement: EvidenceRequirement::Metadata,
369        }
370    }
371
372    fn unmanaged_policy_draft(id: &str) -> ConfigRegistryDraft {
373        let mut d = managed_policy_draft(id);
374        d.managed = false;
375        d
376    }
377
378    // ---- acceptance #1 ---------------------------------------------------
379
380    #[test]
381    fn policy_can_be_installed_as_managed_through_registry() {
382        // Governance path is `ConfigRegistry::register` — gated by
383        // `red.registry:register` on `registry:<id>`. The same surface
384        // that pins managed configs also pins managed policies.
385        let store = store();
386        let seeder = seed_registry_admin(&store);
387        let reg = ConfigRegistry::new();
388        let entry = reg
389            .register(
390                &store,
391                &seeder,
392                &registry_admin_ctx(),
393                managed_policy_draft("p-baseline-readonly"),
394                1_000,
395            )
396            .expect("register");
397        assert!(entry.managed);
398        assert_eq!(entry.resource_type, RESOURCE_TYPE_POLICY);
399
400        // Sanity: an unmanaged sibling registers with managed=false.
401        let other = reg
402            .register(
403                &store,
404                &seeder,
405                &registry_admin_ctx(),
406                unmanaged_policy_draft("p-tenant-custom"),
407                1_000,
408            )
409            .unwrap();
410        assert!(!other.managed);
411    }
412
413    // ---- acceptance #2 ---------------------------------------------------
414
415    #[test]
416    fn ordinary_allow_all_user_cannot_put_drop_attach_or_detach_managed_policy() {
417        // alice@platform has `policy:*` on `*` — would normally let her
418        // do anything to any policy. The managed gate must block all
419        // four ops because she lacks the operator structural shape.
420        let store = store();
421        let seeder = seed_registry_admin(&store);
422        store.create_user("alice", "p", Role::Admin).unwrap();
423        let alice = UserId::platform("alice");
424        store
425            .put_policy(allow_all_policies("p-alice-allow"))
426            .unwrap();
427        store
428            .attach_policy(PrincipalRef::User(alice.clone()), "p-alice-allow")
429            .unwrap();
430
431        let reg = ConfigRegistry::new();
432        reg.register(
433            &store,
434            &seeder,
435            &registry_admin_ctx(),
436            managed_policy_draft("p-baseline-readonly"),
437            1_000,
438        )
439        .unwrap();
440
441        let gate = ManagedPolicyGate::new(&reg);
442        let ctx = EvalContext {
443            principal_tenant: None,
444            current_tenant: None,
445            peer_ip: None,
446            mfa_present: false,
447            now_ms: 1_700_000_000_001,
448            principal_is_admin_role: true,
449            principal_is_system_owned: false, // <-- the key gate
450            principal_is_platform_scoped: true,
451        };
452        for op in [
453            PolicyOp::Put,
454            PolicyOp::Drop,
455            PolicyOp::Attach,
456            PolicyOp::Detach,
457        ] {
458            let decision = gate.check_mutation(&store, &alice, &ctx, "p-baseline-readonly", op);
459            match decision {
460                ManagedPolicyDecision::Deny {
461                    entry_id,
462                    matched_action,
463                    matched_resource,
464                    reason,
465                    op: got_op,
466                    ..
467                } => {
468                    assert_eq!(entry_id, "p-baseline-readonly");
469                    assert_eq!(got_op, op);
470                    assert_eq!(matched_action, op.action());
471                    assert_eq!(matched_resource, "policy:p-baseline-readonly");
472                    assert!(
473                        matches!(
474                            reason,
475                            DenyReason::NotStructurallyEligible {
476                                is_system_owned: false,
477                                is_platform_scoped: true
478                            }
479                        ),
480                        "op={op:?} got {reason:?}"
481                    );
482                }
483                other => panic!("expected Deny for {op:?}, got {other:?}"),
484            }
485        }
486    }
487
488    // ---- acceptance #3 ---------------------------------------------------
489
490    #[test]
491    fn re_putting_managed_policy_without_managed_metadata_still_blocked() {
492        // The "managed" bit lives in the registry, not in the policy
493        // document. A caller who rewrites the policy JSON to omit any
494        // managed hint must NOT thereby unlock the policy: the gate is
495        // consulted per call against the registry entry, which still
496        // says managed=true.
497        let store = store();
498        let seeder = seed_registry_admin(&store);
499        store.create_user("alice", "p", Role::Admin).unwrap();
500        let alice = UserId::platform("alice");
501        store
502            .put_policy(allow_all_policies("p-alice-allow"))
503            .unwrap();
504        store
505            .attach_policy(PrincipalRef::User(alice.clone()), "p-alice-allow")
506            .unwrap();
507
508        let reg = ConfigRegistry::new();
509        reg.register(
510            &store,
511            &seeder,
512            &registry_admin_ctx(),
513            managed_policy_draft("p-baseline-readonly"),
514            1_000,
515        )
516        .unwrap();
517
518        let gate = ManagedPolicyGate::new(&reg);
519        let ctx = EvalContext {
520            principal_tenant: None,
521            current_tenant: None,
522            peer_ip: None,
523            mfa_present: false,
524            now_ms: 1_700_000_000_002,
525            principal_is_admin_role: true,
526            principal_is_system_owned: false,
527            principal_is_platform_scoped: true,
528        };
529
530        // First attempt — straightforward put — blocked.
531        let d1 = gate.check_mutation(&store, &alice, &ctx, "p-baseline-readonly", PolicyOp::Put);
532        assert!(matches!(d1, ManagedPolicyDecision::Deny { .. }), "{d1:?}");
533
534        // Second attempt — caller "submits" a stripped policy doc; the
535        // gate result is identical because it only consults the
536        // registry. The registry entry stays managed at v1; no
537        // supersede happened.
538        let d2 = gate.check_mutation(&store, &alice, &ctx, "p-baseline-readonly", PolicyOp::Put);
539        assert!(matches!(d2, ManagedPolicyDecision::Deny { .. }), "{d2:?}");
540
541        assert_eq!(reg.get_active("p-baseline-readonly").unwrap().version, 1);
542        assert!(reg.get_active("p-baseline-readonly").unwrap().managed);
543    }
544
545    // ---- acceptance #4 ---------------------------------------------------
546
547    #[test]
548    fn structurally_eligible_caller_with_matching_policy_is_allowed() {
549        let store = store();
550        let seeder = seed_registry_admin(&store);
551        store
552            .create_system_user("ops", "p", Role::Admin, None)
553            .expect("create system-owned user");
554        let ops = UserId::platform("ops");
555        // Grant ops `policy:*` on the managed policy resource.
556        store
557            .put_policy(allow_policy_action(
558                "p-ops-policy",
559                "policy:*",
560                "policy:p-baseline-readonly",
561            ))
562            .unwrap();
563        store
564            .attach_policy(PrincipalRef::User(ops.clone()), "p-ops-policy")
565            .unwrap();
566
567        let reg = ConfigRegistry::new();
568        reg.register(
569            &store,
570            &seeder,
571            &registry_admin_ctx(),
572            managed_policy_draft("p-baseline-readonly"),
573            1_000,
574        )
575        .unwrap();
576
577        let gate = ManagedPolicyGate::new(&reg);
578        let ctx = EvalContext {
579            principal_tenant: None,
580            current_tenant: None,
581            peer_ip: None,
582            mfa_present: false,
583            now_ms: 1_700_000_000_003,
584            principal_is_admin_role: true,
585            principal_is_system_owned: true,
586            principal_is_platform_scoped: true,
587        };
588        for op in [
589            PolicyOp::Put,
590            PolicyOp::Drop,
591            PolicyOp::Attach,
592            PolicyOp::Detach,
593        ] {
594            let decision = gate.check_mutation(&store, &ops, &ctx, "p-baseline-readonly", op);
595            assert!(
596                matches!(decision, ManagedPolicyDecision::Allow { .. }),
597                "op={op:?} got {decision:?}"
598            );
599            assert!(decision.permitted());
600        }
601    }
602
603    #[test]
604    fn structurally_eligible_caller_without_matching_policy_is_policy_denied() {
605        // Operator shape is right, but no allow grants the per-op
606        // action on the managed policy — DefaultDeny falls out of the
607        // evaluator.
608        let store = store();
609        let seeder = seed_registry_admin(&store);
610        store
611            .create_system_user("ops", "p", Role::Write, None)
612            .unwrap();
613        let ops = UserId::platform("ops");
614        // Unrelated policy so the evaluator goes through IAM rather
615        // than short-circuiting on "no policies".
616        store
617            .put_policy(
618                Policy::from_json_str(
619                    r#"{"id":"p-unrelated","version":1,"statements":[{"effect":"allow","actions":["select"],"resources":["table:public.x"]}]}"#,
620                )
621                .unwrap(),
622            )
623            .unwrap();
624        store
625            .attach_policy(PrincipalRef::User(ops.clone()), "p-unrelated")
626            .unwrap();
627
628        let reg = ConfigRegistry::new();
629        reg.register(
630            &store,
631            &seeder,
632            &registry_admin_ctx(),
633            managed_policy_draft("p-baseline-readonly"),
634            1_000,
635        )
636        .unwrap();
637
638        let gate = ManagedPolicyGate::new(&reg);
639        let ctx = EvalContext {
640            principal_tenant: None,
641            current_tenant: None,
642            peer_ip: None,
643            mfa_present: false,
644            now_ms: 1_700_000_000_004,
645            principal_is_admin_role: false,
646            principal_is_system_owned: true,
647            principal_is_platform_scoped: true,
648        };
649        let decision =
650            gate.check_mutation(&store, &ops, &ctx, "p-baseline-readonly", PolicyOp::Put);
651        match decision {
652            ManagedPolicyDecision::Deny { reason, .. } => {
653                assert!(matches!(reason, DenyReason::PolicyDenied), "got {reason:?}");
654            }
655            other => panic!("expected Deny(PolicyDenied), got {other:?}"),
656        }
657    }
658
659    #[test]
660    fn explicit_deny_overrides_structural_allow() {
661        // System-owned operator with a broad policy-* allow *and* an
662        // explicit deny on the managed policy resource — deny wins
663        // (policy-first, per #644).
664        let store = store();
665        let seeder = seed_registry_admin(&store);
666        store
667            .create_system_user("ops", "p", Role::Admin, None)
668            .unwrap();
669        let ops = UserId::platform("ops");
670        store.put_policy(allow_all_policies("p-allow")).unwrap();
671        store
672            .put_policy(deny_policy_action(
673                "p-deny",
674                "policy:put",
675                "policy:p-baseline-readonly",
676            ))
677            .unwrap();
678        store
679            .attach_policy(PrincipalRef::User(ops.clone()), "p-allow")
680            .unwrap();
681        store
682            .attach_policy(PrincipalRef::User(ops.clone()), "p-deny")
683            .unwrap();
684
685        let reg = ConfigRegistry::new();
686        reg.register(
687            &store,
688            &seeder,
689            &registry_admin_ctx(),
690            managed_policy_draft("p-baseline-readonly"),
691            1_000,
692        )
693        .unwrap();
694
695        let gate = ManagedPolicyGate::new(&reg);
696        let ctx = EvalContext {
697            principal_tenant: None,
698            current_tenant: None,
699            peer_ip: None,
700            mfa_present: false,
701            now_ms: 1_700_000_000_005,
702            principal_is_admin_role: true,
703            principal_is_system_owned: true,
704            principal_is_platform_scoped: true,
705        };
706        // The put op is explicitly denied → PolicyDenied even for the
707        // structurally-eligible operator.
708        let decision =
709            gate.check_mutation(&store, &ops, &ctx, "p-baseline-readonly", PolicyOp::Put);
710        match decision {
711            ManagedPolicyDecision::Deny { reason, .. } => {
712                assert!(matches!(reason, DenyReason::PolicyDenied), "got {reason:?}");
713            }
714            other => panic!("expected Deny(PolicyDenied), got {other:?}"),
715        }
716        // A different op (drop) is not covered by the deny, so the
717        // broad allow still wins.
718        let drop_decision =
719            gate.check_mutation(&store, &ops, &ctx, "p-baseline-readonly", PolicyOp::Drop);
720        assert!(
721            matches!(drop_decision, ManagedPolicyDecision::Allow { .. }),
722            "got {drop_decision:?}"
723        );
724    }
725
726    // ---- acceptance #5 ---------------------------------------------------
727
728    #[test]
729    fn deny_carries_resource_and_reason_for_audit_hook() {
730        // The Deny payload must give the audit hook / Control Event
731        // integration enough detail to reconstruct what was attempted
732        // and why it failed.
733        let store = store();
734        let seeder = seed_registry_admin(&store);
735        store.create_user("alice", "p", Role::Admin).unwrap();
736        let alice = UserId::platform("alice");
737
738        let reg = ConfigRegistry::new();
739        let mut draft = managed_policy_draft("p-tenant-isolation");
740        draft.evidence_requirement = EvidenceRequirement::Full;
741        reg.register(&store, &seeder, &registry_admin_ctx(), draft, 1_000)
742            .unwrap();
743
744        let gate = ManagedPolicyGate::new(&reg);
745        let ctx = EvalContext {
746            principal_tenant: None,
747            current_tenant: None,
748            peer_ip: None,
749            mfa_present: false,
750            now_ms: 1_700_000_000_006,
751            principal_is_admin_role: true,
752            principal_is_system_owned: false,
753            principal_is_platform_scoped: true,
754        };
755        let decision =
756            gate.check_mutation(&store, &alice, &ctx, "p-tenant-isolation", PolicyOp::Attach);
757        match decision {
758            ManagedPolicyDecision::Deny {
759                entry_id,
760                entry_version,
761                op,
762                matched_action,
763                matched_resource,
764                reason,
765            } => {
766                assert_eq!(entry_id, "p-tenant-isolation");
767                assert_eq!(entry_version, 1);
768                assert_eq!(op, PolicyOp::Attach);
769                assert_eq!(matched_action, "policy:attach");
770                assert_eq!(matched_resource, "policy:p-tenant-isolation");
771                let rendered = reason.to_string();
772                assert!(
773                    rendered.contains("structurally eligible"),
774                    "reason should be audit-renderable: {rendered}"
775                );
776            }
777            other => panic!("expected Deny, got {other:?}"),
778        }
779    }
780
781    // ---- passthrough / resource-type discipline -------------------------
782
783    #[test]
784    fn unmanaged_policy_passes_through() {
785        // Registry has an entry but managed=false — the gate must not
786        // interfere with ordinary IAM rules.
787        let store = store();
788        let seeder = seed_registry_admin(&store);
789        let reg = ConfigRegistry::new();
790        reg.register(
791            &store,
792            &seeder,
793            &registry_admin_ctx(),
794            unmanaged_policy_draft("p-tenant-custom"),
795            1_000,
796        )
797        .unwrap();
798
799        let gate = ManagedPolicyGate::new(&reg);
800        let alice = UserId::platform("alice");
801        let d = gate.check_mutation(
802            &store,
803            &alice,
804            &EvalContext::default(),
805            "p-tenant-custom",
806            PolicyOp::Put,
807        );
808        assert!(matches!(d, ManagedPolicyDecision::PassThrough { .. }));
809        assert!(d.permitted());
810    }
811
812    #[test]
813    fn unknown_policy_passes_through() {
814        let store = store();
815        let _ = seed_registry_admin(&store);
816        let reg = ConfigRegistry::new();
817        let gate = ManagedPolicyGate::new(&reg);
818        let alice = UserId::platform("alice");
819        let d = gate.check_mutation(
820            &store,
821            &alice,
822            &EvalContext::default(),
823            "p-anything",
824            PolicyOp::Drop,
825        );
826        assert!(matches!(d, ManagedPolicyDecision::PassThrough { .. }));
827    }
828
829    #[test]
830    fn entry_with_unrelated_resource_type_does_not_gate_policy_mutations() {
831        // Pin the contract: a registry entry whose `resource_type` is
832        // not `policy` (e.g. `config_key` reusing a name that collides
833        // with a policy id) must not silently fire the policy gate.
834        let store = store();
835        let seeder = seed_registry_admin(&store);
836        let reg = ConfigRegistry::new();
837        let mut d = managed_policy_draft("p-baseline-readonly");
838        d.resource_type = "config_key".into();
839        reg.register(&store, &seeder, &registry_admin_ctx(), d, 1_000)
840            .unwrap();
841
842        let gate = ManagedPolicyGate::new(&reg);
843        let alice = UserId::platform("alice");
844        let dec = gate.check_mutation(
845            &store,
846            &alice,
847            &EvalContext::default(),
848            "p-baseline-readonly",
849            PolicyOp::Put,
850        );
851        assert!(
852            matches!(dec, ManagedPolicyDecision::PassThrough { .. }),
853            "got {dec:?}"
854        );
855    }
856
857    // ---- #647 system-owned guardrail integration: tenant-scope half ----
858    //
859    // Acceptance #4 for #647 asks that tenant-scoped and platform-scoped
860    // system-owned users be tested *separately*. The Allow-path test
861    // (`structurally_eligible_caller_with_matching_policy_is_allowed`)
862    // already pins the platform-scoped operator. A tenant-scoped
863    // system-owned user is *not* a platform-scoped operator — the gate
864    // requires both flags — so even with `policy:*` granted the gate
865    // must Deny on `NotStructurallyEligible` and never reach the policy
866    // evaluator. Pins acceptance #3 too: system-owned alone is not
867    // enough; platform scope is part of the operator-principal shape.
868
869    #[test]
870    fn tenant_scoped_system_owned_user_is_structurally_ineligible_for_managed_policy() {
871        let store = store();
872        let seeder = seed_registry_admin(&store);
873        store
874            .create_system_user("ops", "p", Role::Admin, Some("acme"))
875            .expect("create tenant-scoped system-owned user");
876        let ops_acme = UserId::scoped("acme", "ops");
877        // Grant the broadest possible policy permission — proves the
878        // Deny below is structural, not policy-evaluator fallout.
879        store
880            .put_policy(allow_all_policies("p-ops-acme-allow"))
881            .unwrap();
882        store
883            .attach_policy(PrincipalRef::User(ops_acme.clone()), "p-ops-acme-allow")
884            .unwrap();
885
886        let reg = ConfigRegistry::new();
887        reg.register(
888            &store,
889            &seeder,
890            &registry_admin_ctx(),
891            managed_policy_draft("p-baseline-readonly"),
892            1_000,
893        )
894        .unwrap();
895
896        let gate = ManagedPolicyGate::new(&reg);
897        // Mirror the runtime EvalContext shape for a tenant-scoped
898        // system-owned user — system_owned=true (the user record says
899        // so) AND platform_scoped=false (tenant.is_some()).
900        let ctx = EvalContext {
901            principal_tenant: Some("acme".into()),
902            current_tenant: Some("acme".into()),
903            peer_ip: None,
904            mfa_present: false,
905            now_ms: 1_700_000_000_100,
906            principal_is_admin_role: true,
907            principal_is_system_owned: true,
908            principal_is_platform_scoped: false,
909        };
910
911        for op in [
912            PolicyOp::Put,
913            PolicyOp::Drop,
914            PolicyOp::Attach,
915            PolicyOp::Detach,
916        ] {
917            let decision = gate.check_mutation(&store, &ops_acme, &ctx, "p-baseline-readonly", op);
918            match decision {
919                ManagedPolicyDecision::Deny {
920                    reason:
921                        DenyReason::NotStructurallyEligible {
922                            is_system_owned,
923                            is_platform_scoped,
924                        },
925                    op: got_op,
926                    ..
927                } => {
928                    assert_eq!(got_op, op);
929                    assert!(is_system_owned, "system_owned flag should propagate");
930                    assert!(
931                        !is_platform_scoped,
932                        "tenant-scoped principal must report platform_scoped=false"
933                    );
934                }
935                other => panic!("expected NotStructurallyEligible Deny for {op:?}, got {other:?}"),
936            }
937        }
938    }
939
940    #[test]
941    fn split_required_resource_handles_bare_string() {
942        assert_eq!(
943            split_required_resource("policy:p-baseline", "p-baseline"),
944            ("policy", "p-baseline")
945        );
946        // Bare string with no colon falls back to (policy, policy_id),
947        // not the bare value, so the resource always aligns with the
948        // policy id under evaluation.
949        assert_eq!(
950            split_required_resource("p-anything-else", "p-baseline"),
951            ("policy", "p-baseline")
952        );
953    }
954}