fixes infomaniac different than in doc

This commit is contained in:
ValueOn AG 2026-04-29 00:57:28 +02:00
parent b405cebdec
commit 9816f13ae9
2 changed files with 84 additions and 166 deletions

View file

@ -187,76 +187,67 @@ async def resolveOwnerIdentity(token: str) -> InfomaniakOwnerIdentity:
) )
async def resolveAccessibleAccountIds(token: str) -> List[int]: async def listAccessibleDrives(token: str) -> List[Dict[str, Any]]:
"""Return every Infomaniak account_id the PAT has access to. """Return every kDrive the PAT can reach (admin OR user role).
Hits ``GET /1/accounts`` -- the only Infomaniak endpoint that lists Hits ``GET /2/drive/init?with=drives`` -- the only PAT-friendly
*all* account_ids of a token in one call. Requires the PAT scope endpoint that enumerates a user's drives **independently of the
``accounts`` (Infomaniak responds 403 with Drive-Manager admin role**. The plain ``/2/drive?account_id=...``
``code: 'all_scopes', context: {scopes: ['accounts']}`` if missing). 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** The endpoint requires only the ``drive`` PAT scope -- no
sufficient for kDrive: a standalone or free-tier kDrive lives on a ``accounts``, no ``user_info``, no admin permission. Each entry
different account_id than its kSuite counterpart. ``/2/drive`` is matches the shape documented for ``GET /2/drive/{drive_id}``
queried per account_id, so we resolve them all here and union the (``id``, ``name``, ``account_id``, ``role``, ...).
drive listings in :class:`KdriveAdapter`.
Raises :class:`InfomaniakIdentityError` when the PAT does not carry 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"): if isinstance(payload, dict) and payload.get("error"):
raise InfomaniakIdentityError( raise InfomaniakIdentityError(
"Could not list Infomaniak accounts. The PAT must carry the " "Could not list Infomaniak kDrives. The PAT must carry the "
"'accounts' scope so kDrive can discover the owning account " f"'drive' scope (/2/drive/init said: {payload['error']})."
f"(/1/accounts said: {payload['error']})."
) )
data = _unwrapData(payload) data = _unwrapData(payload)
if not isinstance(data, list): if not isinstance(data, dict):
raise InfomaniakIdentityError( raise InfomaniakIdentityError(
"Unexpected /1/accounts response shape (expected a list)." "Unexpected /2/drive/init response shape (expected an object)."
) )
accountIds: List[int] = [] drives = data.get("drives") or []
for entry in data: if not isinstance(drives, list):
if not isinstance(entry, dict):
continue
accountId = entry.get("id")
if isinstance(accountId, int):
accountIds.append(accountId)
if not accountIds:
raise InfomaniakIdentityError( raise InfomaniakIdentityError(
"/1/accounts returned no accounts -- the PAT cannot reach any " "Unexpected /2/drive/init response: 'drives' is not a list."
"Infomaniak account."
) )
return accountIds return [d for d in drives if isinstance(d, dict) and d.get("id")]
class KdriveAdapter(ServiceAdapter): class KdriveAdapter(ServiceAdapter):
"""kDrive ServiceAdapter -- browse drives, folders, files within all """kDrive ServiceAdapter -- browse drives, folders, files.
accounts the PAT can reach.
Infomaniak's ``/2/drive`` listing endpoint requires the integer Drive enumeration goes through :func:`listAccessibleDrives` which
``account_id`` of the *drive-owning* account as a query arg. A user calls ``/2/drive/init?with=drives``. That endpoint returns every
may own kDrives in several accounts (typically a kSuite account drive the PAT can read regardless of the Drive-Manager admin role
plus a standalone / free-tier kDrive account), and the kSuite -- unlike the documented ``/2/drive?account_id=...`` listing which
account_id from PIM does **not** cover the standalone case. 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 The drive list is cached on the adapter instance so each browse
:func:`resolveAccessibleAccountIds` (``GET /1/accounts`` with the pays for one ``/2/drive/init`` call at most.
``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`.
""" """
def __init__(self, accessToken: str): def __init__(self, accessToken: str):
self._token = accessToken self._token = accessToken
self._accountIds: Optional[List[int]] = None self._drives: Optional[List[Dict[str, Any]]] = None
async def _ensureAccountIds(self) -> List[int]: async def _ensureDrives(self) -> List[Dict[str, Any]]:
if self._accountIds is not None: if self._drives is not None:
return self._accountIds return self._drives
self._accountIds = await resolveAccessibleAccountIds(self._token) self._drives = await listAccessibleDrives(self._token)
return self._accountIds return self._drives
async def browse( async def browse(
self, self,
@ -278,41 +269,23 @@ class KdriveAdapter(ServiceAdapter):
return await self._listChildren(driveId, fileId=fileId, limit=limit) return await self._listChildren(driveId, fileId=fileId, limit=limit)
async def _listDrives(self) -> List[ExternalEntry]: async def _listDrives(self) -> List[ExternalEntry]:
accountIds = await self._ensureAccountIds() drives = await self._ensureDrives()
seen: set = set()
entries: List[ExternalEntry] = [] entries: List[ExternalEntry] = []
for accountId in accountIds: for drive in drives:
result = await _infomaniakGet( driveId = str(drive.get("id", ""))
self._token, f"/2/drive?account_id={accountId}" if not driveId:
)
if isinstance(result, dict) and result.get("error"):
logger.warning(
f"kDrive list-drives for account {accountId} failed: {result['error']}"
)
continue continue
data = _unwrapData(result) entries.append(ExternalEntry(
drives: List[Dict[str, Any]] name=drive.get("name") or driveId,
if isinstance(data, list): path=f"/{driveId}",
drives = [d for d in data if isinstance(d, dict)] isFolder=True,
elif isinstance(data, dict): metadata={
drives = data.get("drives", {}).get("accounts", []) or [] "id": driveId,
else: "kind": "drive",
drives = [] "accountId": drive.get("account_id"),
for drive in drives: "role": drive.get("role"),
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,
},
))
return entries return entries
async def _listChildren( async def _listChildren(

View file

@ -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 https://manager.infomaniak.com/v3/ng/accounts/token/list with the API
scopes: scopes:
- ``accounts`` -> account discovery (REQUIRED for kDrive)
- ``drive`` -> kDrive (active adapter) - ``drive`` -> kDrive (active adapter)
- ``workspace:calendar`` -> Calendar (active adapter) - ``workspace:calendar`` -> Calendar (active adapter)
- ``workspace:contact`` -> Contacts (active adapter) - ``workspace:contact`` -> Contacts (active adapter)
@ -15,14 +14,16 @@ scopes:
Validation strategy Validation strategy
------------------- -------------------
The submit endpoint validates the PAT in three deterministic steps, The submit endpoint validates the PAT in two deterministic steps,
each addressing exactly one scope: each addressing one scope:
1. ``resolveAccessibleAccountIds(pat)`` -> ``GET /1/accounts`` proves 1. ``listAccessibleDrives(pat)`` -> ``GET /2/drive/init?with=drives``
the ``accounts`` scope is on the PAT. Without this scope, kDrive proves the ``drive`` scope is on the PAT and -- as a side effect --
cannot enumerate the owning account_ids (a standalone or free-tier confirms the user has at least one accessible kDrive. This is the
kDrive lives on a *different* account_id than its kSuite *only* listing endpoint that returns drives where the user has
counterpart, so the kSuite account_id from PIM is not enough). ``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 2. ``resolveOwnerIdentity(pat)`` -> PIM Calendar (preferred) or PIM
Contacts (fallback) yields the user's display name + their kSuite 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`` that at least one of ``workspace:calendar`` / ``workspace:contact``
is on the PAT (the connection would otherwise be blank in the UI). 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`` Mail has no separate probe: its scope is recorded in ``grantedScopes``
so a future adapter can pick it up without re-issuing the token. 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 import logging
from typing import Dict, Any from typing import Dict, Any
import hashlib import hashlib
import httpx
from modules.interfaces.interfaceDbApp import getInterface from modules.interfaces.interfaceDbApp import getInterface
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection 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.shared.i18nRegistry import apiRouteContext
from modules.connectors.providerInfomaniak.connectorInfomaniak import ( from modules.connectors.providerInfomaniak.connectorInfomaniak import (
resolveOwnerIdentity, resolveOwnerIdentity,
resolveAccessibleAccountIds, listAccessibleDrives,
InfomaniakIdentityError, InfomaniakIdentityError,
) )
@ -60,8 +56,6 @@ routeApiMsg = apiRouteContext("routeSecurityInfomaniak")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
INFOMANIAK_API_BASE = "https://api.infomaniak.com"
# Infomaniak PATs do not expire unless the user sets an explicit lifetime in # 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 # 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 # 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") @router.post("/connections/{connectionId}/token")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def submit_infomaniak_token( async def submit_infomaniak_token(
@ -143,18 +88,18 @@ async def submit_infomaniak_token(
Body: Body:
{ "token": "<personal-access-token from Infomaniak Manager>" } { "token": "<personal-access-token from Infomaniak Manager>" }
Validation order (all three must succeed before persisting): Validation order (both must succeed before persisting):
1. ``resolveAccessibleAccountIds(pat)`` -> proves the 1. ``listAccessibleDrives(pat)`` -> proves the ``drive`` scope
``accounts`` scope is on the PAT (required for kDrive is on the PAT and confirms the user can see at least one
account discovery). kDrive (uses ``/2/drive/init?with=drives`` so users with
``role: 'user'`` are also covered).
2. ``resolveOwnerIdentity(pat)`` -> display name + kSuite 2. ``resolveOwnerIdentity(pat)`` -> display name + kSuite
account_id for the connection UI label. account_id for the connection UI label (proves at least one
3. ``/2/drive?account_id=<first>`` -> proves the ``drive`` of ``workspace:calendar`` / ``workspace:contact`` is present).
scope is on the PAT.
No data derived from the PAT is stored as adapter state -- both No PAT-derived data is stored as adapter state -- both the drive
account list and owner identity are re-resolved lazily by the list and the owner identity are re-resolved lazily by the adapters
adapters at request time. at request time.
""" """
pat = (body or {}).get("token") pat = (body or {}).get("token")
if not isinstance(pat, str) or not pat.strip(): if not isinstance(pat, str) or not pat.strip():
@ -177,19 +122,19 @@ async def submit_infomaniak_token(
) )
try: try:
accountIds = await resolveAccessibleAccountIds(pat) drives = await listAccessibleDrives(pat)
except InfomaniakIdentityError as e: except InfomaniakIdentityError as e:
logger.warning( logger.warning(
f"Infomaniak token submit for connection {connectionId} could not " f"Infomaniak token submit for connection {connectionId} could not "
f"list accounts: {e}" f"list drives: {e}"
) )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg( detail=routeApiMsg(
"Token rejected by Infomaniak (missing scope 'accounts'). " "Token rejected by Infomaniak (missing scope 'drive'). "
"kDrive needs the 'accounts' scope to discover the owning " "Required scopes: 'drive' (kDrive) and "
"Infomaniak account. Required scopes: 'accounts', 'drive', " "'workspace:calendar' (or 'workspace:contact'). Mail "
"'workspace:calendar', 'workspace:contact'." "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] tokenFingerprint = "pat-" + hashlib.sha256(pat.encode("utf-8")).hexdigest()[:8]
username = identity["displayName"] or f"infomaniak-{tokenFingerprint}" username = identity["displayName"] or f"infomaniak-{tokenFingerprint}"
expiresAt = createExpirationTimestamp(_INFOMANIAK_TOKEN_EXPIRES_IN_SEC) expiresAt = createExpirationTimestamp(_INFOMANIAK_TOKEN_EXPIRES_IN_SEC)
@ -223,7 +165,6 @@ async def submit_infomaniak_token(
connection.externalId = str(identity["accountId"]) connection.externalId = str(identity["accountId"])
connection.externalUsername = username connection.externalUsername = username
connection.grantedScopes = [ connection.grantedScopes = [
"accounts",
"drive", "drive",
"workspace:mail", "workspace:mail",
"workspace:calendar", "workspace:calendar",
@ -244,11 +185,15 @@ async def submit_infomaniak_token(
) )
interface.saveConnectionToken(token) interface.saveConnectionToken(token)
driveSummary = [
{"id": d.get("id"), "name": d.get("name"), "role": d.get("role")}
for d in drives
]
logger.info( logger.info(
f"Infomaniak PAT stored for connection {connectionId} " f"Infomaniak PAT stored for connection {connectionId} "
f"(user {currentUser.id}, externalUsername={username}, " f"(user {currentUser.id}, externalUsername={username}, "
f"kSuiteAccountId={identity['accountId']}, " f"kSuiteAccountId={identity['accountId']}, "
f"accessibleAccounts={accountIds})" f"accessibleDrives={driveSummary})"
) )
return { return {