use serde::{Deserialize, Serialize};
use super::deployment::{Deployment, McpServerType};
use crate::auth::{AuthenticatedUser, Permission};
use crate::errors::ServicesValidationError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolsetRef {
pub id: String,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access: Option<ToolAccess>,
}
const fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolAccess {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<Permission>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
}
impl ToolAccess {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.scopes.is_empty() && self.roles.is_empty() && self.groups.is_empty()
}
#[must_use]
pub const fn needs_groups(&self) -> bool {
!self.groups.is_empty()
}
#[must_use]
pub fn permits(&self, user: Option<&AuthenticatedUser>, groups: &[String]) -> bool {
let Some(user) = user else {
return false;
};
let scope_match = self
.scopes
.iter()
.any(|required| user.permissions.iter().any(|held| held.implies(required)));
let role_match = self.roles.iter().any(|role| user.roles.contains(role));
let group_match = self.groups.iter().any(|group| groups.contains(group));
scope_match || role_match || group_match
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedAccess<'a> {
Tool(&'a ToolAccess),
Toolset(&'a ToolAccess),
Server,
}
impl<'a> ResolvedAccess<'a> {
#[must_use]
pub const fn resolve(tool: Option<&'a ToolAccess>, toolset: Option<&'a ToolAccess>) -> Self {
match (tool, toolset) {
(Some(access), _) => Self::Tool(access),
(None, Some(access)) => Self::Toolset(access),
(None, None) => Self::Server,
}
}
#[must_use]
pub const fn access(&self) -> Option<&'a ToolAccess> {
match *self {
Self::Tool(access) | Self::Toolset(access) => Some(access),
Self::Server => None,
}
}
#[must_use]
pub fn needs_groups(&self) -> bool {
self.access().is_some_and(ToolAccess::needs_groups)
}
#[must_use]
pub fn permits(&self, user: Option<&AuthenticatedUser>, groups: &[String]) -> bool {
self.access()
.is_none_or(|access| access.permits(user, groups))
}
}
impl Deployment {
pub(super) fn validate_toolsets(&self, name: &str) -> Result<(), ServicesValidationError> {
if self.toolsets.is_empty() {
return Ok(());
}
if !matches!(self.server_type, McpServerType::Internal) {
return Err(ServicesValidationError::invalid_field(format!(
"MCP server '{name}': toolsets are only valid on internal servers."
)));
}
let mut seen = std::collections::HashSet::new();
for toolset in &self.toolsets {
if toolset.id.trim().is_empty() {
return Err(ServicesValidationError::invalid_field(format!(
"MCP server '{name}': a toolset entry has an empty id."
)));
}
if !seen.insert(toolset.id.as_str()) {
return Err(ServicesValidationError::invalid_field(format!(
"MCP server '{name}': toolset '{}' is listed twice.",
toolset.id
)));
}
if toolset.access.as_ref().is_some_and(ToolAccess::is_empty) {
return Err(ServicesValidationError::invalid_field(format!(
"MCP server '{name}': toolset '{}' declares an empty access block; list \
scopes, roles or groups, or remove it.",
toolset.id
)));
}
}
for (tool, meta) in &self.tools {
if meta.access.as_ref().is_some_and(ToolAccess::is_empty) {
return Err(ServicesValidationError::invalid_field(format!(
"MCP server '{name}': tool '{tool}' declares an empty access block; list \
scopes, roles or groups, or remove it."
)));
}
}
Ok(())
}
pub fn enabled_toolsets(&self) -> impl Iterator<Item = &ToolsetRef> {
self.toolsets.iter().filter(|toolset| toolset.enabled)
}
}