Skip to main content

plsql_privileges/
ambiguity_feed.rs

1//! Privilege-ambiguity feed.
2//!
3//! The privilege resolver ([`crate::resolve_privileges`]) records two
4//! kinds of "can't decide statically" outcome:
5//!
6//! * [`AuthorizationAmbiguity`] — a unit's authorization depends on
7//!   runtime role state (`RuntimeGrantOrRole`) or invoker-rights
8//!   resolution (`InvokerRightsRuntimeResolution`).
9//! * [`CrossSchemaWrite`] with a non-`None` `runtime_ambiguity` — a
10//!   cross-schema write whose grant can't be confirmed statically.
11//!
12//! Those signals are useless if they stay trapped in the privilege
13//! model. This module turns them into a flat feed two downstream
14//! layers consume:
15//!
16//! 1. **Symbol resolution** — when a reference resolves to an object
17//!    whose authorization is ambiguous, the resolver should not claim
18//!    `High` confidence. [`downgrade_confidence`] computes the capped
19//!    confidence for a given prior + reason.
20//! 2. **SAST evidence** — each ambiguity becomes an [`Evidence`]
21//!    record (stable `code`, dependent-role notes) a security rule
22//!    can attach to its finding so an operator sees *why* the
23//!    analyser hedged.
24//!
25//! The feed is pure data: it neither imports the symbol crate nor the
26//! SAST crate (both are same-layer / downstream), so there is no
27//! dependency cycle — the engine orchestration layer wires the feed
28//! into whichever consumer needs it. Object/schema/role names are
29//! interned [`plsql_core`] ids; the feed keeps them typed and renders
30//! them via `Debug` for evidence text (the engine resolves ids back
31//! to source identifiers through its interner when presenting).
32//!
33//! ## /oracle evidence
34//!
35//! * `DATABASE-REFERENCE.md` PL/SQL Language Reference — Invoker's
36//!   vs. Definer's Rights: `AUTHID CURRENT_USER` defers privilege
37//!   resolution to call time, which is exactly the static-analysis
38//!   gap this feed records.
39//! * `LOW-LEVEL-CATALOGS.md` — `SESSION_ROLES` / `DBA_ROLE_PRIVS`:
40//!   the runtime role set that makes `RuntimeGrantOrRole` undecidable
41//!   without a live session.
42
43use serde::{Deserialize, Serialize};
44
45use plsql_core::{
46    Confidence, ConfidenceLevel, Evidence, ObjectName, RoleName, SchemaName, UnknownReason,
47};
48
49use crate::model::PrivilegeModel;
50
51/// Stable evidence code so SAST rules / golden tests can match on it.
52pub const AMBIGUITY_EVIDENCE_CODE: &str = "PRIV-AMBIGUITY";
53
54/// One downstream-consumable ambiguity record.
55#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56pub struct AmbiguityFeedEntry {
57    /// Owning schema of the affected object.
58    pub schema: SchemaName,
59    /// The affected object.
60    pub object: ObjectName,
61    /// Why the authorization is undecidable statically.
62    pub reason: UnknownReason,
63    /// Roles whose runtime state would change the outcome (may be
64    /// empty when the ambiguity is not role-driven).
65    pub dependent_roles: Vec<RoleName>,
66    /// The confidence ceiling a symbol-resolution result that lands
67    /// on this object must not exceed.
68    pub confidence_ceiling: Confidence,
69    /// Ready-to-attach SAST evidence record.
70    pub sast_evidence: Evidence,
71}
72
73/// The strongest confidence a result may claim when its authorization
74/// hinges on `reason`. Runtime role/grant state and invoker-rights
75/// resolution are genuinely undecidable without a live session, so
76/// they cap at `Low`; anything else we treat as `Opaque` (we don't
77/// even know enough to call it `Low`).
78#[must_use]
79pub fn confidence_ceiling_for(reason: UnknownReason) -> ConfidenceLevel {
80    match reason {
81        UnknownReason::RuntimeGrantOrRole | UnknownReason::InvokerRightsRuntimeResolution => {
82            ConfidenceLevel::Low
83        }
84        _ => ConfidenceLevel::Opaque,
85    }
86}
87
88/// Cap `prior` at the ceiling implied by `reason`. Never *raises*
89/// confidence — if the prior is already at/under the ceiling it is
90/// returned unchanged (only the explanation is appended). Ordering
91/// uses `ConfidenceLevel`'s derived `Ord` where `High < Medium <
92/// Low < Opaque` (a larger discriminant == less confident), so the
93/// capped level is `max(prior, ceiling)`.
94#[must_use]
95pub fn downgrade_confidence(prior: &Confidence, reason: UnknownReason) -> Confidence {
96    let ceiling = confidence_ceiling_for(reason);
97    let level = prior.level.max(ceiling);
98    let note = format!(
99        "privilege authorization is ambiguous ({reason:?}); confidence capped at {ceiling:?}"
100    );
101    let explanation = match &prior.explanation {
102        Some(prev) if !prev.is_empty() => format!("{prev}; {note}"),
103        _ => note,
104    };
105    Confidence::new(level, explanation)
106}
107
108fn ceiling_confidence(reason: UnknownReason, context: &str) -> Confidence {
109    Confidence::new(confidence_ceiling_for(reason), context.to_string())
110}
111
112/// Build the flat ambiguity feed from a resolved [`PrivilegeModel`].
113///
114/// Deterministic: entries appear in the model's own order
115/// (`runtime_ambiguities` first, then ambiguous `cross_schema_writes`).
116/// `O(n)` over the two source vectors.
117#[must_use]
118pub fn ambiguity_feed(model: &PrivilegeModel) -> Vec<AmbiguityFeedEntry> {
119    let mut feed = Vec::new();
120
121    for amb in &model.runtime_ambiguities {
122        let summary = format!(
123            "{:?}.{:?} authorization depends on runtime role state ({:?})",
124            amb.schema, amb.object, amb.reason
125        );
126        let mut ev = Evidence::new(AMBIGUITY_EVIDENCE_CODE, summary);
127        if !amb.dependent_roles.is_empty() {
128            ev = ev.with_note(format!("dependent roles: {:?}", amb.dependent_roles));
129        }
130        ev.confidence = Some(ceiling_confidence(
131            amb.reason,
132            "static analysis cannot confirm the grant without a live session",
133        ));
134        feed.push(AmbiguityFeedEntry {
135            schema: amb.schema,
136            object: amb.object,
137            reason: amb.reason,
138            dependent_roles: amb.dependent_roles.clone(),
139            confidence_ceiling: ceiling_confidence(
140                amb.reason,
141                "authorization ambiguity from privilege model",
142            ),
143            sast_evidence: ev,
144        });
145    }
146
147    for csw in &model.cross_schema_writes {
148        let Some(reason) = csw.runtime_ambiguity else {
149            continue;
150        };
151        let summary = format!(
152            "cross-schema write {:?}.{:?} -> {:?}.{:?} ({:?}) cannot be confirmed statically ({:?})",
153            csw.caller_schema,
154            csw.caller_object,
155            csw.target_schema,
156            csw.target_object,
157            csw.privilege,
158            reason
159        );
160        let mut ev = Evidence::new(AMBIGUITY_EVIDENCE_CODE, summary);
161        ev = ev.with_note("cross-schema write authorization is runtime-dependent");
162        ev.confidence = Some(ceiling_confidence(
163            reason,
164            "grant for the cross-schema write is runtime-resolved",
165        ));
166        feed.push(AmbiguityFeedEntry {
167            schema: csw.target_schema,
168            object: csw.target_object,
169            reason,
170            dependent_roles: Vec::new(),
171            confidence_ceiling: ceiling_confidence(reason, "cross-schema write ambiguity"),
172            sast_evidence: ev,
173        });
174    }
175
176    feed
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::model::{AuthorizationAmbiguity, CrossSchemaWrite};
183    use plsql_catalog::GrantPrivilege;
184    use plsql_core::SymbolId;
185
186    fn sn(id: u64) -> SchemaName {
187        SchemaName::from(SymbolId::new(id))
188    }
189    fn on(id: u64) -> ObjectName {
190        ObjectName::from(SymbolId::new(id))
191    }
192    fn rn(id: u64) -> RoleName {
193        RoleName::from(SymbolId::new(id))
194    }
195
196    #[test]
197    fn confidence_ceiling_runtime_role_is_low() {
198        assert_eq!(
199            confidence_ceiling_for(UnknownReason::RuntimeGrantOrRole),
200            ConfidenceLevel::Low
201        );
202        assert_eq!(
203            confidence_ceiling_for(UnknownReason::InvokerRightsRuntimeResolution),
204            ConfidenceLevel::Low
205        );
206    }
207
208    #[test]
209    fn confidence_ceiling_other_reasons_are_opaque() {
210        assert_eq!(
211            confidence_ceiling_for(UnknownReason::DynamicSqlOpaque),
212            ConfidenceLevel::Opaque
213        );
214    }
215
216    #[test]
217    fn downgrade_never_raises_confidence() {
218        let prior = Confidence::new(ConfidenceLevel::Opaque, None);
219        let out = downgrade_confidence(&prior, UnknownReason::RuntimeGrantOrRole);
220        assert_eq!(out.level, ConfidenceLevel::Opaque);
221    }
222
223    #[test]
224    fn downgrade_caps_high_to_low_for_runtime_role() {
225        let prior = Confidence::new(ConfidenceLevel::High, Some("resolved in catalog".into()));
226        let out = downgrade_confidence(&prior, UnknownReason::RuntimeGrantOrRole);
227        assert_eq!(out.level, ConfidenceLevel::Low);
228        let expl = out.explanation.unwrap();
229        assert!(expl.contains("resolved in catalog"));
230        assert!(expl.contains("capped at Low"));
231    }
232
233    #[test]
234    fn empty_model_yields_empty_feed() {
235        assert!(ambiguity_feed(&PrivilegeModel::default()).is_empty());
236    }
237
238    #[test]
239    fn runtime_ambiguity_becomes_feed_entry_with_evidence() {
240        let model = PrivilegeModel {
241            runtime_ambiguities: vec![AuthorizationAmbiguity {
242                schema: sn(1),
243                object: on(2),
244                reason: UnknownReason::RuntimeGrantOrRole,
245                dependent_roles: vec![rn(3)],
246                evidence: Evidence::new("X", "x"),
247            }],
248            ..PrivilegeModel::default()
249        };
250        let feed = ambiguity_feed(&model);
251        assert_eq!(feed.len(), 1);
252        let e = &feed[0];
253        assert_eq!(e.schema, sn(1));
254        assert_eq!(e.reason, UnknownReason::RuntimeGrantOrRole);
255        assert_eq!(e.confidence_ceiling.level, ConfidenceLevel::Low);
256        assert_eq!(e.sast_evidence.code, AMBIGUITY_EVIDENCE_CODE);
257        assert_eq!(e.dependent_roles, vec![rn(3)]);
258        assert!(
259            e.sast_evidence
260                .notes
261                .iter()
262                .any(|n| n.contains("dependent roles"))
263        );
264    }
265
266    #[test]
267    fn cross_schema_write_without_ambiguity_is_skipped() {
268        let model = PrivilegeModel {
269            cross_schema_writes: vec![CrossSchemaWrite {
270                caller_schema: sn(1),
271                caller_object: on(2),
272                target_schema: sn(3),
273                target_object: on(4),
274                privilege: GrantPrivilege::Update,
275                confidence: Confidence::new(ConfidenceLevel::High, None),
276                evidence: Evidence::new("X", "x"),
277                runtime_ambiguity: None,
278            }],
279            ..PrivilegeModel::default()
280        };
281        assert!(ambiguity_feed(&model).is_empty());
282    }
283
284    #[test]
285    fn cross_schema_write_with_ambiguity_targets_written_object() {
286        let model = PrivilegeModel {
287            cross_schema_writes: vec![CrossSchemaWrite {
288                caller_schema: sn(1),
289                caller_object: on(2),
290                target_schema: sn(3),
291                target_object: on(4),
292                privilege: GrantPrivilege::Update,
293                confidence: Confidence::new(ConfidenceLevel::Low, None),
294                evidence: Evidence::new("X", "x"),
295                runtime_ambiguity: Some(UnknownReason::RuntimeGrantOrRole),
296            }],
297            ..PrivilegeModel::default()
298        };
299        let feed = ambiguity_feed(&model);
300        assert_eq!(feed.len(), 1);
301        // The feed entry keys on the *written* object so the symbol
302        // layer downgrades references that resolve to it.
303        assert_eq!(feed[0].schema, sn(3));
304        assert_eq!(feed[0].object, on(4));
305    }
306}