Skip to content

Commit 1d2d788

Browse files
committed
group canonicalization to avoid collisions, security config, integration test
1 parent 4b2d4cf commit 1d2d788

21 files changed

Lines changed: 1106 additions & 57 deletions

cloud_securityconfig/roles.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ openrag_user_role:
3333
{"bool":{"should":[
3434
{"term":{"owner":"${user.name}"}},
3535
{"term":{"allowed_users":"${user.name}"}},
36+
{"terms":{"allowed_groups":[${user.roles}]}},
3637
{"bool":{"must_not":{"exists":{"field":"owner"}}}}
3738
],"minimum_should_match":1}}
3839
- index_patterns: ["api_keys", "api_keys*"]

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ openrag = "tui.main:run_tui"
7979
filterwarnings = [
8080
"error::RuntimeWarning",
8181
]
82+
markers = [
83+
"openrag_skip_app_onboard: selected tests manage their own infrastructure clients and do not need the session app onboarding fixture",
84+
]
8285

8386
[tool.uv]
8487
package = true
@@ -152,4 +155,3 @@ exclude = ["flows/components"]
152155
module = "api.settings.endpoints"
153156
disable_error_code = ["return-value"]
154157

155-

securityconfig/roles.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ openrag_user_role:
3333
{"bool":{"should":[
3434
{"term":{"owner":"${user.name}"}},
3535
{"term":{"allowed_users":"${user.name}"}},
36+
{"terms":{"allowed_groups":[${user.roles}]}},
3637
{"bool":{"must_not":{"exists":{"field":"owner"}}}}
3738
],"minimum_should_match":1}}
3839
- index_patterns: ["api_keys", "api_keys*"]

src/app/container.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from services.docling_polling_service import DoclingPollingService
2424
from services.document_service import DocumentService
2525
from services.flows_service import FlowsService
26+
from services.group_acl_service import GroupACLService
2627
from services.knowledge_filter_service import KnowledgeFilterService
2728
from services.langflow_file_service import LangflowFileService
2829
from services.langflow_mcp_service import LangflowMCPService
@@ -121,6 +122,7 @@ async def initialize_services():
121122
langflow_connector_service=langflow_connector_service,
122123
openrag_connector_service=openrag_connector_service,
123124
)
125+
group_acl_service = GroupACLService(connector_service)
124126

125127
auth_service = AuthService(
126128
session_manager,
@@ -205,6 +207,7 @@ def _lazy_session_factory():
205207
"langflow_file_service": langflow_file_service,
206208
"auth_service": auth_service,
207209
"connector_service": connector_service,
210+
"group_acl_service": group_acl_service,
208211
"knowledge_filter_service": knowledge_filter_service,
209212
"models_service": models_service,
210213
"monitor_service": monitor_service,

src/connectors/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,12 @@ async def _detect_base_url(self) -> Optional[str]:
143143
Subclasses (OneDrive, SharePoint) should override this method.
144144
"""
145145
return None
146+
147+
async def get_current_user_group_roles(self) -> List[str]:
148+
"""Return OpenSearch backend roles for the current connector user.
149+
150+
Connectors that support upstream group ACLs can override this hook.
151+
The core ACL service calls it generically so new connectors only need
152+
to implement their own provider-specific group lookup.
153+
"""
154+
return []

src/connectors/google_drive/connector.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
from utils.logging_config import get_logger
1414

1515
from ..base import BaseConnector, ConnectorDocument, DocumentACL
16+
from ..google_drive_acl import (
17+
get_current_user_google_group_roles,
18+
google_drive_group_role,
19+
)
1620
from .oauth import GoogleDriveOAuth
1721

1822
logger = get_logger(__name__)
@@ -90,6 +94,8 @@ def emit(self, doc: ConnectorDocument) -> None:
9094
logger.debug(f"Emitting document: {doc.id} ({doc.filename})")
9195

9296
def __init__(self, config: Dict[str, Any]) -> None:
97+
super().__init__(config)
98+
9399
# Read from config OR env (backend env, not NEXT_PUBLIC_*):
94100
env_client_id = os.getenv(self.CLIENT_ID_ENV_VAR)
95101
env_client_secret = os.getenv(self.CLIENT_SECRET_ENV_VAR)
@@ -570,6 +576,12 @@ async def authenticate(self) -> bool:
570576
logger.error("[GoogleDrive] authenticate failed: %s", e)
571577
return False
572578

579+
async def get_current_user_group_roles(self) -> List[str]:
580+
"""Return canonical group ACL roles for the connected Google user."""
581+
if not self._authenticated and not await self.authenticate():
582+
return []
583+
return await get_current_user_google_group_roles(self.service, self.creds)
584+
573585
async def list_files(
574586
self,
575587
page_token: Optional[str] = None,
@@ -651,7 +663,9 @@ def _extract_google_drive_acl(self, file_meta: Dict) -> DocumentACL:
651663

652664
# Add allowed groups
653665
elif perm_type == "group" and email:
654-
allowed_groups.append(email)
666+
group_role = google_drive_group_role(email)
667+
if group_role:
668+
allowed_groups.append(group_role)
655669

656670
# Fallback to file owners if no owner found in permissions
657671
if not owner and file_meta.get("owners"):

src/connectors/google_drive/oauth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class GoogleDriveOAuth:
2525
"profile",
2626
"https://www.googleapis.com/auth/drive.readonly",
2727
"https://www.googleapis.com/auth/drive.metadata.readonly",
28+
"https://www.googleapis.com/auth/admin.directory.group.readonly",
2829
]
2930

3031
AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"

src/connectors/google_drive_acl.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Google Drive group ACL helpers."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from typing import Any
7+
8+
import jwt
9+
from googleapiclient.discovery import build
10+
from googleapiclient.errors import HttpError
11+
12+
from utils.group_acl import canonical_group_role
13+
from utils.logging_config import get_logger
14+
15+
16+
logger = get_logger(__name__)
17+
18+
GOOGLE_DRIVE_GROUP_PROVIDER = "gdrive"
19+
20+
21+
def _group_tenant(group_email: str) -> str:
22+
if "@" in group_email:
23+
return group_email.rsplit("@", 1)[1].lower()
24+
return "global"
25+
26+
27+
def google_drive_group_role(group_email: str | None) -> str | None:
28+
"""Return the canonical OpenSearch role for a Google Drive group email."""
29+
if not group_email:
30+
return None
31+
email = group_email.strip().lower()
32+
if not email:
33+
return None
34+
return canonical_group_role(
35+
GOOGLE_DRIVE_GROUP_PROVIDER,
36+
_group_tenant(email),
37+
email,
38+
)
39+
40+
41+
def _email_from_id_token(id_token: str | None) -> str | None:
42+
if not id_token:
43+
return None
44+
try:
45+
claims = jwt.decode(
46+
id_token,
47+
options={"verify_signature": False, "verify_aud": False},
48+
)
49+
email = claims.get("email")
50+
if email:
51+
return str(email)
52+
except Exception as e:
53+
logger.debug("Could not decode Google id_token email", error=str(e))
54+
return None
55+
56+
57+
async def _execute_google_request(request: Any) -> dict[str, Any]:
58+
return await asyncio.to_thread(request.execute)
59+
60+
61+
async def _get_drive_user_email(drive_service: Any, credentials: Any) -> str | None:
62+
email = _email_from_id_token(getattr(credentials, "id_token", None))
63+
if email:
64+
return email
65+
66+
if drive_service is None:
67+
return None
68+
69+
try:
70+
about = await _execute_google_request(
71+
drive_service.about().get(fields="user(emailAddress)")
72+
)
73+
return about.get("user", {}).get("emailAddress")
74+
except Exception as e:
75+
logger.warning("Google Drive group ACL lookup could not resolve user email", error=str(e))
76+
return None
77+
78+
79+
async def get_current_user_google_group_roles(
80+
drive_service: Any,
81+
credentials: Any,
82+
) -> list[str]:
83+
"""Fetch Google Workspace groups for the connected Drive user."""
84+
if credentials is None:
85+
return []
86+
87+
user_email = await _get_drive_user_email(drive_service, credentials)
88+
if not user_email:
89+
return []
90+
91+
try:
92+
directory_service = build(
93+
"admin",
94+
"directory_v1",
95+
credentials=credentials,
96+
cache_discovery=False,
97+
)
98+
except Exception as e:
99+
logger.warning("Could not create Google Directory client for group ACLs", error=str(e))
100+
return []
101+
102+
roles: list[str] = []
103+
seen: set[str] = set()
104+
page_token: str | None = None
105+
106+
try:
107+
while True:
108+
request = directory_service.groups().list(
109+
customer="my_customer",
110+
userKey=user_email,
111+
maxResults=200,
112+
pageToken=page_token,
113+
fields="nextPageToken,groups(email)",
114+
)
115+
response = await _execute_google_request(request)
116+
117+
for group in response.get("groups", []) or []:
118+
role = google_drive_group_role(group.get("email"))
119+
if role and role not in seen:
120+
seen.add(role)
121+
roles.append(role)
122+
123+
page_token = response.get("nextPageToken")
124+
if not page_token:
125+
break
126+
except HttpError as e:
127+
status = getattr(getattr(e, "resp", None), "status", None)
128+
if status in (401, 403):
129+
logger.warning(
130+
"Google Directory group ACL lookup denied; check Admin SDK API and "
131+
"admin.directory.group.readonly consent",
132+
status_code=status,
133+
)
134+
return []
135+
logger.warning("Google Directory group ACL lookup failed", error=str(e))
136+
return []
137+
except Exception as e:
138+
logger.warning("Google Directory group ACL lookup errored", error=str(e))
139+
return []
140+
141+
return roles
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Microsoft Graph group ACL helpers shared by Microsoft connectors."""
2+
3+
from __future__ import annotations
4+
5+
import inspect
6+
from typing import Any
7+
8+
import httpx
9+
import jwt
10+
11+
from utils.group_acl import canonical_group_role, canonical_group_roles
12+
from utils.logging_config import get_logger
13+
14+
15+
logger = get_logger(__name__)
16+
17+
MICROSOFT_GRAPH_GROUP_PROVIDER = "m365"
18+
19+
20+
def tenant_id_from_access_token(access_token: str | None, fallback: str | None = None) -> str:
21+
"""Read the tenant id from a Microsoft access token without validating it."""
22+
if access_token:
23+
raw_token = access_token.removeprefix("Bearer ").strip()
24+
try:
25+
claims = jwt.decode(
26+
raw_token,
27+
options={"verify_signature": False, "verify_aud": False},
28+
)
29+
token_tenant = claims.get("tid")
30+
if token_tenant:
31+
return token_tenant
32+
except Exception as e:
33+
logger.debug("Could not decode Microsoft access token tenant", error=str(e))
34+
return fallback or "common"
35+
36+
37+
def microsoft_group_role(
38+
group_id: str | None,
39+
*,
40+
access_token: str | None = None,
41+
tenant_id: str | None = None,
42+
) -> str | None:
43+
"""Return the canonical OpenSearch role for a Microsoft group id."""
44+
if not group_id:
45+
return None
46+
resolved_tenant = tenant_id_from_access_token(access_token, fallback=tenant_id)
47+
return canonical_group_role(
48+
MICROSOFT_GRAPH_GROUP_PROVIDER,
49+
resolved_tenant,
50+
group_id,
51+
)
52+
53+
54+
async def _maybe_await(value: Any) -> Any:
55+
if inspect.isawaitable(value):
56+
return await value
57+
return value
58+
59+
60+
async def get_oauth_access_token(oauth: Any) -> str | None:
61+
"""Return an access token string from either old dict or current string APIs."""
62+
if oauth is None:
63+
return None
64+
token_value = await _maybe_await(oauth.get_access_token())
65+
if isinstance(token_value, dict):
66+
return token_value.get("access_token")
67+
if isinstance(token_value, str):
68+
return token_value.removeprefix("Bearer ").strip()
69+
return None
70+
71+
72+
async def get_current_user_microsoft_group_roles(
73+
oauth: Any,
74+
graph_base_url: str,
75+
*,
76+
tenant_id: str | None = None,
77+
timeout_seconds: float = 10.0,
78+
) -> list[str]:
79+
"""Fetch transitive Microsoft group memberships for the current OAuth user."""
80+
if oauth is None:
81+
return []
82+
83+
try:
84+
access_token = await get_oauth_access_token(oauth)
85+
except Exception as e:
86+
logger.warning("Unable to get Microsoft Graph token for group ACLs", error=str(e))
87+
return []
88+
89+
if not access_token:
90+
return []
91+
92+
resolved_tenant = tenant_id_from_access_token(access_token, fallback=tenant_id)
93+
headers = {"Authorization": f"Bearer {access_token}"}
94+
url = f"{graph_base_url}/me/transitiveMemberOf/microsoft.graph.group"
95+
params: dict[str, str] | None = {"$select": "id"}
96+
group_ids: list[str] = []
97+
98+
try:
99+
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
100+
while url:
101+
response = await client.get(url, headers=headers, params=params)
102+
params = None
103+
if response.status_code in (401, 403):
104+
logger.warning(
105+
"Microsoft Graph group ACL lookup denied",
106+
status_code=response.status_code,
107+
response_text=response.text[:500],
108+
)
109+
return []
110+
if response.status_code != 200:
111+
logger.warning(
112+
"Microsoft Graph group ACL lookup failed",
113+
status_code=response.status_code,
114+
response_text=response.text[:500],
115+
)
116+
return []
117+
118+
data = response.json()
119+
for entry in data.get("value", []):
120+
group_id = entry.get("id")
121+
if group_id:
122+
group_ids.append(group_id)
123+
url = data.get("@odata.nextLink")
124+
except Exception as e:
125+
logger.warning("Microsoft Graph group ACL lookup errored", error=str(e))
126+
return []
127+
128+
return canonical_group_roles(
129+
MICROSOFT_GRAPH_GROUP_PROVIDER,
130+
resolved_tenant,
131+
group_ids,
132+
)

0 commit comments

Comments
 (0)