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.
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