Skip to main content

plsql_privileges/
doctor.rs

1//! Doctor surface for [`PrivilegeModel`].
2//!
3//! Aggregates per-model completeness signals so an agent reading the
4//! engine output has a single first-stop for "how complete is my
5//! privilege analysis?". Follows the project-wide
6//! `/world-class-doctor-mode` convention: one stable shape per layer,
7//! always serde-able, always derivable in O(n) from the model.
8
9use serde::{Deserialize, Serialize};
10
11use plsql_core::UnknownReason;
12
13use crate::model::{AuthorizationMode, PrivilegeModel};
14
15/// Aggregated diagnostic counts for a single [`PrivilegeModel`]. The
16/// shape is stable across versions — new fields are added behind
17/// `#[serde(default)]` so older snapshots keep deserializing.
18#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
19pub struct PrivilegeDoctorReport {
20    /// Total resolved privileges.
21    pub privileges_total: usize,
22    /// Resolved privileges granted to `PUBLIC`.
23    pub public_grants_total: usize,
24    /// Synonym-mediated privilege paths.
25    pub synonym_paths_total: usize,
26    /// Public-synonym paths (`is_public == true`).
27    pub public_synonym_paths: usize,
28    /// `ACCESSIBLE BY` entries observed.
29    pub access_control_entries_total: usize,
30    /// Cross-schema writes observed.
31    pub cross_schema_writes_total: usize,
32    /// Cross-schema writes whose authorization could not be resolved
33    /// statically (carries `runtime_ambiguity = Some(_)`).
34    pub cross_schema_writes_ambiguous: usize,
35    /// `AuthorizationAmbiguity` entries — authorizations that flip on
36    /// runtime role state.
37    pub authorization_ambiguities_total: usize,
38    /// Distinct `UnknownReason` codes seen in the ambiguity list,
39    /// sorted by reason code for stable output.
40    pub ambiguity_reasons: Vec<DoctorReasonRow>,
41    /// Distinct schemas mentioned across the model. Useful to
42    /// cross-check against [`plsql_catalog::CatalogDoctorReport`] — if
43    /// the catalog has N schemas but the privilege model only mentions
44    /// M, the gap is surfaced by callers.
45    pub schemas_observed_total: usize,
46    /// Distinct `definer` and `invoker` AuthorizationMode counts seen
47    /// in evidence. We do not currently track AuthorizationMode on
48    /// every ResolvedPrivilege — the surface is reserved for the
49    /// engine layer to populate when the orchestration ships.
50    pub authid_distribution: AuthidDistribution,
51    /// Diagnostics recorded on the model itself.
52    pub diagnostics_total: usize,
53    /// Overall posture — `Clean` / `Caution` / `Unknown`. Set by
54    /// [`classify_posture`].
55    pub posture: PrivilegePosture,
56    /// One-line operator hints derived from the counts above.
57    pub remediation_hints: Vec<String>,
58}
59
60/// Per-reason count row. Sorted by `reason` for stable serialization.
61#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
62pub struct DoctorReasonRow {
63    pub reason: UnknownReason,
64    pub count: usize,
65}
66
67/// Bucketed `AUTHID` distribution. Pre-populated with zeros so consumers
68/// can rely on every bucket existing even when the count is 0.
69#[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    /// Record one observation of `mode`.
77    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/// Overall posture for the privilege model. Three-state by design —
86/// `Caution` is for anything that an agent should investigate; `Unknown`
87/// is reserved for cases where the model itself is suspect (e.g.
88/// `runtime_ambiguities` outnumber `privileges`).
89#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
90pub enum PrivilegePosture {
91    /// No ambiguity / no cross-schema-write uncertainty.
92    Clean,
93    /// At least one ambiguity or one cross-schema-write requires
94    /// follow-up but the model is otherwise consistent.
95    #[default]
96    Caution,
97    /// The model contains more uncertainty than resolved data — the
98    /// privilege resolver itself likely has gaps in catalog input.
99    Unknown,
100}
101
102/// Build a [`PrivilegeDoctorReport`] from a [`PrivilegeModel`].
103#[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}