open-edison 0.1.17__py3-none-any.whl → 0.1.26__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.
- {open_edison-0.1.17.dist-info → open_edison-0.1.26.dist-info}/METADATA +124 -51
- open_edison-0.1.26.dist-info/RECORD +17 -0
- src/cli.py +2 -1
- src/config.py +63 -51
- src/events.py +153 -0
- src/middleware/data_access_tracker.py +165 -406
- src/middleware/session_tracking.py +93 -29
- src/oauth_manager.py +281 -0
- src/permissions.py +292 -0
- src/server.py +525 -98
- src/single_user_mcp.py +215 -153
- src/telemetry.py +4 -40
- open_edison-0.1.17.dist-info/RECORD +0 -14
- {open_edison-0.1.17.dist-info → open_edison-0.1.26.dist-info}/WHEEL +0 -0
- {open_edison-0.1.17.dist-info → open_edison-0.1.26.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.17.dist-info → open_edison-0.1.26.dist-info}/licenses/LICENSE +0 -0
src/permissions.py
ADDED
@@ -0,0 +1,292 @@
|
|
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
|
+
# Detect repository root (same logic as in src.config)
|
18
|
+
_ROOT_DIR = Path(__file__).parent.parent
|
19
|
+
|
20
|
+
|
21
|
+
def _default_permissions_dir() -> Path:
|
22
|
+
"""Resolve default permissions directory.
|
23
|
+
|
24
|
+
In development (repo checkout with pyproject.toml), prefer repository root so
|
25
|
+
we use repo-local tool/resource/prompt permissions JSON files. Otherwise fall
|
26
|
+
back to the standard user config directory.
|
27
|
+
"""
|
28
|
+
try:
|
29
|
+
if (_ROOT_DIR / "pyproject.toml").exists():
|
30
|
+
return _ROOT_DIR
|
31
|
+
except Exception:
|
32
|
+
pass
|
33
|
+
return get_config_dir()
|
34
|
+
|
35
|
+
|
36
|
+
# ACL ranking for permission levels
|
37
|
+
ACL_RANK: dict[str, int] = {"PUBLIC": 0, "PRIVATE": 1, "SECRET": 2}
|
38
|
+
|
39
|
+
|
40
|
+
class PermissionsError(Exception):
|
41
|
+
"""Exception raised for permissions-related errors"""
|
42
|
+
|
43
|
+
def __init__(self, message: str, permissions_path: Path | None = None):
|
44
|
+
self.message = message
|
45
|
+
self.permissions_path = permissions_path
|
46
|
+
super().__init__(self.message)
|
47
|
+
|
48
|
+
|
49
|
+
@dataclass
|
50
|
+
class ToolPermission:
|
51
|
+
"""Individual tool permission configuration"""
|
52
|
+
|
53
|
+
enabled: bool = False
|
54
|
+
write_operation: bool = False
|
55
|
+
read_private_data: bool = False
|
56
|
+
read_untrusted_public_data: bool = False
|
57
|
+
acl: str = "PUBLIC"
|
58
|
+
description: str | None = None
|
59
|
+
|
60
|
+
|
61
|
+
@dataclass
|
62
|
+
class ResourcePermission:
|
63
|
+
"""Individual resource permission configuration"""
|
64
|
+
|
65
|
+
enabled: bool = False
|
66
|
+
write_operation: bool = False
|
67
|
+
read_private_data: bool = False
|
68
|
+
read_untrusted_public_data: bool = False
|
69
|
+
|
70
|
+
|
71
|
+
@dataclass
|
72
|
+
class PromptPermission:
|
73
|
+
"""Individual prompt permission configuration"""
|
74
|
+
|
75
|
+
enabled: bool = False
|
76
|
+
write_operation: bool = False
|
77
|
+
read_private_data: bool = False
|
78
|
+
read_untrusted_public_data: bool = False
|
79
|
+
|
80
|
+
|
81
|
+
@dataclass
|
82
|
+
class PermissionsMetadata:
|
83
|
+
"""Metadata for permissions files"""
|
84
|
+
|
85
|
+
description: str
|
86
|
+
last_updated: str # noqa
|
87
|
+
|
88
|
+
|
89
|
+
@dataclass
|
90
|
+
class Permissions:
|
91
|
+
"""Main permissions class"""
|
92
|
+
|
93
|
+
tool_permissions: dict[str, ToolPermission]
|
94
|
+
resource_permissions: dict[str, ResourcePermission]
|
95
|
+
prompt_permissions: dict[str, PromptPermission]
|
96
|
+
tool_metadata: PermissionsMetadata | None = None
|
97
|
+
resource_metadata: PermissionsMetadata | None = None
|
98
|
+
prompt_metadata: PermissionsMetadata | None = None
|
99
|
+
|
100
|
+
def __init__(
|
101
|
+
self,
|
102
|
+
permissions_dir: Path | None = None,
|
103
|
+
*,
|
104
|
+
tool_permissions: dict[str, ToolPermission] | None = None,
|
105
|
+
resource_permissions: dict[str, ResourcePermission] | None = None,
|
106
|
+
prompt_permissions: dict[str, PromptPermission] | None = None,
|
107
|
+
) -> None:
|
108
|
+
"""Load permissions from JSON files or provide them directly."""
|
109
|
+
if permissions_dir is None:
|
110
|
+
permissions_dir = _default_permissions_dir()
|
111
|
+
|
112
|
+
if tool_permissions is None:
|
113
|
+
tool_permissions_path = permissions_dir / "tool_permissions.json"
|
114
|
+
tool_permissions, tool_metadata = self._load_permission_file(
|
115
|
+
tool_permissions_path, ToolPermission
|
116
|
+
)
|
117
|
+
self.tool_metadata = tool_metadata
|
118
|
+
|
119
|
+
if resource_permissions is None:
|
120
|
+
resource_permissions_path = permissions_dir / "resource_permissions.json"
|
121
|
+
resource_permissions, resource_metadata = self._load_permission_file(
|
122
|
+
resource_permissions_path, ResourcePermission
|
123
|
+
)
|
124
|
+
self.resource_metadata = resource_metadata
|
125
|
+
|
126
|
+
if prompt_permissions is None:
|
127
|
+
prompt_permissions_path = permissions_dir / "prompt_permissions.json"
|
128
|
+
prompt_permissions, prompt_metadata = self._load_permission_file(
|
129
|
+
prompt_permissions_path, PromptPermission
|
130
|
+
)
|
131
|
+
self.prompt_metadata = prompt_metadata
|
132
|
+
|
133
|
+
self.tool_permissions = tool_permissions
|
134
|
+
self.resource_permissions = resource_permissions
|
135
|
+
self.prompt_permissions = prompt_permissions
|
136
|
+
|
137
|
+
@classmethod
|
138
|
+
def _extract_metadata(cls, data: dict[str, Any]) -> PermissionsMetadata | None:
|
139
|
+
"""Extract metadata from permission file data."""
|
140
|
+
metadata_data = data.get("_metadata", {})
|
141
|
+
if not metadata_data:
|
142
|
+
return None
|
143
|
+
|
144
|
+
return PermissionsMetadata(
|
145
|
+
description=str(metadata_data.get("description", "")),
|
146
|
+
last_updated=str(metadata_data.get("last_updated", "")),
|
147
|
+
)
|
148
|
+
|
149
|
+
@classmethod
|
150
|
+
def _validate_server_data(cls, server_name: str, server_items_data: Any) -> None:
|
151
|
+
"""Validate server data structure."""
|
152
|
+
if not isinstance(server_items_data, dict):
|
153
|
+
log.warning(
|
154
|
+
f"Invalid server data for {server_name}: expected dict, got {type(server_items_data)}"
|
155
|
+
)
|
156
|
+
raise PermissionsError(
|
157
|
+
f"Invalid server data for {server_name}: expected dict, got {type(server_items_data)}"
|
158
|
+
)
|
159
|
+
|
160
|
+
@classmethod
|
161
|
+
def _validate_item_data(cls, server_name: str, item_name: str, item_data: Any) -> None:
|
162
|
+
"""Validate item data structure."""
|
163
|
+
if not isinstance(item_data, dict):
|
164
|
+
log.warning(
|
165
|
+
f"Invalid item permissions for {server_name}/{item_name}: expected dict, got {type(item_data)}"
|
166
|
+
)
|
167
|
+
raise PermissionsError(
|
168
|
+
f"Invalid item permissions for {server_name}/{item_name}: expected dict, got {type(item_data)}"
|
169
|
+
)
|
170
|
+
|
171
|
+
@classmethod
|
172
|
+
def _load_permission_file(
|
173
|
+
cls,
|
174
|
+
file_path: Path,
|
175
|
+
permission_class: type[ToolPermission] | type[ResourcePermission] | type[PromptPermission],
|
176
|
+
) -> tuple[dict[str, Any], PermissionsMetadata | None]:
|
177
|
+
"""Load permissions from a single JSON file.
|
178
|
+
|
179
|
+
Returns a tuple of (permissions_dict, metadata)
|
180
|
+
"""
|
181
|
+
permissions: dict[str, Any] = {}
|
182
|
+
metadata: PermissionsMetadata | None = None
|
183
|
+
|
184
|
+
if not file_path.exists():
|
185
|
+
raise PermissionsError(f"Permissions file not found at {file_path}")
|
186
|
+
|
187
|
+
with open(file_path) as f:
|
188
|
+
data: dict[str, Any] = json.load(f)
|
189
|
+
|
190
|
+
# Extract metadata
|
191
|
+
metadata = cls._extract_metadata(data)
|
192
|
+
|
193
|
+
# Parse permissions with duplicate checking
|
194
|
+
for server_name, server_items_data in data.items():
|
195
|
+
if server_name == "_metadata":
|
196
|
+
continue
|
197
|
+
|
198
|
+
cls._validate_server_data(server_name, server_items_data)
|
199
|
+
|
200
|
+
for item_name, item_data in server_items_data.items(): # type: ignore
|
201
|
+
cls._validate_item_data(server_name, item_name, item_data)
|
202
|
+
|
203
|
+
# Type casting for clarity
|
204
|
+
item_name_str: str = str(item_name) # type: ignore
|
205
|
+
item_data_dict: dict[str, Any] = item_data # type: ignore
|
206
|
+
|
207
|
+
# Create permission object (flat structure)
|
208
|
+
permissions[server_name + "_" + item_name_str] = permission_class(**item_data_dict)
|
209
|
+
|
210
|
+
log.debug(f"Loaded {len(permissions)} items from {len(data)} servers in {file_path}")
|
211
|
+
|
212
|
+
return permissions, metadata
|
213
|
+
|
214
|
+
def get_tool_permission(self, tool_name: str) -> ToolPermission:
|
215
|
+
"""Get permission for a specific tool"""
|
216
|
+
if tool_name not in self.tool_permissions:
|
217
|
+
raise PermissionsError(f"Tool '{tool_name}' not found in permissions")
|
218
|
+
return self.tool_permissions[tool_name]
|
219
|
+
|
220
|
+
def get_resource_permission(self, resource_name: str) -> ResourcePermission:
|
221
|
+
"""Get permission for a specific resource"""
|
222
|
+
if resource_name not in self.resource_permissions:
|
223
|
+
raise PermissionsError(f"Resource '{resource_name}' not found in permissions")
|
224
|
+
return self.resource_permissions[resource_name]
|
225
|
+
|
226
|
+
def get_prompt_permission(self, prompt_name: str) -> PromptPermission:
|
227
|
+
"""Get permission for a specific prompt"""
|
228
|
+
if prompt_name not in self.prompt_permissions:
|
229
|
+
raise PermissionsError(f"Prompt '{prompt_name}' not found in permissions")
|
230
|
+
return self.prompt_permissions[prompt_name]
|
231
|
+
|
232
|
+
def is_tool_enabled(self, tool_name: str) -> bool:
|
233
|
+
"""Check if a tool is enabled
|
234
|
+
Also checks if the server is enabled"""
|
235
|
+
permission = self.get_tool_permission(tool_name)
|
236
|
+
server_name = self.server_name_from_tool_name(tool_name)
|
237
|
+
server_enabled = self.is_server_enabled(server_name)
|
238
|
+
return permission.enabled and server_enabled
|
239
|
+
|
240
|
+
def is_resource_enabled(self, resource_name: str) -> bool:
|
241
|
+
"""Check if a resource is enabled
|
242
|
+
Also checks if the server is enabled"""
|
243
|
+
permission = self.get_resource_permission(resource_name)
|
244
|
+
server_name = self.server_name_from_tool_name(resource_name)
|
245
|
+
server_enabled = self.is_server_enabled(server_name)
|
246
|
+
return permission.enabled and server_enabled
|
247
|
+
|
248
|
+
def is_prompt_enabled(self, prompt_name: str) -> bool:
|
249
|
+
"""Check if a prompt is enabled
|
250
|
+
Also checks if the server is enabled"""
|
251
|
+
permission = self.get_prompt_permission(prompt_name)
|
252
|
+
server_name = self.server_name_from_tool_name(prompt_name)
|
253
|
+
server_enabled = self.is_server_enabled(server_name)
|
254
|
+
return permission.enabled and server_enabled
|
255
|
+
|
256
|
+
@staticmethod
|
257
|
+
def server_name_from_tool_name(tool_name: str) -> str:
|
258
|
+
"""Get the server name from a tool name"""
|
259
|
+
parts = tool_name.split("_")
|
260
|
+
if len(parts) == 0:
|
261
|
+
raise PermissionsError(f"Tool name {tool_name} is invalid")
|
262
|
+
if parts[0] == "builtin":
|
263
|
+
return "builtin"
|
264
|
+
|
265
|
+
server_names = {s.name for s in Config().mcp_servers}
|
266
|
+
for i in range(len(parts)):
|
267
|
+
server_name = "_".join(parts[:i])
|
268
|
+
if server_name in server_names:
|
269
|
+
return server_name
|
270
|
+
raise PermissionsError(f"Server name not found for tool {tool_name}")
|
271
|
+
|
272
|
+
@staticmethod
|
273
|
+
def is_server_enabled(server_name: str) -> bool:
|
274
|
+
"""Check if a server is enabled"""
|
275
|
+
if server_name == "builtin":
|
276
|
+
return True
|
277
|
+
server_config = next((s for s in Config().mcp_servers if s.name == server_name), None)
|
278
|
+
return server_config is not None and server_config.enabled
|
279
|
+
|
280
|
+
|
281
|
+
def normalize_acl(value: str | None, *, default: str = "PUBLIC") -> str:
|
282
|
+
"""Normalize ACL string, defaulting and uppercasing; validate against known values."""
|
283
|
+
try:
|
284
|
+
if value is None:
|
285
|
+
return default
|
286
|
+
acl = str(value).upper().strip()
|
287
|
+
if acl not in ACL_RANK:
|
288
|
+
# Fallback to default if invalid
|
289
|
+
return default
|
290
|
+
return acl
|
291
|
+
except Exception:
|
292
|
+
return default
|