diff --git a/modules/connectors/providerInfomaniak/connectorInfomaniak.py b/modules/connectors/providerInfomaniak/connectorInfomaniak.py index 80fa4d17..9153ad73 100644 --- a/modules/connectors/providerInfomaniak/connectorInfomaniak.py +++ b/modules/connectors/providerInfomaniak/connectorInfomaniak.py @@ -187,76 +187,67 @@ async def resolveOwnerIdentity(token: str) -> InfomaniakOwnerIdentity: ) -async def resolveAccessibleAccountIds(token: str) -> List[int]: - """Return every Infomaniak account_id the PAT has access to. +async def listAccessibleDrives(token: str) -> List[Dict[str, Any]]: + """Return every kDrive the PAT can reach (admin OR user role). - Hits ``GET /1/accounts`` -- the only Infomaniak endpoint that lists - *all* account_ids of a token in one call. Requires the PAT scope - ``accounts`` (Infomaniak responds 403 with - ``code: 'all_scopes', context: {scopes: ['accounts']}`` if missing). + Hits ``GET /2/drive/init?with=drives`` -- the only PAT-friendly + endpoint that enumerates a user's drives **independently of the + Drive-Manager admin role**. The plain ``/2/drive?account_id=...`` + listing is filtered to drives where the caller is an admin and + therefore returns an empty array for everyone with ``role: user``, + even though the same user can read/write the drive's files via + ``/2/drive/{driveId}/...``. - The kSuite account_id from PIM (``resolveOwnerIdentity``) is **not** - sufficient for kDrive: a standalone or free-tier kDrive lives on a - different account_id than its kSuite counterpart. ``/2/drive`` is - queried per account_id, so we resolve them all here and union the - drive listings in :class:`KdriveAdapter`. + The endpoint requires only the ``drive`` PAT scope -- no + ``accounts``, no ``user_info``, no admin permission. Each entry + matches the shape documented for ``GET /2/drive/{drive_id}`` + (``id``, ``name``, ``account_id``, ``role``, ...). Raises :class:`InfomaniakIdentityError` when the PAT does not carry - the ``accounts`` scope or the response is malformed. + the ``drive`` scope or the response is malformed. """ - payload = await _infomaniakGet(token, "/1/accounts") + payload = await _infomaniakGet(token, "/2/drive/init?with=drives") if isinstance(payload, dict) and payload.get("error"): raise InfomaniakIdentityError( - "Could not list Infomaniak accounts. The PAT must carry the " - "'accounts' scope so kDrive can discover the owning account " - f"(/1/accounts said: {payload['error']})." + "Could not list Infomaniak kDrives. The PAT must carry the " + f"'drive' scope (/2/drive/init said: {payload['error']})." ) data = _unwrapData(payload) - if not isinstance(data, list): + if not isinstance(data, dict): raise InfomaniakIdentityError( - "Unexpected /1/accounts response shape (expected a list)." + "Unexpected /2/drive/init response shape (expected an object)." ) - accountIds: List[int] = [] - for entry in data: - if not isinstance(entry, dict): - continue - accountId = entry.get("id") - if isinstance(accountId, int): - accountIds.append(accountId) - if not accountIds: + drives = data.get("drives") or [] + if not isinstance(drives, list): raise InfomaniakIdentityError( - "/1/accounts returned no accounts -- the PAT cannot reach any " - "Infomaniak account." + "Unexpected /2/drive/init response: 'drives' is not a list." ) - return accountIds + return [d for d in drives if isinstance(d, dict) and d.get("id")] class KdriveAdapter(ServiceAdapter): - """kDrive ServiceAdapter -- browse drives, folders, files within all - accounts the PAT can reach. + """kDrive ServiceAdapter -- browse drives, folders, files. - Infomaniak's ``/2/drive`` listing endpoint requires the integer - ``account_id`` of the *drive-owning* account as a query arg. A user - may own kDrives in several accounts (typically a kSuite account - plus a standalone / free-tier kDrive account), and the kSuite - account_id from PIM does **not** cover the standalone case. + Drive enumeration goes through :func:`listAccessibleDrives` which + calls ``/2/drive/init?with=drives``. That endpoint returns every + drive the PAT can read regardless of the Drive-Manager admin role + -- unlike the documented ``/2/drive?account_id=...`` listing which + silently returns an empty array for users with ``role: 'user'`` + (the most common case for kSuite members). - The only PAT-friendly way to enumerate every account_id is - :func:`resolveAccessibleAccountIds` (``GET /1/accounts`` with the - ``accounts`` scope). This adapter therefore resolves the full - account list once per instance and unions the ``/2/drive`` listing - across all of them in :meth:`_listDrives`. + The drive list is cached on the adapter instance so each browse + pays for one ``/2/drive/init`` call at most. """ def __init__(self, accessToken: str): self._token = accessToken - self._accountIds: Optional[List[int]] = None + self._drives: Optional[List[Dict[str, Any]]] = None - async def _ensureAccountIds(self) -> List[int]: - if self._accountIds is not None: - return self._accountIds - self._accountIds = await resolveAccessibleAccountIds(self._token) - return self._accountIds + async def _ensureDrives(self) -> List[Dict[str, Any]]: + if self._drives is not None: + return self._drives + self._drives = await listAccessibleDrives(self._token) + return self._drives async def browse( self, @@ -278,41 +269,23 @@ class KdriveAdapter(ServiceAdapter): return await self._listChildren(driveId, fileId=fileId, limit=limit) async def _listDrives(self) -> List[ExternalEntry]: - accountIds = await self._ensureAccountIds() - seen: set = set() + drives = await self._ensureDrives() entries: List[ExternalEntry] = [] - for accountId in accountIds: - result = await _infomaniakGet( - self._token, f"/2/drive?account_id={accountId}" - ) - if isinstance(result, dict) and result.get("error"): - logger.warning( - f"kDrive list-drives for account {accountId} failed: {result['error']}" - ) + for drive in drives: + driveId = str(drive.get("id", "")) + if not driveId: continue - data = _unwrapData(result) - drives: List[Dict[str, Any]] - if isinstance(data, list): - drives = [d for d in data if isinstance(d, dict)] - elif isinstance(data, dict): - drives = data.get("drives", {}).get("accounts", []) or [] - else: - drives = [] - for drive in drives: - driveId = str(drive.get("id", "")) - if not driveId or driveId in seen: - continue - seen.add(driveId) - entries.append(ExternalEntry( - name=drive.get("name") or driveId, - path=f"/{driveId}", - isFolder=True, - metadata={ - "id": driveId, - "kind": "drive", - "accountId": accountId, - }, - )) + entries.append(ExternalEntry( + name=drive.get("name") or driveId, + path=f"/{driveId}", + isFolder=True, + metadata={ + "id": driveId, + "kind": "drive", + "accountId": drive.get("account_id"), + "role": drive.get("role"), + }, + )) return entries async def _listChildren( diff --git a/modules/routes/routeSecurityInfomaniak.py b/modules/routes/routeSecurityInfomaniak.py index 5bf079a1..d938b45e 100644 --- a/modules/routes/routeSecurityInfomaniak.py +++ b/modules/routes/routeSecurityInfomaniak.py @@ -7,7 +7,6 @@ The user must create a Personal Access Token (PAT) at https://manager.infomaniak.com/v3/ng/accounts/token/list with the API scopes: -- ``accounts`` -> account discovery (REQUIRED for kDrive) - ``drive`` -> kDrive (active adapter) - ``workspace:calendar`` -> Calendar (active adapter) - ``workspace:contact`` -> Contacts (active adapter) @@ -15,14 +14,16 @@ scopes: Validation strategy ------------------- -The submit endpoint validates the PAT in three deterministic steps, -each addressing exactly one scope: +The submit endpoint validates the PAT in two deterministic steps, +each addressing one scope: -1. ``resolveAccessibleAccountIds(pat)`` -> ``GET /1/accounts`` proves - the ``accounts`` scope is on the PAT. Without this scope, kDrive - cannot enumerate the owning account_ids (a standalone or free-tier - kDrive lives on a *different* account_id than its kSuite - counterpart, so the kSuite account_id from PIM is not enough). +1. ``listAccessibleDrives(pat)`` -> ``GET /2/drive/init?with=drives`` + proves the ``drive`` scope is on the PAT and -- as a side effect -- + confirms the user has at least one accessible kDrive. This is the + *only* listing endpoint that returns drives where the user has + ``role: 'user'`` (the documented ``/2/drive?account_id=...`` listing + is filtered to admin-only drives and would silently return ``[]`` + for a standard kSuite member). 2. ``resolveOwnerIdentity(pat)`` -> PIM Calendar (preferred) or PIM Contacts (fallback) yields the user's display name + their kSuite @@ -30,10 +31,6 @@ each addressing exactly one scope: that at least one of ``workspace:calendar`` / ``workspace:contact`` is on the PAT (the connection would otherwise be blank in the UI). -3. ``GET /2/drive?account_id={firstAccountId}`` is the final scope - probe -- 200 means the ``drive`` scope is present. 401/403 means - the scope is missing. - Mail has no separate probe: its scope is recorded in ``grantedScopes`` so a future adapter can pick it up without re-issuing the token. """ @@ -42,7 +39,6 @@ from fastapi import APIRouter, HTTPException, Request, status, Depends, Path, Bo import logging from typing import Dict, Any import hashlib -import httpx from modules.interfaces.interfaceDbApp import getInterface from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection @@ -52,7 +48,7 @@ from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp from modules.shared.i18nRegistry import apiRouteContext from modules.connectors.providerInfomaniak.connectorInfomaniak import ( resolveOwnerIdentity, - resolveAccessibleAccountIds, + listAccessibleDrives, InfomaniakIdentityError, ) @@ -60,8 +56,6 @@ routeApiMsg = apiRouteContext("routeSecurityInfomaniak") logger = logging.getLogger(__name__) -INFOMANIAK_API_BASE = "https://api.infomaniak.com" - # Infomaniak PATs do not expire unless the user sets an explicit lifetime in # the Manager (up to 30 years). We persist a 10-year horizon so the central # tokenStatus helper does not flag the connection as "no token". Mirrors @@ -81,55 +75,6 @@ router = APIRouter( ) -async def _probeDriveScope(client: httpx.AsyncClient, pat: str, accountId: int) -> None: - """Confirm the ``drive`` scope is on the PAT. - - Issues ``GET /2/drive?account_id={accountId}`` -- a clean 200 means - both the ``drive`` scope is present and the resolved ``account_id`` - is correct. 401/403 means the scope is missing; anything else means - Infomaniak is misbehaving and we refuse to persist. - """ - url = f"{INFOMANIAK_API_BASE}/2/drive?account_id={accountId}" - try: - resp = await client.get( - url, - headers={"Authorization": f"Bearer {pat}", "Accept": "application/json"}, - ) - except httpx.HTTPError as e: - logger.error(f"Infomaniak drive-probe network error ({url}): {e}") - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=routeApiMsg("Could not reach Infomaniak to validate the token"), - ) - - if resp.status_code == 200: - return - if resp.status_code in (401, 403): - logger.warning( - f"Infomaniak drive-probe rejected PAT ({url}): " - f"{resp.status_code} {resp.text[:200]}" - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg( - "Token rejected by Infomaniak (missing scope 'drive'). " - "Required scopes: 'drive' (kDrive) and 'workspace:calendar' " - "(or 'workspace:contact'). Recommended for upcoming " - "services: 'workspace:mail'." - ), - ) - logger.error( - f"Infomaniak drive-probe unexpected response ({url}): " - f"{resp.status_code} {resp.text[:200]}" - ) - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=routeApiMsg( - "Infomaniak drive-probe returned an unexpected response." - ), - ) - - @router.post("/connections/{connectionId}/token") @limiter.limit("10/minute") async def submit_infomaniak_token( @@ -143,18 +88,18 @@ async def submit_infomaniak_token( Body: { "token": "" } - Validation order (all three must succeed before persisting): - 1. ``resolveAccessibleAccountIds(pat)`` -> proves the - ``accounts`` scope is on the PAT (required for kDrive - account discovery). + Validation order (both must succeed before persisting): + 1. ``listAccessibleDrives(pat)`` -> proves the ``drive`` scope + is on the PAT and confirms the user can see at least one + kDrive (uses ``/2/drive/init?with=drives`` so users with + ``role: 'user'`` are also covered). 2. ``resolveOwnerIdentity(pat)`` -> display name + kSuite - account_id for the connection UI label. - 3. ``/2/drive?account_id=`` -> proves the ``drive`` - scope is on the PAT. + account_id for the connection UI label (proves at least one + of ``workspace:calendar`` / ``workspace:contact`` is present). - No data derived from the PAT is stored as adapter state -- both - account list and owner identity are re-resolved lazily by the - adapters at request time. + No PAT-derived data is stored as adapter state -- both the drive + list and the owner identity are re-resolved lazily by the adapters + at request time. """ pat = (body or {}).get("token") if not isinstance(pat, str) or not pat.strip(): @@ -177,19 +122,19 @@ async def submit_infomaniak_token( ) try: - accountIds = await resolveAccessibleAccountIds(pat) + drives = await listAccessibleDrives(pat) except InfomaniakIdentityError as e: logger.warning( f"Infomaniak token submit for connection {connectionId} could not " - f"list accounts: {e}" + f"list drives: {e}" ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg( - "Token rejected by Infomaniak (missing scope 'accounts'). " - "kDrive needs the 'accounts' scope to discover the owning " - "Infomaniak account. Required scopes: 'accounts', 'drive', " - "'workspace:calendar', 'workspace:contact'." + "Token rejected by Infomaniak (missing scope 'drive'). " + "Required scopes: 'drive' (kDrive) and " + "'workspace:calendar' (or 'workspace:contact'). Mail " + "scope 'workspace:mail' is reserved." ), ) @@ -209,9 +154,6 @@ async def submit_infomaniak_token( ), ) - async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as client: - await _probeDriveScope(client, pat, accountIds[0]) - tokenFingerprint = "pat-" + hashlib.sha256(pat.encode("utf-8")).hexdigest()[:8] username = identity["displayName"] or f"infomaniak-{tokenFingerprint}" expiresAt = createExpirationTimestamp(_INFOMANIAK_TOKEN_EXPIRES_IN_SEC) @@ -223,7 +165,6 @@ async def submit_infomaniak_token( connection.externalId = str(identity["accountId"]) connection.externalUsername = username connection.grantedScopes = [ - "accounts", "drive", "workspace:mail", "workspace:calendar", @@ -244,11 +185,15 @@ async def submit_infomaniak_token( ) interface.saveConnectionToken(token) + driveSummary = [ + {"id": d.get("id"), "name": d.get("name"), "role": d.get("role")} + for d in drives + ] logger.info( f"Infomaniak PAT stored for connection {connectionId} " f"(user {currentUser.id}, externalUsername={username}, " f"kSuiteAccountId={identity['accountId']}, " - f"accessibleAccounts={accountIds})" + f"accessibleDrives={driveSummary})" ) return {