nodedb 0.3.0

Local-first, real-time, edge-to-cloud hybrid database for multi-modal workloads
Documentation
// SPDX-License-Identifier: BUSL-1.1

//! `GRANT/REVOKE ROLE x TO/FROM user` handlers.
//!
//! Reuses the existing `CatalogEntry::PutUser` variant. The
//! mutated role list is built locally from the user's current
//! record, then `CredentialStore::prepare_user_update` clones the
//! `StoredUser` with the new roles and the proposer ships the
//! whole record through raft. Followers' appliers reinstall the
//! updated user via `install_replicated_user` — no separate
//! `Add/RemoveRole` variant needed.

use pgwire::api::results::{Response, Tag};
use pgwire::error::PgWireResult;

use crate::control::catalog_entry::CatalogEntry;
use crate::control::metadata_proposer::propose_catalog_entry;
use crate::control::security::audit::AuditEvent;
use crate::control::security::identity::{AuthenticatedIdentity, Role};
use crate::control::state::SharedState;

use super::super::super::types::{parse_role, require_tenant_admin, sqlstate_error};

fn current_roles(state: &SharedState, username: &str) -> PgWireResult<Vec<Role>> {
    state
        .credentials
        .get_user(username)
        .map(|r| r.roles)
        .ok_or_else(|| sqlstate_error("42704", &format!("user '{username}' not found")))
}

fn propose_user_with_roles(
    state: &SharedState,
    username: &str,
    new_roles: Vec<Role>,
    invalidation: crate::control::security::buses::SessionInvalidationReason,
) -> PgWireResult<()> {
    let stored = state
        .credentials
        .prepare_user_update(username, None, Some(new_roles))
        .map_err(|e| sqlstate_error("42704", &e.to_string()))?;
    let entry = CatalogEntry::PutUser(Box::new(stored.clone()));
    let log_index = propose_catalog_entry(state, &entry)
        .map_err(|e| sqlstate_error("XX000", &format!("metadata propose: {e}")))?;
    if log_index == 0 {
        if let Some(catalog) = state.credentials.catalog() {
            catalog
                .put_user(&stored)
                .map_err(|e| sqlstate_error("XX000", &format!("catalog write: {e}")))?;
        }
        state
            .credentials
            .install_replicated_user(&stored, Some(invalidation));
    }
    Ok(())
}

/// `GRANT <role>[, ...] TO <grantee>`.
///
/// The grantee is resolved to a user or a custom role. For a user, every
/// listed role is added to the user's role set. For a role, the single
/// listed role becomes the grantee's inheritance parent (role-to-role
/// membership) — the role hierarchy permits one parent, so granting more
/// than one role to a role is rejected.
pub fn grant_role(
    state: &SharedState,
    identity: &AuthenticatedIdentity,
    roles: &[String],
    grantee: &str,
) -> PgWireResult<Vec<Response>> {
    require_tenant_admin(identity, "grant roles")?;
    if roles.is_empty() {
        return Err(sqlstate_error("42601", "GRANT: missing role name"));
    }

    if state.credentials.get_user(grantee).is_some() {
        grant_roles_to_user(state, identity, roles, grantee)
    } else if state.roles.get_role(grantee).is_some() {
        grant_role_to_role(state, identity, roles, grantee)
    } else {
        Err(sqlstate_error(
            "42704",
            &format!("grantee '{grantee}' is not a known user or role"),
        ))
    }
}

fn grant_roles_to_user(
    state: &SharedState,
    identity: &AuthenticatedIdentity,
    role_names: &[String],
    username: &str,
) -> PgWireResult<Vec<Response>> {
    let mut roles = current_roles(state, username)?;
    for name in role_names {
        let role = parse_role(name);
        if matches!(role, Role::Superuser) && !identity.is_superuser {
            return Err(sqlstate_error(
                "42501",
                "only superuser can grant superuser role",
            ));
        }
        if !roles.contains(&role) {
            roles.push(role);
        }
    }
    propose_user_with_roles(
        state,
        username,
        roles,
        crate::control::security::buses::SessionInvalidationReason::RoleGranted,
    )?;

    state.audit_record(
        AuditEvent::PrivilegeChange,
        Some(identity.tenant_id),
        &identity.username,
        &format!(
            "granted role(s) {} to user '{username}'",
            role_names.join(", ")
        ),
    );

    Ok(vec![Response::Execution(Tag::new("GRANT"))])
}

fn grant_role_to_role(
    state: &SharedState,
    identity: &AuthenticatedIdentity,
    role_names: &[String],
    child: &str,
) -> PgWireResult<Vec<Response>> {
    if role_names.len() != 1 {
        return Err(sqlstate_error(
            "0A000",
            "a role can inherit from only one parent role; grant one role at a time",
        ));
    }
    let parent = &role_names[0];
    super::super::role::set_role_parent(state, child, Some(parent))?;

    state.audit_record(
        AuditEvent::PrivilegeChange,
        Some(identity.tenant_id),
        &identity.username,
        &format!("granted role '{parent}' to role '{child}'"),
    );

    Ok(vec![Response::Execution(Tag::new("GRANT"))])
}

/// `REVOKE <role>[, ...] FROM <grantee>`.
pub fn revoke_role(
    state: &SharedState,
    identity: &AuthenticatedIdentity,
    roles: &[String],
    grantee: &str,
) -> PgWireResult<Vec<Response>> {
    require_tenant_admin(identity, "revoke roles")?;
    if roles.is_empty() {
        return Err(sqlstate_error("42601", "REVOKE: missing role name"));
    }

    // Reject self-superuser revocation before resolving the grantee — an
    // admin must not be able to strip their own superuser role even if the
    // grantee name does not resolve to a stored user record.
    if grantee == identity.username
        && roles
            .iter()
            .any(|r| matches!(parse_role(r), Role::Superuser))
    {
        return Err(sqlstate_error(
            "42501",
            "cannot revoke your own superuser role",
        ));
    }

    if state.credentials.get_user(grantee).is_some() {
        revoke_roles_from_user(state, identity, roles, grantee)
    } else if state.roles.get_role(grantee).is_some() {
        revoke_role_from_role(state, identity, roles, grantee)
    } else {
        Err(sqlstate_error(
            "42704",
            &format!("grantee '{grantee}' is not a known user or role"),
        ))
    }
}

fn revoke_roles_from_user(
    state: &SharedState,
    identity: &AuthenticatedIdentity,
    role_names: &[String],
    username: &str,
) -> PgWireResult<Vec<Response>> {
    let mut roles = current_roles(state, username)?;
    let revoked: Vec<Role> = role_names.iter().map(|n| parse_role(n)).collect();
    for role in &revoked {
        if !roles.contains(role) {
            return Err(sqlstate_error(
                "42704",
                &format!("user '{username}' does not have role '{role}'"),
            ));
        }
    }
    roles.retain(|r| !revoked.contains(r));
    propose_user_with_roles(
        state,
        username,
        roles,
        crate::control::security::buses::SessionInvalidationReason::RoleRevoked,
    )?;

    state.audit_record(
        AuditEvent::PrivilegeChange,
        Some(identity.tenant_id),
        &identity.username,
        &format!(
            "revoked role(s) {} from user '{username}'",
            role_names.join(", ")
        ),
    );

    Ok(vec![Response::Execution(Tag::new("REVOKE"))])
}

fn revoke_role_from_role(
    state: &SharedState,
    identity: &AuthenticatedIdentity,
    role_names: &[String],
    child: &str,
) -> PgWireResult<Vec<Response>> {
    if role_names.len() != 1 {
        return Err(sqlstate_error(
            "0A000",
            "a role inherits from at most one parent role; revoke one role at a time",
        ));
    }
    let parent = &role_names[0];
    let current_parent = state.roles.get_role(child).and_then(|r| r.parent);
    if current_parent.as_deref() != Some(parent.as_str()) {
        return Err(sqlstate_error(
            "42704",
            &format!("role '{child}' does not inherit from '{parent}'"),
        ));
    }
    super::super::role::set_role_parent(state, child, None)?;

    state.audit_record(
        AuditEvent::PrivilegeChange,
        Some(identity.tenant_id),
        &identity.username,
        &format!("revoked role '{parent}' from role '{child}'"),
    );

    Ok(vec![Response::Execution(Tag::new("REVOKE"))])
}