Skip to main content

uqa_sql/catalog/roles/
definition.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Role declaration candidates, authorization and requested-name binding.
8
9use super::{
10    guards::RoleCatalogGuards,
11    memberships::{
12        insufficient_privilege, require_role_attribute_authority, role_has_transitive_admin,
13        role_is_superuser,
14    },
15    RoleDefinition, RoleIdentity, RoleMembership, RoleMembershipKey, RoleReference,
16    RoleReferenceNames,
17};
18use crate::catalog::roles::identity::{RoleBinding, RoleSubject};
19use crate::{
20    ast::{AlterRoleStmt, RoleAttribute, RoleSpecification},
21    SQLError,
22};
23use std::collections::BTreeMap;
24
25pub trait RoleNotices {
26    fn notice(&self, notice: crate::SQLNotice);
27}
28
29#[derive(Clone, Copy)]
30pub struct RoleValidationContext<'a> {
31    pub names: &'a dyn RoleReferenceNames,
32    pub roles: &'a dyn RoleCatalogGuards,
33    pub notices: &'a dyn RoleNotices,
34}
35
36pub fn require_role_creation(context: &RoleValidationContext<'_>) -> Result<(), SQLError> {
37    let current = context.names.current_role();
38    require_createrole(&context.roles.role_definitions(), &current, "create role")
39}
40
41fn require_createrole(
42    roles: &BTreeMap<String, RoleDefinition>,
43    current: &(impl RoleSubject + ?Sized),
44    action: &str,
45) -> Result<(), SQLError> {
46    let allowed = current.role_definition(roles).is_some_and(|role| {
47        role.has(RoleAttribute::Superuser) || role.has(RoleAttribute::CreateRole)
48    });
49    if allowed {
50        Ok(())
51    } else {
52        Err(insufficient_privilege(&format!(
53            "permission denied to {action}"
54        )))
55    }
56}
57
58pub fn require_role_administration_for(
59    catalog: &dyn RoleCatalogGuards,
60    roles: &BTreeMap<String, RoleDefinition>,
61    current: &(impl RoleSubject + ?Sized),
62    target: &str,
63    action: &str,
64) -> Result<(), SQLError> {
65    if role_is_superuser(roles, current) {
66        return Ok(());
67    }
68    let can_create_roles = current
69        .role_definition(roles)
70        .is_some_and(|role| role.has(RoleAttribute::CreateRole));
71    let memberships = catalog.role_memberships();
72    if can_create_roles
73        && current
74            .role_definition(roles)
75            .zip(roles.get(target))
76            .is_some_and(|(member, role)| {
77                role_has_transitive_admin(&memberships, member.identity(), role.identity())
78            })
79    {
80        Ok(())
81    } else {
82        Err(insufficient_privilege(&format!(
83            "permission denied to {action}"
84        )))
85    }
86}
87
88pub fn create_role_candidate(
89    roles: &BTreeMap<String, RoleDefinition>,
90    current: &(impl RoleSubject + ?Sized),
91    definition: RoleDefinition,
92) -> Result<(BTreeMap<String, RoleDefinition>, bool), SQLError> {
93    if roles.contains_key(&definition.name) {
94        return Err(SQLError::Routine {
95            sqlstate: "42710".into(),
96            message: format!("role \"{}\" already exists", definition.name),
97        });
98    }
99    let current_is_superuser = role_is_superuser(roles, current);
100    let mut next_roles = roles.clone();
101    next_roles.insert(definition.name.clone(), definition);
102    Ok((next_roles, current_is_superuser))
103}
104
105pub fn alter_role_candidate(
106    context: &RoleValidationContext<'_>,
107    roles: &BTreeMap<String, RoleDefinition>,
108    current: &(impl RoleSubject + ?Sized),
109    name: String,
110    statement: &AlterRoleStmt,
111) -> Result<BTreeMap<String, RoleDefinition>, SQLError> {
112    let existing = roles.get(&name).cloned().ok_or_else(|| SQLError::Routine {
113        sqlstate: "42704".into(),
114        message: format!("role \"{name}\" does not exist"),
115    })?;
116    require_role_administration_for(context.roles, roles, current, &name, "alter role")?;
117    require_role_attribute_authority(
118        roles,
119        current,
120        statement.attributes.keys().copied(),
121        "alter role",
122    )?;
123    let current_is_superuser = current
124        .role_definition(roles)
125        .is_some_and(|role| role.has(RoleAttribute::Superuser));
126    if (statement.attributes.contains_key(&RoleAttribute::Superuser)
127        || existing.has(RoleAttribute::Superuser))
128        && !current_is_superuser
129    {
130        return Err(insufficient_privilege(
131            "must be superuser to alter superuser roles or change superuser attribute",
132        ));
133    }
134    let mut updated = existing;
135    for (&attribute, &enabled) in &statement.attributes {
136        if enabled {
137            updated.attributes.insert(attribute);
138        } else {
139            updated.attributes.remove(&attribute);
140        }
141    }
142    if let Some(value) = statement.connection_limit {
143        updated.connection_limit = value;
144    }
145    updated.advance_revision()?;
146    let mut next = roles.clone();
147    next.insert(name, updated);
148    Ok(next)
149}
150
151/// Initial CREATEROLE authority is checked once; each later target uses the current membership graph after preceding removals.
152pub struct RoleDropAuthority {
153    current: RoleBinding,
154    session: RoleReference,
155}
156
157impl RoleDropAuthority {
158    pub fn new(
159        current: RoleReference,
160        session: RoleReference,
161        roles: &BTreeMap<String, RoleDefinition>,
162    ) -> Result<Self, SQLError> {
163        require_createrole(roles, &current, "drop role")?;
164        let session = match session {
165            RoleReference::Bound(_) => session,
166            RoleReference::Named(_) => {
167                RoleReference::Bound(std::sync::Arc::new(session.bind(roles)?))
168            }
169        };
170        Ok(Self {
171            current: current.bind(roles)?,
172            session,
173        })
174    }
175
176    /// Resolve only the next target. Execution must finish its object wait and remove its memberships before requesting another target.
177    pub fn resolve_target(
178        &self,
179        context: &RoleValidationContext<'_>,
180        requested: &RoleSpecification,
181        if_exists: bool,
182        roles: &BTreeMap<String, RoleDefinition>,
183    ) -> Result<Option<RoleBinding>, SQLError> {
184        // PostgreSQL treats the exact lowercase name public as a role specifier even when quoted.
185        let name = match requested {
186            RoleSpecification::Named(name) if name != "public" => name,
187            _ => {
188                return Err(SQLError::Routine {
189                    sqlstate: "22023".into(),
190                    message: "cannot use special role specifier in DROP ROLE".into(),
191                });
192            }
193        };
194        let Some(role) = roles.get(name) else {
195            if if_exists {
196                context.notices.notice(crate::SQLNotice::notice(format!(
197                    "role \"{name}\" does not exist, skipping"
198                )));
199                return Ok(None);
200            }
201            return Err(SQLError::Routine {
202                sqlstate: "42704".into(),
203                message: format!("role \"{name}\" does not exist"),
204            });
205        };
206        require_role_drop_authority(context, roles, &self.current, &self.session, name)?;
207        RoleBinding::from_definition(role).map(Some)
208    }
209}
210
211pub fn role_drop_memberships_candidate(
212    memberships: &BTreeMap<RoleMembershipKey, RoleMembership>,
213    removed: RoleIdentity,
214) -> BTreeMap<RoleMembershipKey, RoleMembership> {
215    memberships
216        .iter()
217        .filter(|(_, membership)| {
218            membership.role.identity() != removed && membership.member.identity() != removed
219        })
220        .map(|(key, membership)| (*key, membership.clone()))
221        .collect()
222}
223
224fn require_role_drop_authority(
225    context: &RoleValidationContext<'_>,
226    roles: &BTreeMap<String, RoleDefinition>,
227    current: &(impl RoleSubject + ?Sized),
228    session: &(impl RoleSubject + ?Sized),
229    name: &str,
230) -> Result<(), SQLError> {
231    let protected_user = if current.role_name(roles) == Some(name)
232        || context.names.outer_role().role_name(roles) == Some(name)
233    {
234        Some("current")
235    } else if session.role_name(roles) == Some(name) {
236        Some("session")
237    } else {
238        None
239    };
240    if let Some(subject) = protected_user {
241        return Err(SQLError::Routine {
242            sqlstate: "55006".into(),
243            message: format!("{subject} user cannot be dropped"),
244        });
245    }
246    if roles
247        .get(name)
248        .is_some_and(|role| role.has(RoleAttribute::Superuser))
249        && !role_is_superuser(roles, current)
250    {
251        return Err(insufficient_privilege("permission denied to drop role"));
252    }
253    if role_is_superuser(roles, current) {
254        return Ok(());
255    }
256    let memberships = context.roles.role_memberships();
257    if current
258        .role_definition(roles)
259        .zip(roles.get(name))
260        .is_some_and(|(member, role)| {
261            role_has_transitive_admin(&memberships, member.identity(), role.identity())
262        })
263    {
264        Ok(())
265    } else {
266        Err(insufficient_privilege("permission denied to drop role"))
267    }
268}
269
270#[cfg(test)]
271mod tests;