slave 0.13.1

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB migrations, add server-rendered web apps, and route them by domain or path
//! Who may do what in a team.

use slave_framework::database::Executor;

use crate::base::auth::AuthUser;
use crate::base::error::{AppError, AppResult};
use crate::modules::team::Membership;

/// Everything in the team, including its members and owners.
pub const OWNER: &str = "owner";
/// Everything in the team except managing owners.
pub const ADMIN: &str = "admin";
/// Only the modules granted to them.
pub const STAFF: &str = "staff";

/// The parts of a team that staff can be given. Add yours here; check them
/// with [`require_access`]. Managing members is for owners and admins only.
pub const MODULES: &[&str] = &["catalog", "orders", "customers", "theme", "settings"];

/// What the caller may do in one team.
#[derive(Debug, Clone)]
pub struct Access {
    /// `owner`, `admin`, `staff`, or `super_admin` for the platform team
    /// acting on a team they are not a member of.
    pub role: String,
    /// The modules they may use (all of them for every role but `staff`).
    pub modules: Vec<String>,
}

impl Access {
    pub fn can_use(&self, module: &str) -> bool {
        self.modules.iter().any(|m| m == module)
    }

    /// Owners, admins and super admins manage members.
    pub fn manages_members(&self) -> bool {
        self.role != STAFF
    }

    /// Only owners and super admins touch owners.
    pub fn manages_owners(&self) -> bool {
        self.role == OWNER || self.role == crate::base::auth::SUPER_ADMIN_ROLE
    }
}

/// The caller's access to team `team_id`, failing with `Forbidden` unless
/// they are a member (or a super admin) and, if `module` is given, may use it.
pub async fn require_access(
    db: &impl Executor,
    user: &AuthUser,
    team_id: i64,
    module: Option<&str>,
) -> AppResult<Access> {
    let membership = find_membership(db, team_id, user.id).await?;
    let access = match membership {
        Some(membership) => access_of(&membership),
        None if user.is_super_admin() => Access {
            role: crate::base::auth::SUPER_ADMIN_ROLE.to_owned(),
            modules: all_modules(),
        },
        None => return Err(AppError::Forbidden),
    };
    match module {
        Some(module) if !access.can_use(module) => Err(AppError::Forbidden),
        _ => Ok(access),
    }
}

/// User `user_id`'s membership of team `team_id`, if any.
pub(crate) async fn find_membership(
    db: &impl Executor,
    team_id: i64,
    user_id: i64,
) -> AppResult<Option<Membership>> {
    let membership: Option<Membership> = db
        .query(format!(
            "SELECT {} FROM type::record('membership', [$team_id, $user_id])",
            Membership::FIELDS
        ))
        .bind(("team_id", team_id))
        .bind(("user_id", user_id))
        .await?
        .take(0)?;
    Ok(membership)
}

pub(crate) fn access_of(membership: &Membership) -> Access {
    let modules = if membership.role == STAFF {
        split_modules(&membership.modules)
    } else {
        all_modules()
    };
    Access {
        role: membership.role.clone(),
        modules,
    }
}

pub(crate) fn split_modules(modules: &str) -> Vec<String> {
    modules
        .split(',')
        .map(str::trim)
        .filter(|m| !m.is_empty())
        .map(str::to_owned)
        .collect()
}

fn all_modules() -> Vec<String> {
    MODULES.iter().map(|m| (*m).to_owned()).collect()
}

/// Checks a role name and, for staff, the module names.
pub(crate) fn validate_role(role: &str, modules: &[String]) -> AppResult<()> {
    if ![OWNER, ADMIN, STAFF].contains(&role) {
        return Err(AppError::Validation(format!(
            "role must be `{OWNER}`, `{ADMIN}` or `{STAFF}`, not `{role}`"
        )));
    }
    if let Some(unknown) = modules.iter().find(|m| !MODULES.contains(&m.as_str())) {
        return Err(AppError::Validation(format!(
            "unknown module `{unknown}`; choose from {}",
            MODULES.join(", ")
        )));
    }
    Ok(())
}