From 9816f13ae92cbe7c9abd8deb43bbb00f5f55cf85 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Wed, 29 Apr 2026 00:57:28 +0200
Subject: [PATCH] fixes infomaniac different than in doc
---
.../providerInfomaniak/connectorInfomaniak.py | 133 +++++++-----------
modules/routes/routeSecurityInfomaniak.py | 117 ++++-----------
2 files changed, 84 insertions(+), 166 deletions(-)
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 {