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}