1use serde::{Deserialize, Serialize};
44
45use plsql_core::{
46 Confidence, ConfidenceLevel, Evidence, ObjectName, RoleName, SchemaName, UnknownReason,
47};
48
49use crate::model::PrivilegeModel;
50
51pub const AMBIGUITY_EVIDENCE_CODE: &str = "PRIV-AMBIGUITY";
53
54#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56pub struct AmbiguityFeedEntry {
57 pub schema: SchemaName,
59 pub object: ObjectName,
61 pub reason: UnknownReason,
63 pub dependent_roles: Vec<RoleName>,
66 pub confidence_ceiling: Confidence,
69 pub sast_evidence: Evidence,
71}
72
73#[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#[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#[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 assert_eq!(feed[0].schema, sn(3));
304 assert_eq!(feed[0].object, on(4));
305 }
306}