1use serde::{Deserialize, Serialize};
10
11use plsql_core::UnknownReason;
12
13use crate::model::{AuthorizationMode, PrivilegeModel};
14
15#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
19pub struct PrivilegeDoctorReport {
20 pub privileges_total: usize,
22 pub public_grants_total: usize,
24 pub synonym_paths_total: usize,
26 pub public_synonym_paths: usize,
28 pub access_control_entries_total: usize,
30 pub cross_schema_writes_total: usize,
32 pub cross_schema_writes_ambiguous: usize,
35 pub authorization_ambiguities_total: usize,
38 pub ambiguity_reasons: Vec<DoctorReasonRow>,
41 pub schemas_observed_total: usize,
46 pub authid_distribution: AuthidDistribution,
51 pub diagnostics_total: usize,
53 pub posture: PrivilegePosture,
56 pub remediation_hints: Vec<String>,
58}
59
60#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
62pub struct DoctorReasonRow {
63 pub reason: UnknownReason,
64 pub count: usize,
65}
66
67#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
70pub struct AuthidDistribution {
71 pub definer: usize,
72 pub invoker: usize,
73}
74
75impl AuthidDistribution {
76 pub fn record(&mut self, mode: AuthorizationMode) {
78 match mode {
79 AuthorizationMode::Definer => self.definer = self.definer.saturating_add(1),
80 AuthorizationMode::Invoker => self.invoker = self.invoker.saturating_add(1),
81 }
82 }
83}
84
85#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
90pub enum PrivilegePosture {
91 Clean,
93 #[default]
96 Caution,
97 Unknown,
100}
101
102#[must_use]
104pub fn doctor_report(model: &PrivilegeModel) -> PrivilegeDoctorReport {
105 let mut ambiguity_counts: std::collections::BTreeMap<UnknownReason, usize> =
106 std::collections::BTreeMap::new();
107 for entry in &model.runtime_ambiguities {
108 *ambiguity_counts.entry(entry.reason).or_insert(0) += 1;
109 }
110 let cross_schema_writes_ambiguous = model
111 .cross_schema_writes
112 .iter()
113 .filter(|w| w.runtime_ambiguity.is_some())
114 .count();
115 let public_synonym_paths = model.synonym_paths.iter().filter(|p| p.is_public).count();
116 let schemas_observed_total = distinct_schema_count(model);
117
118 let ambiguity_reasons = ambiguity_counts
119 .into_iter()
120 .map(|(reason, count)| DoctorReasonRow { reason, count })
121 .collect::<Vec<_>>();
122
123 let posture = classify_posture(
124 model.privileges.len(),
125 model.runtime_ambiguities.len(),
126 cross_schema_writes_ambiguous,
127 );
128
129 let remediation_hints = build_remediation_hints(
130 model.privileges.len(),
131 model.runtime_ambiguities.len(),
132 cross_schema_writes_ambiguous,
133 public_synonym_paths,
134 model.diagnostics.len(),
135 );
136
137 PrivilegeDoctorReport {
138 privileges_total: model.privileges.len(),
139 public_grants_total: model.public_grants.len(),
140 synonym_paths_total: model.synonym_paths.len(),
141 public_synonym_paths,
142 access_control_entries_total: model.access_control.len(),
143 cross_schema_writes_total: model.cross_schema_writes.len(),
144 cross_schema_writes_ambiguous,
145 authorization_ambiguities_total: model.runtime_ambiguities.len(),
146 ambiguity_reasons,
147 schemas_observed_total,
148 authid_distribution: AuthidDistribution::default(),
149 diagnostics_total: model.diagnostics.len(),
150 posture,
151 remediation_hints,
152 }
153}
154
155fn distinct_schema_count(model: &PrivilegeModel) -> usize {
156 let mut seen = std::collections::BTreeSet::new();
157 let record = |seen: &mut std::collections::BTreeSet<plsql_core::SchemaName>,
158 s: plsql_core::SchemaName| {
159 seen.insert(s);
160 };
161 for r in &model.privileges {
162 record(&mut seen, r.object_owner);
163 }
164 for r in &model.public_grants {
165 record(&mut seen, r.object_owner);
166 }
167 for entry in &model.access_control {
168 record(&mut seen, entry.declaring_schema);
169 }
170 for w in &model.cross_schema_writes {
171 record(&mut seen, w.caller_schema);
172 record(&mut seen, w.target_schema);
173 }
174 for p in &model.synonym_paths {
175 record(&mut seen, p.synonym_schema);
176 record(&mut seen, p.target_schema);
177 }
178 seen.len()
179}
180
181fn classify_posture(
182 privileges_total: usize,
183 ambiguities_total: usize,
184 cross_schema_ambiguous: usize,
185) -> PrivilegePosture {
186 if ambiguities_total > privileges_total && ambiguities_total > 0 {
187 return PrivilegePosture::Unknown;
188 }
189 if ambiguities_total > 0 || cross_schema_ambiguous > 0 {
190 return PrivilegePosture::Caution;
191 }
192 PrivilegePosture::Clean
193}
194
195fn build_remediation_hints(
196 privileges_total: usize,
197 ambiguities_total: usize,
198 cross_schema_ambiguous: usize,
199 public_synonym_paths: usize,
200 diagnostics_total: usize,
201) -> Vec<String> {
202 let mut hints = Vec::new();
203 if ambiguities_total > 0 {
204 hints.push(format!(
205 "Review {ambiguities_total} authorization ambiguity record(s) — \
206 role-state-dependent authorizations need explicit role configuration."
207 ));
208 }
209 if cross_schema_ambiguous > 0 {
210 hints.push(format!(
211 "{cross_schema_ambiguous} cross-schema write(s) carry a runtime ambiguity — \
212 verify the calling unit has the expected grant chain at deploy time."
213 ));
214 }
215 if public_synonym_paths > 0 {
216 hints.push(format!(
217 "{public_synonym_paths} public synonym path(s) observed — public synonyms can \
218 be retargeted by anyone with CREATE PUBLIC SYNONYM, so they are an audit hotspot."
219 ));
220 }
221 if diagnostics_total > 0 {
222 hints.push(format!(
223 "{diagnostics_total} diagnostic(s) emitted during privilege resolution — \
224 read the model's diagnostics list for typed UnknownReason payloads."
225 ));
226 }
227 if privileges_total == 0 && ambiguities_total == 0 {
228 hints.push(String::from(
229 "Privilege model is empty — confirm the catalog snapshot includes ALL_TAB_PRIVS \
230 rows (capability probe should detect this in plsql-catalog).",
231 ));
232 }
233 hints
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 use plsql_catalog::{GrantPrivilege, Grantee};
241 use plsql_core::{
242 Confidence, ConfidenceLevel, Evidence, ObjectName, RoleName, SchemaName, SymbolId,
243 UnknownReason,
244 };
245
246 use crate::model::{
247 AccessControlEntry, AuthorizationAmbiguity, CrossSchemaWrite, PrivilegeModel,
248 ResolvedPrivilege, SynonymPrivilegePath,
249 };
250
251 fn schema(id: u64) -> SchemaName {
252 SchemaName::from(SymbolId::new(id))
253 }
254
255 fn object(id: u64) -> ObjectName {
256 ObjectName::from(SymbolId::new(id))
257 }
258
259 fn role(id: u64) -> RoleName {
260 RoleName::from(SymbolId::new(id))
261 }
262
263 fn priv_grant(owner: SchemaName, target: ObjectName) -> ResolvedPrivilege {
264 ResolvedPrivilege {
265 object_owner: owner,
266 object_name: target,
267 privilege: GrantPrivilege::Select,
268 grantee: Grantee::Public,
269 grant_option: crate::model::GrantOption::None,
270 via_role: None,
271 confidence: Confidence::new(ConfidenceLevel::High, None),
272 evidence: Evidence::default(),
273 }
274 }
275
276 #[test]
277 fn empty_model_yields_clean_posture_with_setup_hint() {
278 let model = PrivilegeModel::default();
279 let report = doctor_report(&model);
280 assert_eq!(report.posture, PrivilegePosture::Clean);
281 assert_eq!(report.privileges_total, 0);
282 assert!(
283 report
284 .remediation_hints
285 .iter()
286 .any(|h| h.contains("Privilege model is empty"))
287 );
288 }
289
290 #[test]
291 fn ambiguities_drive_caution_posture_and_per_reason_counts() {
292 let mut model = PrivilegeModel::default();
293 model.privileges.push(priv_grant(schema(1), object(2)));
294 model.privileges.push(priv_grant(schema(1), object(3)));
295 model.runtime_ambiguities.push(AuthorizationAmbiguity {
296 schema: schema(1),
297 object: object(2),
298 reason: UnknownReason::RuntimeGrantOrRole,
299 dependent_roles: vec![role(7)],
300 evidence: Evidence::default(),
301 });
302 let report = doctor_report(&model);
303 assert_eq!(report.posture, PrivilegePosture::Caution);
304 assert_eq!(report.authorization_ambiguities_total, 1);
305 assert_eq!(report.ambiguity_reasons.len(), 1);
306 assert_eq!(report.ambiguity_reasons[0].count, 1);
307 assert!(
308 report
309 .remediation_hints
310 .iter()
311 .any(|h| h.contains("authorization ambiguity record"))
312 );
313 }
314
315 #[test]
316 fn cross_schema_write_with_runtime_ambiguity_counts_separately() {
317 let mut model = PrivilegeModel::default();
318 model.cross_schema_writes.push(CrossSchemaWrite {
319 caller_schema: schema(1),
320 caller_object: object(2),
321 target_schema: schema(4),
322 target_object: object(5),
323 privilege: GrantPrivilege::Update,
324 confidence: Confidence::new(ConfidenceLevel::Medium, None),
325 evidence: Evidence::default(),
326 runtime_ambiguity: Some(UnknownReason::RuntimeGrantOrRole),
327 });
328 let report = doctor_report(&model);
329 assert_eq!(report.cross_schema_writes_total, 1);
330 assert_eq!(report.cross_schema_writes_ambiguous, 1);
331 assert_eq!(report.posture, PrivilegePosture::Caution);
332 assert!(
333 report
334 .remediation_hints
335 .iter()
336 .any(|h| h.contains("cross-schema write"))
337 );
338 }
339
340 #[test]
341 fn ambiguity_outnumbering_privileges_yields_unknown_posture() {
342 let mut model = PrivilegeModel::default();
343 model.privileges.push(priv_grant(schema(1), object(2)));
344 for object_id in 100..105 {
345 model.runtime_ambiguities.push(AuthorizationAmbiguity {
346 schema: schema(1),
347 object: object(object_id),
348 reason: UnknownReason::RuntimeGrantOrRole,
349 dependent_roles: Vec::new(),
350 evidence: Evidence::default(),
351 });
352 }
353 let report = doctor_report(&model);
354 assert_eq!(report.posture, PrivilegePosture::Unknown);
355 assert_eq!(report.authorization_ambiguities_total, 5);
356 }
357
358 #[test]
359 fn public_synonym_paths_are_counted_and_surfaced_as_hint() {
360 let mut model = PrivilegeModel::default();
361 for (idx, is_public) in [true, true, false].into_iter().enumerate() {
362 model.synonym_paths.push(SynonymPrivilegePath {
363 synonym_schema: schema((idx + 1) as u64),
364 synonym_name: object((idx + 10) as u64),
365 target_schema: schema((idx + 20) as u64),
366 target_object: object((idx + 30) as u64),
367 is_public,
368 confidence: Confidence::new(ConfidenceLevel::High, None),
369 });
370 }
371 let report = doctor_report(&model);
372 assert_eq!(report.synonym_paths_total, 3);
373 assert_eq!(report.public_synonym_paths, 2);
374 assert!(
375 report
376 .remediation_hints
377 .iter()
378 .any(|h| h.contains("public synonym path"))
379 );
380 }
381
382 #[test]
383 fn distinct_schema_count_unions_all_record_kinds() {
384 let mut model = PrivilegeModel::default();
385 model.privileges.push(priv_grant(schema(1), object(10)));
386 model.public_grants.push(priv_grant(schema(2), object(11)));
387 model.access_control.push(AccessControlEntry {
388 declaring_schema: schema(3),
389 declaring_object: object(12),
390 allowed_callers: Vec::new(),
391 });
392 model.cross_schema_writes.push(CrossSchemaWrite {
393 caller_schema: schema(4),
394 caller_object: object(13),
395 target_schema: schema(5),
396 target_object: object(14),
397 privilege: GrantPrivilege::Update,
398 confidence: Confidence::new(ConfidenceLevel::High, None),
399 evidence: Evidence::default(),
400 runtime_ambiguity: None,
401 });
402 let report = doctor_report(&model);
403 assert_eq!(report.schemas_observed_total, 5);
404 }
405
406 #[test]
407 fn authid_distribution_records_each_mode_once() {
408 let mut dist = AuthidDistribution::default();
409 dist.record(AuthorizationMode::Definer);
410 dist.record(AuthorizationMode::Definer);
411 dist.record(AuthorizationMode::Invoker);
412 assert_eq!(dist.definer, 2);
413 assert_eq!(dist.invoker, 1);
414 }
415}