gateway/modules/routes/routeSecurityInfomaniak.py
2026-04-29 00:57:28 +02:00

217 lines
8.2 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Infomaniak Personal-Access-Token onboarding for data connections.
Infomaniak does NOT support OAuth scopes for kDrive/kSuite data access.
The user must create a Personal Access Token (PAT) at
https://manager.infomaniak.com/v3/ng/accounts/token/list with the API
scopes:
- ``drive`` -> kDrive (active adapter)
- ``workspace:calendar`` -> Calendar (active adapter)
- ``workspace:contact`` -> Contacts (active adapter)
- ``workspace:mail`` -> Mail (adapter pending; scope reserved)
Validation strategy
-------------------
The submit endpoint validates the PAT in two deterministic steps,
each addressing one scope:
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
account_id, used purely for connection labelling. This also proves
that at least one of ``workspace:calendar`` / ``workspace:contact``
is on the PAT (the connection would otherwise be blank in the UI).
Mail has no separate probe: its scope is recorded in ``grantedScopes``
so a future adapter can pick it up without re-issuing the token.
"""
from fastapi import APIRouter, HTTPException, Request, status, Depends, Path, Body
import logging
from typing import Dict, Any
import hashlib
from modules.interfaces.interfaceDbApp import getInterface
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.auth import getCurrentUser, limiter
from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp
from modules.shared.i18nRegistry import apiRouteContext
from modules.connectors.providerInfomaniak.connectorInfomaniak import (
resolveOwnerIdentity,
listAccessibleDrives,
InfomaniakIdentityError,
)
routeApiMsg = apiRouteContext("routeSecurityInfomaniak")
logger = logging.getLogger(__name__)
# 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
# ClickUp.
_INFOMANIAK_TOKEN_EXPIRES_IN_SEC = 10 * 365 * 24 * 3600
router = APIRouter(
prefix="/api/infomaniak",
tags=["Security Infomaniak"],
responses={
404: {"description": "Not found"},
400: {"description": "Bad request"},
401: {"description": "Unauthorized"},
500: {"description": "Internal server error"},
},
)
@router.post("/connections/{connectionId}/token")
@limiter.limit("10/minute")
async def submit_infomaniak_token(
request: Request,
connectionId: str = Path(..., description="UserConnection id"),
body: Dict[str, Any] = Body(..., description="{ 'token': '<PAT>' }"),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Validate and persist an Infomaniak Personal Access Token (PAT).
Body:
{ "token": "<personal-access-token from Infomaniak Manager>" }
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 (proves at least one
of ``workspace:calendar`` / ``workspace:contact`` is present).
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():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg("Missing 'token' in request body"),
)
pat = pat.strip()
interface = getInterface(currentUser)
connection = None
for conn in interface.getUserConnections(currentUser.id):
if conn.id == connectionId and conn.authority == AuthAuthority.INFOMANIAK:
connection = conn
break
if not connection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=routeApiMsg("Infomaniak connection not found"),
)
try:
drives = await listAccessibleDrives(pat)
except InfomaniakIdentityError as e:
logger.warning(
f"Infomaniak token submit for connection {connectionId} could not "
f"list drives: {e}"
)
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'). Mail "
"scope 'workspace:mail' is reserved."
),
)
try:
identity = await resolveOwnerIdentity(pat)
except InfomaniakIdentityError as e:
logger.warning(
f"Infomaniak token submit for connection {connectionId} could not "
f"resolve owner identity: {e}"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=routeApiMsg(
"Could not derive your Infomaniak account from the token. "
"Please ensure the PAT carries 'workspace:calendar' or "
"'workspace:contact' so we can identify your account."
),
)
tokenFingerprint = "pat-" + hashlib.sha256(pat.encode("utf-8")).hexdigest()[:8]
username = identity["displayName"] or f"infomaniak-{tokenFingerprint}"
expiresAt = createExpirationTimestamp(_INFOMANIAK_TOKEN_EXPIRES_IN_SEC)
try:
connection.status = ConnectionStatus.ACTIVE
connection.lastChecked = getUtcTimestamp()
connection.expiresAt = expiresAt
connection.externalId = str(identity["accountId"])
connection.externalUsername = username
connection.grantedScopes = [
"drive",
"workspace:mail",
"workspace:calendar",
"workspace:contact",
]
interface.db.recordModify(UserConnection, connectionId, connection.model_dump())
token = Token(
userId=currentUser.id,
authority=AuthAuthority.INFOMANIAK,
connectionId=connectionId,
tokenPurpose=TokenPurpose.DATA_CONNECTION,
tokenAccess=pat,
tokenRefresh=None,
tokenType="bearer",
expiresAt=expiresAt,
createdAt=getUtcTimestamp(),
)
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"accessibleDrives={driveSummary})"
)
return {
"id": connection.id,
"status": "connected",
"type": "infomaniak",
"externalUsername": username,
"externalEmail": None,
"lastChecked": connection.lastChecked,
}
except HTTPException:
raise
except Exception as e:
logger.error(
f"Error persisting Infomaniak token for connection {connectionId}: {e}",
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=routeApiMsg("Failed to store Infomaniak token"),
)