Skip to main content

pgroles_core/
report.rs

1use serde::Serialize;
2use thiserror::Error;
3
4use crate::diff::Change;
5use crate::manifest::{ObjectType, SchemaBindingFacet};
6use crate::model::{DefaultPrivKey, GrantKey};
7use crate::ownership::{
8    ManagedScope, MembershipKey, OwnershipIndex, SchemaFacetKey, describe_change, grant_schema_name,
9};
10use crate::visual::VisualManagedScope;
11
12pub const BUNDLE_PLAN_SCHEMA_VERSION: &str = "pgroles.bundle_plan.v1";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum PlanOutputMode {
16    Full,
17    Redacted,
18}
19
20#[derive(Debug, Clone)]
21pub struct BundleReportContext<'a> {
22    pub ownership: &'a OwnershipIndex,
23    pub managed_scope: &'a ManagedScope,
24}
25
26#[derive(Debug, Error)]
27pub enum BundlePlanError {
28    #[error("missing managed owner for change: {change}")]
29    MissingOwner { change: String },
30
31    #[error("bundle change is missing required scope details: {change}")]
32    InvalidChange { change: String },
33}
34
35#[derive(Debug, Error)]
36pub enum BundlePlanRenderError {
37    #[error(transparent)]
38    Plan(#[from] BundlePlanError),
39
40    #[error(transparent)]
41    Json(#[from] serde_json::Error),
42}
43
44#[derive(Debug, Clone, Serialize)]
45pub struct BundlePlanJson {
46    pub schema_version: String,
47    pub managed_scope: VisualManagedScope,
48    pub changes: Vec<AnnotatedPlanChange>,
49}
50
51#[derive(Debug, Clone, Serialize)]
52pub struct AnnotatedPlanChange {
53    pub category: BundleChangeCategory,
54    pub owner: BundleChangeOwner,
55    pub change: Change,
56}
57
58#[derive(Debug, Clone, Serialize)]
59pub struct BundleChangeOwner {
60    pub document: String,
61    pub managed_key: ManagedOwnershipKey,
62}
63
64#[derive(Debug, Clone, Copy, Serialize)]
65#[serde(rename_all = "snake_case")]
66pub enum BundleChangeCategory {
67    Role,
68    Schema,
69    Grant,
70    DefaultPrivilege,
71    Membership,
72    Retirement,
73}
74
75#[derive(Debug, Clone, Serialize)]
76#[serde(tag = "kind", rename_all = "snake_case")]
77pub enum ManagedOwnershipKey {
78    Role {
79        name: String,
80    },
81    SchemaFacet {
82        schema: String,
83        facet: SchemaBindingFacet,
84    },
85    Grant {
86        role: String,
87        object_type: ObjectType,
88        schema: Option<String>,
89        name: Option<String>,
90    },
91    DefaultPrivilege {
92        owner: String,
93        schema: String,
94        on_type: ObjectType,
95        grantee: String,
96    },
97    Membership {
98        role: String,
99        member: String,
100    },
101}
102
103pub fn shape_plan_changes(changes: &[Change], mode: PlanOutputMode) -> Vec<Change> {
104    match mode {
105        PlanOutputMode::Full => changes.to_vec(),
106        PlanOutputMode::Redacted => changes
107            .iter()
108            .map(|change| match change {
109                Change::SetPassword { name, .. } => Change::SetPassword {
110                    name: name.clone(),
111                    password: "[REDACTED]".to_string(),
112                },
113                other => other.clone(),
114            })
115            .collect(),
116    }
117}
118
119pub fn render_plan_json(
120    changes: &[Change],
121    mode: PlanOutputMode,
122) -> Result<String, serde_json::Error> {
123    serde_json::to_string_pretty(&shape_plan_changes(changes, mode))
124}
125
126pub fn build_bundle_plan(
127    changes: &[Change],
128    context: &BundleReportContext<'_>,
129    mode: PlanOutputMode,
130) -> Result<BundlePlanJson, BundlePlanError> {
131    let shaped_changes = shape_plan_changes(changes, mode);
132    let annotated_changes = shaped_changes
133        .iter()
134        .map(|change| {
135            Ok(AnnotatedPlanChange {
136                category: bundle_change_category(change),
137                owner: lookup_bundle_change_owner(change, context.ownership)?,
138                change: change.clone(),
139            })
140        })
141        .collect::<Result<Vec<_>, BundlePlanError>>()?;
142
143    Ok(BundlePlanJson {
144        schema_version: BUNDLE_PLAN_SCHEMA_VERSION.to_string(),
145        managed_scope: VisualManagedScope::from(context.managed_scope),
146        changes: annotated_changes,
147    })
148}
149
150pub fn render_bundle_plan_json(
151    changes: &[Change],
152    context: &BundleReportContext<'_>,
153    mode: PlanOutputMode,
154) -> Result<String, BundlePlanRenderError> {
155    let plan = build_bundle_plan(changes, context, mode)?;
156    Ok(serde_json::to_string_pretty(&plan)?)
157}
158
159fn lookup_bundle_change_owner(
160    change: &Change,
161    ownership: &OwnershipIndex,
162) -> Result<BundleChangeOwner, BundlePlanError> {
163    match change {
164        Change::CreateRole { name, .. }
165        | Change::AlterRole { name, .. }
166        | Change::SetComment { name, .. }
167        | Change::SetPassword { name, .. }
168        | Change::DropRole { name } => ownership
169            .roles
170            .get(name)
171            .cloned()
172            .map(|document| BundleChangeOwner {
173                document,
174                managed_key: ManagedOwnershipKey::Role { name: name.clone() },
175            })
176            .ok_or_else(|| BundlePlanError::MissingOwner {
177                change: describe_change(change),
178            }),
179        Change::TerminateSessions { role } | Change::DropOwned { role } => ownership
180            .roles
181            .get(role)
182            .cloned()
183            .map(|document| BundleChangeOwner {
184                document,
185                managed_key: ManagedOwnershipKey::Role { name: role.clone() },
186            })
187            .ok_or_else(|| BundlePlanError::MissingOwner {
188                change: describe_change(change),
189            }),
190        Change::ReassignOwned { from_role, .. } => ownership
191            .roles
192            .get(from_role)
193            .cloned()
194            .map(|document| BundleChangeOwner {
195                document,
196                managed_key: ManagedOwnershipKey::Role {
197                    name: from_role.clone(),
198                },
199            })
200            .ok_or_else(|| BundlePlanError::MissingOwner {
201                change: describe_change(change),
202            }),
203        Change::CreateSchema { name, .. } => {
204            lookup_bundle_schema_owner_or_bindings(name, ownership, change)
205        }
206        Change::AlterSchemaOwner { name, .. }
207        | Change::EnsureSchemaOwnerPrivileges { name, .. } => {
208            lookup_bundle_schema_facet(name, SchemaBindingFacet::Owner, ownership, change)
209        }
210        Change::Grant {
211            role,
212            object_type,
213            schema,
214            name,
215            ..
216        }
217        | Change::Revoke {
218            role,
219            object_type,
220            schema,
221            name,
222            ..
223        } => {
224            let grant_key = GrantKey {
225                role: role.clone(),
226                object_type: *object_type,
227                schema: schema.clone(),
228                name: name.clone(),
229            };
230
231            if let Some(document) = ownership.grants.get(&grant_key) {
232                return Ok(BundleChangeOwner {
233                    document: document.clone(),
234                    managed_key: ManagedOwnershipKey::Grant {
235                        role: grant_key.role.clone(),
236                        object_type: grant_key.object_type,
237                        schema: grant_key.schema.clone(),
238                        name: grant_key.name.clone(),
239                    },
240                });
241            }
242
243            if *object_type == ObjectType::Database {
244                return ownership
245                    .roles
246                    .get(role)
247                    .cloned()
248                    .map(|document| BundleChangeOwner {
249                        document,
250                        managed_key: ManagedOwnershipKey::Role { name: role.clone() },
251                    })
252                    .ok_or_else(|| BundlePlanError::MissingOwner {
253                        change: describe_change(change),
254                    });
255            }
256
257            let schema_name =
258                grant_schema_name(&grant_key).ok_or_else(|| BundlePlanError::InvalidChange {
259                    change: describe_change(change),
260                })?;
261            lookup_bundle_schema_facet(
262                &schema_name,
263                SchemaBindingFacet::Bindings,
264                ownership,
265                change,
266            )
267        }
268        Change::SetDefaultPrivilege {
269            owner,
270            schema,
271            on_type,
272            grantee,
273            ..
274        }
275        | Change::RevokeDefaultPrivilege {
276            owner,
277            schema,
278            on_type,
279            grantee,
280            ..
281        } => {
282            let key = DefaultPrivKey {
283                owner: owner.clone(),
284                schema: schema.clone(),
285                on_type: *on_type,
286                grantee: grantee.clone(),
287            };
288
289            if let Some(document) = ownership.default_privileges.get(&key) {
290                return Ok(BundleChangeOwner {
291                    document: document.clone(),
292                    managed_key: ManagedOwnershipKey::DefaultPrivilege {
293                        owner: key.owner.clone(),
294                        schema: key.schema.clone(),
295                        on_type: key.on_type,
296                        grantee: key.grantee.clone(),
297                    },
298                });
299            }
300
301            lookup_bundle_schema_facet(schema, SchemaBindingFacet::Bindings, ownership, change)
302        }
303        Change::AddMember { role, member, .. } | Change::RemoveMember { role, member } => {
304            let key = MembershipKey {
305                role: role.clone(),
306                member: member.clone(),
307            };
308
309            if let Some(document) = ownership.memberships.get(&key) {
310                return Ok(BundleChangeOwner {
311                    document: document.clone(),
312                    managed_key: ManagedOwnershipKey::Membership {
313                        role: role.clone(),
314                        member: member.clone(),
315                    },
316                });
317            }
318
319            ownership
320                .roles
321                .get(role)
322                .cloned()
323                .map(|document| BundleChangeOwner {
324                    document,
325                    managed_key: ManagedOwnershipKey::Role { name: role.clone() },
326                })
327                .ok_or_else(|| BundlePlanError::MissingOwner {
328                    change: describe_change(change),
329                })
330        }
331    }
332}
333
334fn lookup_bundle_schema_owner_or_bindings(
335    schema: &str,
336    ownership: &OwnershipIndex,
337    change: &Change,
338) -> Result<BundleChangeOwner, BundlePlanError> {
339    lookup_bundle_schema_facet(schema, SchemaBindingFacet::Owner, ownership, change).or_else(|_| {
340        lookup_bundle_schema_facet(schema, SchemaBindingFacet::Bindings, ownership, change)
341    })
342}
343
344fn lookup_bundle_schema_facet(
345    schema: &str,
346    facet: SchemaBindingFacet,
347    ownership: &OwnershipIndex,
348    change: &Change,
349) -> Result<BundleChangeOwner, BundlePlanError> {
350    let facet_key = SchemaFacetKey {
351        schema: schema.to_string(),
352        facet,
353    };
354
355    ownership
356        .schema_facets
357        .get(&facet_key)
358        .cloned()
359        .map(|document| BundleChangeOwner {
360            document,
361            managed_key: ManagedOwnershipKey::SchemaFacet {
362                schema: schema.to_string(),
363                facet,
364            },
365        })
366        .ok_or_else(|| BundlePlanError::MissingOwner {
367            change: describe_change(change),
368        })
369}
370
371fn bundle_change_category(change: &Change) -> BundleChangeCategory {
372    match change {
373        Change::CreateRole { .. }
374        | Change::AlterRole { .. }
375        | Change::SetComment { .. }
376        | Change::SetPassword { .. }
377        | Change::DropRole { .. } => BundleChangeCategory::Role,
378        Change::CreateSchema { .. }
379        | Change::AlterSchemaOwner { .. }
380        | Change::EnsureSchemaOwnerPrivileges { .. } => BundleChangeCategory::Schema,
381        Change::Grant { .. } | Change::Revoke { .. } => BundleChangeCategory::Grant,
382        Change::SetDefaultPrivilege { .. } | Change::RevokeDefaultPrivilege { .. } => {
383            BundleChangeCategory::DefaultPrivilege
384        }
385        Change::AddMember { .. } | Change::RemoveMember { .. } => BundleChangeCategory::Membership,
386        Change::ReassignOwned { .. }
387        | Change::DropOwned { .. }
388        | Change::TerminateSessions { .. } => BundleChangeCategory::Retirement,
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::composition::{self, PolicyDocument};
396    use crate::diff::diff;
397    use crate::model::RoleGraph;
398
399    #[test]
400    fn render_plan_json_redacts_passwords_in_redacted_mode() {
401        let changes = vec![Change::SetPassword {
402            name: "app".to_string(),
403            password: "super-secret".to_string(),
404        }];
405
406        let redacted = shape_plan_changes(&changes, PlanOutputMode::Redacted);
407        assert_eq!(
408            redacted[0],
409            Change::SetPassword {
410                name: "app".to_string(),
411                password: "[REDACTED]".to_string(),
412            }
413        );
414
415        let json =
416            render_plan_json(&changes, PlanOutputMode::Redacted).expect("json should render");
417        assert!(json.contains("[REDACTED]"));
418        assert!(!json.contains("super-secret"));
419    }
420
421    #[test]
422    fn bundle_plan_json_contract_is_versioned_and_typed() {
423        let bundle = composition::parse_policy_bundle(
424            r#"
425sources:
426  - file: app.yaml
427"#,
428        )
429        .expect("bundle should parse");
430        let documents = vec![PolicyDocument {
431            source: "app.yaml".to_string(),
432            fragment: composition::parse_policy_fragment(
433                r#"
434policy:
435  name: app
436scope:
437  roles: [app]
438roles:
439  - name: app
440    login: false
441"#,
442            )
443            .expect("fragment should parse"),
444        }];
445        let composed =
446            composition::compose_bundle(&bundle, &documents).expect("bundle should compose");
447        let changes = diff(&RoleGraph::default(), &composed.desired);
448
449        let plan = build_bundle_plan(
450            &changes,
451            &composed.report_context(),
452            PlanOutputMode::Redacted,
453        )
454        .expect("bundle plan should annotate");
455        let json = serde_json::to_value(&plan).expect("bundle plan should serialize");
456
457        assert_eq!(json["schema_version"], BUNDLE_PLAN_SCHEMA_VERSION);
458        assert_eq!(json["managed_scope"]["roles"][0], "app");
459        assert_eq!(json["changes"][0]["category"], "role");
460        assert_eq!(json["changes"][0]["owner"]["document"], "app");
461        assert_eq!(json["changes"][0]["owner"]["managed_key"]["kind"], "role");
462        assert_eq!(json["changes"][0]["owner"]["managed_key"]["name"], "app");
463    }
464
465    #[test]
466    fn bundle_plan_full_mode_preserves_password_values() {
467        let bundle = composition::parse_policy_bundle(
468            r#"
469sources:
470  - file: app.yaml
471"#,
472        )
473        .expect("bundle should parse");
474        let documents = vec![PolicyDocument {
475            source: "app.yaml".to_string(),
476            fragment: composition::parse_policy_fragment(
477                r#"
478policy:
479  name: app
480scope:
481  roles: [app]
482roles:
483  - name: app
484    login: true
485"#,
486            )
487            .expect("fragment should parse"),
488        }];
489        let composed =
490            composition::compose_bundle(&bundle, &documents).expect("bundle should compose");
491        let changes = vec![Change::SetPassword {
492            name: "app".to_string(),
493            password: "super-secret".to_string(),
494        }];
495
496        let redacted = build_bundle_plan(
497            &changes,
498            &composed.report_context(),
499            PlanOutputMode::Redacted,
500        )
501        .expect("redacted plan should build");
502        let full = build_bundle_plan(&changes, &composed.report_context(), PlanOutputMode::Full)
503            .expect("full plan should build");
504
505        assert_eq!(
506            redacted.changes[0].change,
507            Change::SetPassword {
508                name: "app".to_string(),
509                password: "[REDACTED]".to_string(),
510            }
511        );
512        assert_eq!(
513            full.changes[0].change,
514            Change::SetPassword {
515                name: "app".to_string(),
516                password: "super-secret".to_string(),
517            }
518        );
519    }
520}