1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use crate::manifest::{
7 AuthProvider, DefaultPrivilege, ExpandedManifest, Grant, ManifestError, Membership,
8 PolicyManifest, Profile, RoleDefinition, RoleRetirement, SchemaBinding, SchemaBindingFacet,
9 default_role_pattern, expand_manifest,
10};
11use crate::model::{DefaultPrivKey, GrantKey, RoleGraph};
12use crate::ownership::{
13 ManagedChangeSurface, ManagedScope, MembershipKey, OwnershipIndex, SchemaFacetKey,
14};
15use crate::report::BundleReportContext;
16
17#[derive(Debug, Error)]
18pub enum CompositionError {
19 #[error("YAML parse error: {0}")]
20 Yaml(#[from] serde_yaml::Error),
21
22 #[error("policy bundle must declare at least one source")]
23 MissingSources,
24
25 #[error("policy document \"{document}\" defines role \"{role}\" outside its declared scope")]
26 RoleOutOfScope { document: String, role: String },
27
28 #[error(
29 "policy document \"{document}\" manages schema \"{schema}\" owner outside its declared scope"
30 )]
31 SchemaOwnerOutOfScope { document: String, schema: String },
32
33 #[error(
34 "policy document \"{document}\" manages schema \"{schema}\" bindings outside its declared scope"
35 )]
36 SchemaBindingsOutOfScope { document: String, schema: String },
37
38 #[error("policy documents \"{first}\" and \"{second}\" both manage role \"{role}\"")]
39 DuplicateManagedRole {
40 role: String,
41 first: String,
42 second: String,
43 },
44
45 #[error(
46 "policy documents \"{first}\" and \"{second}\" both manage schema facet \"{schema}.{facet}\""
47 )]
48 DuplicateManagedSchemaFacet {
49 schema: String,
50 facet: String,
51 first: String,
52 second: String,
53 },
54
55 #[error("policy documents \"{first}\" and \"{second}\" both manage grant {target}")]
56 DuplicateManagedGrant {
57 target: String,
58 first: String,
59 second: String,
60 },
61
62 #[error("policy documents \"{first}\" and \"{second}\" both manage default privilege {target}")]
63 DuplicateManagedDefaultPrivilege {
64 target: String,
65 first: String,
66 second: String,
67 },
68
69 #[error(
70 "policy documents \"{first}\" and \"{second}\" both manage membership \"{role}\" -> \"{member}\""
71 )]
72 DuplicateManagedMembership {
73 role: String,
74 member: String,
75 first: String,
76 second: String,
77 },
78
79 #[error("policy document \"{document}\" failed validation: {error}")]
80 InvalidDocument {
81 document: String,
82 error: ManifestError,
83 },
84
85 #[error("composed policy failed validation: {0}")]
86 InvalidComposedManifest(ManifestError),
87}
88
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
90pub struct PolicyBundle {
91 #[serde(default)]
92 pub shared: SharedPolicy,
93
94 #[serde(default)]
95 pub sources: Vec<BundleSource>,
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct SharedPolicy {
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub default_owner: Option<String>,
102
103 #[serde(default)]
104 pub auth_providers: Vec<AuthProvider>,
105
106 #[serde(default)]
107 pub profiles: BTreeMap<String, Profile>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct BundleSource {
112 pub file: String,
113}
114
115#[derive(Debug, Clone)]
116pub struct PolicyDocument {
117 pub source: String,
118 pub fragment: PolicyFragment,
119}
120
121impl PolicyDocument {
122 pub fn label(&self) -> &str {
123 self.fragment
124 .policy
125 .name
126 .as_deref()
127 .unwrap_or(self.source.as_str())
128 }
129}
130
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct PolicyFragment {
133 #[serde(default)]
134 pub policy: FragmentMetadata,
135
136 #[serde(default)]
137 pub scope: FragmentScope,
138
139 #[serde(default)]
140 pub schemas: Vec<SchemaBinding>,
141
142 #[serde(default)]
143 pub roles: Vec<RoleDefinition>,
144
145 #[serde(default)]
146 pub grants: Vec<Grant>,
147
148 #[serde(default)]
149 pub default_privileges: Vec<DefaultPrivilege>,
150
151 #[serde(default)]
152 pub memberships: Vec<Membership>,
153
154 #[serde(default)]
155 pub retirements: Vec<RoleRetirement>,
156}
157
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159pub struct FragmentMetadata {
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub name: Option<String>,
162}
163
164#[derive(Debug, Clone, Default, Serialize, Deserialize)]
165pub struct FragmentScope {
166 #[serde(default)]
167 pub roles: Vec<String>,
168
169 #[serde(default)]
170 pub schemas: Vec<ScopedSchema>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ScopedSchema {
175 pub name: String,
176
177 #[serde(default)]
178 pub facets: Vec<SchemaBindingFacet>,
179}
180
181#[derive(Debug, Clone)]
182pub struct ComposedPolicy {
183 pub manifest: PolicyManifest,
184 pub expanded: ExpandedManifest,
185 pub desired: RoleGraph,
186 pub ownership: OwnershipIndex,
187 pub managed_scope: ManagedScope,
188 pub managed_change_surface: ManagedChangeSurface,
189}
190
191impl ComposedPolicy {
192 pub fn report_context(&self) -> BundleReportContext<'_> {
193 BundleReportContext {
194 ownership: &self.ownership,
195 managed_scope: &self.managed_scope,
196 }
197 }
198}
199
200pub fn parse_policy_bundle(yaml: &str) -> Result<PolicyBundle, CompositionError> {
201 Ok(serde_yaml::from_str(yaml)?)
202}
203
204pub fn parse_policy_fragment(yaml: &str) -> Result<PolicyFragment, CompositionError> {
205 Ok(serde_yaml::from_str(yaml)?)
206}
207
208pub fn compose_bundle(
209 bundle: &PolicyBundle,
210 documents: &[PolicyDocument],
211) -> Result<ComposedPolicy, CompositionError> {
212 if bundle.sources.is_empty() || documents.is_empty() {
213 return Err(CompositionError::MissingSources);
214 }
215
216 let mut ownership = OwnershipIndex::default();
217 let mut merged_schemas: BTreeMap<String, SchemaBinding> = BTreeMap::new();
218 let mut manifest = PolicyManifest {
219 default_owner: bundle.shared.default_owner.clone(),
220 auth_providers: bundle.shared.auth_providers.clone(),
221 profiles: bundle.shared.profiles.clone(),
222 schemas: Vec::new(),
223 roles: Vec::new(),
224 grants: Vec::new(),
225 default_privileges: Vec::new(),
226 memberships: Vec::new(),
227 retirements: Vec::new(),
228 };
229
230 for document in documents {
231 validate_document_scope(bundle, document)?;
232
233 let document_manifest = document_manifest(bundle, &document.fragment);
234 let expanded = expand_manifest(&document_manifest).map_err(|error| {
235 CompositionError::InvalidDocument {
236 document: document.label().to_string(),
237 error,
238 }
239 })?;
240 let desired =
241 RoleGraph::from_expanded(&expanded, document_manifest.default_owner.as_deref())
242 .map_err(|error| CompositionError::InvalidDocument {
243 document: document.label().to_string(),
244 error,
245 })?;
246
247 register_document_ownership(&mut ownership, document, &expanded, &desired)?;
248 merge_document_manifest(&mut manifest, &mut merged_schemas, &document.fragment);
249 }
250
251 manifest.schemas = merged_schemas.into_values().collect();
252
253 let expanded = expand_manifest(&manifest).map_err(CompositionError::InvalidComposedManifest)?;
254 let desired = RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref())
255 .map_err(CompositionError::InvalidComposedManifest)?;
256 let managed_scope = ownership.managed_scope();
257 let managed_change_surface = ownership.managed_change_surface();
258
259 Ok(ComposedPolicy {
260 manifest,
261 expanded,
262 desired,
263 ownership,
264 managed_scope,
265 managed_change_surface,
266 })
267}
268
269fn document_manifest(bundle: &PolicyBundle, fragment: &PolicyFragment) -> PolicyManifest {
270 PolicyManifest {
271 default_owner: bundle.shared.default_owner.clone(),
272 auth_providers: bundle.shared.auth_providers.clone(),
273 profiles: bundle.shared.profiles.clone(),
274 schemas: fragment.schemas.clone(),
275 roles: fragment.roles.clone(),
276 grants: fragment.grants.clone(),
277 default_privileges: fragment.default_privileges.clone(),
278 memberships: fragment.memberships.clone(),
279 retirements: fragment.retirements.clone(),
280 }
281}
282
283fn validate_document_scope(
284 bundle: &PolicyBundle,
285 document: &PolicyDocument,
286) -> Result<(), CompositionError> {
287 let owned_roles: BTreeSet<&str> = document
288 .fragment
289 .scope
290 .roles
291 .iter()
292 .map(String::as_str)
293 .collect();
294 let schema_scope = schema_scope_map(&document.fragment.scope);
295 let document_name = document.label().to_string();
296
297 for role in &document.fragment.roles {
298 if !owned_roles.contains(role.name.as_str()) {
299 return Err(CompositionError::RoleOutOfScope {
300 document: document_name.clone(),
301 role: role.name.clone(),
302 });
303 }
304 }
305
306 for retirement in &document.fragment.retirements {
307 if !owned_roles.contains(retirement.role.as_str()) {
308 return Err(CompositionError::RoleOutOfScope {
309 document: document_name.clone(),
310 role: retirement.role.clone(),
311 });
312 }
313 }
314
315 for schema in &document.fragment.schemas {
316 let manages_bindings =
317 !schema.profiles.is_empty() || schema.role_pattern != default_role_pattern();
318 if manages_bindings
319 && !has_schema_facet(&schema_scope, &schema.name, SchemaBindingFacet::Bindings)
320 {
321 return Err(CompositionError::SchemaBindingsOutOfScope {
322 document: document_name.clone(),
323 schema: schema.name.clone(),
324 });
325 }
326
327 let manages_owner = schema.owner.is_some() || bundle.shared.default_owner.is_some();
328 if manages_owner
329 && !has_schema_facet(&schema_scope, &schema.name, SchemaBindingFacet::Owner)
330 {
331 return Err(CompositionError::SchemaOwnerOutOfScope {
332 document: document_name.clone(),
333 schema: schema.name.clone(),
334 });
335 }
336 }
337
338 for grant in &document.fragment.grants {
339 if let Some(schema) = schema_name_for_grant(grant)
340 && !has_schema_facet(&schema_scope, schema, SchemaBindingFacet::Bindings)
341 {
342 return Err(CompositionError::SchemaBindingsOutOfScope {
343 document: document_name.clone(),
344 schema: schema.to_string(),
345 });
346 }
347 }
348
349 for default_privilege in &document.fragment.default_privileges {
350 if !has_schema_facet(
351 &schema_scope,
352 &default_privilege.schema,
353 SchemaBindingFacet::Bindings,
354 ) {
355 return Err(CompositionError::SchemaBindingsOutOfScope {
356 document: document_name.clone(),
357 schema: default_privilege.schema.clone(),
358 });
359 }
360 }
361
362 Ok(())
363}
364
365fn schema_scope_map(scope: &FragmentScope) -> BTreeMap<String, BTreeSet<SchemaBindingFacet>> {
366 let mut result = BTreeMap::new();
367
368 for schema in &scope.schemas {
369 let entry = result
370 .entry(schema.name.clone())
371 .or_insert_with(BTreeSet::new);
372 for facet in &schema.facets {
373 entry.insert(*facet);
374 }
375 }
376
377 result
378}
379
380fn has_schema_facet(
381 scope: &BTreeMap<String, BTreeSet<SchemaBindingFacet>>,
382 schema: &str,
383 facet: SchemaBindingFacet,
384) -> bool {
385 scope
386 .get(schema)
387 .is_some_and(|facets| facets.contains(&facet))
388}
389
390fn schema_name_for_grant(grant: &Grant) -> Option<&str> {
391 match grant.object.object_type {
392 crate::manifest::ObjectType::Database => None,
393 crate::manifest::ObjectType::Schema => grant.object.name.as_deref(),
394 _ => grant.object.schema.as_deref(),
395 }
396}
397
398fn register_document_ownership(
399 ownership: &mut OwnershipIndex,
400 document: &PolicyDocument,
401 expanded: &ExpandedManifest,
402 desired: &RoleGraph,
403) -> Result<(), CompositionError> {
404 let label = document.label().to_string();
405
406 for role in &expanded.roles {
407 register_role_owner(ownership, &role.name, &label)?;
408 }
409
410 for retirement in &document.fragment.retirements {
411 register_role_owner(ownership, &retirement.role, &label)?;
412 }
413
414 for schema in &document.fragment.scope.schemas {
415 for facet in &schema.facets {
416 register_schema_facet_owner(ownership, &schema.name, *facet, &label)?;
417 }
418 }
419
420 for grant in desired.grants.keys() {
421 register_grant_owner(ownership, grant, &label)?;
422 }
423
424 for default_privilege in desired.default_privileges.keys() {
425 register_default_privilege_owner(ownership, default_privilege, &label)?;
426 }
427
428 for membership in &desired.memberships {
429 register_membership_owner(
430 ownership,
431 &MembershipKey {
432 role: membership.role.clone(),
433 member: membership.member.clone(),
434 },
435 &label,
436 )?;
437 }
438
439 Ok(())
440}
441
442fn register_role_owner(
443 ownership: &mut OwnershipIndex,
444 role: &str,
445 owner: &str,
446) -> Result<(), CompositionError> {
447 if let Some(existing) = ownership.roles.insert(role.to_string(), owner.to_string()) {
448 return Err(CompositionError::DuplicateManagedRole {
449 role: role.to_string(),
450 first: existing,
451 second: owner.to_string(),
452 });
453 }
454
455 Ok(())
456}
457
458fn register_schema_facet_owner(
459 ownership: &mut OwnershipIndex,
460 schema: &str,
461 facet: SchemaBindingFacet,
462 owner: &str,
463) -> Result<(), CompositionError> {
464 let key = SchemaFacetKey {
465 schema: schema.to_string(),
466 facet,
467 };
468
469 if let Some(existing) = ownership
470 .schema_facets
471 .insert(key.clone(), owner.to_string())
472 {
473 return Err(CompositionError::DuplicateManagedSchemaFacet {
474 schema: schema.to_string(),
475 facet: facet.to_string(),
476 first: existing,
477 second: owner.to_string(),
478 });
479 }
480
481 Ok(())
482}
483
484fn register_grant_owner(
485 ownership: &mut OwnershipIndex,
486 key: &GrantKey,
487 owner: &str,
488) -> Result<(), CompositionError> {
489 if let Some(existing) = ownership.grants.insert(key.clone(), owner.to_string()) {
490 return Err(CompositionError::DuplicateManagedGrant {
491 target: format_grant_key(key),
492 first: existing,
493 second: owner.to_string(),
494 });
495 }
496
497 Ok(())
498}
499
500fn register_default_privilege_owner(
501 ownership: &mut OwnershipIndex,
502 key: &DefaultPrivKey,
503 owner: &str,
504) -> Result<(), CompositionError> {
505 if let Some(existing) = ownership
506 .default_privileges
507 .insert(key.clone(), owner.to_string())
508 {
509 return Err(CompositionError::DuplicateManagedDefaultPrivilege {
510 target: format_default_privilege_key(key),
511 first: existing,
512 second: owner.to_string(),
513 });
514 }
515
516 Ok(())
517}
518
519fn register_membership_owner(
520 ownership: &mut OwnershipIndex,
521 key: &MembershipKey,
522 owner: &str,
523) -> Result<(), CompositionError> {
524 if let Some(existing) = ownership.memberships.insert(key.clone(), owner.to_string()) {
525 return Err(CompositionError::DuplicateManagedMembership {
526 role: key.role.clone(),
527 member: key.member.clone(),
528 first: existing,
529 second: owner.to_string(),
530 });
531 }
532
533 Ok(())
534}
535
536fn format_grant_key(key: &GrantKey) -> String {
537 let target = match (&key.schema, &key.name) {
538 (Some(schema), Some(name)) => format!("{schema}.{name}"),
539 (Some(schema), None) => schema.clone(),
540 (None, Some(name)) => name.clone(),
541 (None, None) => "<unnamed>".to_string(),
542 };
543
544 format!(
545 "for role \"{}\" on {} \"{}\"",
546 key.role, key.object_type, target
547 )
548}
549
550fn format_default_privilege_key(key: &DefaultPrivKey) -> String {
551 format!(
552 "owner \"{}\" schema \"{}\" on {} to \"{}\"",
553 key.owner, key.schema, key.on_type, key.grantee
554 )
555}
556
557fn merge_document_manifest(
558 manifest: &mut PolicyManifest,
559 merged_schemas: &mut BTreeMap<String, SchemaBinding>,
560 fragment: &PolicyFragment,
561) {
562 manifest.roles.extend(fragment.roles.clone());
563 manifest.grants.extend(fragment.grants.clone());
564 manifest
565 .default_privileges
566 .extend(fragment.default_privileges.clone());
567 manifest.memberships.extend(fragment.memberships.clone());
568 manifest.retirements.extend(fragment.retirements.clone());
569
570 for schema in &fragment.schemas {
571 let entry = merged_schemas
572 .entry(schema.name.clone())
573 .or_insert_with(|| SchemaBinding {
574 name: schema.name.clone(),
575 profiles: Vec::new(),
576 role_pattern: default_role_pattern(),
577 owner: None,
578 });
579
580 if schema.owner.is_some() {
581 entry.owner = schema.owner.clone();
582 }
583
584 if !schema.profiles.is_empty() {
585 entry.profiles = schema.profiles.clone();
586 }
587
588 if schema.role_pattern != default_role_pattern() {
589 entry.role_pattern = schema.role_pattern.clone();
590 }
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use crate::diff::Change;
598 use crate::manifest::{ObjectType, Privilege};
599 use crate::ownership::{
600 ManagedChangeError, ManagedChangeSurface, ManagedSchemaScope, OwnershipIndex,
601 validate_changes_against_managed_surface,
602 };
603
604 fn bundle_with_editor_profile() -> PolicyBundle {
605 let bundle = r#"
606shared:
607 profiles:
608 editor:
609 grants:
610 - privileges: [USAGE]
611 object: { type: schema }
612sources:
613 - file: platform.yaml
614 - file: app.yaml
615"#;
616 parse_policy_bundle(bundle).expect("bundle should parse")
617 }
618
619 #[test]
620 fn compose_bundle_merges_schema_owner_and_bindings() {
621 let bundle = bundle_with_editor_profile();
622 let platform = PolicyDocument {
623 source: "platform.yaml".to_string(),
624 fragment: parse_policy_fragment(
625 r#"
626policy:
627 name: platform
628scope:
629 roles: [app_owner]
630 schemas:
631 - name: inventory
632 facets: [owner]
633roles:
634 - name: app_owner
635 login: false
636schemas:
637 - name: inventory
638 owner: app_owner
639"#,
640 )
641 .expect("platform fragment should parse"),
642 };
643 let app = PolicyDocument {
644 source: "app.yaml".to_string(),
645 fragment: parse_policy_fragment(
646 r#"
647policy:
648 name: app
649scope:
650 schemas:
651 - name: inventory
652 facets: [bindings]
653schemas:
654 - name: inventory
655 profiles: [editor]
656"#,
657 )
658 .expect("app fragment should parse"),
659 };
660
661 let composed = compose_bundle(&bundle, &[platform, app]).expect("bundle should compose");
662
663 assert_eq!(composed.expanded.schemas.len(), 1);
664 assert_eq!(
665 composed.expanded.schemas[0].owner.as_deref(),
666 Some("app_owner")
667 );
668 assert!(composed.desired.roles.contains_key("inventory-editor"));
669 assert!(composed.ownership.roles.contains_key("inventory-editor"));
670 assert_eq!(
671 composed.managed_scope.schemas.get("inventory"),
672 Some(&ManagedSchemaScope {
673 owner: true,
674 bindings: true
675 })
676 );
677 }
678
679 #[test]
680 fn compose_bundle_rejects_role_outside_scope() {
681 let bundle = PolicyBundle {
682 shared: SharedPolicy::default(),
683 sources: vec![BundleSource {
684 file: "app.yaml".to_string(),
685 }],
686 };
687 let document = PolicyDocument {
688 source: "app.yaml".to_string(),
689 fragment: parse_policy_fragment(
690 r#"
691roles:
692 - name: app
693 login: true
694"#,
695 )
696 .expect("fragment should parse"),
697 };
698
699 let error = compose_bundle(&bundle, &[document]).expect_err("scope validation should fail");
700 assert!(matches!(
701 error,
702 CompositionError::RoleOutOfScope { role, .. } if role == "app"
703 ));
704 }
705
706 #[test]
707 fn compose_bundle_rejects_duplicate_generated_roles() {
708 let bundle = PolicyBundle {
709 shared: SharedPolicy {
710 profiles: BTreeMap::from([(
711 "viewer".to_string(),
712 Profile {
713 login: None,
714 inherit: None,
715 grants: vec![],
716 default_privileges: vec![],
717 },
718 )]),
719 ..SharedPolicy::default()
720 },
721 sources: vec![
722 BundleSource {
723 file: "a.yaml".to_string(),
724 },
725 BundleSource {
726 file: "b.yaml".to_string(),
727 },
728 ],
729 };
730 let first = PolicyDocument {
731 source: "a.yaml".to_string(),
732 fragment: parse_policy_fragment(
733 r#"
734policy:
735 name: first
736scope:
737 schemas:
738 - name: inventory
739 facets: [bindings]
740schemas:
741 - name: inventory
742 profiles: [viewer]
743"#,
744 )
745 .expect("first fragment should parse"),
746 };
747 let second = PolicyDocument {
748 source: "b.yaml".to_string(),
749 fragment: parse_policy_fragment(
750 r#"
751policy:
752 name: second
753scope:
754 roles: [inventory-viewer]
755roles:
756 - name: inventory-viewer
757 login: true
758"#,
759 )
760 .expect("second fragment should parse"),
761 };
762
763 let error =
764 compose_bundle(&bundle, &[first, second]).expect_err("generated role should conflict");
765 assert!(matches!(
766 error,
767 CompositionError::DuplicateManagedRole { role, .. } if role == "inventory-viewer"
768 ));
769 }
770
771 #[test]
772 fn compose_bundle_rejects_duplicate_grants() {
773 let bundle = PolicyBundle {
774 shared: SharedPolicy::default(),
775 sources: vec![
776 BundleSource {
777 file: "a.yaml".to_string(),
778 },
779 BundleSource {
780 file: "b.yaml".to_string(),
781 },
782 ],
783 };
784 let first = PolicyDocument {
785 source: "a.yaml".to_string(),
786 fragment: parse_policy_fragment(
787 r#"
788policy:
789 name: first
790scope:
791 roles: [app]
792roles:
793 - name: app
794grants:
795 - role: app
796 privileges: [CONNECT]
797 object: { type: database, name: appdb }
798"#,
799 )
800 .expect("first fragment should parse"),
801 };
802 let second = PolicyDocument {
803 source: "b.yaml".to_string(),
804 fragment: parse_policy_fragment(
805 r#"
806policy:
807 name: second
808grants:
809 - role: app
810 privileges: [CREATE]
811 object: { type: database, name: appdb }
812"#,
813 )
814 .expect("second fragment should parse"),
815 };
816
817 let error =
818 compose_bundle(&bundle, &[first, second]).expect_err("grant ownership should conflict");
819 assert!(matches!(
820 error,
821 CompositionError::DuplicateManagedGrant { first, second, .. }
822 if first == "first" && second == "second"
823 ));
824 }
825
826 #[test]
827 fn register_default_privilege_owner_rejects_duplicates() {
828 let mut ownership = OwnershipIndex::default();
829 let key = DefaultPrivKey {
830 owner: "app_owner".to_string(),
831 schema: "inventory".to_string(),
832 on_type: crate::manifest::ObjectType::Table,
833 grantee: "app".to_string(),
834 };
835
836 register_default_privilege_owner(&mut ownership, &key, "first")
837 .expect("first owner should register");
838 let error = register_default_privilege_owner(&mut ownership, &key, "second")
839 .expect_err("default privilege ownership should conflict");
840 assert!(matches!(
841 error,
842 CompositionError::DuplicateManagedDefaultPrivilege { first, second, .. }
843 if first == "first" && second == "second"
844 ));
845 }
846
847 #[test]
848 fn compose_bundle_rejects_duplicate_membership_selectors() {
849 let bundle = PolicyBundle {
850 shared: SharedPolicy::default(),
851 sources: vec![
852 BundleSource {
853 file: "a.yaml".to_string(),
854 },
855 BundleSource {
856 file: "b.yaml".to_string(),
857 },
858 ],
859 };
860 let first = PolicyDocument {
861 source: "a.yaml".to_string(),
862 fragment: parse_policy_fragment(
863 r#"
864policy:
865 name: first
866memberships:
867 - role: editor
868 members:
869 - name: app
870"#,
871 )
872 .expect("first fragment should parse"),
873 };
874 let second = PolicyDocument {
875 source: "b.yaml".to_string(),
876 fragment: parse_policy_fragment(
877 r#"
878policy:
879 name: second
880memberships:
881 - role: editor
882 members:
883 - name: app
884 admin: true
885"#,
886 )
887 .expect("second fragment should parse"),
888 };
889
890 let error = compose_bundle(&bundle, &[first, second])
891 .expect_err("membership ownership should conflict");
892 assert!(matches!(
893 error,
894 CompositionError::DuplicateManagedMembership { role, member, .. }
895 if role == "editor" && member == "app"
896 ));
897 }
898
899 #[test]
900 fn managed_surface_allows_revoke_of_removed_database_grant_for_managed_role() {
901 let surface = ManagedChangeSurface {
902 roles: BTreeSet::from(["app".to_string()]),
903 ..ManagedChangeSurface::default()
904 };
905
906 let result = validate_changes_against_managed_surface(
907 &[Change::Revoke {
908 role: "app".to_string(),
909 privileges: BTreeSet::from([Privilege::Connect]),
910 object_type: ObjectType::Database,
911 schema: None,
912 name: Some("appdb".to_string()),
913 }],
914 &surface,
915 );
916
917 assert!(result.is_ok(), "managed role should own database revokes");
918 }
919
920 #[test]
921 fn managed_surface_rejects_unmanaged_membership_removal() {
922 let surface = ManagedChangeSurface::default();
923
924 let error = validate_changes_against_managed_surface(
925 &[Change::RemoveMember {
926 role: "editor".to_string(),
927 member: "app".to_string(),
928 }],
929 &surface,
930 )
931 .expect_err("unmanaged membership removal should be rejected");
932
933 assert!(matches!(
934 error,
935 ManagedChangeError::OutOfScope { change } if change.contains("remove membership")
936 ));
937 }
938
939 #[test]
940 fn schema_scope_facet_display_matches_yaml_values() {
941 assert_eq!(SchemaBindingFacet::Owner.to_string(), "owner");
942 assert_eq!(SchemaBindingFacet::Bindings.to_string(), "bindings");
943 assert_eq!(Privilege::Usage.to_string(), "USAGE");
944 }
945}