Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions auth/google_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,12 @@ def handle_auth_callback(
)
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"

# Allow partial scope grants without raising an exception.
# When users decline some scopes on Google's consent screen,
# oauthlib raises because the granted scopes differ from requested.
if "OAUTHLIB_RELAX_TOKEN_SCOPE" not in os.environ:
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"

store = get_oauth21_session_store()
parsed_response = urlparse(authorization_response)
state_values = parse_qs(parsed_response.query).get("state")
Expand All @@ -504,6 +510,27 @@ def handle_auth_callback(
credentials = flow.credentials
logger.info("Successfully exchanged authorization code for tokens.")

# Handle partial OAuth grants: if the user declined some scopes on
# Google's consent screen, credentials.granted_scopes contains only
# what was actually authorized. Store those instead of the inflated
# requested scopes so that refresh() sends the correct scope set.
granted = getattr(credentials, "granted_scopes", None)
if granted and set(granted) != set(credentials.scopes or []):
logger.warning(
"Partial OAuth grant detected. Requested: %s, Granted: %s",
credentials.scopes,
granted,
)
credentials = Credentials(
token=credentials.token,
refresh_token=credentials.refresh_token,
token_uri=credentials.token_uri,
client_id=credentials.client_id,
client_secret=credentials.client_secret,
scopes=list(granted),
expiry=credentials.expiry,
)
Comment on lines +542 to +552

Copilot AI Feb 25, 2026

Copy link

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 with scopes=list(granted) but other fields from the original credentials (notably id_token, and potentially token metadata) are dropped. This file later relies on credentials.id_token for 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).

Suggested change
credentials = Credentials(
token=credentials.token,
refresh_token=credentials.refresh_token,
token_uri=credentials.token_uri,
client_id=credentials.client_id,
client_secret=credentials.client_secret,
scopes=list(granted),
expiry=credentials.expiry,
)
# Preserve all credential metadata when adjusting scopes for a
# partial OAuth grant. Prefer using with_scopes() if available so
# fields like id_token and quota_project_id are retained.
if hasattr(credentials, "with_scopes") and callable(
getattr(credentials, "with_scopes")
):
credentials = credentials.with_scopes(list(granted))
else:
credentials = Credentials(
token=credentials.token,
refresh_token=credentials.refresh_token,
id_token=getattr(credentials, "id_token", None),
token_uri=credentials.token_uri,
client_id=credentials.client_id,
client_secret=credentials.client_secret,
scopes=list(granted),
expiry=credentials.expiry,
quota_project_id=getattr(credentials, "quota_project_id", None),
)

Copilot uses AI. Check for mistakes.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Get user info to determine user_id (using email here)
user_info = get_user_info(credentials)
if not user_info or "email" not in user_info:
Expand Down
248 changes: 248 additions & 0 deletions auth/permissions.py
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))

Comment thread
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

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new permissions parsing/mapping logic is non-trivial and currently untested. Please add unit tests for parse_permissions_arg() (valid parsing, duplicate service, bad format, unknown service/level) and for cumulative scope expansion in get_scopes_for_permission() to prevent regressions as services/levels evolve.

Copilot uses AI. Check for mistakes.
raise ValueError(
f"Unknown level '{level}' for service '{service}'. "
f"Valid levels: {valid}"
)
result[service] = level
Comment on lines +219 to +247

Copilot AI Feb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auth/permissions.py introduces core parsing/validation logic for --permissions (including error cases like bad format, unknown service, invalid level, duplicates) but there are no unit tests exercising these behaviors. Adding tests for parse_permissions_arg() and get_scopes_for_permission() would lock in the expected CLI contract and scope expansion rules.

Copilot uses AI. Check for mistakes.
return result
Comment on lines +219 to +248

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_permissions_arg() has multiple validation branches (bad format, duplicate service, unknown service, invalid level) but there are no tests covering these error paths. Add unit tests for invalid inputs so the CLI-facing validation behavior is locked in.

Copilot uses AI. Check for mistakes.
18 changes: 18 additions & 0 deletions auth/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copilot AI Feb 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New granular-permissions scope resolution path in get_scopes_for_tools() isn’t covered by the existing scopes tests. Please add unit tests that: (1) set granular permissions, (2) verify returned scopes are BASE_SCOPES + the expected cumulative permission scopes, and (3) verify it correctly overrides read-only/full maps.

Copilot uses AI. Check for mistakes.
pass

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_scopes_for_tools() now has a new behavior branch for granular permissions mode, but there are no corresponding unit tests. Since this function already has focused tests in tests/test_scopes.py, please add coverage for permissions mode (e.g., setting permissions then asserting returned scopes are BASE_SCOPES + permission scopes, and that non-permitted tool scopes aren’t included).

Suggested change
pass
logger.debug(
"auth.permissions not available; falling back to tool-based scope mapping",
exc_info=True,
)

Copilot uses AI. Check for mistakes.

if enabled_tools is None:
# Default behavior - return all scopes
enabled_tools = TOOL_SCOPES_MAP.keys()
Expand Down
46 changes: 42 additions & 4 deletions core/tool_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment suggests granular permissions can intentionally exclude gmail.send even though gmail.modify is present at lower levels; however, elsewhere in the codebase gmail.modify is explicitly treated as covering gmail.send via SCOPE_HIERARCHY (auth/scopes.py). Since the OAuth consent will still reflect the broader gmail.modify scope, the comment is misleading—please reword to reflect that hierarchy expansion is skipped only for tool filtering, not to change what the upstream OAuth scope implies.

Suggested change
# 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).

Copilot uses AI. Check for mistakes.
if permissions_mode:
perm_allowed = get_allowed_scopes_set() or set()

for tool_name, tool_obj in tool_components.items():
if tool_name in tools_to_remove:
continue

func_to_check = tool_obj
if hasattr(tool_obj, "fn"):
func_to_check = tool_obj.fn

required_scopes = getattr(func_to_check, "_required_google_scopes", [])
if required_scopes:
if not all(scope in perm_allowed for scope in required_scopes):
logger.info(
"Permissions mode: Disabling tool '%s' (requires: %s)",
tool_name,
required_scopes,
)
tools_to_remove.add(tool_name)

for tool_name in tools_to_remove:
try:
server.local_provider.remove_tool(tool_name)
Expand All @@ -167,7 +200,12 @@ def filter_server_tools(server):

if tools_removed > 0:
enabled_count = len(enabled_tools) if enabled_tools is not None else "all"
mode = "Read-Only" if is_read_only_mode() else "Full"
if permissions_mode:
mode = "Permissions"
elif is_read_only_mode():
mode = "Read-Only"
else:
mode = "Full"
logger.info(
f"Tool filtering: removed {tools_removed} tools, {enabled_count} enabled. Mode: {mode}"
)
Loading