1use super::policies::{EvalContext, ResourceRef};
35use super::registry::{ConfigRegistry, ConfigRegistryEntry, EvidenceRequirement};
36use super::store::AuthStore;
37use super::UserId;
38
39pub const RESOURCE_TYPE_POLICY: &str = "policy";
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum PolicyOp {
51 Put,
53 Drop,
55 Attach,
57 Detach,
59}
60
61impl PolicyOp {
62 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#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum ManagedPolicyDecision {
78 PassThrough { policy_id: String, op: PolicyOp },
81 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 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#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum DenyReason {
108 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 pub fn permitted(&self) -> bool {
127 matches!(self, Self::PassThrough { .. } | Self::Allow { .. })
128 }
129}
130
131pub 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 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 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
206fn 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 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 #[test]
339 fn policy_can_be_installed_as_managed_through_registry() {
340 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 ®istry_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 let other = reg
360 .register(
361 &store,
362 &seeder,
363 ®istry_admin_ctx(),
364 unmanaged_policy_draft("p-tenant-custom"),
365 1_000,
366 )
367 .unwrap();
368 assert!(!other.managed);
369 }
370
371 #[test]
374 fn ordinary_allow_all_user_can_put_drop_attach_or_detach_managed_policy() {
375 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 ®istry_admin_ctx(),
395 managed_policy_draft("p-baseline-readonly"),
396 1_000,
397 )
398 .unwrap();
399
400 let gate = ManagedPolicyGate::new(®);
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 #[test]
427 fn re_putting_managed_policy_without_managed_metadata_still_uses_registry_gate() {
428 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 ®istry_admin_ctx(),
448 managed_policy_draft("p-baseline-readonly"),
449 1_000,
450 )
451 .unwrap();
452
453 let gate = ManagedPolicyGate::new(®);
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 let d1 = gate.check_mutation(&store, &alice, &ctx, "p-baseline-readonly", PolicyOp::Put);
466 assert!(matches!(d1, ManagedPolicyDecision::Allow { .. }), "{d1:?}");
467
468 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 #[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 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 ®istry_admin_ctx(),
506 managed_policy_draft("p-baseline-readonly"),
507 1_000,
508 )
509 .unwrap();
510
511 let gate = ManagedPolicyGate::new(®);
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 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 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 ®istry_admin_ctx(),
565 managed_policy_draft("p-baseline-readonly"),
566 1_000,
567 )
568 .unwrap();
569
570 let gate = ManagedPolicyGate::new(®);
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 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 ®istry_admin_ctx(),
620 managed_policy_draft("p-baseline-readonly"),
621 1_000,
622 )
623 .unwrap();
624
625 let gate = ManagedPolicyGate::new(®);
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 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 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 #[test]
657 fn deny_carries_resource_and_reason_for_audit_hook() {
658 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, ®istry_admin_ctx(), draft, 1_000)
670 .unwrap();
671
672 let gate = ManagedPolicyGate::new(®);
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 #[test]
711 fn unmanaged_policy_passes_through() {
712 let store = store();
715 let seeder = seed_registry_admin(&store);
716 let reg = ConfigRegistry::new();
717 reg.register(
718 &store,
719 &seeder,
720 ®istry_admin_ctx(),
721 unmanaged_policy_draft("p-tenant-custom"),
722 1_000,
723 )
724 .unwrap();
725
726 let gate = ManagedPolicyGate::new(®);
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(®);
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 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, ®istry_admin_ctx(), d, 1_000)
767 .unwrap();
768
769 let gate = ManagedPolicyGate::new(®);
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 ®istry_admin_ctx(),
804 managed_policy_draft("p-baseline-readonly"),
805 1_000,
806 )
807 .unwrap();
808
809 let gate = ManagedPolicyGate::new(®);
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 assert_eq!(
844 split_required_resource("p-anything-else", "p-baseline"),
845 ("policy", "p-baseline")
846 );
847 }
848}