Skip to main content

pgroles_core/
composition.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
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: HashMap<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: HashMap::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}