alibi-plugins 0.8.0

Built-in authentication plugins and API metadata for Alibi
Documentation
use super::super::OrganizationConfig;
use alibi_core::error::codes;
use alibi_core::types::OrganizationPermissions;
use alibi_core::wire::SessionView;
use alibi_core::{AuthContext, AuthError, AuthRequest, AuthResult, AuthSchema};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};

type OrganizationRoles = HashMap<String, OrganizationPermissions>;

// The pinned runtime's cacheAllRoles map is shared by organization plugin
// instances. Fresh permission checks replace only the selected organization's
// snapshot; delegated role grants subsequently use that snapshot.
fn role_cache() -> &'static Mutex<HashMap<String, OrganizationRoles>> {
    static CACHE: OnceLock<Mutex<HashMap<String, OrganizationRoles>>> = OnceLock::new();
    CACHE.get_or_init(Mutex::default)
}

#[must_use]
pub(crate) fn org_error(status: u16, code: &'static str) -> AuthError {
    let message = match code {
        codes::UNAUTHORIZED => "Unauthorized",
        codes::YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION => {
            "You are not allowed to update this organization"
        }
        codes::YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION => {
            "You are not allowed to create a new organization"
        }
        codes::YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_ORGANIZATIONS => {
            "You have reached the maximum number of organizations"
        }
        codes::NO_ACTIVE_ORGANIZATION => "No active organization",
        codes::ORGANIZATION_NOT_FOUND => "Organization not found",
        codes::ORGANIZATION_ALREADY_EXISTS => "Organization already exists",
        codes::ORGANIZATION_SLUG_ALREADY_TAKEN => "Organization slug already taken",
        codes::ORGANIZATION_DELETION_DISABLED => "Organization deletion is disabled",
        codes::YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION => {
            "You are not allowed to delete this organization"
        }
        codes::TEAM_NOT_FOUND => "Team not found",
        codes::MEMBER_NOT_FOUND => "Member not found",
        codes::INVITATION_LIMIT_REACHED => "Invitation limit reached",
        codes::INVITATION_NOT_FOUND => "Invitation not found",
        codes::USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION => {
            "User is already invited to this organization"
        }
        codes::YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION => {
            "You are not allowed to invite users to this organization"
        }
        codes::YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION => {
            "You are not allowed to create teams in this organization"
        }
        codes::YOU_ARE_NOT_ALLOWED_TO_DELETE_TEAMS_IN_THIS_ORGANIZATION => {
            "You are not allowed to delete teams in this organization"
        }
        codes::YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM => "You are not allowed to update this team",
        codes::YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM => "You are not allowed to delete this team",
        codes::YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION => {
            "You are not allowed to access this organization as an owner"
        }
        codes::YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION => {
            "You are not a member of this organization"
        }
        codes::USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION => {
            "User is not a member of the organization"
        }
        codes::USER_IS_NOT_A_MEMBER_OF_THE_TEAM => "User is not a member of the team",
        codes::YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER => {
            "You are not allowed to update this member"
        }
        codes::YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER => {
            "You are not allowed to delete this member"
        }
        codes::YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER => {
            "You cannot leave the organization as the only owner"
        }
        codes::YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER => {
            "You are not allowed to create a new member"
        }
        codes::YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER => {
            "You are not allowed to remove a team member"
        }
        codes::YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM => "You do not have an active team",
        codes::TEAM_MEMBER_LIMIT_REACHED => "Team member limit reached",
        codes::YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS => {
            "You have reached the maximum number of teams"
        }
        codes::UNABLE_TO_REMOVE_LAST_TEAM => "Unable to remove last team",
        codes::INVALID_TEAM_ID => "Team id contains a reserved character",
        codes::MISSING_AC_INSTANCE => {
            "Dynamic Access Control requires a pre-defined ac instance on the server auth plugin. Read server logs for more information"
        }
        codes::YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE => {
            "You must be in an organization to create a role"
        }
        codes::YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE => "You are not allowed to create a role",
        codes::YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE => "You are not allowed to update a role",
        codes::YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE => "You are not allowed to delete a role",
        codes::YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE => "You are not allowed to read a role",
        codes::YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE => "You are not allowed to list a role",
        codes::TOO_MANY_ROLES => "This organization has too many roles",
        codes::INVALID_RESOURCE => "The provided permission includes an invalid resource",
        codes::ROLE_NAME_IS_ALREADY_TAKEN => "That role name is already taken",
        codes::CANNOT_DELETE_A_PRE_DEFINED_ROLE => "Cannot delete a pre-defined role",
        codes::ROLE_IS_ASSIGNED_TO_MEMBERS => {
            "Cannot delete a role that is assigned to members. Please reassign the members to a different role first"
        }
        codes::ROLE_NOT_FOUND => "Role not found",
        _ => "Organization operation failed",
    };
    AuthError::Upstream {
        status,
        code,
        message,
    }
}

pub(crate) async fn session<S: AuthSchema>(
    req: &AuthRequest,
    ctx: &AuthContext<S>,
) -> AuthResult<(alibi_core::AuthenticatedUser<S>, SessionView)> {
    ctx.require_cached_session(req)
        .await
        .map_err(|error| match error {
            AuthError::Unauthenticated | AuthError::SessionNotFound => {
                org_error(401, codes::UNAUTHORIZED)
            }
            error @ (AuthError::Api { .. }
            | AuthError::Upstream { .. }
            | AuthError::BadRequest(_)
            | AuthError::InvalidRequest(_)
            | AuthError::Validation(_)
            | AuthError::InvalidCredentials
            | AuthError::AuthenticationFailed(_)
            | AuthError::Forbidden(_)
            | AuthError::SessionCreationCancelled
            | AuthError::UserCreationCancelled
            | AuthError::BannedUser(_)
            | AuthError::Unauthorized
            | AuthError::UserNotFound
            | AuthError::NotFound(_)
            | AuthError::Conflict(_)
            | AuthError::MethodNotAllowed(_)
            | AuthError::PayloadTooLarge(_)
            | AuthError::UnprocessableEntity(_)
            | AuthError::RateLimited { .. }
            | AuthError::NotImplemented(_)
            | AuthError::Config(_)
            | AuthError::Database(_)
            | AuthError::Serialization(_)
            | AuthError::Plugin { .. }
            | AuthError::CallbackFailure(_)
            | AuthError::Internal(_)
            | AuthError::Encryption(_)
            | AuthError::PasswordHash(_)
            | AuthError::Jwt(_)) => error,
        })
}

fn configured_roles(config: &OrganizationConfig) -> OrganizationRoles {
    let defaults = super::super::extensions::default_organization_statements();
    let mut roles = if config.roles.is_none() {
        HashMap::from([
            ("owner".to_owned(), defaults.clone()),
            (
                "admin".to_owned(),
                defaults
                    .into_iter()
                    .map(|(key, actions)| {
                        if key == "organization" {
                            (key, vec!["update".to_owned()])
                        } else {
                            (key, actions)
                        }
                    })
                    .collect(),
            ),
            (
                "member".to_owned(),
                [("ac".to_owned(), vec!["read".to_owned()])].into(),
            ),
        ])
    } else {
        HashMap::new()
    };
    for (role, permission) in config.roles.iter().flatten() {
        let mut permissions = permission.additional.clone();
        for (resource, actions) in [
            ("organization", &permission.organization),
            ("member", &permission.member),
            ("invitation", &permission.invitation),
            ("apiKey", &permission.api_key),
            ("team", &permission.team),
            ("ac", &permission.ac),
        ] {
            _ = permissions.insert(resource.to_owned(), actions.clone());
        }
        _ = roles.insert(role.clone(), permissions);
    }
    roles
}

/// Loads the roles of an organization through the shared role cache.
///
/// # Errors
///
/// Returns errors from organization-role storage or the shared role cache.
pub async fn organization_roles<S: AuthSchema>(
    config: &OrganizationConfig,
    ctx: &AuthContext<S>,
    org_id: &str,
) -> AuthResult<OrganizationRoles> {
    let mut roles = configured_roles(config);
    if config.dynamic_access_control.enabled && config.access_control.is_some() {
        for role in ctx.database.list_organization_roles(org_id).await? {
            let parsed: serde_json::Value = serde_json::from_str(role.permission.as_str())
                .map_err(|error| AuthError::CallbackFailure(Box::new(error.into())))?;
            let record: OrganizationPermissions =
                serde_json::from_value(parsed).map_err(|_error| AuthError::Api {
                    status: 500,
                    code: None,
                    message: format!("Invalid permissions for role {}", role.role),
                })?;
            let permissions = roles.entry(role.role).or_default();
            for (resource, actions) in record {
                let merged = permissions.entry(resource).or_default();
                for action in actions {
                    if !merged.contains(&action) {
                        merged.push(action);
                    }
                }
            }
        }
    }
    _ = role_cache()
        .lock()
        .map_err(|_error| AuthError::internal("Organization role cache unavailable"))?
        .insert(org_id.to_owned(), roles.clone());
    Ok(roles)
}

pub(super) fn cached_has_permissions(
    role: &str,
    required: &OrganizationPermissions,
    config: &OrganizationConfig,
    org_id: &str,
) -> AuthResult<bool> {
    let mut cache = role_cache()
        .lock()
        .map_err(|_error| AuthError::internal("Organization role cache unavailable"))?;
    let roles = cache
        .entry(org_id.to_owned())
        .or_insert_with(|| configured_roles(config));
    let permitted = role_has_permissions(role, required, roles);
    drop(cache);
    Ok(permitted)
}

/// Checks whether a role grants every required permission.
///
/// # Errors
///
/// Propagates errors from role loading and authorization callbacks.
pub async fn has_permissions<S: AuthSchema>(
    role: &str,
    required: &OrganizationPermissions,
    config: &OrganizationConfig,
    ctx: &AuthContext<S>,
    org_id: &str,
) -> AuthResult<bool> {
    let roles = organization_roles(config, ctx, org_id).await?;
    Ok(role_has_permissions(role, required, &roles))
}

#[must_use]
pub(crate) fn role_has_permissions<H: std::hash::BuildHasher>(
    role: &str,
    required: &OrganizationPermissions,
    roles: &HashMap<String, OrganizationPermissions, H>,
) -> bool {
    use crate::access::{Connector, authorize};
    role.split(',').any(|name| {
        roles.get(name).is_some_and(|permissions| {
            authorize(
                |resource| permissions.get(resource).map(Vec::as_slice),
                required.iter().map(|(resource, actions)| {
                    (resource.as_str(), actions.as_slice(), Connector::And)
                }),
                Connector::And,
            )
            .success()
        })
    })
}

///
/// # Errors
///
/// Propagates errors from role loading and authorization callbacks.
pub(crate) async fn has_action<S: AuthSchema>(
    role: &str,
    resource: &str,
    action: &str,
    config: &OrganizationConfig,
    ctx: &AuthContext<S>,
    org_id: &str,
) -> AuthResult<bool> {
    has_permissions(
        role,
        &[(resource.to_owned(), vec![action.to_owned()])].into(),
        config,
        ctx,
        org_id,
    )
    .await
}