From 754b416f5f518f1d25906efc9c797e9086f1802c Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 5 Mar 2026 22:01:34 +1000 Subject: [PATCH 1/4] Add new resource limits --- src/config/limits.rs | 159 +++++++++++++++++++++-- src/db/postgres/org_sso_configs.rs | 8 ++ src/db/postgres/vector_stores.rs | 27 ++++ src/db/repos/org_sso_configs.rs | 3 + src/db/repos/vector_stores.rs | 12 ++ src/db/sqlite/org_sso_configs.rs | 8 ++ src/db/sqlite/vector_stores.rs | 27 ++++ src/routes/admin/api_keys.rs | 53 ++++++++ src/routes/admin/conversations.rs | 18 +++ src/routes/admin/domain_verifications.rs | 18 +++ src/routes/admin/dynamic_providers.rs | 49 +++++++ src/routes/admin/org_sso_configs.rs | 12 +- src/routes/admin/projects.rs | 11 ++ src/routes/admin/prompts.rs | 14 ++ src/routes/admin/service_accounts.rs | 15 +++ src/routes/admin/sso_group_mappings.rs | 15 +++ src/routes/admin/teams.rs | 11 ++ src/routes/api/vector_stores.rs | 64 +++++++++ src/services/api_keys.rs | 8 ++ src/services/org_sso_configs.rs | 5 + src/services/providers.rs | 5 + src/services/vector_stores.rs | 20 +++ 22 files changed, 549 insertions(+), 13 deletions(-) diff --git a/src/config/limits.rs b/src/config/limits.rs index ded9e1e..53d9de1 100644 --- a/src/config/limits.rs +++ b/src/config/limits.rs @@ -25,29 +25,86 @@ pub struct LimitsConfig { /// Resource limits for entity counts. /// /// These limits prevent unbounded growth of resources that could cause -/// performance issues or resource exhaustion. +/// performance issues or resource exhaustion. Set any limit to 0 for unlimited. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] pub struct ResourceLimits { - /// Maximum RBAC policies per organization. - /// Set to 0 for unlimited. Default: 100 policies per org. - /// - /// This limit prevents resource exhaustion from unbounded policy growth. - /// Organizations hitting this limit must delete or disable existing policies - /// before creating new ones. + /// Maximum RBAC policies per organization. Default: 100. #[serde(default = "default_max_policies_per_org")] pub max_policies_per_org: u32, - /// Maximum dynamic providers per user (BYOK). - /// Set to 0 for unlimited. Default: 10 providers per user. + /// Maximum dynamic providers per user (BYOK). Default: 10. #[serde(default = "default_max_providers_per_user")] pub max_providers_per_user: u32, - /// Maximum API keys per user (self-service). - /// Set to 0 for unlimited. Default: 25 keys per user. + /// Maximum dynamic providers per organization. Default: 100. + #[serde(default = "default_max_providers_per_org")] + pub max_providers_per_org: u32, + + /// Maximum dynamic providers per team. Default: 50. + #[serde(default = "default_max_providers_per_team")] + pub max_providers_per_team: u32, + + /// Maximum dynamic providers per project. Default: 50. + #[serde(default = "default_max_providers_per_project")] + pub max_providers_per_project: u32, + + /// Maximum API keys per user (self-service). Default: 25. #[serde(default = "default_max_api_keys_per_user")] pub max_api_keys_per_user: u32, + + /// Maximum API keys per organization. Default: 500. + #[serde(default = "default_max_api_keys_per_org")] + pub max_api_keys_per_org: u32, + + /// Maximum API keys per team. Default: 100. + #[serde(default = "default_max_api_keys_per_team")] + pub max_api_keys_per_team: u32, + + /// Maximum API keys per project. Default: 100. + #[serde(default = "default_max_api_keys_per_project")] + pub max_api_keys_per_project: u32, + + /// Maximum teams per organization. Default: 100. + #[serde(default = "default_max_teams_per_org")] + pub max_teams_per_org: u32, + + /// Maximum projects per organization. Default: 1000. + #[serde(default = "default_max_projects_per_org")] + pub max_projects_per_org: u32, + + /// Maximum service accounts per organization. Default: 50. + #[serde(default = "default_max_service_accounts_per_org")] + pub max_service_accounts_per_org: u32, + + /// Maximum vector stores per owner (org/team/project/user). Default: 100. + #[serde(default = "default_max_vector_stores_per_owner")] + pub max_vector_stores_per_owner: u32, + + /// Maximum files per vector store. Default: 10,000. + #[serde(default = "default_max_files_per_vector_store")] + pub max_files_per_vector_store: u32, + + /// Maximum conversations per owner (project/user). Default: 10,000. + #[serde(default = "default_max_conversations_per_owner")] + pub max_conversations_per_owner: u32, + + /// Maximum prompts per owner (org/team/project/user). Default: 5,000. + #[serde(default = "default_max_prompts_per_owner")] + pub max_prompts_per_owner: u32, + + /// Maximum SSO configurations per organization. Default: 5. + #[serde(default = "default_max_sso_configs_per_org")] + pub max_sso_configs_per_org: u32, + + /// Maximum domain verifications per SSO configuration. Default: 50. + #[serde(default = "default_max_domains_per_sso_config")] + pub max_domains_per_sso_config: u32, + + /// Maximum SSO group mappings per organization. Default: 500. + #[serde(default = "default_max_sso_group_mappings_per_org")] + pub max_sso_group_mappings_per_org: u32, } impl Default for ResourceLimits { @@ -55,7 +112,23 @@ impl Default for ResourceLimits { Self { max_policies_per_org: default_max_policies_per_org(), max_providers_per_user: default_max_providers_per_user(), + max_providers_per_org: default_max_providers_per_org(), + max_providers_per_team: default_max_providers_per_team(), + max_providers_per_project: default_max_providers_per_project(), max_api_keys_per_user: default_max_api_keys_per_user(), + max_api_keys_per_org: default_max_api_keys_per_org(), + max_api_keys_per_team: default_max_api_keys_per_team(), + max_api_keys_per_project: default_max_api_keys_per_project(), + max_teams_per_org: default_max_teams_per_org(), + max_projects_per_org: default_max_projects_per_org(), + max_service_accounts_per_org: default_max_service_accounts_per_org(), + max_vector_stores_per_owner: default_max_vector_stores_per_owner(), + max_files_per_vector_store: default_max_files_per_vector_store(), + max_conversations_per_owner: default_max_conversations_per_owner(), + max_prompts_per_owner: default_max_prompts_per_owner(), + max_sso_configs_per_org: default_max_sso_configs_per_org(), + max_domains_per_sso_config: default_max_domains_per_sso_config(), + max_sso_group_mappings_per_org: default_max_sso_group_mappings_per_org(), } } } @@ -68,10 +141,74 @@ fn default_max_providers_per_user() -> u32 { 10 } +fn default_max_providers_per_org() -> u32 { + 100 +} + +fn default_max_providers_per_team() -> u32 { + 50 +} + +fn default_max_providers_per_project() -> u32 { + 50 +} + fn default_max_api_keys_per_user() -> u32 { 25 } +fn default_max_api_keys_per_org() -> u32 { + 500 +} + +fn default_max_api_keys_per_team() -> u32 { + 100 +} + +fn default_max_api_keys_per_project() -> u32 { + 100 +} + +fn default_max_teams_per_org() -> u32 { + 100 +} + +fn default_max_projects_per_org() -> u32 { + 1000 +} + +fn default_max_service_accounts_per_org() -> u32 { + 50 +} + +fn default_max_vector_stores_per_owner() -> u32 { + 100 +} + +fn default_max_files_per_vector_store() -> u32 { + 10_000 +} + +fn default_max_conversations_per_owner() -> u32 { + 10_000 +} + +fn default_max_prompts_per_owner() -> u32 { + 5_000 +} + +fn default_max_sso_configs_per_org() -> u32 { + 5 +} + +fn default_max_domains_per_sso_config() -> u32 { + 50 +} + +fn default_max_sso_group_mappings_per_org() -> u32 { + 500 +} + /// Rate limiting defaults. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] diff --git a/src/db/postgres/org_sso_configs.rs b/src/db/postgres/org_sso_configs.rs index 05d1837..50ab4db 100644 --- a/src/db/postgres/org_sso_configs.rs +++ b/src/db/postgres/org_sso_configs.rs @@ -522,4 +522,12 @@ impl OrgSsoConfigRepo for PostgresOrgSsoConfigRepo { .await?; Ok(result.0) } + + async fn count_by_org(&self, org_id: Uuid) -> DbResult { + let row = sqlx::query("SELECT COUNT(*) as count FROM org_sso_configs WHERE org_id = $1") + .bind(org_id) + .fetch_one(&self.read_pool) + .await?; + Ok(row.get::("count")) + } } diff --git a/src/db/postgres/vector_stores.rs b/src/db/postgres/vector_stores.rs index de6be5f..664e652 100644 --- a/src/db/postgres/vector_stores.rs +++ b/src/db/postgres/vector_stores.rs @@ -1219,6 +1219,33 @@ impl VectorStoresRepo for PostgresVectorStoresRepo { Ok(result.rows_affected()) } + // ==================== Counts ==================== + + async fn count_by_owner( + &self, + owner_type: VectorStoreOwnerType, + owner_id: Uuid, + ) -> DbResult { + let row = sqlx::query( + "SELECT COUNT(*) as count FROM vector_stores WHERE owner_type = $1 AND owner_id = $2 AND deleted_at IS NULL", + ) + .bind(owner_type.as_str()) + .bind(owner_id) + .fetch_one(&self.read_pool) + .await?; + Ok(row.get::("count")) + } + + async fn count_files_in_vector_store(&self, vector_store_id: Uuid) -> DbResult { + let row = sqlx::query( + "SELECT COUNT(*) as count FROM vector_store_files WHERE vector_store_id = $1 AND deleted_at IS NULL", + ) + .bind(vector_store_id) + .fetch_one(&self.read_pool) + .await?; + Ok(row.get::("count")) + } + // ==================== Aggregates ==================== // Note: Chunk operations are handled by the VectorStore trait, // as chunks are stored in the vector database (pgvector/Qdrant), not the relational database. diff --git a/src/db/repos/org_sso_configs.rs b/src/db/repos/org_sso_configs.rs index bdaea0a..74a3ba2 100644 --- a/src/db/repos/org_sso_configs.rs +++ b/src/db/repos/org_sso_configs.rs @@ -99,4 +99,7 @@ pub trait OrgSsoConfigRepo: Send + Sync { /// /// Used to determine if email discovery should be shown on the login page. async fn any_enabled(&self) -> DbResult; + + /// Count SSO configurations for an organization. + async fn count_by_org(&self, org_id: Uuid) -> DbResult; } diff --git a/src/db/repos/vector_stores.rs b/src/db/repos/vector_stores.rs index 226d8e2..bada9cd 100644 --- a/src/db/repos/vector_stores.rs +++ b/src/db/repos/vector_stores.rs @@ -175,6 +175,18 @@ pub trait VectorStoresRepo: Send + Sync { /// Used when deleting a file to clean up any soft-deleted references first. async fn hard_delete_soft_deleted_references(&self, file_id: Uuid) -> DbResult; + // ==================== Counts ==================== + + /// Count vector stores by owner (excluding soft-deleted). + async fn count_by_owner( + &self, + owner_type: VectorStoreOwnerType, + owner_id: Uuid, + ) -> DbResult; + + /// Count active (non-deleted) files in a vector store. + async fn count_files_in_vector_store(&self, vector_store_id: Uuid) -> DbResult; + // ==================== Aggregates ==================== // Note: Chunk operations (create, get, delete) are handled by the VectorStore trait, // as chunks are stored in the vector database (pgvector/Qdrant), not the relational database. diff --git a/src/db/sqlite/org_sso_configs.rs b/src/db/sqlite/org_sso_configs.rs index 57d7c2e..0ce2270 100644 --- a/src/db/sqlite/org_sso_configs.rs +++ b/src/db/sqlite/org_sso_configs.rs @@ -588,6 +588,14 @@ impl OrgSsoConfigRepo for SqliteOrgSsoConfigRepo { .await?; Ok(row.col::("has_any") != 0) } + + async fn count_by_org(&self, org_id: Uuid) -> DbResult { + let row = sqlx::query("SELECT COUNT(*) as count FROM org_sso_configs WHERE org_id = ?") + .bind(org_id.to_string()) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("count")) + } } #[cfg(test)] diff --git a/src/db/sqlite/vector_stores.rs b/src/db/sqlite/vector_stores.rs index a9a171c..ce9a33a 100644 --- a/src/db/sqlite/vector_stores.rs +++ b/src/db/sqlite/vector_stores.rs @@ -1242,6 +1242,33 @@ impl VectorStoresRepo for SqliteVectorStoresRepo { Ok(result.rows_affected()) } + // ==================== Counts ==================== + + async fn count_by_owner( + &self, + owner_type: VectorStoreOwnerType, + owner_id: Uuid, + ) -> DbResult { + let row = sqlx::query( + "SELECT COUNT(*) as count FROM vector_stores WHERE owner_type = ? AND owner_id = ? AND deleted_at IS NULL", + ) + .bind(owner_type.as_str()) + .bind(owner_id.to_string()) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("count")) + } + + async fn count_files_in_vector_store(&self, vector_store_id: Uuid) -> DbResult { + let row = sqlx::query( + "SELECT COUNT(*) as count FROM vector_store_files WHERE vector_store_id = ? AND deleted_at IS NULL", + ) + .bind(vector_store_id.to_string()) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("count")) + } + // ==================== Aggregates ==================== // Note: Chunk operations are handled by the VectorStore trait, // as chunks are stored in the vector database (pgvector/Qdrant), not the relational database. diff --git a/src/routes/admin/api_keys.rs b/src/routes/admin/api_keys.rs index de976e7..7b5e639 100644 --- a/src/routes/admin/api_keys.rs +++ b/src/routes/admin/api_keys.rs @@ -319,6 +319,59 @@ pub async fn create( } } + // Check per-scope API key limits + let limits = &state.config.limits.resource_limits; + match &input.owner { + crate::models::ApiKeyOwner::Organization { org_id } => { + let max = limits.max_api_keys_per_org; + if max > 0 { + let count = services.api_keys.count_by_org(*org_id, false).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Organization has reached the maximum number of API keys ({max})" + ))); + } + } + } + crate::models::ApiKeyOwner::Team { team_id } => { + let max = limits.max_api_keys_per_team; + if max > 0 { + let count = services.api_keys.count_by_team(*team_id, false).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Team has reached the maximum number of API keys ({max})" + ))); + } + } + } + crate::models::ApiKeyOwner::Project { project_id } => { + let max = limits.max_api_keys_per_project; + if max > 0 { + let count = services + .api_keys + .count_by_project(*project_id, false) + .await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Project has reached the maximum number of API keys ({max})" + ))); + } + } + } + crate::models::ApiKeyOwner::User { user_id } => { + let max = limits.max_api_keys_per_user; + if max > 0 { + let count = services.api_keys.count_by_user(*user_id, false).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "User has reached the maximum number of API keys ({max})" + ))); + } + } + } + crate::models::ApiKeyOwner::ServiceAccount { .. } => {} + } + // Get the key generation prefix from config let prefix = state.config.auth.api_key_config().generation_prefix(); diff --git a/src/routes/admin/conversations.rs b/src/routes/admin/conversations.rs index 1d8cbd3..6d57473 100644 --- a/src/routes/admin/conversations.rs +++ b/src/routes/admin/conversations.rs @@ -73,6 +73,24 @@ pub async fn create( } } + // Check conversation limit + let max = state + .config + .limits + .resource_limits + .max_conversations_per_owner; + if max > 0 { + let count = services + .conversations + .count_by_owner(input.owner.owner_type(), input.owner.owner_id(), false) + .await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Owner has reached the maximum number of conversations ({max})" + ))); + } + } + let conversation = services.conversations.create(input).await?; Ok((StatusCode::CREATED, Json(conversation))) } diff --git a/src/routes/admin/domain_verifications.rs b/src/routes/admin/domain_verifications.rs index 847e341..d5dee4e 100644 --- a/src/routes/admin/domain_verifications.rs +++ b/src/routes/admin/domain_verifications.rs @@ -186,6 +186,24 @@ pub async fn create( )) })?; + // Check domain verification limit + let max = state + .config + .limits + .resource_limits + .max_domains_per_sso_config; + if max > 0 { + let count = services + .domain_verifications + .count_by_config(sso_config.id) + .await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "SSO configuration has reached the maximum number of domain verifications ({max})" + ))); + } + } + // Create the domain verification let verification = services .domain_verifications diff --git a/src/routes/admin/dynamic_providers.rs b/src/routes/admin/dynamic_providers.rs index c77ca6f..51af292 100644 --- a/src/routes/admin/dynamic_providers.rs +++ b/src/routes/admin/dynamic_providers.rs @@ -111,6 +111,55 @@ pub async fn create( )?; let actor = AuditActor::from(&admin_auth); + // Check per-scope provider limits + let limits = &state.config.limits.resource_limits; + match &input.owner { + crate::models::ProviderOwner::Organization { org_id } => { + let max = limits.max_providers_per_org; + if max > 0 { + let count = services.providers.count_by_org(*org_id).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Organization has reached the maximum number of providers ({max})" + ))); + } + } + } + crate::models::ProviderOwner::Team { team_id } => { + let max = limits.max_providers_per_team; + if max > 0 { + let count = services.providers.count_by_team(*team_id).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Team has reached the maximum number of providers ({max})" + ))); + } + } + } + crate::models::ProviderOwner::Project { project_id } => { + let max = limits.max_providers_per_project; + if max > 0 { + let count = services.providers.count_by_project(*project_id).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Project has reached the maximum number of providers ({max})" + ))); + } + } + } + crate::models::ProviderOwner::User { user_id } => { + let max = limits.max_providers_per_user; + if max > 0 { + let count = services.providers.count_by_user(*user_id).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "User has reached the maximum number of providers ({max})" + ))); + } + } + } + } + // Validate provider type crate::services::validate_provider_type(&input.provider_type)?; diff --git a/src/routes/admin/org_sso_configs.rs b/src/routes/admin/org_sso_configs.rs index 2dfef6b..6fb871a 100644 --- a/src/routes/admin/org_sso_configs.rs +++ b/src/routes/admin/org_sso_configs.rs @@ -282,8 +282,16 @@ pub async fn create( None, )?; - // Check if org already has an SSO config - if services + // Check SSO config limit (DB also enforces one-per-org via UNIQUE constraint) + let max = state.config.limits.resource_limits.max_sso_configs_per_org; + if max > 0 { + let count = services.org_sso_configs.count_by_org(org.id).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Organization has reached the maximum number of SSO configurations ({max})" + ))); + } + } else if services .org_sso_configs .get_by_org_id(org.id) .await? diff --git a/src/routes/admin/projects.rs b/src/routes/admin/projects.rs index ffa256f..f4acfc1 100644 --- a/src/routes/admin/projects.rs +++ b/src/routes/admin/projects.rs @@ -74,6 +74,17 @@ pub async fn create( None, )?; + // Check project limit + let max = state.config.limits.resource_limits.max_projects_per_org; + if max > 0 { + let count = services.projects.count_by_org(org.id, false).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Organization has reached the maximum number of projects ({max})" + ))); + } + } + let project = services.projects.create(org.id, input).await?; // Auto-add the creator as an admin member of the project diff --git a/src/routes/admin/prompts.rs b/src/routes/admin/prompts.rs index aeb6508..b5a27f6 100644 --- a/src/routes/admin/prompts.rs +++ b/src/routes/admin/prompts.rs @@ -57,6 +57,20 @@ pub async fn create( authz.require("prompt", "create", None, None, None, None)?; + // Check prompt limit + let max = state.config.limits.resource_limits.max_prompts_per_owner; + if max > 0 { + let count = services + .prompts + .count_by_owner(input.owner.owner_type(), input.owner.owner_id(), false) + .await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Owner has reached the maximum number of prompts ({max})" + ))); + } + } + let prompt = services.prompts.create(input).await?; // Extract org_id and project_id from owner for audit log diff --git a/src/routes/admin/service_accounts.rs b/src/routes/admin/service_accounts.rs index 37847c7..c0e25b0 100644 --- a/src/routes/admin/service_accounts.rs +++ b/src/routes/admin/service_accounts.rs @@ -78,6 +78,21 @@ pub async fn create( None, )?; + // Check service account limit + let max = state + .config + .limits + .resource_limits + .max_service_accounts_per_org; + if max > 0 { + let count = services.service_accounts.count_by_org(org.id).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Organization has reached the maximum number of service accounts ({max})" + ))); + } + } + let sa = services .service_accounts .create(org.id, input.clone()) diff --git a/src/routes/admin/sso_group_mappings.rs b/src/routes/admin/sso_group_mappings.rs index ed4e2df..f5e6328 100644 --- a/src/routes/admin/sso_group_mappings.rs +++ b/src/routes/admin/sso_group_mappings.rs @@ -282,6 +282,21 @@ pub async fn create( // Validate role is not a reserved system role validate_role_not_reserved(input.role.as_deref())?; + // Check SSO group mapping limit + let max = state + .config + .limits + .resource_limits + .max_sso_group_mappings_per_org; + if max > 0 { + let count = services.sso_group_mappings.count_by_org(org.id).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Organization has reached the maximum number of SSO group mappings ({max})" + ))); + } + } + let mapping = services .sso_group_mappings .create(org.id, input.clone()) diff --git a/src/routes/admin/teams.rs b/src/routes/admin/teams.rs index c37e944..3bf592a 100644 --- a/src/routes/admin/teams.rs +++ b/src/routes/admin/teams.rs @@ -91,6 +91,17 @@ pub async fn create( None, )?; + // Check team limit + let max = state.config.limits.resource_limits.max_teams_per_org; + if max > 0 { + let count = services.teams.count_by_org(org.id, false).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Organization has reached the maximum number of teams ({max})" + ))); + } + } + let team = services.teams.create(org.id, input).await?; // Log audit event (fire-and-forget) diff --git a/src/routes/api/vector_stores.rs b/src/routes/api/vector_stores.rs index 1bfb013..a8f0778 100644 --- a/src/routes/api/vector_stores.rs +++ b/src/routes/api/vector_stores.rs @@ -276,6 +276,26 @@ pub async fn api_v1_vector_stores_create( } } + // Check vector store limit per owner + let max = state + .config + .limits + .resource_limits + .max_vector_stores_per_owner; + if max > 0 { + let count = services + .vector_stores + .count_by_owner(input.owner.owner_type(), input.owner.owner_id()) + .await?; + if count >= max as i64 { + return Err(ApiError::new( + StatusCode::CONFLICT, + "limit_reached", + format!("Owner has reached the maximum number of vector stores ({max})"), + )); + } + } + // Extract file_ids and chunking_strategy before creating vector store let file_ids = input.file_ids.clone(); let chunking_strategy = input.chunking_strategy.clone(); @@ -809,6 +829,26 @@ pub async fn api_v1_vector_stores_create_file( vector_store.owner_id, )?; + // Check files-per-vector-store limit + let max = state + .config + .limits + .resource_limits + .max_files_per_vector_store; + if max > 0 { + let count = services + .vector_stores + .count_files_in_vector_store(vector_store_id) + .await?; + if count >= max as i64 { + return Err(ApiError::new( + StatusCode::CONFLICT, + "limit_reached", + format!("Vector store has reached the maximum number of files ({max})"), + )); + } + } + // Verify the file exists and get its content hash for deduplication let file = services.files.get(input.file_id).await?.ok_or_else(|| { ApiError::new( @@ -1442,6 +1482,30 @@ pub async fn api_v1_vector_stores_create_file_batch( vector_store.owner_id, )?; + // Check files-per-vector-store limit + let max = state + .config + .limits + .resource_limits + .max_files_per_vector_store; + if max > 0 { + let current = services + .vector_stores + .count_files_in_vector_store(vector_store_id) + .await?; + let new_total = current + input.file_ids.len() as i64; + if new_total > max as i64 { + return Err(ApiError::new( + StatusCode::CONFLICT, + "limit_reached", + format!( + "Adding {} files would exceed the vector store file limit ({max}, currently {current})", + input.file_ids.len() + ), + )); + } + } + // Validate embedding model compatibility before processing any files. // This ensures the gateway's configured embedding model matches the vector store's model, // preventing incompatible vectors from being stored. diff --git a/src/services/api_keys.rs b/src/services/api_keys.rs index 1e4d33a..470405f 100644 --- a/src/services/api_keys.rs +++ b/src/services/api_keys.rs @@ -75,6 +75,14 @@ impl ApiKeyService { .await } + /// Count API keys for a team + pub async fn count_by_team(&self, team_id: Uuid, include_revoked: bool) -> DbResult { + self.db + .api_keys() + .count_by_team(team_id, include_revoked) + .await + } + /// Count API keys for a project pub async fn count_by_project(&self, project_id: Uuid, include_revoked: bool) -> DbResult { self.db diff --git a/src/services/org_sso_configs.rs b/src/services/org_sso_configs.rs index 1dcbc63..ea71026 100644 --- a/src/services/org_sso_configs.rs +++ b/src/services/org_sso_configs.rs @@ -433,6 +433,11 @@ impl OrgSsoConfigService { Ok(results) } + /// Count SSO configurations for an organization. + pub async fn count_by_org(&self, org_id: Uuid) -> DbResult { + self.db.org_sso_configs().count_by_org(org_id).await + } + /// List all enabled SSO configurations of a specific provider type with their secrets. /// /// Used for initializing the SAML or OIDC authenticator registries on startup. diff --git a/src/services/providers.rs b/src/services/providers.rs index 9b0c0ad..38f56cb 100644 --- a/src/services/providers.rs +++ b/src/services/providers.rs @@ -280,6 +280,11 @@ impl DynamicProviderService { self.db.providers().count_by_org(org_id).await } + /// Count providers for a team + pub async fn count_by_team(&self, team_id: Uuid) -> DbResult { + self.db.providers().count_by_team(team_id).await + } + /// List providers for a project pub async fn list_by_project( &self, diff --git a/src/services/vector_stores.rs b/src/services/vector_stores.rs index 3ff809d..0d502d3 100644 --- a/src/services/vector_stores.rs +++ b/src/services/vector_stores.rs @@ -316,6 +316,26 @@ impl VectorStoresService { .await } + /// Count vector stores by owner (excluding soft-deleted). + pub async fn count_by_owner( + &self, + owner_type: VectorStoreOwnerType, + owner_id: Uuid, + ) -> DbResult { + self.db + .vector_stores() + .count_by_owner(owner_type, owner_id) + .await + } + + /// Count active (non-deleted) files in a vector store. + pub async fn count_files_in_vector_store(&self, vector_store_id: Uuid) -> DbResult { + self.db + .vector_stores() + .count_files_in_vector_store(vector_store_id) + .await + } + /// Recalculate and update vector store statistics. /// /// This updates `usage_bytes` and `file_counts` based on current file state. From ecad31ae555a55a8b388315b1904cf4af0dfc9c8 Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 12 Mar 2026 08:36:32 +1000 Subject: [PATCH 2/4] Review fixes and additional limits --- src/config/limits.rs | 50 +++++++++++++++++++++++++++++ src/db/postgres/files.rs | 15 +++++++++ src/db/postgres/projects.rs | 14 ++++++++ src/db/repos/files.rs | 7 ++++ src/db/repos/projects.rs | 1 + src/db/sqlite/files.rs | 14 ++++++++ src/db/sqlite/org_sso_configs.rs | 4 +-- src/db/sqlite/projects.rs | 14 ++++++++ src/db/sqlite/vector_stores.rs | 8 ++--- src/routes/admin/org_sso_configs.rs | 12 +------ src/routes/admin/projects.rs | 18 +++++++++-- src/routes/admin/teams.rs | 11 +++++++ src/routes/admin/users.rs | 25 +++++++++++++++ src/routes/api/files.rs | 26 +++++++++++++++ src/services/files.rs | 9 ++++++ src/services/projects.rs | 8 +++++ 16 files changed, 217 insertions(+), 19 deletions(-) diff --git a/src/config/limits.rs b/src/config/limits.rs index 53d9de1..b580d15 100644 --- a/src/config/limits.rs +++ b/src/config/limits.rs @@ -26,6 +26,11 @@ pub struct LimitsConfig { /// /// These limits prevent unbounded growth of resources that could cause /// performance issues or resource exhaustion. Set any limit to 0 for unlimited. +/// +/// **Enforcement model:** Limits are best-effort. Under concurrent load, the +/// `count → compare → create` pattern may allow a small number of requests +/// to exceed the configured limit. This is acceptable for configuration +/// guardrails; use database-level constraints for strict enforcement. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[serde(deny_unknown_fields)] @@ -105,6 +110,26 @@ pub struct ResourceLimits { /// Maximum SSO group mappings per organization. Default: 500. #[serde(default = "default_max_sso_group_mappings_per_org")] pub max_sso_group_mappings_per_org: u32, + + /// Maximum members per organization. Default: 10,000. + #[serde(default = "default_max_members_per_org")] + pub max_members_per_org: u32, + + /// Maximum members per team. Default: 10,000. + #[serde(default = "default_max_members_per_team")] + pub max_members_per_team: u32, + + /// Maximum members per project. Default: 10,000. + #[serde(default = "default_max_members_per_project")] + pub max_members_per_project: u32, + + /// Maximum uploaded files per owner (org/team/project/user). Default: 10,000. + #[serde(default = "default_max_files_per_owner")] + pub max_files_per_owner: u32, + + /// Maximum projects per team. Default: 100. + #[serde(default = "default_max_projects_per_team")] + pub max_projects_per_team: u32, } impl Default for ResourceLimits { @@ -129,6 +154,11 @@ impl Default for ResourceLimits { max_sso_configs_per_org: default_max_sso_configs_per_org(), max_domains_per_sso_config: default_max_domains_per_sso_config(), max_sso_group_mappings_per_org: default_max_sso_group_mappings_per_org(), + max_members_per_org: default_max_members_per_org(), + max_members_per_team: default_max_members_per_team(), + max_members_per_project: default_max_members_per_project(), + max_files_per_owner: default_max_files_per_owner(), + max_projects_per_team: default_max_projects_per_team(), } } } @@ -209,6 +239,26 @@ fn default_max_sso_group_mappings_per_org() -> u32 { 500 } +fn default_max_members_per_org() -> u32 { + 10_000 +} + +fn default_max_members_per_team() -> u32 { + 10_000 +} + +fn default_max_members_per_project() -> u32 { + 10_000 +} + +fn default_max_files_per_owner() -> u32 { + 10_000 +} + +fn default_max_projects_per_team() -> u32 { + 100 +} + /// Rate limiting defaults. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] diff --git a/src/db/postgres/files.rs b/src/db/postgres/files.rs index 5dfb022..c151b1e 100644 --- a/src/db/postgres/files.rs +++ b/src/db/postgres/files.rs @@ -406,6 +406,21 @@ impl FilesRepo for PostgresFilesRepo { Ok(()) } + async fn count_by_owner( + &self, + owner_type: VectorStoreOwnerType, + owner_id: Uuid, + ) -> DbResult { + let row = sqlx::query( + "SELECT COUNT(*) as count FROM files WHERE owner_type = $1 AND owner_id = $2", + ) + .bind(owner_type.as_str()) + .bind(owner_id) + .fetch_one(&self.read_pool) + .await?; + Ok(row.get("count")) + } + async fn count_file_references(&self, file_id: Uuid) -> DbResult { let result = sqlx::query( r#" diff --git a/src/db/postgres/projects.rs b/src/db/postgres/projects.rs index 15b4a3f..bec88cb 100644 --- a/src/db/postgres/projects.rs +++ b/src/db/postgres/projects.rs @@ -281,6 +281,20 @@ impl ProjectRepo for PostgresProjectRepo { Ok(row.get::("count")) } + async fn count_by_team(&self, team_id: Uuid, include_deleted: bool) -> DbResult { + let query = if include_deleted { + "SELECT COUNT(*) as count FROM projects WHERE team_id = $1" + } else { + "SELECT COUNT(*) as count FROM projects WHERE team_id = $1 AND deleted_at IS NULL" + }; + + let row = sqlx::query(query) + .bind(team_id) + .fetch_one(&self.read_pool) + .await?; + Ok(row.get::("count")) + } + async fn update(&self, id: Uuid, input: UpdateProject) -> DbResult { let has_name_update = input.name.is_some(); let has_team_update = input.team_id.is_some(); diff --git a/src/db/repos/files.rs b/src/db/repos/files.rs index 2be7918..0f446da 100644 --- a/src/db/repos/files.rs +++ b/src/db/repos/files.rs @@ -40,6 +40,13 @@ pub trait FilesRepo: Send + Sync { status_details: Option, ) -> DbResult<()>; + /// Count files by owner + async fn count_by_owner( + &self, + owner_type: VectorStoreOwnerType, + owner_id: Uuid, + ) -> DbResult; + /// Count references to a file across collections /// Used to determine if a file can be deleted async fn count_file_references(&self, file_id: Uuid) -> DbResult; diff --git a/src/db/repos/projects.rs b/src/db/repos/projects.rs index 579d2f1..6544a34 100644 --- a/src/db/repos/projects.rs +++ b/src/db/repos/projects.rs @@ -19,6 +19,7 @@ pub trait ProjectRepo: Send + Sync { async fn get_by_slug(&self, org_id: Uuid, slug: &str) -> DbResult>; async fn list_by_org(&self, org_id: Uuid, params: ListParams) -> DbResult>; async fn count_by_org(&self, org_id: Uuid, include_deleted: bool) -> DbResult; + async fn count_by_team(&self, team_id: Uuid, include_deleted: bool) -> DbResult; async fn update(&self, id: Uuid, input: UpdateProject) -> DbResult; async fn delete(&self, id: Uuid) -> DbResult<()>; } diff --git a/src/db/sqlite/files.rs b/src/db/sqlite/files.rs index a783402..de870f7 100644 --- a/src/db/sqlite/files.rs +++ b/src/db/sqlite/files.rs @@ -405,6 +405,20 @@ impl FilesRepo for SqliteFilesRepo { Ok(()) } + async fn count_by_owner( + &self, + owner_type: VectorStoreOwnerType, + owner_id: Uuid, + ) -> DbResult { + let row = + query("SELECT COUNT(*) as count FROM files WHERE owner_type = ? AND owner_id = ?") + .bind(owner_type.as_str()) + .bind(owner_id.to_string()) + .fetch_one(&self.pool) + .await?; + Ok(row.col("count")) + } + async fn count_file_references(&self, file_id: Uuid) -> DbResult { let result = query( r#" diff --git a/src/db/sqlite/org_sso_configs.rs b/src/db/sqlite/org_sso_configs.rs index 0ce2270..946a554 100644 --- a/src/db/sqlite/org_sso_configs.rs +++ b/src/db/sqlite/org_sso_configs.rs @@ -590,11 +590,11 @@ impl OrgSsoConfigRepo for SqliteOrgSsoConfigRepo { } async fn count_by_org(&self, org_id: Uuid) -> DbResult { - let row = sqlx::query("SELECT COUNT(*) as count FROM org_sso_configs WHERE org_id = ?") + let row = query("SELECT COUNT(*) as count FROM org_sso_configs WHERE org_id = ?") .bind(org_id.to_string()) .fetch_one(&self.pool) .await?; - Ok(row.get::("count")) + Ok(row.col("count")) } } diff --git a/src/db/sqlite/projects.rs b/src/db/sqlite/projects.rs index 8ca9dfd..e4b9df7 100644 --- a/src/db/sqlite/projects.rs +++ b/src/db/sqlite/projects.rs @@ -302,6 +302,20 @@ impl ProjectRepo for SqliteProjectRepo { Ok(row.col::("count")) } + async fn count_by_team(&self, team_id: Uuid, include_deleted: bool) -> DbResult { + let sql = if include_deleted { + "SELECT COUNT(*) as count FROM projects WHERE team_id = ?" + } else { + "SELECT COUNT(*) as count FROM projects WHERE team_id = ? AND deleted_at IS NULL" + }; + + let row = query(sql) + .bind(team_id.to_string()) + .fetch_one(&self.pool) + .await?; + Ok(row.col::("count")) + } + async fn update(&self, id: Uuid, input: UpdateProject) -> DbResult { let has_name_update = input.name.is_some(); let has_team_update = input.team_id.is_some(); diff --git a/src/db/sqlite/vector_stores.rs b/src/db/sqlite/vector_stores.rs index ce9a33a..fe47df6 100644 --- a/src/db/sqlite/vector_stores.rs +++ b/src/db/sqlite/vector_stores.rs @@ -1249,24 +1249,24 @@ impl VectorStoresRepo for SqliteVectorStoresRepo { owner_type: VectorStoreOwnerType, owner_id: Uuid, ) -> DbResult { - let row = sqlx::query( + let row = query( "SELECT COUNT(*) as count FROM vector_stores WHERE owner_type = ? AND owner_id = ? AND deleted_at IS NULL", ) .bind(owner_type.as_str()) .bind(owner_id.to_string()) .fetch_one(&self.pool) .await?; - Ok(row.get::("count")) + Ok(row.col("count")) } async fn count_files_in_vector_store(&self, vector_store_id: Uuid) -> DbResult { - let row = sqlx::query( + let row = query( "SELECT COUNT(*) as count FROM vector_store_files WHERE vector_store_id = ? AND deleted_at IS NULL", ) .bind(vector_store_id.to_string()) .fetch_one(&self.pool) .await?; - Ok(row.get::("count")) + Ok(row.col("count")) } // ==================== Aggregates ==================== diff --git a/src/routes/admin/org_sso_configs.rs b/src/routes/admin/org_sso_configs.rs index 6fb871a..4df4f33 100644 --- a/src/routes/admin/org_sso_configs.rs +++ b/src/routes/admin/org_sso_configs.rs @@ -282,7 +282,7 @@ pub async fn create( None, )?; - // Check SSO config limit (DB also enforces one-per-org via UNIQUE constraint) + // Check SSO config limit (best-effort; DB UNIQUE constraint is the hard guard) let max = state.config.limits.resource_limits.max_sso_configs_per_org; if max > 0 { let count = services.org_sso_configs.count_by_org(org.id).await?; @@ -291,16 +291,6 @@ pub async fn create( "Organization has reached the maximum number of SSO configurations ({max})" ))); } - } else if services - .org_sso_configs - .get_by_org_id(org.id) - .await? - .is_some() - { - return Err(AdminError::Conflict(format!( - "Organization '{}' already has an SSO configuration", - org_slug - ))); } // Validate default_team_id belongs to the org if provided diff --git a/src/routes/admin/projects.rs b/src/routes/admin/projects.rs index f4acfc1..75fd489 100644 --- a/src/routes/admin/projects.rs +++ b/src/routes/admin/projects.rs @@ -74,8 +74,9 @@ pub async fn create( None, )?; - // Check project limit - let max = state.config.limits.resource_limits.max_projects_per_org; + // Check project limit per org + let limits = &state.config.limits.resource_limits; + let max = limits.max_projects_per_org; if max > 0 { let count = services.projects.count_by_org(org.id, false).await?; if count >= max as i64 { @@ -85,6 +86,19 @@ pub async fn create( } } + // Check project limit per team + if let Some(team_id) = input.team_id { + let max_per_team = limits.max_projects_per_team; + if max_per_team > 0 { + let count = services.projects.count_by_team(team_id, false).await?; + if count >= max_per_team as i64 { + return Err(AdminError::Conflict(format!( + "Team has reached the maximum number of projects ({max_per_team})" + ))); + } + } + } + let project = services.projects.create(org.id, input).await?; // Auto-add the creator as an admin member of the project diff --git a/src/routes/admin/teams.rs b/src/routes/admin/teams.rs index 3bf592a..0c54067 100644 --- a/src/routes/admin/teams.rs +++ b/src/routes/admin/teams.rs @@ -540,6 +540,17 @@ pub async fn add_member( None, )?; + // Check team member limit + let max = state.config.limits.resource_limits.max_members_per_team; + if max > 0 { + let count = services.teams.count_members(team.id).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Team has reached the maximum number of members ({max})" + ))); + } + } + let user_id = input.user_id; let role = input.role.clone(); let member = services.teams.add_member(team.id, input).await?; diff --git a/src/routes/admin/users.rs b/src/routes/admin/users.rs index cf06363..fcd0f93 100644 --- a/src/routes/admin/users.rs +++ b/src/routes/admin/users.rs @@ -506,6 +506,17 @@ pub async fn add_org_member( None, )?; + // Check org member limit + let max = state.config.limits.resource_limits.max_members_per_org; + if max > 0 { + let count = services.users.count_org_members(org.id, false).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Organization has reached the maximum number of members ({max})" + ))); + } + } + services .users .add_to_org( @@ -818,6 +829,20 @@ pub async fn add_project_member( Some(&project.id.to_string()), )?; + // Check project member limit + let max = state.config.limits.resource_limits.max_members_per_project; + if max > 0 { + let count = services + .users + .count_project_members(project.id, false) + .await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Project has reached the maximum number of members ({max})" + ))); + } + } + services .users .add_to_project( diff --git a/src/routes/api/files.rs b/src/routes/api/files.rs index 98ee84f..1587b58 100644 --- a/src/routes/api/files.rs +++ b/src/routes/api/files.rs @@ -375,6 +375,32 @@ pub async fn api_v1_files_upload( )); } + // Check file limit per owner + let max = state.config.limits.resource_limits.max_files_per_owner; + if max > 0 { + let count = services + .files + .count_by_owner(owner_type, owner_id) + .await + .map_err(|e| { + ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "count_error", + format!("Failed to count files: {}", e), + ) + })?; + if count >= max as i64 { + return Err(ApiError::new( + StatusCode::CONFLICT, + "limit_exceeded", + format!( + "{} has reached the maximum number of files ({max})", + owner_type_name + ), + )); + } + } + // Create file with configured storage backend let storage_backend = services.files.configured_backend(); let input = FilesService::create_file_input( diff --git a/src/services/files.rs b/src/services/files.rs index 3fa7223..57c4e2a 100644 --- a/src/services/files.rs +++ b/src/services/files.rs @@ -53,6 +53,15 @@ impl FilesService { self.storage.backend_name() } + /// Count files by owner. + pub async fn count_by_owner( + &self, + owner_type: VectorStoreOwnerType, + owner_id: Uuid, + ) -> DbResult { + self.db.files().count_by_owner(owner_type, owner_id).await + } + /// Upload a new file. /// /// The file is stored according to the configured storage backend: diff --git a/src/services/projects.rs b/src/services/projects.rs index 10ea629..09e90e2 100644 --- a/src/services/projects.rs +++ b/src/services/projects.rs @@ -57,6 +57,14 @@ impl ProjectService { .await } + /// Count projects for a team + pub async fn count_by_team(&self, team_id: Uuid, include_deleted: bool) -> DbResult { + self.db + .projects() + .count_by_team(team_id, include_deleted) + .await + } + /// Update a project by ID pub async fn update(&self, id: Uuid, input: UpdateProject) -> DbResult { self.db.projects().update(id, input).await From 981bd3bd0f70b4074b0728c049c4757dfe609bcc Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 12 Mar 2026 19:12:07 +1000 Subject: [PATCH 3/4] Remove SSO config limit --- src/config/limits.rs | 9 --------- src/db/postgres/org_sso_configs.rs | 8 -------- src/db/repos/org_sso_configs.rs | 3 --- src/db/sqlite/org_sso_configs.rs | 8 -------- src/routes/admin/org_sso_configs.rs | 11 ----------- src/routes/api/vector_stores.rs | 13 +++++++++++++ src/services/org_sso_configs.rs | 5 ----- 7 files changed, 13 insertions(+), 44 deletions(-) diff --git a/src/config/limits.rs b/src/config/limits.rs index b580d15..5868a0b 100644 --- a/src/config/limits.rs +++ b/src/config/limits.rs @@ -99,10 +99,6 @@ pub struct ResourceLimits { #[serde(default = "default_max_prompts_per_owner")] pub max_prompts_per_owner: u32, - /// Maximum SSO configurations per organization. Default: 5. - #[serde(default = "default_max_sso_configs_per_org")] - pub max_sso_configs_per_org: u32, - /// Maximum domain verifications per SSO configuration. Default: 50. #[serde(default = "default_max_domains_per_sso_config")] pub max_domains_per_sso_config: u32, @@ -151,7 +147,6 @@ impl Default for ResourceLimits { max_files_per_vector_store: default_max_files_per_vector_store(), max_conversations_per_owner: default_max_conversations_per_owner(), max_prompts_per_owner: default_max_prompts_per_owner(), - max_sso_configs_per_org: default_max_sso_configs_per_org(), max_domains_per_sso_config: default_max_domains_per_sso_config(), max_sso_group_mappings_per_org: default_max_sso_group_mappings_per_org(), max_members_per_org: default_max_members_per_org(), @@ -227,10 +222,6 @@ fn default_max_prompts_per_owner() -> u32 { 5_000 } -fn default_max_sso_configs_per_org() -> u32 { - 5 -} - fn default_max_domains_per_sso_config() -> u32 { 50 } diff --git a/src/db/postgres/org_sso_configs.rs b/src/db/postgres/org_sso_configs.rs index 50ab4db..05d1837 100644 --- a/src/db/postgres/org_sso_configs.rs +++ b/src/db/postgres/org_sso_configs.rs @@ -522,12 +522,4 @@ impl OrgSsoConfigRepo for PostgresOrgSsoConfigRepo { .await?; Ok(result.0) } - - async fn count_by_org(&self, org_id: Uuid) -> DbResult { - let row = sqlx::query("SELECT COUNT(*) as count FROM org_sso_configs WHERE org_id = $1") - .bind(org_id) - .fetch_one(&self.read_pool) - .await?; - Ok(row.get::("count")) - } } diff --git a/src/db/repos/org_sso_configs.rs b/src/db/repos/org_sso_configs.rs index 74a3ba2..bdaea0a 100644 --- a/src/db/repos/org_sso_configs.rs +++ b/src/db/repos/org_sso_configs.rs @@ -99,7 +99,4 @@ pub trait OrgSsoConfigRepo: Send + Sync { /// /// Used to determine if email discovery should be shown on the login page. async fn any_enabled(&self) -> DbResult; - - /// Count SSO configurations for an organization. - async fn count_by_org(&self, org_id: Uuid) -> DbResult; } diff --git a/src/db/sqlite/org_sso_configs.rs b/src/db/sqlite/org_sso_configs.rs index 946a554..57d7c2e 100644 --- a/src/db/sqlite/org_sso_configs.rs +++ b/src/db/sqlite/org_sso_configs.rs @@ -588,14 +588,6 @@ impl OrgSsoConfigRepo for SqliteOrgSsoConfigRepo { .await?; Ok(row.col::("has_any") != 0) } - - async fn count_by_org(&self, org_id: Uuid) -> DbResult { - let row = query("SELECT COUNT(*) as count FROM org_sso_configs WHERE org_id = ?") - .bind(org_id.to_string()) - .fetch_one(&self.pool) - .await?; - Ok(row.col("count")) - } } #[cfg(test)] diff --git a/src/routes/admin/org_sso_configs.rs b/src/routes/admin/org_sso_configs.rs index 4df4f33..a828d3e 100644 --- a/src/routes/admin/org_sso_configs.rs +++ b/src/routes/admin/org_sso_configs.rs @@ -282,17 +282,6 @@ pub async fn create( None, )?; - // Check SSO config limit (best-effort; DB UNIQUE constraint is the hard guard) - let max = state.config.limits.resource_limits.max_sso_configs_per_org; - if max > 0 { - let count = services.org_sso_configs.count_by_org(org.id).await?; - if count >= max as i64 { - return Err(AdminError::Conflict(format!( - "Organization has reached the maximum number of SSO configurations ({max})" - ))); - } - } - // Validate default_team_id belongs to the org if provided if let Some(team_id) = input.default_team_id { let team = services diff --git a/src/routes/api/vector_stores.rs b/src/routes/api/vector_stores.rs index a8f0778..1b79dee 100644 --- a/src/routes/api/vector_stores.rs +++ b/src/routes/api/vector_stores.rs @@ -1482,6 +1482,19 @@ pub async fn api_v1_vector_stores_create_file_batch( vector_store.owner_id, )?; + // Cap batch size to prevent oversized requests from causing expensive DB operations + const MAX_BATCH_SIZE: usize = 500; + if input.file_ids.len() > MAX_BATCH_SIZE { + return Err(ApiError::new( + StatusCode::BAD_REQUEST, + "batch_too_large", + format!( + "Batch size {} exceeds maximum of {MAX_BATCH_SIZE}", + input.file_ids.len() + ), + )); + } + // Check files-per-vector-store limit let max = state .config diff --git a/src/services/org_sso_configs.rs b/src/services/org_sso_configs.rs index ea71026..1dcbc63 100644 --- a/src/services/org_sso_configs.rs +++ b/src/services/org_sso_configs.rs @@ -433,11 +433,6 @@ impl OrgSsoConfigService { Ok(results) } - /// Count SSO configurations for an organization. - pub async fn count_by_org(&self, org_id: Uuid) -> DbResult { - self.db.org_sso_configs().count_by_org(org_id).await - } - /// List all enabled SSO configurations of a specific provider type with their secrets. /// /// Used for initializing the SAML or OIDC authenticator registries on startup. From f0b6fdb96c4e24f6620a50de871707108dad26e8 Mon Sep 17 00:00:00 2001 From: ScriptSmith Date: Thu, 12 Mar 2026 21:04:06 +1000 Subject: [PATCH 4/4] Address review comments --- src/routes/admin/api_keys.rs | 23 ++++++++++++++++++++++- src/routes/admin/org_sso_configs.rs | 14 ++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/routes/admin/api_keys.rs b/src/routes/admin/api_keys.rs index 7b5e639..2b87738 100644 --- a/src/routes/admin/api_keys.rs +++ b/src/routes/admin/api_keys.rs @@ -369,7 +369,28 @@ pub async fn create( } } } - crate::models::ApiKeyOwner::ServiceAccount { .. } => {} + crate::models::ApiKeyOwner::ServiceAccount { service_account_id } => { + // Service accounts belong to an org — apply the org-level limit + let sa = services + .service_accounts + .get_by_id(*service_account_id) + .await? + .ok_or_else(|| { + AdminError::NotFound(format!( + "Service account '{}' not found", + service_account_id + )) + })?; + let max = limits.max_api_keys_per_org; + if max > 0 { + let count = services.api_keys.count_by_org(sa.org_id, false).await?; + if count >= max as i64 { + return Err(AdminError::Conflict(format!( + "Organization has reached the maximum number of API keys ({max})" + ))); + } + } + } } // Get the key generation prefix from config diff --git a/src/routes/admin/org_sso_configs.rs b/src/routes/admin/org_sso_configs.rs index a828d3e..3a0f3cc 100644 --- a/src/routes/admin/org_sso_configs.rs +++ b/src/routes/admin/org_sso_configs.rs @@ -282,6 +282,20 @@ pub async fn create( None, )?; + // Check if org already has an SSO config (DB UNIQUE constraint is the hard guard, + // but this gives a clean 409 instead of an unhandled constraint violation) + if services + .org_sso_configs + .get_by_org_id(org.id) + .await? + .is_some() + { + return Err(AdminError::Conflict(format!( + "Organization '{}' already has an SSO configuration", + org_slug + ))); + } + // Validate default_team_id belongs to the org if provided if let Some(team_id) = input.default_team_id { let team = services