-
-
Notifications
You must be signed in to change notification settings - Fork 925
enh: Partial Scope Grants & Granular Permissions #510
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
aa520b7
cfc68d8
e81d0e3
86a8e1b
6087724
bb19724
92d2c15
e394ad9
8ae833f
9f329fc
1e90782
f2986dc
edf9e94
58256e4
34ada2c
65e3475
768ec5e
252aa2a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,248 @@ | ||
| """ | ||
| Granular per-service permission levels. | ||
|
|
||
| Each service has named permission levels (cumulative), mapping to a list of | ||
| OAuth scopes. The levels for a service are ordered from least to most | ||
| permissive — requesting level N implicitly includes all scopes from levels < N. | ||
|
|
||
| Usage: | ||
| --permissions gmail:organize drive:readonly | ||
|
|
||
| Gmail levels: readonly, organize, drafts, send, full | ||
| Other services: readonly, full (extensible by adding entries to SERVICE_PERMISSION_LEVELS) | ||
| """ | ||
|
|
||
| import logging | ||
| from typing import Dict, List, Optional, Tuple | ||
|
|
||
| from auth.scopes import ( | ||
| GMAIL_READONLY_SCOPE, | ||
| GMAIL_LABELS_SCOPE, | ||
| GMAIL_MODIFY_SCOPE, | ||
| GMAIL_COMPOSE_SCOPE, | ||
| GMAIL_SEND_SCOPE, | ||
| GMAIL_SETTINGS_BASIC_SCOPE, | ||
| DRIVE_READONLY_SCOPE, | ||
| DRIVE_FILE_SCOPE, | ||
| DRIVE_SCOPE, | ||
| CALENDAR_READONLY_SCOPE, | ||
| CALENDAR_EVENTS_SCOPE, | ||
| CALENDAR_SCOPE, | ||
| DOCS_READONLY_SCOPE, | ||
| DOCS_WRITE_SCOPE, | ||
| SHEETS_READONLY_SCOPE, | ||
| SHEETS_WRITE_SCOPE, | ||
| CHAT_READONLY_SCOPE, | ||
| CHAT_WRITE_SCOPE, | ||
| CHAT_SPACES_SCOPE, | ||
| CHAT_SPACES_READONLY_SCOPE, | ||
| FORMS_BODY_SCOPE, | ||
| FORMS_BODY_READONLY_SCOPE, | ||
| FORMS_RESPONSES_READONLY_SCOPE, | ||
| SLIDES_SCOPE, | ||
| SLIDES_READONLY_SCOPE, | ||
| TASKS_SCOPE, | ||
| TASKS_READONLY_SCOPE, | ||
| CONTACTS_SCOPE, | ||
| CONTACTS_READONLY_SCOPE, | ||
| CUSTOM_SEARCH_SCOPE, | ||
| SCRIPT_PROJECTS_SCOPE, | ||
| SCRIPT_PROJECTS_READONLY_SCOPE, | ||
| SCRIPT_DEPLOYMENTS_SCOPE, | ||
| SCRIPT_DEPLOYMENTS_READONLY_SCOPE, | ||
| SCRIPT_PROCESSES_READONLY_SCOPE, | ||
| SCRIPT_METRICS_SCOPE, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # Ordered permission levels per service. | ||
| # Each entry is (level_name, [additional_scopes_at_this_level]). | ||
| # Scopes are CUMULATIVE: level N includes all scopes from levels 0..N. | ||
| SERVICE_PERMISSION_LEVELS: Dict[str, List[Tuple[str, List[str]]]] = { | ||
| "gmail": [ | ||
| ("readonly", [GMAIL_READONLY_SCOPE]), | ||
| ("organize", [GMAIL_LABELS_SCOPE, GMAIL_MODIFY_SCOPE]), | ||
| ("drafts", [GMAIL_COMPOSE_SCOPE]), | ||
| ("send", [GMAIL_SEND_SCOPE]), | ||
| ("full", [GMAIL_SETTINGS_BASIC_SCOPE]), | ||
| ], | ||
| "drive": [ | ||
| ("readonly", [DRIVE_READONLY_SCOPE]), | ||
| ("full", [DRIVE_SCOPE, DRIVE_FILE_SCOPE]), | ||
| ], | ||
| "calendar": [ | ||
| ("readonly", [CALENDAR_READONLY_SCOPE]), | ||
| ("full", [CALENDAR_SCOPE, CALENDAR_EVENTS_SCOPE]), | ||
| ], | ||
| "docs": [ | ||
| ("readonly", [DOCS_READONLY_SCOPE, DRIVE_READONLY_SCOPE]), | ||
| ("full", [DOCS_WRITE_SCOPE, DRIVE_READONLY_SCOPE, DRIVE_FILE_SCOPE]), | ||
| ], | ||
| "sheets": [ | ||
| ("readonly", [SHEETS_READONLY_SCOPE, DRIVE_READONLY_SCOPE]), | ||
| ("full", [SHEETS_WRITE_SCOPE, DRIVE_READONLY_SCOPE]), | ||
| ], | ||
| "chat": [ | ||
| ("readonly", [CHAT_READONLY_SCOPE, CHAT_SPACES_READONLY_SCOPE]), | ||
| ("full", [CHAT_WRITE_SCOPE, CHAT_SPACES_SCOPE]), | ||
| ], | ||
| "forms": [ | ||
| ("readonly", [FORMS_BODY_READONLY_SCOPE, FORMS_RESPONSES_READONLY_SCOPE]), | ||
| ("full", [FORMS_BODY_SCOPE, FORMS_RESPONSES_READONLY_SCOPE]), | ||
| ], | ||
| "slides": [ | ||
| ("readonly", [SLIDES_READONLY_SCOPE]), | ||
| ("full", [SLIDES_SCOPE]), | ||
| ], | ||
| "tasks": [ | ||
| ("readonly", [TASKS_READONLY_SCOPE]), | ||
| ("full", [TASKS_SCOPE]), | ||
| ], | ||
| "contacts": [ | ||
| ("readonly", [CONTACTS_READONLY_SCOPE]), | ||
| ("full", [CONTACTS_SCOPE]), | ||
| ], | ||
| "search": [ | ||
| ("readonly", [CUSTOM_SEARCH_SCOPE]), | ||
| ("full", [CUSTOM_SEARCH_SCOPE]), | ||
| ], | ||
| "appscript": [ | ||
| ( | ||
| "readonly", | ||
| [ | ||
| SCRIPT_PROJECTS_READONLY_SCOPE, | ||
| SCRIPT_DEPLOYMENTS_READONLY_SCOPE, | ||
| SCRIPT_PROCESSES_READONLY_SCOPE, | ||
| SCRIPT_METRICS_SCOPE, | ||
| DRIVE_READONLY_SCOPE, | ||
| ], | ||
| ), | ||
| ( | ||
| "full", | ||
| [ | ||
| SCRIPT_PROJECTS_SCOPE, | ||
| SCRIPT_DEPLOYMENTS_SCOPE, | ||
| SCRIPT_PROCESSES_READONLY_SCOPE, | ||
| SCRIPT_METRICS_SCOPE, | ||
| DRIVE_FILE_SCOPE, | ||
| ], | ||
| ), | ||
| ], | ||
| } | ||
|
|
||
| # Module-level state: parsed --permissions config | ||
| # Dict mapping service_name -> level_name, e.g. {"gmail": "organize"} | ||
| _PERMISSIONS: Optional[Dict[str, str]] = None | ||
|
|
||
|
|
||
| def set_permissions(permissions: Dict[str, str]) -> None: | ||
| """Set granular permissions from parsed --permissions argument.""" | ||
| global _PERMISSIONS | ||
| _PERMISSIONS = permissions | ||
| logger.info("Granular permissions set: %s", permissions) | ||
|
|
||
|
|
||
| def get_permissions() -> Optional[Dict[str, str]]: | ||
| """Return current permissions dict, or None if not using granular mode.""" | ||
| return _PERMISSIONS | ||
|
|
||
|
|
||
| def is_permissions_mode() -> bool: | ||
| """Check if granular permissions mode is active.""" | ||
| return _PERMISSIONS is not None | ||
|
|
||
|
|
||
| def get_scopes_for_permission(service: str, level: str) -> List[str]: | ||
| """ | ||
| Get cumulative scopes for a service at a given permission level. | ||
|
|
||
| Returns all scopes up to and including the named level. | ||
| Raises ValueError if service or level is unknown. | ||
| """ | ||
| levels = SERVICE_PERMISSION_LEVELS.get(service) | ||
| if levels is None: | ||
| raise ValueError(f"Unknown service: '{service}'") | ||
|
|
||
| cumulative: List[str] = [] | ||
| found = False | ||
| for level_name, level_scopes in levels: | ||
| cumulative.extend(level_scopes) | ||
| if level_name == level: | ||
| found = True | ||
| break | ||
|
|
||
| if not found: | ||
| valid = [name for name, _ in levels] | ||
| raise ValueError( | ||
| f"Unknown permission level '{level}' for service '{service}'. " | ||
| f"Valid levels: {valid}" | ||
| ) | ||
|
|
||
| return list(set(cumulative)) | ||
|
|
||
|
taylorwilsdon marked this conversation as resolved.
|
||
|
|
||
| def get_all_permission_scopes() -> List[str]: | ||
| """ | ||
| Get the combined scopes for all services at their configured permission levels. | ||
|
|
||
| Only meaningful when is_permissions_mode() is True. | ||
| """ | ||
| if _PERMISSIONS is None: | ||
| return [] | ||
|
|
||
| all_scopes: set = set() | ||
| for service, level in _PERMISSIONS.items(): | ||
| all_scopes.update(get_scopes_for_permission(service, level)) | ||
| return list(all_scopes) | ||
|
|
||
|
|
||
| def get_allowed_scopes_set() -> Optional[set]: | ||
| """ | ||
| Get the set of allowed scopes under permissions mode (for tool filtering). | ||
|
|
||
| Returns None if permissions mode is not active. | ||
| """ | ||
| if _PERMISSIONS is None: | ||
| return None | ||
| return set(get_all_permission_scopes()) | ||
|
|
||
|
|
||
| def get_valid_levels(service: str) -> List[str]: | ||
| """Get valid permission level names for a service.""" | ||
| levels = SERVICE_PERMISSION_LEVELS.get(service) | ||
| if levels is None: | ||
| return [] | ||
| return [name for name, _ in levels] | ||
|
|
||
|
|
||
| def parse_permissions_arg(permissions_list: List[str]) -> Dict[str, str]: | ||
| """ | ||
| Parse --permissions arguments like ["gmail:organize", "drive:full"]. | ||
|
|
||
| Returns dict mapping service -> level. | ||
| Raises ValueError on parse errors (unknown service, invalid level, bad format). | ||
| """ | ||
| result: Dict[str, str] = {} | ||
| for entry in permissions_list: | ||
| if ":" not in entry: | ||
| raise ValueError( | ||
| f"Invalid permission format: '{entry}'. " | ||
| f"Expected 'service:level' (e.g., 'gmail:organize', 'drive:readonly')" | ||
| ) | ||
| service, level = entry.split(":", 1) | ||
| if service in result: | ||
| raise ValueError(f"Duplicate service in permissions: '{service}'") | ||
| if service not in SERVICE_PERMISSION_LEVELS: | ||
| raise ValueError( | ||
| f"Unknown service: '{service}'. " | ||
| f"Valid services: {sorted(SERVICE_PERMISSION_LEVELS.keys())}" | ||
| ) | ||
| valid = get_valid_levels(service) | ||
| if level not in valid: | ||
|
Comment on lines
+213
to
+242
|
||
| raise ValueError( | ||
| f"Unknown level '{level}' for service '{service}'. " | ||
| f"Valid levels: {valid}" | ||
| ) | ||
| result[service] = level | ||
|
Comment on lines
+219
to
+247
|
||
| return result | ||
|
Comment on lines
+219
to
+248
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -291,6 +291,24 @@ def get_scopes_for_tools(enabled_tools=None): | |||||||||||
| Returns: | ||||||||||||
| List of unique scopes for the enabled tools plus base scopes. | ||||||||||||
| """ | ||||||||||||
| # Granular permissions mode overrides both full and read-only scope maps. | ||||||||||||
| # Lazy import with guard to avoid circular dependency during module init | ||||||||||||
| # (SCOPES = get_scopes_for_tools() runs at import time before auth.permissions | ||||||||||||
| # is fully loaded, but permissions mode is never active at that point). | ||||||||||||
| try: | ||||||||||||
| from auth.permissions import is_permissions_mode, get_all_permission_scopes | ||||||||||||
|
|
||||||||||||
| if is_permissions_mode(): | ||||||||||||
| scopes = BASE_SCOPES.copy() | ||||||||||||
| scopes.extend(get_all_permission_scopes()) | ||||||||||||
| logger.debug( | ||||||||||||
| "Generated scopes from granular permissions: %d unique scopes", | ||||||||||||
| len(set(scopes)), | ||||||||||||
| ) | ||||||||||||
| return list(set(scopes)) | ||||||||||||
| except ImportError: | ||||||||||||
|
Comment on lines
+294
to
+309
|
||||||||||||
| pass | ||||||||||||
|
||||||||||||
| pass | |
| logger.debug( | |
| "auth.permissions not available; falling back to tool-based scope mapping", | |
| exc_info=True, | |
| ) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |||||||||||||||||||
| from typing import Set, Optional, Callable | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from auth.oauth_config import is_oauth21_enabled | ||||||||||||||||||||
| from auth.permissions import is_permissions_mode, get_allowed_scopes_set | ||||||||||||||||||||
| from auth.scopes import is_read_only_mode, get_all_read_only_scopes | ||||||||||||||||||||
|
|
||||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||||
|
|
@@ -104,7 +105,13 @@ def filter_server_tools(server): | |||||||||||||||||||
| """Remove disabled tools from the server after registration.""" | ||||||||||||||||||||
| enabled_tools = get_enabled_tools() | ||||||||||||||||||||
| oauth21_enabled = is_oauth21_enabled() | ||||||||||||||||||||
| if enabled_tools is None and not oauth21_enabled and not is_read_only_mode(): | ||||||||||||||||||||
| permissions_mode = is_permissions_mode() | ||||||||||||||||||||
| if ( | ||||||||||||||||||||
| enabled_tools is None | ||||||||||||||||||||
| and not oauth21_enabled | ||||||||||||||||||||
| and not is_read_only_mode() | ||||||||||||||||||||
| and not permissions_mode | ||||||||||||||||||||
| ): | ||||||||||||||||||||
| return | ||||||||||||||||||||
|
|
||||||||||||||||||||
| tools_removed = 0 | ||||||||||||||||||||
|
|
@@ -126,8 +133,8 @@ def filter_server_tools(server): | |||||||||||||||||||
| tools_to_remove.add("start_google_auth") | ||||||||||||||||||||
| logger.info("OAuth 2.1 enabled: disabling start_google_auth tool") | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # 3. Read-only mode filtering | ||||||||||||||||||||
| if read_only_mode: | ||||||||||||||||||||
| # 3. Read-only mode filtering (skipped when granular permissions are active) | ||||||||||||||||||||
| if read_only_mode and not permissions_mode: | ||||||||||||||||||||
| for tool_name, tool_obj in tool_components.items(): | ||||||||||||||||||||
| if tool_name in tools_to_remove: | ||||||||||||||||||||
| continue | ||||||||||||||||||||
|
|
@@ -147,6 +154,32 @@ def filter_server_tools(server): | |||||||||||||||||||
| ) | ||||||||||||||||||||
| tools_to_remove.add(tool_name) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| # 4. Granular permissions filtering | ||||||||||||||||||||
| # No scope hierarchy expansion here — permission levels are already cumulative | ||||||||||||||||||||
| # and explicitly define allowed scopes. Hierarchy expansion would defeat the | ||||||||||||||||||||
| # purpose (e.g. gmail.modify in the hierarchy covers gmail.send, but the | ||||||||||||||||||||
| # "organize" permission level intentionally excludes gmail.send). | ||||||||||||||||||||
|
Comment on lines
+158
to
+161
|
||||||||||||||||||||
| # No scope hierarchy expansion here — permission levels are already cumulative | |
| # and explicitly define allowed scopes. Hierarchy expansion would defeat the | |
| # purpose (e.g. gmail.modify in the hierarchy covers gmail.send, but the | |
| # "organize" permission level intentionally excludes gmail.send). | |
| # No scope hierarchy expansion is applied here for tool filtering — permission | |
| # levels already define the exact scopes that should enable a tool. We use the | |
| # scopes from permissions mode as-is when deciding which tools to keep, even | |
| # though upstream OAuth consent and SCOPE_HIERARCHY still treat broader scopes | |
| # (e.g. gmail.modify) as covering narrower ones (e.g. gmail.send). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When handling partial grants, a new
Credentials(...)object is constructed withscopes=list(granted)but other fields from the original credentials (notablyid_token, and potentially token metadata) are dropped. This file later relies oncredentials.id_tokenfor session/email extraction and logging, so the partial-grant path can cause avoidable warnings/behavior differences. Preserve the extra credential fields when recreating the object (or update the scopes on the existing credentials if supported).