open-edison 0.1.19__py3-none-any.whl → 0.1.29__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/permissions.py ADDED
@@ -0,0 +1,281 @@
1
+ """
2
+ Permissions management for Open Edison
3
+
4
+ Simple JSON-based permissions for single-user MCP proxy.
5
+ Reads tool, resource, and prompt permission files and provides a singleton interface.
6
+ """
7
+
8
+ import json
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from loguru import logger as log
14
+
15
+ from src.config import Config, get_config_dir
16
+
17
+
18
+ def _default_permissions_dir() -> Path:
19
+ return get_config_dir()
20
+
21
+
22
+ # ACL ranking for permission levels
23
+ ACL_RANK: dict[str, int] = {"PUBLIC": 0, "PRIVATE": 1, "SECRET": 2}
24
+
25
+
26
+ class PermissionsError(Exception):
27
+ """Exception raised for permissions-related errors"""
28
+
29
+ def __init__(self, message: str, permissions_path: Path | None = None):
30
+ self.message = message
31
+ self.permissions_path = permissions_path
32
+ super().__init__(self.message)
33
+
34
+
35
+ @dataclass
36
+ class ToolPermission:
37
+ """Individual tool permission configuration"""
38
+
39
+ enabled: bool = False
40
+ write_operation: bool = False
41
+ read_private_data: bool = False
42
+ read_untrusted_public_data: bool = False
43
+ acl: str = "PUBLIC"
44
+ description: str | None = None
45
+
46
+
47
+ @dataclass
48
+ class ResourcePermission:
49
+ """Individual resource permission configuration"""
50
+
51
+ enabled: bool = False
52
+ write_operation: bool = False
53
+ read_private_data: bool = False
54
+ read_untrusted_public_data: bool = False
55
+
56
+
57
+ @dataclass
58
+ class PromptPermission:
59
+ """Individual prompt permission configuration"""
60
+
61
+ enabled: bool = False
62
+ write_operation: bool = False
63
+ read_private_data: bool = False
64
+ read_untrusted_public_data: bool = False
65
+ # Optional metadata fields (ignored by enforcement but accepted from JSON)
66
+ description: str | None = None
67
+ acl: str = "PUBLIC"
68
+
69
+
70
+ @dataclass
71
+ class PermissionsMetadata:
72
+ """Metadata for permissions files"""
73
+
74
+ description: str
75
+ last_updated: str # noqa
76
+
77
+
78
+ @dataclass
79
+ class Permissions:
80
+ """Main permissions class"""
81
+
82
+ tool_permissions: dict[str, ToolPermission]
83
+ resource_permissions: dict[str, ResourcePermission]
84
+ prompt_permissions: dict[str, PromptPermission]
85
+ tool_metadata: PermissionsMetadata | None = None
86
+ resource_metadata: PermissionsMetadata | None = None
87
+ prompt_metadata: PermissionsMetadata | None = None
88
+
89
+ def __init__(
90
+ self,
91
+ permissions_dir: Path | None = None,
92
+ *,
93
+ tool_permissions: dict[str, ToolPermission] | None = None,
94
+ resource_permissions: dict[str, ResourcePermission] | None = None,
95
+ prompt_permissions: dict[str, PromptPermission] | None = None,
96
+ ) -> None:
97
+ """Load permissions from JSON files or provide them directly."""
98
+ if permissions_dir is None:
99
+ permissions_dir = _default_permissions_dir()
100
+
101
+ if tool_permissions is None:
102
+ tool_permissions_path = permissions_dir / "tool_permissions.json"
103
+ tool_permissions, tool_metadata = self._load_permission_file(
104
+ tool_permissions_path, ToolPermission
105
+ )
106
+ self.tool_metadata = tool_metadata
107
+
108
+ if resource_permissions is None:
109
+ resource_permissions_path = permissions_dir / "resource_permissions.json"
110
+ resource_permissions, resource_metadata = self._load_permission_file(
111
+ resource_permissions_path, ResourcePermission
112
+ )
113
+ self.resource_metadata = resource_metadata
114
+
115
+ if prompt_permissions is None:
116
+ prompt_permissions_path = permissions_dir / "prompt_permissions.json"
117
+ prompt_permissions, prompt_metadata = self._load_permission_file(
118
+ prompt_permissions_path, PromptPermission
119
+ )
120
+ self.prompt_metadata = prompt_metadata
121
+
122
+ self.tool_permissions = tool_permissions
123
+ self.resource_permissions = resource_permissions
124
+ self.prompt_permissions = prompt_permissions
125
+
126
+ @classmethod
127
+ def _extract_metadata(cls, data: dict[str, Any]) -> PermissionsMetadata | None:
128
+ """Extract metadata from permission file data."""
129
+ metadata_data = data.get("_metadata", {})
130
+ if not metadata_data:
131
+ return None
132
+
133
+ return PermissionsMetadata(
134
+ description=str(metadata_data.get("description", "")),
135
+ last_updated=str(metadata_data.get("last_updated", "")),
136
+ )
137
+
138
+ @classmethod
139
+ def _validate_server_data(cls, server_name: str, server_items_data: Any) -> None:
140
+ """Validate server data structure."""
141
+ if not isinstance(server_items_data, dict):
142
+ log.warning(
143
+ f"Invalid server data for {server_name}: expected dict, got {type(server_items_data)}"
144
+ )
145
+ raise PermissionsError(
146
+ f"Invalid server data for {server_name}: expected dict, got {type(server_items_data)}"
147
+ )
148
+
149
+ @classmethod
150
+ def _validate_item_data(cls, server_name: str, item_name: str, item_data: Any) -> None:
151
+ """Validate item data structure."""
152
+ if not isinstance(item_data, dict):
153
+ log.warning(
154
+ f"Invalid item permissions for {server_name}/{item_name}: expected dict, got {type(item_data)}"
155
+ )
156
+ raise PermissionsError(
157
+ f"Invalid item permissions for {server_name}/{item_name}: expected dict, got {type(item_data)}"
158
+ )
159
+
160
+ @classmethod
161
+ def _load_permission_file(
162
+ cls,
163
+ file_path: Path,
164
+ permission_class: type[ToolPermission] | type[ResourcePermission] | type[PromptPermission],
165
+ ) -> tuple[dict[str, Any], PermissionsMetadata | None]:
166
+ """Load permissions from a single JSON file.
167
+
168
+ Returns a tuple of (permissions_dict, metadata)
169
+ """
170
+ permissions: dict[str, Any] = {}
171
+ metadata: PermissionsMetadata | None = None
172
+
173
+ if not file_path.exists():
174
+ raise PermissionsError(f"Permissions file not found at {file_path}")
175
+
176
+ with open(file_path) as f:
177
+ data: dict[str, Any] = json.load(f)
178
+
179
+ # Extract metadata
180
+ metadata = cls._extract_metadata(data)
181
+
182
+ # Parse permissions with duplicate checking
183
+ for server_name, server_items_data in data.items():
184
+ if server_name == "_metadata":
185
+ continue
186
+
187
+ cls._validate_server_data(server_name, server_items_data)
188
+
189
+ for item_name, item_data in server_items_data.items(): # type: ignore
190
+ cls._validate_item_data(server_name, item_name, item_data)
191
+
192
+ # Type casting for clarity
193
+ item_name_str: str = str(item_name) # type: ignore
194
+ item_data_dict: dict[str, Any] = item_data # type: ignore
195
+
196
+ # Create permission object (flat structure)
197
+ permissions[server_name + "_" + item_name_str] = permission_class(**item_data_dict)
198
+
199
+ log.debug(f"Loaded {len(permissions)} items from {len(data)} servers in {file_path}")
200
+
201
+ return permissions, metadata
202
+
203
+ def get_tool_permission(self, tool_name: str) -> ToolPermission:
204
+ """Get permission for a specific tool"""
205
+ if tool_name not in self.tool_permissions:
206
+ raise PermissionsError(f"Tool '{tool_name}' not found in permissions")
207
+ return self.tool_permissions[tool_name]
208
+
209
+ def get_resource_permission(self, resource_name: str) -> ResourcePermission:
210
+ """Get permission for a specific resource"""
211
+ if resource_name not in self.resource_permissions:
212
+ raise PermissionsError(f"Resource '{resource_name}' not found in permissions")
213
+ return self.resource_permissions[resource_name]
214
+
215
+ def get_prompt_permission(self, prompt_name: str) -> PromptPermission:
216
+ """Get permission for a specific prompt"""
217
+ if prompt_name not in self.prompt_permissions:
218
+ raise PermissionsError(f"Prompt '{prompt_name}' not found in permissions")
219
+ return self.prompt_permissions[prompt_name]
220
+
221
+ def is_tool_enabled(self, tool_name: str) -> bool:
222
+ """Check if a tool is enabled
223
+ Also checks if the server is enabled"""
224
+ permission = self.get_tool_permission(tool_name)
225
+ server_name = self.server_name_from_tool_name(tool_name)
226
+ server_enabled = self.is_server_enabled(server_name)
227
+ return permission.enabled and server_enabled
228
+
229
+ def is_resource_enabled(self, resource_name: str) -> bool:
230
+ """Check if a resource is enabled
231
+ Also checks if the server is enabled"""
232
+ permission = self.get_resource_permission(resource_name)
233
+ server_name = self.server_name_from_tool_name(resource_name)
234
+ server_enabled = self.is_server_enabled(server_name)
235
+ return permission.enabled and server_enabled
236
+
237
+ def is_prompt_enabled(self, prompt_name: str) -> bool:
238
+ """Check if a prompt is enabled
239
+ Also checks if the server is enabled"""
240
+ permission = self.get_prompt_permission(prompt_name)
241
+ server_name = self.server_name_from_tool_name(prompt_name)
242
+ server_enabled = self.is_server_enabled(server_name)
243
+ return permission.enabled and server_enabled
244
+
245
+ @staticmethod
246
+ def server_name_from_tool_name(tool_name: str) -> str:
247
+ """Get the server name from a tool name"""
248
+ parts = tool_name.split("_")
249
+ if len(parts) == 0:
250
+ raise PermissionsError(f"Tool name {tool_name} is invalid")
251
+ if parts[0] == "builtin":
252
+ return "builtin"
253
+
254
+ server_names = {s.name for s in Config().mcp_servers}
255
+ for i in range(len(parts)):
256
+ server_name = "_".join(parts[:i])
257
+ if server_name in server_names:
258
+ return server_name
259
+ raise PermissionsError(f"Server name not found for tool {tool_name}")
260
+
261
+ @staticmethod
262
+ def is_server_enabled(server_name: str) -> bool:
263
+ """Check if a server is enabled"""
264
+ if server_name == "builtin":
265
+ return True
266
+ server_config = next((s for s in Config().mcp_servers if s.name == server_name), None)
267
+ return server_config is not None and server_config.enabled
268
+
269
+
270
+ def normalize_acl(value: str | None, *, default: str = "PUBLIC") -> str:
271
+ """Normalize ACL string, defaulting and uppercasing; validate against known values."""
272
+ try:
273
+ if value is None:
274
+ return default
275
+ acl = str(value).upper().strip()
276
+ if acl not in ACL_RANK:
277
+ # Fallback to default if invalid
278
+ return default
279
+ return acl
280
+ except Exception:
281
+ return default