Skip to main content

plsql_privileges/
resolve.rs

1use plsql_catalog::{CatalogObject, CatalogSnapshot, GrantPrivilege, Grantee, SchemaCatalog};
2use plsql_core::{
3    Confidence, ConfidenceLevel, Evidence, ObjectName, RoleName, SchemaName, UnknownReason,
4};
5use tracing::instrument;
6
7use crate::{
8    AccessControlEntry, AuthorizationAmbiguity, AuthorizationMode, CrossSchemaWrite, GrantOption,
9    PrivilegeConfig, PrivilegeModel, ResolvedPrivilege, SynonymPrivilegePath,
10};
11
12/// Resolve a privilege model from a catalog snapshot and configuration.
13#[instrument(level = "debug", skip_all)]
14pub fn resolve_privileges(snapshot: &CatalogSnapshot, config: &PrivilegeConfig) -> PrivilegeModel {
15    let mut model = PrivilegeModel::default();
16
17    for (schema_name, schema_catalog) in &snapshot.schemas {
18        resolve_grants_for_schema(schema_name, schema_catalog, config, &mut model);
19        resolve_access_control_for_schema(schema_name, schema_catalog, &mut model);
20        resolve_cross_schema_writes(schema_catalog, config, &mut model);
21        resolve_synonym_paths(schema_name, schema_catalog, &mut model);
22    }
23
24    model.public_grants = model
25        .privileges
26        .iter()
27        .filter(|p| matches!(p.grantee, Grantee::Public))
28        .cloned()
29        .collect();
30
31    model
32}
33
34/// Determine the authorization mode for a PL/SQL unit from its catalog metadata.
35pub fn authorization_mode_for_object(
36    schema_catalog: &SchemaCatalog,
37    object_name: &ObjectName,
38) -> Option<AuthorizationMode> {
39    match schema_catalog.objects.get(object_name)? {
40        CatalogObject::Package(pkg) => pkg.authid_current_user.map(|invoker| {
41            if invoker {
42                AuthorizationMode::Invoker
43            } else {
44                AuthorizationMode::Definer
45            }
46        }),
47        CatalogObject::Procedure(proc) => proc.signature.authid_current_user.map(|invoker| {
48            if invoker {
49                AuthorizationMode::Invoker
50            } else {
51                AuthorizationMode::Definer
52            }
53        }),
54        CatalogObject::Function(func) => func.signature.authid_current_user.map(|invoker| {
55            if invoker {
56                AuthorizationMode::Invoker
57            } else {
58                AuthorizationMode::Definer
59            }
60        }),
61        _ => Some(AuthorizationMode::Definer),
62    }
63}
64
65fn resolve_grants_for_schema(
66    schema_name: &SchemaName,
67    schema_catalog: &SchemaCatalog,
68    config: &PrivilegeConfig,
69    model: &mut PrivilegeModel,
70) {
71    for grant in &schema_catalog.grants {
72        let (confidence, via_role) = grant_confidence(&grant.grantee, config);
73
74        model.privileges.push(ResolvedPrivilege {
75            object_owner: grant.object_owner,
76            object_name: grant.object_name,
77            privilege: grant.privilege,
78            grantee: grant.grantee.clone(),
79            grant_option: if grant.grantable {
80                GrantOption::Grantable
81            } else if grant.with_hierarchy {
82                GrantOption::Hierarchy
83            } else {
84                GrantOption::None
85            },
86            via_role,
87            confidence,
88            evidence: Evidence::new(
89                "privilege-grant",
90                format!(
91                    "Grant {:?} on {:?}.{:?} to {:?}",
92                    grant.privilege, schema_name, grant.object_name, grant.grantee
93                ),
94            ),
95        });
96
97        if let Grantee::Role(role) = &grant.grantee {
98            if !config.enabled_roles.contains(role) {
99                model.runtime_ambiguities.push(AuthorizationAmbiguity {
100                    schema: grant.object_owner,
101                    object: grant.object_name,
102                    reason: UnknownReason::RuntimeGrantOrRole,
103                    dependent_roles: vec![*role],
104                    evidence: Evidence::new(
105                        "runtime-role-ambiguity",
106                        format!("Grant to role {:?} may not be enabled at runtime", role),
107                    ),
108                });
109            }
110        }
111    }
112}
113
114fn resolve_access_control_for_schema(
115    schema_name: &SchemaName,
116    schema_catalog: &SchemaCatalog,
117    model: &mut PrivilegeModel,
118) {
119    for obj in schema_catalog.objects.values() {
120        let accessible_by = match obj {
121            CatalogObject::Package(pkg) => pkg.accessible_by.clone(),
122            CatalogObject::Procedure(proc) => proc.signature.accessible_by.clone(),
123            CatalogObject::Function(func) => func.signature.accessible_by.clone(),
124            _ => vec![],
125        };
126
127        if !accessible_by.is_empty() {
128            model.access_control.push(AccessControlEntry {
129                declaring_schema: *schema_name,
130                declaring_object: object_name_for(obj),
131                allowed_callers: accessible_by,
132            });
133        }
134    }
135}
136
137fn resolve_cross_schema_writes(
138    schema_catalog: &SchemaCatalog,
139    config: &PrivilegeConfig,
140    model: &mut PrivilegeModel,
141) {
142    for grant in &schema_catalog.grants {
143        if !matches!(
144            grant.privilege,
145            GrantPrivilege::Insert | GrantPrivilege::Update | GrantPrivilege::Delete
146        ) {
147            continue;
148        }
149
150        if let Grantee::User(user) = &grant.grantee {
151            let gs = SchemaName::new(user.symbol());
152            if gs != grant.object_owner {
153                let (confidence, runtime_ambiguity) = write_ambiguity(&grant.grantee, config);
154
155                model.cross_schema_writes.push(CrossSchemaWrite {
156                    caller_schema: gs,
157                    caller_object: ObjectName::new(user.symbol()),
158                    target_schema: grant.object_owner,
159                    target_object: grant.object_name,
160                    privilege: grant.privilege,
161                    confidence,
162                    evidence: Evidence::new(
163                        "cross-schema-write",
164                        format!(
165                            "Write grant {:?} on {:?}.{:?} to {:?}",
166                            grant.privilege, grant.object_owner, grant.object_name, user
167                        ),
168                    ),
169                    runtime_ambiguity,
170                });
171            }
172        }
173    }
174}
175
176fn resolve_synonym_paths(
177    schema_name: &SchemaName,
178    schema_catalog: &SchemaCatalog,
179    model: &mut PrivilegeModel,
180) {
181    for (syn_name, syn_target) in &schema_catalog.synonyms {
182        let target_schema = syn_target.target_owner.unwrap_or(*schema_name);
183
184        model.synonym_paths.push(SynonymPrivilegePath {
185            synonym_schema: *schema_name,
186            synonym_name: ObjectName::new(syn_name.symbol()),
187            target_schema,
188            target_object: syn_target.target_name,
189            is_public: syn_target.public_synonym,
190            confidence: Confidence::new(
191                ConfidenceLevel::Medium,
192                Some("Synonym target can change at runtime".to_string()),
193            ),
194        });
195    }
196}
197
198fn grant_confidence(grantee: &Grantee, config: &PrivilegeConfig) -> (Confidence, Option<RoleName>) {
199    match grantee {
200        Grantee::User(_) => (Confidence::new(ConfidenceLevel::High, None), None),
201        Grantee::Role(role) => {
202            if config.enabled_roles.contains(role) {
203                (
204                    Confidence::new(
205                        ConfidenceLevel::High,
206                        Some(format!("Role {:?} is enabled in profile", role)),
207                    ),
208                    Some(*role),
209                )
210            } else {
211                (
212                    Confidence::new(
213                        ConfidenceLevel::Low,
214                        Some(format!("Role {:?} may not be enabled at runtime", role)),
215                    ),
216                    Some(*role),
217                )
218            }
219        }
220        Grantee::Public => (
221            Confidence::new(ConfidenceLevel::High, Some("PUBLIC grant".to_string())),
222            None,
223        ),
224    }
225}
226
227fn write_ambiguity(
228    grantee: &Grantee,
229    config: &PrivilegeConfig,
230) -> (Confidence, Option<UnknownReason>) {
231    match grantee {
232        Grantee::Role(role) => {
233            if config.enabled_roles.contains(role) {
234                (Confidence::new(ConfidenceLevel::High, None), None)
235            } else {
236                (
237                    Confidence::new(
238                        ConfidenceLevel::Low,
239                        Some(format!("Role {:?} may not be active", role)),
240                    ),
241                    Some(UnknownReason::RuntimeGrantOrRole),
242                )
243            }
244        }
245        _ => (Confidence::new(ConfidenceLevel::High, None), None),
246    }
247}
248
249fn object_name_for(obj: &CatalogObject) -> ObjectName {
250    match obj {
251        CatalogObject::Table(t) => t.common.name,
252        CatalogObject::View(v) => v.common.name,
253        CatalogObject::MaterializedView(m) => m.common.name,
254        CatalogObject::Sequence(s) => s.common.name,
255        CatalogObject::Type(t) => t.common.name,
256        CatalogObject::Package(p) => p.common.name,
257        CatalogObject::Procedure(p) => p.common.name,
258        CatalogObject::Function(f) => f.common.name,
259        CatalogObject::Trigger(t) => t.common.name,
260        CatalogObject::SchedulerJob(j) => j.common.name,
261        CatalogObject::EditioningView(e) => e.common.name,
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use plsql_catalog::{ObjectCommon, ObjectType, PackageMetadata, SchemaCatalog};
269    use plsql_core::SymbolId;
270    use std::collections::HashMap;
271
272    fn make_schema_name(s: &str) -> SchemaName {
273        SchemaName::new(SymbolId::new(s.len() as u64))
274    }
275
276    fn make_object_name(s: &str) -> ObjectName {
277        ObjectName::new(SymbolId::new(s.len() as u64 + 100))
278    }
279
280    #[test]
281    fn test_empty_catalog_produces_empty_model() {
282        let snapshot = CatalogSnapshot {
283            schemas: HashMap::new(),
284            ..CatalogSnapshot::default()
285        };
286        let config = PrivilegeConfig::default();
287        let model = resolve_privileges(&snapshot, &config);
288        assert!(model.privileges.is_empty());
289        assert!(model.public_grants.is_empty());
290        assert!(model.access_control.is_empty());
291        assert!(model.cross_schema_writes.is_empty());
292        assert!(model.synonym_paths.is_empty());
293        assert!(model.runtime_ambiguities.is_empty());
294    }
295
296    #[test]
297    fn test_definer_vs_invoker_authorization() {
298        use plsql_catalog::CatalogObject;
299
300        let owner = make_schema_name("OWNER");
301        let pkg_name = make_object_name("MY_PKG");
302
303        // Definer-rights package.
304        let mut schema = SchemaCatalog::default();
305        schema.objects.insert(
306            pkg_name,
307            CatalogObject::Package(PackageMetadata {
308                common: ObjectCommon {
309                    owner,
310                    name: pkg_name,
311                    object_type: ObjectType::Package,
312                    ..ObjectCommon::default()
313                },
314                authid_current_user: Some(false),
315                ..PackageMetadata::default()
316            }),
317        );
318
319        let mode = authorization_mode_for_object(&schema, &pkg_name);
320        assert_eq!(mode, Some(AuthorizationMode::Definer));
321
322        // Now make it invoker-rights.
323        if let Some(CatalogObject::Package(pkg)) = schema.objects.get_mut(&pkg_name) {
324            pkg.authid_current_user = Some(true);
325        }
326        let mode = authorization_mode_for_object(&schema, &pkg_name);
327        assert_eq!(mode, Some(AuthorizationMode::Invoker));
328    }
329
330    #[test]
331    fn authorization_mode_unknown_authid_nonroutine_and_absent() {
332        use plsql_catalog::{CatalogObject, TableMetadata};
333
334        let owner = make_schema_name("OWNER");
335        let pkg_name = make_object_name("UNK_PKG");
336        let tbl_name = make_object_name("SOME_TBL");
337        let absent = make_object_name("NOPE");
338
339        let mut schema = SchemaCatalog::default();
340
341        // Package whose AUTHID could not be determined (None). R13:
342        // surface the uncertainty as None — must NOT be silently
343        // downgraded to Definer (claiming definer-rights when the
344        // object may actually be invoker-rights would mask a
345        // privilege-escalation surface).
346        schema.objects.insert(
347            pkg_name,
348            CatalogObject::Package(PackageMetadata {
349                common: ObjectCommon {
350                    owner,
351                    name: pkg_name,
352                    object_type: ObjectType::Package,
353                    ..ObjectCommon::default()
354                },
355                authid_current_user: None,
356                ..PackageMetadata::default()
357            }),
358        );
359        // Non-routine object: AUTHID is not a concept for tables;
360        // the resolver treats them as definer-scoped.
361        schema
362            .objects
363            .insert(tbl_name, CatalogObject::Table(TableMetadata::default()));
364
365        assert_eq!(
366            authorization_mode_for_object(&schema, &pkg_name),
367            None,
368            "unknown AUTHID must surface as None (R13), never silently Definer"
369        );
370        assert_eq!(
371            authorization_mode_for_object(&schema, &tbl_name),
372            Some(AuthorizationMode::Definer),
373            "non-routine objects resolve to Definer"
374        );
375        assert_eq!(
376            authorization_mode_for_object(&schema, &absent),
377            None,
378            "object absent from the catalog resolves to None"
379        );
380    }
381}