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