feat: Add bot access control with org membership check (#499)
All checks were successful
Botlib CI / build (push) Successful in 53s
BotServer CI / build (push) Successful in 19m23s
Bottest CI / build (push) Successful in 45s
BotUI CI / build (push) Successful in 58s

- Add is_public column to bots table (migration 6.3.2-01)
- Add user_organizations to Diesel schema for org membership lookup
- Implement check_bot_access(): public bots allow all users, private bots
  require user membership in the bot's organization via user_organizations
- Wire access check into websocket_handler before WS upgrade
- Read is_public from bots table instead of bot_configuration
- Add database_name field to Bot model
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-04-26 19:45:30 -03:00
parent 0911448548
commit 7d2d8a5674
5 changed files with 125 additions and 20 deletions

View file

@ -0,0 +1 @@
ALTER TABLE bots DROP COLUMN IF EXISTS is_public;

View file

@ -0,0 +1,2 @@
ALTER TABLE bots ADD COLUMN IF NOT EXISTS is_public BOOLEAN DEFAULT FALSE;
UPDATE bots SET is_public = FALSE WHERE is_public IS NULL;

View file

@ -53,10 +53,61 @@ use regex;
use tokio::sync::Mutex as AsyncMutex;
use uuid::Uuid;
use serde::{Deserialize, Serialize};
use diesel::OptionalExtension;
pub mod channels;
pub mod multimedia;
/// Check if a user has access to a bot
/// Returns Ok(()) if access is allowed, Err with status code and message if not
pub fn check_bot_access(
conn: &mut PgConnection,
bot_id: Uuid,
user_id: Uuid,
) -> Result<(), (StatusCode, String)> {
use crate::core::shared::models::schema::bots;
if bot_id == Uuid::nil() {
return Err((StatusCode::NOT_FOUND, "Bot not found".to_string()));
}
let bot_result = bots::table
.filter(bots::id.eq(bot_id))
.select((bots::is_public, bots::org_id))
.first::<(bool, Option<Uuid>)>(conn)
.optional();
match bot_result {
Ok(Some((public, bot_org_id))) => {
if public {
return Ok(());
}
if let Some(org_id) = bot_org_id {
use crate::core::shared::models::schema::user_organizations;
let is_member = user_organizations::table
.filter(user_organizations::user_id.eq(user_id))
.filter(user_organizations::org_id.eq(org_id))
.select(user_organizations::id)
.first::<Uuid>(conn)
.optional()
.unwrap_or(None)
.is_some();
if is_member {
return Ok(());
}
Err((StatusCode::FORBIDDEN, "Access denied - not a member of this organization".to_string()))
} else {
Err((StatusCode::FORBIDDEN, "Access denied - bot is private".to_string()))
}
}
_ => Err((StatusCode::NOT_FOUND, "Bot not found".to_string())),
}
}
pub fn get_default_bot(conn: &mut PgConnection) -> (Uuid, String) {
use crate::core::shared::models::schema::bots::dsl::*;
use diesel::prelude::*;
@ -147,18 +198,27 @@ pub async fn get_bot_config(
}
};
// Query bot_configuration table for this bot's configuration
use crate::core::shared::models::schema::bot_configuration::dsl::*;
// Get bot_id and is_public from bots table
let (target_bot_id, is_public) = match get_bot_id_by_name(&mut conn, &bot_name) {
Ok(found_id) => {
// Query is_public from bots table
use crate::core::shared::models::schema::bots::dsl::*;
use diesel::OptionalExtension;
let public_result = bots
.filter(id.eq(found_id))
.select(is_public)
.first::<bool>(&mut conn)
.optional();
let mut is_public = false;
let mut theme_color1: Option<String> = None;
let mut theme_color2: Option<String> = None;
let mut theme_title: Option<String> = None;
let mut theme_logo: Option<String> = None;
let mut theme_logo_text: Option<String> = None;
let target_bot_id = match get_bot_id_by_name(&mut conn, &bot_name) {
Ok(found_id) => found_id,
match public_result {
Ok(Some(p)) => (found_id, p),
Ok(None) => (found_id, false),
Err(e) => {
warn!("Failed to query is_public for bot '{}': {}", bot_name, e);
(found_id, false)
}
}
}
Err(e) => {
warn!("Failed to find bot ID for name '{}': {}", bot_name, e);
return Ok(Json(BotConfigResponse {
@ -172,7 +232,15 @@ pub async fn get_bot_config(
}
};
// Query all config values for this specific bot
let mut theme_color1: Option<String> = None;
let mut theme_color2: Option<String> = None;
let mut theme_title: Option<String> = None;
let mut theme_logo: Option<String> = None;
let mut theme_logo_text: Option<String> = None;
// Query theme config values from bot_configuration table
use crate::core::shared::models::schema::bot_configuration::dsl::*;
match bot_configuration
.filter(bot_id.eq(target_bot_id))
.select((config_key, config_value))
@ -198,7 +266,9 @@ pub async fn get_bot_config(
match clean_key.to_lowercase().as_str() {
"public" => {
is_public = value.eq_ignore_ascii_case("true") || value == "1";
// Also check config table for backward compatibility
// But is_public from bots table takes precedence
info!("Found 'public' in config table: {}", value);
}
"theme-color1" => {
theme_color1 = Some(value);
@ -1557,32 +1627,46 @@ pub async fn websocket_handler(
info!("WebSocket: session_id from params = {:?}, user_id = {:?}", session_id, user_id);
// Look up bot_id from bot_name
let bot_id = {
let (bot_id, _bot_is_public) = {
let conn = state.conn.get().ok();
if let Some(mut db_conn) = conn {
use crate::core::shared::models::schema::bots::dsl::*;
// Try to parse as UUID first, if that fails treat as bot name
let result: Result<Uuid, _> = if let Ok(uuid) = Uuid::parse_str(&bot_name) {
let result: Result<(Uuid, bool), _> = if let Ok(uuid) = Uuid::parse_str(&bot_name) {
// Parameter is a UUID, look up by id
bots.filter(id.eq(uuid)).select(id).first(&mut db_conn)
bots.filter(id.eq(uuid))
.select((id, is_public))
.first(&mut db_conn)
} else {
// Parameter is a bot name, look up by name
bots.filter(name.eq(&bot_name))
.select(id)
.select((id, is_public))
.first(&mut db_conn)
};
result.unwrap_or_else(|_| {
log::warn!("Bot not found: {}, using nil bot_id", bot_name);
Uuid::nil()
(Uuid::nil(), false)
})
} else {
log::warn!("Could not get database connection, using nil bot_id");
Uuid::nil()
(Uuid::nil(), false)
}
};
// Check bot access before upgrading WebSocket
if bot_id != Uuid::nil() {
let conn = state.conn.get().ok();
if let Some(mut db_conn) = conn {
if let Err((status, msg)) = check_bot_access(&mut db_conn, bot_id, user_id) {
return (status, msg).into_response();
}
} else {
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
}
}
ws.on_upgrade(move |socket| handle_websocket(socket, state, session_id, user_id, bot_id))
.into_response()
}

View file

@ -104,6 +104,8 @@ pub struct Bot {
pub updated_at: DateTime<Utc>,
pub is_active: Option<bool>,
pub org_id: Option<Uuid>,
pub database_name: Option<String>,
pub is_public: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Identifiable)]

View file

@ -51,6 +51,7 @@ diesel::table! {
updated_at -> Timestamptz,
is_active -> Nullable<Bool>,
database_name -> Nullable<Varchar>,
is_public -> Bool,
}
}
@ -296,6 +297,20 @@ diesel::joinable!(rbac_group_roles -> rbac_roles (role_id));
diesel::joinable!(website_crawls -> bots (bot_id));
diesel::joinable!(organization_invitations -> organizations (org_id));
diesel::table! {
user_organizations (id) {
id -> Uuid,
user_id -> Uuid,
org_id -> Uuid,
role -> Varchar,
is_default -> Bool,
joined_at -> Timestamptz,
}
}
diesel::joinable!(user_organizations -> users (user_id));
diesel::joinable!(user_organizations -> organizations (org_id));
diesel::table! {
user_preferences (id) {
id -> Uuid,
@ -349,9 +364,10 @@ diesel::allow_tables_to_appear_in_same_query!(
rbac_user_groups,
rbac_group_roles,
users,
user_organizations,
website_crawls,
bots,
bot_configuration,
organizations,
organization_invitations,
);
);