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, BTreeSet};
24
25pub trait RoleNotices {
26    fn notice(&self, level: &str, message: &str);
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(
197                    "NOTICE",
198                    &format!("role \"{name}\" does not exist, skipping"),
199                );
200                return Ok(None);
201            }
202            return Err(SQLError::Routine {
203                sqlstate: "42704".into(),
204                message: format!("role \"{name}\" does not exist"),
205            });
206        };
207        require_role_drop_authority(context, roles, &self.current, &self.session, name)?;
208        RoleBinding::from_definition(role).map(Some)
209    }
210}
211
212pub fn role_drop_memberships_candidate(
213    memberships: &BTreeMap<RoleMembershipKey, RoleMembership>,
214    removed: RoleIdentity,
215) -> BTreeMap<RoleMembershipKey, RoleMembership> {
216    memberships
217        .iter()
218        .filter(|(_, membership)| {
219            membership.role.identity() != removed && membership.member.identity() != removed
220        })
221        .map(|(key, membership)| (*key, membership.clone()))
222        .collect()
223}
224
225fn require_role_drop_authority(
226    context: &RoleValidationContext<'_>,
227    roles: &BTreeMap<String, RoleDefinition>,
228    current: &(impl RoleSubject + ?Sized),
229    session: &(impl RoleSubject + ?Sized),
230    name: &str,
231) -> Result<(), SQLError> {
232    let protected_user = if current.role_name(roles) == Some(name)
233        || context.names.outer_role().role_name(roles) == Some(name)
234    {
235        Some("current")
236    } else if session.role_name(roles) == Some(name) {
237        Some("session")
238    } else {
239        None
240    };
241    if let Some(subject) = protected_user {
242        return Err(SQLError::Routine {
243            sqlstate: "55006".into(),
244            message: format!("{subject} user cannot be dropped"),
245        });
246    }
247    if roles
248        .get(name)
249        .is_some_and(|role| role.has(RoleAttribute::Superuser))
250        && !role_is_superuser(roles, current)
251    {
252        return Err(insufficient_privilege("permission denied to drop role"));
253    }
254    if role_is_superuser(roles, current) {
255        return Ok(());
256    }
257    let memberships = context.roles.role_memberships();
258    if current
259        .role_definition(roles)
260        .zip(roles.get(name))
261        .is_some_and(|(member, role)| {
262            role_has_transitive_admin(&memberships, member.identity(), role.identity())
263        })
264    {
265        Ok(())
266    } else {
267        Err(insufficient_privilege("permission denied to drop role"))
268    }
269}
270
271pub fn ensure_no_grantor_dependencies(
272    memberships: &BTreeMap<RoleMembershipKey, RoleMembership>,
273    identities: &BTreeSet<RoleIdentity>,
274) -> Result<(), SQLError> {
275    for membership in memberships.values() {
276        if identities.contains(&membership.grantor.identity())
277            && !identities.contains(&membership.role.identity())
278            && !identities.contains(&membership.member.identity())
279        {
280            return Err(SQLError::Routine {
281                sqlstate: "2BP01".into(),
282                message: format!(
283                    "role \"{}\" cannot be dropped because some objects depend on it: privileges for membership of role {} in role {}",
284                    membership.grantor.name, membership.member.name, membership.role.name
285                ),
286            });
287        }
288    }
289    Ok(())
290}
291
292#[cfg(test)]
293mod tests;