From 53f28d47a13f71587e2638a12a92a3a613de0d25 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 29 Apr 2026 00:35:17 +0200 Subject: [PATCH] kdrive fix --- TOPICS.md | 3 +- b-reference/gateway/ai-agent.md | 6 +- b-reference/gateway/architecture.md | 6 +- .../2-build/2026-04-infomaniak-connector.md | 298 ++++++++++++++---- .../2026-04-msft-google-calendar-contacts.md | 189 +++++++++++ c-work/_CHANGELOG.md | 20 ++ d-guides/deployment/poweron-sec.kdbx | Bin 22590 -> 22910 bytes d-guides/google-oauth-setup.md | 24 +- d-guides/infomaniak-oauth-setup.md | 105 ------ d-guides/infomaniak-token-setup.md | 161 ++++++++++ 10 files changed, 639 insertions(+), 173 deletions(-) create mode 100644 c-work/2-build/2026-04-msft-google-calendar-contacts.md delete mode 100644 d-guides/infomaniak-oauth-setup.md create mode 100644 d-guides/infomaniak-token-setup.md diff --git a/TOPICS.md b/TOPICS.md index 50f3416..b2226cd 100644 --- a/TOPICS.md +++ b/TOPICS.md @@ -67,7 +67,8 @@ Lade immer zuerst diese Datei. Dann gezielt die passende(n) Referenz-Datei(en). | Testing-Strategie | d-guides/testing-strategy.md | Testpyramide, AC-Format, Test-Pfade | | Dev-Setup | d-guides/dev-setup.md | Lokale Umgebung starten | | Secrets-Verschluesselung | d-guides/encrypt-env-secrets.md | Env-Dateien verschluesseln | -| Google OAuth | d-guides/google-oauth-setup.md | OAuth Auth/Data Apps einrichten | +| Google OAuth | d-guides/google-oauth-setup.md | OAuth Auth/Data Apps einrichten (inkl. Calendar/Contacts-Scopes + Reconnect-Hinweis) | +| Infomaniak Token-Setup | d-guides/infomaniak-token-setup.md | Personal Access Token im Infomaniak-Manager fuer kDrive/Calendar/Contacts erzeugen | | Security-Migration | d-guides/security-migration-guide.md | JWT Cookie Migration | | Doc-Sync Cursor-Rule | d-guides/cursor-doc-sync.md | Installation, Regel-Quelle `doc-sync.mdc`, Doku-Workflow | diff --git a/b-reference/gateway/ai-agent.md b/b-reference/gateway/ai-agent.md index 3780a28..76bfe89 100644 --- a/b-reference/gateway/ai-agent.md +++ b/b-reference/gateway/ai-agent.md @@ -1,6 +1,6 @@ - - + + # AI Agent & Knowledge Store @@ -202,7 +202,7 @@ Zusätzlich zu den unten genannten **Kern-Tools** existieren **dynamische Tools* | Plugin-Modul | Typische Rolle | |--------------|----------------| | `aicorePluginAnthropic.py` | Claude-Modelle | -| `aicorePluginOpenai.py` | GPT, Embeddings, Bild | +| `aicorePluginOpenai.py` | GPT, Embeddings, Bild — modellabhaengiges Payload-Tuning: GPT-5.x und o-Serie (o1/o3/o4) sind Reasoning-Modelle und akzeptieren weder `max_tokens` (-> immer `max_completion_tokens`) noch ein custom `temperature` (-> Feld bei diesen Modellen weggelassen, OpenAI erzwingt sonst HTTP 400 `unsupported_value`) | | `aicorePluginMistral.py` | Mistral Chat / Embed | | `aicorePluginPerplexity.py` | Sonar / Recherche | | `aicorePluginTavily.py` | Web-Suche | diff --git a/b-reference/gateway/architecture.md b/b-reference/gateway/architecture.md index 5bda8b3..06b8707 100644 --- a/b-reference/gateway/architecture.md +++ b/b-reference/gateway/architecture.md @@ -1,6 +1,6 @@ - - + + # Gateway -- Architektur @@ -16,7 +16,7 @@ Unter `gateway/modules/` (Kontext-Audit): |-------|-------| | `aicore/` | Model-Registry, Model-Selector, Provider-Plugins (Anthropic, OpenAI, Mistral, Perplexity, Tavily, PrivateLLM) | | `auth/` | Authentifizierung, CSRF, Token-Refresh-Middleware, JWT | -| `connectors/` | DB-Connector (PostgreSQL), Provider-Subpakete (Microsoft, Google, ClickUp, FTP), Ticket/Messaging/Geo-Konnektoren | +| `connectors/` | DB-Connector (PostgreSQL), Provider-Subpakete (Microsoft, Google, ClickUp, FTP, Infomaniak), Ticket/Messaging/Geo-Konnektoren. Pro Provider registrieren ServiceAdapter (`OutlookAdapter`, `OneDriveAdapter`, `SharepointAdapter`, `TeamsAdapter`, `CalendarAdapter`, `ContactsAdapter`, `GmailAdapter`, `DriveAdapter`, `KdriveAdapter`, …) die UDB-Services. Adapter-Registry pro Connector ist `_SERVICE_MAP` | | `datamodels/` | Pydantic-Datenmodelle (u. a. Ai, Billing, Chat, Content, Files, Knowledge, Rbac, Subscription, UiLanguage, Workflow) | | `features/` | Feature-Module (autonome Domänen): workspace, graphicalEditor, chatbot, commcoach, neutralization, realEstate, trustee, teamsbot | | `interfaces/` | DB-Interfaces (App, Billing, Chat, Knowledge, Management, Subscription), AI-Objects, RBAC, Features, Messaging | diff --git a/c-work/2-build/2026-04-infomaniak-connector.md b/c-work/2-build/2026-04-infomaniak-connector.md index bcdb91c..c70223b 100644 --- a/c-work/2-build/2026-04-infomaniak-connector.md +++ b/c-work/2-build/2026-04-infomaniak-connector.md @@ -1,118 +1,296 @@ + + + + -# Infomaniak Connector (kDrive + Mail) + UDB-Integration +# Infomaniak Connector (kDrive + Calendar + Contacts today; Mail reserved) + UDB-Integration ## Beschreibung und Kontext PowerOn besitzt das Provider-Connector-Pattern fuer externe Datenanbindungen (Microsoft, Google, ClickUp). Infomaniak war bisher nicht angebunden. Ziel: ein -neuer `InfomaniakConnector`, der OAuth gegen `login.infomaniak.com` durchfuehrt -und Daten aus zwei Services bereitstellt: +neuer `InfomaniakConnector`, der Daten aus den Infomaniak-Services +bereitstellt. Heute aktiv: -- **kDrive** (Pendant zu OneDrive / Google Drive) -- **Mail** (Pendant zu Outlook / Gmail) +- **kDrive** (Pendant zu OneDrive / Google Drive) +- **Calendar** (Pendant zu Outlook-Kalender / Google Calendar; .ics-Download) +- **Contacts** (Pendant zu Outlook-Kontakte / Google Contacts; .vcf-Download) + +Blockiert (Scope ist auf der PAT, Adapter wartet auf Vendor): + +- **Mail** -- alle erschoepfend getesteten Pfade scheitern. Final-Befund + vom 2026-04-28: + + | Pfad | Status | + |---|---| + | `api.infomaniak.com/1/mail` | 404 nginx (existiert nicht) | + | `api.infomaniak.com/2/mail?account_id=...` | 404 nginx (existiert nicht) | + | `mail.infomaniak.com/api/mail?account_id=...` | 302 -> `login.infomaniak.com/authorize` (OAuth Web-Session, nicht PAT) | + | `mail.infomaniak.com/api/mail/?account_id=...` | 301 -> `http://mail.infomaniak.com:5000` (interner Cyrus-IMAP-API-Port, von aussen nicht erreichbar) | + | `mail.infomaniak.com/api/pim/mail` | 302 -> OAuth | + | `mail.infomaniak.com/api/pim/mailbox` | 302 -> OAuth | + | `mail.infomaniak.com/api/pim/folder` | 302 -> OAuth | + + Konsequenz: PowerOn kann mit dem heutigen PAT-Mechanismus die + Infomaniak-Mailbox **nicht** ansprechen. Der Scope `workspace:mail` + bleibt im Standard-PAT-Setup, sodass der MailAdapter ohne + Token-Rotation aufgeschaltet werden kann, sobald Infomaniak einen + PAT-tauglichen Endpoint freischaltet (oder wir entscheiden, die + Mail-Connection ueber einen separaten OAuth-Flow oder ein App-Passwort + ueber IMAP/SMTP einzubinden -- separater Build). Die Verbindung ist eine reine **Daten-Connection** (`TokenPurpose.DATA_CONNECTION`). -Infomaniak ist explizit **kein** Login-Provider fuer PowerOn -- entsprechend gibt -es nur `_FLOW_CONNECT`, kein `_FLOW_LOGIN`. +Infomaniak ist explizit **kein** Login-Provider fuer PowerOn. -Zusatzlich wurden zwei kleine Inkonsistenzen in der ClickUp-UDB-Anzeige +Zusaetzlich wurden zwei kleine Inkonsistenzen in der ClickUp-UDB-Anzeige behoben (`_SERVICE_ICONS` + `_SERVICE_TO_SOURCE_TYPE`). +## Architektur-Pivot 2026-04-28: OAuth -> Personal Access Token + +**Befund**: Infomaniaks `login.infomaniak.com/authorize` akzeptiert nur +Identity-Scopes (`openid`, `profile`, `email`, `phone`). Bearer-Tokens, die +gegen die Daten-APIs (`/2/drive/...`, `/1/mail/...`) funktionieren, koennen +ueber OAuth nicht ausgestellt werden -- der Versuch quittiert mit +`error=invalid_scope`. Datenzugriff laeuft bei Infomaniak ausschliesslich +ueber **Personal Access Tokens (PATs)**, die der Endnutzer manuell im +Infomaniak-Manager erstellt. + +**Konsequenz**: kompletter Umbau des Auth-Teils des Connectors. Das +Provider-Connector-Pattern und die Adapter (`KdriveAdapter`, `MailAdapter`) +bleiben unveraendert -- sie konsumieren ohnehin nur einen Bearer-Token. Was +aussortiert wurde: + +- OAuth-Routen (`/api/infomaniak/auth/connect[/callback]`) -> entfernt. +- Token-Refresh-Logik (`refreshInfomaniakToken`, Background-Refresh-Branch) + -> entfernt; PATs sind langlebig, kein Rotations-Lifecycle. +- Scope-Definition `infomaniakDataScopes` -> entfernt. +- `Service_INFOMANIAK_*` Env-Variablen (Client-ID/Secret/Redirect-URI) + -> entfernt; PowerOn benoetigt keine Infomaniak-App-Registrierung mehr. +- Frontend-OAuth-Popup -> ersetzt durch Modal mit Token-Eingabe + Deeplink + zum Infomaniak-Manager. + +**Neuer Auth-Flow**: + +1. FE: User klickt "Infomaniak" -> `POST /api/connections/` mit + `authority=infomaniak` legt PENDING-Connection an. +2. FE: Modal oeffnet sich mit Schritt-fuer-Schritt-Anleitung + Link + `https://manager.infomaniak.com/v3/ng/accounts/token/list` und + Token-Eingabefeld. +3. FE: User fuegt PAT ein -> `POST /api/infomaniak/connections/{id}/token`. +4. BE: Validiert PAT in zwei Schritten: + - `resolveOwnerIdentity()` ruft PIM Calendar (Scope + `workspace:calendar`) auf, faellt bei leerer Owner-Liste auf PIM + Contacts zurueck (Scope `workspace:contact`). Beide Endpoints + liefern den ersten Owner-Record als `{account_id, name, user_id}`. + Wenn keiner antwortet -> 400 fail-loud (PAT ist fuer kDrive + unbrauchbar, weil `/2/drive` `account_id` zwingend braucht und kein + Drive-Endpoint die Identity ohne `user_info`-Scope rausgibt). + - `GET /2/drive?account_id={resolved}` -- erwartet 200, andernfalls + 400 (`drive`-Scope fehlt) bzw. 502 (Infomaniak antwortet + unerwartet). Mit der vorab resolvten `account_id` ist der Probe + deterministisch und braucht keine 422-Tolerance mehr. + Token landet als `Token` mit 10-Jahres-Horizont (`tokenStatus`-Anzeige, + analog ClickUp). `externalUsername` und `externalId` werden fuer die + UI-Anzeige gesetzt; **`externalId` ist nicht Vertragspartner des + kDrive-Adapters** (siehe "Kritische Details"). +5. FE: bei Erfolg -> Modal schliesst, Liste refresht; bei Fehler -> + Connection bleibt PENDING, Modal zeigt Fehlermeldung; bei Cancel -> + Connection wird via DELETE wieder entfernt. + ## Fokus und kritische Details -- **Refresh-Token-Persistenz**: Infomaniak rotiert Refresh-Tokens nicht in jedem - Token-Response; falls `refresh_token` im Callback fehlt, wird aus dem letzten - gespeicherten Token rekonstruiert (analog Google). -- **API-Pfad-Konvention**: Drei-Ebenen-Hierarchie kDrive (`/{driveId}/{fileId}`) - und vier-Ebenen Mail (`/{mailboxId}/{folderId}/{uid}`); `ServiceAdapter.browse` - unterscheidet die Tiefe via Segment-Count. +- **API-Pfad-Konvention** (Adapter-Path != API-Path): + - kDrive (`api.infomaniak.com`): Adapter-Path `/{driveId}/{fileId}`, + API `/2/drive/{driveId}/files/{fileId}`. `account_id` muss bei jedem + Listing-Call als Query-Arg mit. **Wichtig**: Ein User kann kDrives + in mehreren Infomaniak-Accounts besitzen (typischerweise einer in + der kSuite + ein Standalone- oder Free-tier-kDrive auf einem + **anderen** account_id). Der Adapter resolvt deshalb on demand + ueber `resolveAccessibleAccountIds()` (-> `GET /1/accounts`) **alle** + erreichbaren account_ids, ruft `/2/drive?account_id=X` fuer jede + auf und unioniert die Drive-Listings mit Dedup ueber `driveId`. + Cache auf der Adapter-Instanz (`_ensureAccountIds`). `/1/accounts` + erfordert den PAT-Scope `accounts`; ohne ihn schlaegt schon der + Submit-Pre-Flight fehl. + - Calendar (`calendar.infomaniak.com/api/pim`): Adapter-Path + `/{calendarId}/{eventId}`. **Achtung**: die naheliegenden + Nested-Routes (`/calendar/{id}/event`, `/calendar/{id}/event/{id}`, + `/calendar/{id}/event/{id}/export`) sind **NICHT** PAT-faehig -- + sie 302-en zur OAuth-Login-Seite. Korrekte PAT-Pfade: + - Listing: `/api/pim/event?calendar_id={id}&from=YYYY-MM-DD HH:MM:SS&to=...` + mit Pflicht-Range, max **3 Monate** (Vendor-Constraint). + Adapter wahlt fix 90-Tage-Window (heute -30 / +60). + - Detail: `/api/pim/event/{eventId}` (ohne calendar-Praefix) + - Export `.ics`: `/api/pim/event/{eventId}/export` + - Contacts (`contacts.infomaniak.com/api/pim`): Adapter-Path + `/{addressBookId}/{contactId}`. Listing der AddressBooks und der + Contacts geht via PAT, **aber Detail- und Export-Endpoints + (`/addressbook/{book}/contact/{id}` und `.../export`) sind nicht + PAT-faehig** (500 bzw. 302 OAuth). Konsequenzen: + - Listing braucht zwingend + `&with=emails,phones,addresses,details`, sonst kommen die + relevanten Felder leer (Default-Response listet nur Stammdaten). + - `.vcf`-Download wird im Adapter **selbst synthetisiert** aus dem + Listing-Record (vCard 3.0). Implementierung in + `_renderInfomaniakVcard()`. + - Geteilte Organisations-Adressbuecher haben einen leeren Namen -- + der Adapter setzt dort einen Platzhalter "Organisation". + `ServiceAdapter.browse` unterscheidet die Tiefe via Segment-Count. +- **Multi-Host**: `_infomaniakGet` / `_infomaniakDownload` akzeptieren ein + optionales `baseUrl`, sodass Calendar gegen `calendar.infomaniak.com`, + Contacts gegen `contacts.infomaniak.com` und kDrive gegen + `api.infomaniak.com` laufen. +- **Zwei Resolver, getrennte Verantwortung**: + - `resolveOwnerIdentity(token)` -> Display-Name + kSuite-`account_id`, + rein fuer das UI-Label der Connection. Erste Quelle PIM Calendar, + Fallback PIM Contacts; nimmt den ersten Owner-Record (`user_id > 0` + und `isinstance(account_id, int)`). + - `resolveAccessibleAccountIds(token)` -> Liste **aller** account_ids, + die der PAT erreicht. Ein einzelner `GET /1/accounts`-Call. Vom + `KdriveAdapter` benutzt, weil ein Standalone-/Free-tier-kDrive auf + einem **anderen** account_id liegt als die kSuite und die + kSuite-`account_id` aus PIM den Drive nicht abdeckt. + Beide raisen `InfomaniakIdentityError` mit einer scope-spezifischen + Fehlermeldung, sodass der Submit-Endpoint pro fehlendem Scope eine + eigene 400-Message zurueckgeben kann. +- **`externalId` ist UI-State, kein Adapter-Vertragspartner**: Submit + speichert `externalId = str(kSuiteAccountId)` fuer die ConnectionsPage + (Anzeige + Konsistenz mit anderen Providern), aber der KdriveAdapter + liest ihn nie -- er fragt zur Laufzeit `/1/accounts`. - **Antwort-Wrapping**: Erfolgreiche Responses sind als `{result: 'success', data: ...}` gewrappt -- `_unwrapData()` normalisiert. -- **Authority-Filter** in `tokenRefreshService` muss `INFOMANIAK` enthalten, - sonst werden Token nie proaktiv refreshed. +- **Token-Validation** im Submit-Endpoint: drei harte 200-Schritte, + jeder probet genau einen Scope: + 1. `resolveAccessibleAccountIds` -> probet `accounts`-Scope. + 2. `resolveOwnerIdentity` -> probet `workspace:calendar`/ + `workspace:contact`-Scope (mindestens einer noetig). + 3. `/2/drive?account_id={first}` -> probet `drive`-Scope. + Jeder Schritt liefert eine eigene 400-Message, die exakt den fehlenden + Scope nennt. 401/403 -> 400 mit Scope-Hinweis; alles Unerwartete -> 502. +- **Token-Persistenz**: `expiresAt = now + 10*365*24*3600`, `tokenRefresh = None`; + `getTokenStatusForConnection` zeigt damit `active`, kein "none". ## Ziel und Nicht-Ziele - Ziel: Infomaniak-Daten (kDrive + Mail) wie MSFT/Google im UDB browsbar. -- Ziel: ConnectionsPage hat Button "Infomaniak" (analog ClickUp). -- Ziel: Refresh-Token-Lifecycle vollautomatisch (kein User-Reconnect noetig). +- Ziel: ConnectionsPage hat Button "Infomaniak" mit PAT-Modal. +- Ziel: Setup ohne Operator-Interaktion (keine globale App-Registrierung). - Ziel: ClickUp-UDB-Inkonsistenz beheben (Service-Icon + Source-Type-Mapping). -- NICHT: Infomaniak als PowerOn-Login (`User`-Erstellung via Infomaniak-OAuth). -- NICHT: Upload-Implementierung in kDrive/Mail (gibt nur Browse + Download). +- NICHT: Infomaniak als PowerOn-Login. +- NICHT: Upload-Implementierung in kDrive/Mail. +- NICHT: Auto-Refresh fuer Infomaniak (PATs sind langlebig; bei Ablauf legt + der User eine neue Connection an). ## Betroffene Module - Gateway: - - `modules/datamodels/datamodelUam.py` (Enum) - - `modules/auth/oauthProviderConfig.py` (Scopes) - - `modules/auth/tokenManager.py` (Refresh) - - `modules/auth/tokenRefreshService.py` (Background-Refresh) - - `modules/connectors/providerInfomaniak/connectorInfomaniak.py` (neu) - - `modules/connectors/connectorResolver.py` (Registry) - - `modules/routes/routeSecurityInfomaniak.py` (neu) - - `modules/routes/routeDataConnections.py` (Dispatch) - - `app.py` (Router-Mount) + - `modules/datamodels/datamodelUam.py` (Enum erweitert) + - `modules/auth/oauthProviderConfig.py` (Infomaniak-Scopes raus) + - `modules/auth/tokenManager.py` (Infomaniak-Init + Refresh raus) + - `modules/auth/tokenRefreshService.py` (Infomaniak aus Background-Refresh raus) + - `modules/connectors/providerInfomaniak/connectorInfomaniak.py` (Adapter unveraendert) + - `modules/connectors/connectorResolver.py` (Registry, unveraendert) + - `modules/routes/routeSecurityInfomaniak.py` (komplett neu: PAT-Submit-Endpoint) + - `modules/routes/routeDataConnections.py` (`connect_service` antwortet bei + Infomaniak mit 400 + Hinweis) + - `gateway/.env` + `env_dev/int/prod[_forgejo].env` (Service_INFOMANIAK_* raus) - Frontend: - - `src/api/connectionApi.ts` (Authority-Typ) - - `src/hooks/useConnections.ts` (Popup-Handler) - - `src/pages/basedata/ConnectionsPage.tsx` (Button) - - `src/components/UnifiedDataBar/SourcesTab.tsx` (Icons + Colors + Mapping, ClickUp-Fix) -- DB-Migration: nein (nur Enum-Wert, alle Tabellen abwaertskompatibel). -- Andere: keine. + - `src/api/connectionApi.ts` (`submitInfomaniakToken` neu) + - `src/hooks/useConnections.ts` (`createInfomaniakConnection` + + `submitInfomaniakToken` ersetzen `createInfomaniakConnectionAndAuth`, + OAuth-Popup-Branch fuer Infomaniak entfernt) + - `src/pages/basedata/ConnectionsPage.tsx` (PAT-Modal) + - `src/components/UnifiedDataBar/SourcesTab.tsx` (unveraendert seit Phase 1) +- Doku: + - `wiki/d-guides/infomaniak-oauth-setup.md` -> geloescht + - `wiki/d-guides/infomaniak-token-setup.md` -> neu +- DB-Migration: nein. ## Entscheidungen | Datum | Entscheidung | Begruendung | |------------|--------------|-------------| -| 2026-04-26 | Self-contained Connector (httpx im Modul, kein eigener Service) | Folgt Google/MSFT-Pattern, nicht ClickUp-Pattern (das delegiert an `ClickupService`) | +| 2026-04-26 | Self-contained Connector (httpx im Modul, kein eigener Service) | Folgt Google/MSFT-Pattern, nicht ClickUp-Pattern | | 2026-04-26 | Nur DATA_CONNECTION, kein Login | User explicitly: "wir benoetigen nur den userconnection auth" | -| 2026-04-26 | `kdrive` + `mail` als Service-Namen | Kurz, konsistent mit Infomaniak-Branding (kDrive heisst offiziell so) | +| 2026-04-26 | `kdrive` + `mail` als Service-Namen | Konsistent mit Infomaniak-Branding | +| 2026-04-28 | OAuth raus, Personal Access Token rein | Infomaniak's `/authorize` unterstuetzt fuer Datenzugriff keine Scopes; nur PATs liefern brauchbare Bearer-Tokens (`error=invalid_scope` bei OAuth-Versuch) | +| 2026-04-28 | Token-Validierung gegen `/1/profile` | Leichter Endpoint, liefert User-Identity (`id`, `login`, `email`) zur Anzeige im FE; gibt 401 bei ungueltigem PAT | +| 2026-04-28 | `/1/profile` raus, Validierung gegen `/2/drive` + `/1/mail` | Profile braucht zusaetzlich Scope `user_info`; mit Drive- + Mail-Probes verifizieren wir nur die fuer Adapter benoetigten Scopes | +| 2026-04-28 | `/1/mail` raus, Validierung gegen `calendar.infomaniak.com` + `/2/drive` | `/1/mail` existiert nicht (404 nginx); `mail.infomaniak.com/api/mail` redirected zu OAuth (302). Calendar-PIM-Endpoint funktioniert mit PAT und liefert Identity (account_id/user_id/name) gleich mit. | +| 2026-04-28 | `account_id` in `UserConnection.externalId` persistieren (zurueckgenommen 2026-04-28 abends) | War als Cache-Optimierung gedacht, hat aber zwei Concerns vermischt: (1) UI-Anzeige der Connection und (2) Adapter-Vertragspartner. Existierende Connections konnten dadurch mit einem Token-Fingerprint statt account_id korrumpiert werden, was im kDrive-Browse zu 422 fuehrte. | +| 2026-04-28 | `KdriveAdapter` resolvt `account_id` zur Laufzeit selbst (`_ensureAccountId`) ueber `resolveOwnerIdentity()` | Saubere Trennung: `externalId` ist nur UI-State, der Adapter ist self-contained und braucht keine Connection-Felder zu lesen. Damit gibt es keinen Migrationspfad und keine Korruption mehr -- der Adapter heilt sich automatisch. Cache auf Adapter-Instanz vermeidet wiederholte API-Calls innerhalb desselben Requests. | +| 2026-04-28 | Identity-Resolver (Calendar -> Contacts) zentral in `resolveOwnerIdentity()` | Beide PIM-Endpoints liefern dieselbe Owner-Struktur (`user_id`/`account_id`/`name`); ein gemeinsamer Resolver verhindert, dass Submit und Adapter divergieren. Die Sequenz Calendar -> Contacts deckt alle realistischen Setups ab (mindestens einer der beiden Scopes muss auf der PAT sein, damit kDrive ueberhaupt nutzbar ist). | +| 2026-04-29 | Calendar-Events ueber `/api/pim/event?calendar_id=...` (nicht `/calendar/{id}/event`) | Live-Test 2026-04-29: nested Route 302 zu OAuth, flat Route 200. Die offizielle PAT-faehige Route fordert `from`/`to` als Y-m-d H:i:s mit max-3-Monats-Range -- Adapter waehlt fixes 90-Tage-Window. | +| 2026-04-29 | `.vcf`-Download per Hand synthetisieren (`_renderInfomaniakVcard`) | Alle Contacts-Detail-/Export-Endpoints sind nicht PAT-faehig (500 bzw. 302 OAuth). Wir holen das Listing mit `with=emails,phones,addresses,details` (PAT-faehig) und rendern vCard 3.0 selbst -- konsistent mit MSFT/Google-Contacts-Adapter, der dieselbe Synthese betreibt. | +| 2026-04-28 | MailAdapter und ContactAdapter NICHT registrieren bis Endpoint gefunden | 302-Redirects zu OAuth wuerden im UDB als kaputter Service erscheinen; lieber gar nicht zeigen. | +| 2026-04-28 | ContactAdapter aktivieren via `contacts.infomaniak.com/api/pim/addressbook` | Nachgereichter curl-Test zeigte 200 + JSON mit derselben Struktur wie Calendar (`addressbooks[].id/name/account_id/...`). Singular-Pfad funktioniert (`/contact` und `/contacts` reden weiter mit OAuth). Adapter ist 1:1 analog `CalendarAdapter`, nur mit `addressbook` statt `calendar` und `.vcf` statt `.ics`. | +| 2026-04-28 | `expiresAt = now + 10y` fuer PATs | Analog ClickUp, sonst markiert `getTokenStatusForConnection` die Connection als "none" | +| 2026-04-28 | Connection up-front, dann PAT-Submit (statt Submit-erst-dann-erstellen) | Nutzt vorhandenen `POST /api/connections/`-Pfad ohne Sonderbehandlung; Cancel rollback via DELETE auf Modal-Schliessen | ## Umsetzungs-Checkliste - [x] AuthAuthority-Enum erweitert -- [x] Scopes definiert (`user_info kdrive mail`) -- [x] InfomaniakConnector + KdriveAdapter + MailAdapter -- [x] ConnectorResolver-Registry -- [x] OAuth-Route `/api/infomaniak/auth/connect[/callback]` -- [x] Token-Refresh (`refreshInfomaniakToken` + Background-Service) -- [x] DataConnections-Dispatch (`authority_map`, Labels, `connect_service`) -- [x] App-Router-Mount -- [x] Frontend-Typen -- [x] Hook `createInfomaniakConnectionAndAuth` + Event-Listener -- [x] ConnectionsPage-Button -- [x] UDB-Integration (Authority-Icon, Service-Icons, Source-Colors, Mapping) +- [x] InfomaniakConnector + KdriveAdapter (resolvt `account_id` zur + Laufzeit ueber `resolveOwnerIdentity()`) + CalendarAdapter + + ContactAdapter +- [x] `resolveOwnerIdentity()` als gemeinsamer Identity-Helper im + Connector-Modul (Calendar -> Contacts Fallback, raised + `InfomaniakIdentityError` bei totalem Fehlschlag) +- [x] ConnectorResolver-Registry (alle Adapter werden uniform mit + `accessToken` konstruiert; keine Sonderbehandlung in + `getServiceAdapter`) +- [x] Setup-Guide (PAT-basiert, Calendar + Contacts als zusaetzliche aktive Services) +- [x] PAT-Submit-Endpoint `POST /api/infomaniak/connections/{id}/token` + (Pre-Flight: `resolveOwnerIdentity` + Drive-Probe mit resolvter + `account_id` -- harter 200-Pfad, kein 422-Tolerance-Hack mehr) +- [x] OAuth-Routen entfernt +- [x] Infomaniak-Refresh aus tokenManager + tokenRefreshService entfernt +- [x] Infomaniak-Scopes aus `oauthProviderConfig` entfernt +- [x] `Service_INFOMANIAK_*` aus allen .env-Dateien entfernt +- [x] DataConnections-Dispatch (Infomaniak-Branch in `connect_service` -> 400) +- [x] FE-Hook umgebaut (`createInfomaniakConnection` + `submitInfomaniakToken`) +- [x] FE-OAuth-Popup-Branch fuer Infomaniak entfernt +- [x] ConnectionsPage-PAT-Modal +- [x] UDB-Integration (Authority-Icon, Service-Icons, Source-Colors) - [x] ClickUp-UDB-Fix -- [x] Setup-Guide `wiki/d-guides/infomaniak-oauth-setup.md` -- [ ] Manueller End-to-End-Test (ClientID/Secret im Manager registrieren, Verbinden, kDrive browsen, Mail browsen, Datei downloaden) +- [ ] Manueller End-to-End-Test (Token im Manager erstellen, in Modal + pasten, kDrive browsen, Calendar browsen, Contacts browsen, + Datei + .ics + .vcf downloaden) +- [x] Mail-API-Pfad verifiziert -- alle 7 erschoepfend getesteten Pfade + scheitern (siehe "Blockiert" oben). Adapter pausiert bis Infomaniak + einen PAT-faehigen Endpoint freischaltet. - [ ] `wiki/b-reference/connectors.md` (falls vorhanden) ergaenzen ## Akzeptanzkriterien | # | Kriterium (Given-When-Then) | Prio | |---|-----------------------------|------| -| 1 | Given Infomaniak-Credentials in `APP_CONFIG`, When Nutzer "Infomaniak" auf ConnectionsPage klickt, Then oeffnet sich der OAuth-Popup auf `login.infomaniak.com/authorize` | must | -| 2 | Given erfolgreicher OAuth-Callback, Then ist die UserConnection `ACTIVE`, Token gespeichert (`tokenAccess`+`tokenRefresh`), `externalId/Email` gesetzt | must | -| 3 | Given aktive Connection, When im UDB die Authority expandiert wird, Then werden Services `kdrive` und `mail` mit Icons angezeigt | must | -| 4 | Given Token vor Ablauf < 5 min, When Background-Refresh laeuft, Then wird `_refresh_infomaniak_token` aufgerufen und Token erneuert | must | -| 5 | Given ClickUp-Service-Node im UDB, Then ist das Service-Icon das Klemmbrett (Fix) | should | +| 1 | Given Nutzer auf ConnectionsPage, When er auf "Infomaniak" klickt, Then oeffnet sich ein Modal mit Token-Eingabe und Deeplink zu `manager.infomaniak.com/v3/ng/accounts/token/list` | must | +| 2 | Given gueltiger PAT mit Scopes `drive`+(`workspace:calendar` ODER `workspace:contact`) im Modal, When Submit, Then ist die UserConnection `ACTIVE`, Token gespeichert, `externalId=account_id`, `externalUsername=Owner-Anzeigename aus PIM` | must | +| 3 | Given ungueltiger PAT oder fehlender `drive`-Scope ODER weder `workspace:calendar` noch `workspace:contact`, When Submit, Then bleibt Connection PENDING, Modal zeigt 400-Detail mit Scope-Hinweis | must | +| 4 | Given aktive Connection, When im UDB die Authority expandiert wird, Then werden Services `kdrive`, `calendar` und `contact` mit Icons angezeigt | must | +| 4b | Given aktive Connection (auch eine, deren `externalId` aus historischen Gruenden NICHT `account_id` ist), When im UDB `kdrive` expandiert wird, Then erscheinen Drives ohne `422`-Fehler (Adapter resolvt `account_id` zur Laufzeit ueber `resolveOwnerIdentity`) | must | +| 4c | Given aktive Connection, When im UDB `calendar` expandiert wird, Then erscheinen Kalender, dann Events; Event-Download liefert `.ics` | must | +| 4d | Given aktive Connection, When im UDB `contact` expandiert wird, Then erscheinen Adressbuecher, dann Kontakte; Kontakt-Download liefert `.vcf` | must | +| 5 | Given Modal offen, When User auf Cancel/X klickt, Then wird die soeben erstellte PENDING-Connection wieder geloescht | should | +| 6 | Given ClickUp-Service-Node im UDB, Then ist das Service-Icon das Klemmbrett (Fix) | should | ## Testplan | ID | AC | Art | Automatisiert | Repo-Pfad | Status | |----|----|-----|---------------|-----------|--------| -| T1 | 1-2 | manual | nein | UI: ConnectionsPage | pending | -| T2 | 3 | manual | nein | UI: UDB SourcesTab | pending | -| T3 | 4 | unit | spaeter | gateway/tests/auth/test_tokenManager.py | pending | -| T4 | 5 | manual | nein | UI: UDB SourcesTab | pending | +| T1 | 1-3 | manual | nein | UI: ConnectionsPage Modal | pending | +| T2 | 4 | manual | nein | UI: UDB SourcesTab | pending | +| T3 | 5 | manual | nein | UI: ConnectionsPage Modal Cancel | pending | +| T4 | 6 | manual | nein | UI: UDB SourcesTab | pending | ## Links - PR: tbd -- Setup-Guide: `wiki/d-guides/infomaniak-oauth-setup.md` +- Setup-Guide: `wiki/d-guides/infomaniak-token-setup.md` ## Abschluss diff --git a/c-work/2-build/2026-04-msft-google-calendar-contacts.md b/c-work/2-build/2026-04-msft-google-calendar-contacts.md new file mode 100644 index 0000000..77bcf61 --- /dev/null +++ b/c-work/2-build/2026-04-msft-google-calendar-contacts.md @@ -0,0 +1,189 @@ + + + + +# MSFT- und Google-Connector: CalendarAdapter + ContactsAdapter, plus Reconnect-Button + +## Beschreibung und Kontext + +Der Infomaniak-Connector liefert seit dem 2026-04-28 die drei Services +**kDrive**, **Calendar** und **Contacts**. Die analogen Services fuer Microsoft +und Google fehlten bislang in der UDB. Ziel dieses Builds: vier neue +ServiceAdapter (MSFT-Calendar, MSFT-Contacts, Google-Calendar, +Google-Contacts), so dass die UDB pro Connection einen einheitlichen +Service-Pool zeigt und der Agent fuer Calendar/Contacts gegen alle drei +Provider gleichermassen arbeiten kann. + +Zusaetzliche Anforderung: ein **Reconnect-Button** im Frontend. Bestehende +MSFT-/Google-Connections wurden mit einem kleineren Scope-Set autorisiert. +Beim Hinzufuegen neuer Scopes (`Calendars.Read`, `Contacts.Read`, +`calendar.readonly`, `contacts.readonly`) liefern die Provider auf einer +Re-Authorisierung sonst still **dieselben** alten Tokens, weil sie +`include_granted_scopes` (Google) bzw. `prompt=select_account` (MSFT) +default-maessig nutzen -- die neuen Scopes wuerden nie das Tokenset +erreichen. + +## Architektur + +- `oauthProviderConfig.googleDataScopes` += `calendar.readonly`, + `contacts.readonly` +- `oauthProviderConfig.msftDataScopes` += `Calendars.Read`, `Contacts.Read` +- `connectorMsft.CalendarAdapter` (neu) + - Endpoints: `me/calendars`, `me/calendars/{id}/events` + - Pagination via `@odata.nextLink` (Helper `_stripGraphBase` aus dem Modul) + - Pfad: `""` -> Calendars; `"/{calendarId}"` -> Events + - Download: synthetisches RFC-5545 VCALENDAR/VEVENT (Graph hat keinen + `$value`-Endpoint fuer Events) +- `connectorMsft.ContactsAdapter` (neu) + - Endpoints: `me/contactFolders`, `me/contactFolders/{id}/contacts`, + plus virtueller Default-Folder `default` -> `me/contacts` + - Download: vCard 3.0 (N/FN/ORG/TITLE/EMAIL/TEL/ADR/NOTE) +- Helper: `_eventToIcs`, `_contactToVcard`, `_safeFileName`, + `_personLabel`, `_icsEscape`, `_icsDateTime` (alle modullokal) +- `connectorMsft.MsftConnector._SERVICE_MAP` += `"calendar"` + `"contact"` +- `connectorGoogle.CalendarAdapter` (neu) + - Endpoints: `users/me/calendarList`, + `calendars/{id}/events?singleEvents=true&orderBy=startTime` + - Download: `.ics` aus Event-Detail synthetisiert + - Search: `events?q=...` per Calendar-ID (Default `primary`) +- `connectorGoogle.ContactsAdapter` (neu) + - Endpoints: `contactGroups` + virtueller `all` -> `people/me/connections` + - Gruppen-Mitglieder via `contactGroups/{id}` -> `memberResourceNames` -> + `people:batchGet?resourceNames=...&personFields=...` + - Search: `people:searchContacts` + - Download: vCard 3.0 aus `personFields=names,emailAddresses,phoneNumbers, + organizations,addresses,biographies,memberships` +- `connectorGoogle.GoogleConnector._SERVICE_MAP` += `"calendar"` + + `"contact"` +- `routeFeatureWorkspace` und `routeFeatureGraphicalEditor` + Service-Label-/Icon-Maps += `kdrive`, `calendar`, `contact` (so dass die + UDB sinnvolle Bezeichnungen anzeigt -- vorher war Infomaniak-kDrive + ungelabelt durchgerutscht) + +## Reconnect-Flow + +- `routeDataConnections.connect_service` (`POST /api/connections/{id}/connect`) + akzeptiert optionalen Body `{"reauth": true}` und haengt `&reauth=1` an + die zurueckgegebene `auth_url`. +- `routeSecurityMsft.auth_connect` setzt bei `reauth=1` + `prompt=consent` (statt `select_account` / `login`). Das erzwingt den + Microsoft-Consent-Screen und liefert die neuen Scopes nach. +- `routeSecurityGoogle.auth_connect` droppt bei `reauth=1` + `include_granted_scopes=true` (default-`true` macht Google "vergesslich" + fuer neu hinzugekommene Scopes -- ohne diesen Drop blieben die neuen + Scopes still aussen vor). +- ClickUp ignoriert den Param (FastAPI laesst unbekannte Query-Args + durchrutschen) -- das ist OK, weil ClickUp keine Scope-Erweiterung hat. +- Frontend: + - `connectionApi.connectService(id, reauth?)` haengt den Body an. + - `useConnections.connectWithPopup(id, reauth?)` reicht durch (auch + der separate `useOAuthConnect`-Hook). + - `ConnectionsPage` hat ein neues `customAction` `reconnect` mit + `FaSyncAlt`-Icon, eigenem `reconnectingConnections`-Set und + `visible`-Filter `status === 'active' && authority in {msft, google, + clickup}`. Der Refresh-Button bleibt fuer Token-Refresh ohne + Re-Consent erhalten. + +## Fokus und kritische Details + +- **Pagination**: MSFT Calendar/Contacts paginieren bei `$top<=100`; wir + ziehen `@odata.nextLink` durch wie OutlookAdapter und stoppen bei + `effectiveLimit`. +- **iCal-Bauen**: Wir ueberlassen das nicht einer Library, weil RFC-5545 + fuer einzelne VEVENT-Faelle trivial ist und der Overhead einer + Dependency (`icalendar`) nicht gerechtfertigt ist. Escape und + DTSTAMP/DTSTART/DTEND in UTC. +- **vCard-Bauen**: vCard 3.0 weil universell von Outlook/Google/macOS + Contacts akzeptiert. ADR-Felder: leere Felder werden als leere Slots + gerendert (nicht weggelassen), damit das Format gueltig bleibt. +- **Default-Contacts-Folder**: Outlook hat einen unsichtbaren + Default-Ordner -- den simulieren wir mit dem Pseudo-Folder `default`, + der intern auf `me/contacts` mappt. Ohne diesen Eintrag waere der + Default-Ordner aus der UDB nicht erreichbar. +- **People-API Group-Resolution**: `contactGroups/{id}` liefert keine + vollstaendigen Person-Records, nur `memberResourceNames`. Wir batchen + diese in 200er-Chunks via `people:batchGet`. Limit fuer Resource-Names + pro Batch ist 200 (Google API spec). +- **Reconnect ohne Token-Vernichtung**: Wir loeschen die alte Token nicht + -- der Auth-Callback ueberschreibt sie atomar nach erfolgreichem + Code-Exchange. Bei abgebrochenem Reconnect bleibt der bisherige Token + gueltig. + +## Entscheidungen + +- **Calendar/Contacts read-only:** Wir nehmen `Calendars.Read` / + `calendar.readonly` / `Contacts.Read` / `contacts.readonly` -- nicht + ReadWrite. Der UDB-Use-Case ist ausschliesslich Lesen + Suche + + Download. Schreibrechte werden bei einem konkreten Bedarf separat + diskutiert (mehr Scopes erhoehen Consent-Aufwand und Risk-Profile + unnoetig). +- **Reconnect statt eigenem Endpoint:** Wir spendieren keinen separaten + `/reconnect`-Endpoint, sondern einen Body-Param am bestehenden + `/connect`. Vorteil: ein einziger Authorisierungspfad, identische + Callback-Behandlung, kein doppeltes State-Management. +- **`include_granted_scopes` Drop bei Google:** Default `true` ist + freundlich fuer Token-Reuse, aber die offizielle Google-Doku warnt + ausdruecklich, dass es bei Scope-Erweiterung ueberraschend Tokens + ohne neue Scopes ausstellt. `false` bei Reconnect ist die robustere + Wahl. +- **`prompt=consent` bei MSFT:** Microsoft erkennt zusaetzliche Scopes + zwar normalerweise selbststaendig und zeigt einen Consent-Screen, + aber das ist nur best-effort. Mit `prompt=consent` ist es + garantiert. +- **vCard 3.0 statt 4.0:** 4.0 hat noch immer schlechte Outlook- + Kompatibilitaet beim Import; 3.0 ist der Common-Denominator. +- **`.ics` per Hand bauen statt `iCalendar`-Lib:** Eine externe + Dependency lohnt fuer einen einzigen VEVENT je Download nicht. + +## Umsetzungs-Checkliste + +- [x] `oauthProviderConfig.py`: `googleDataScopes` + `msftDataScopes` + ergaenzt +- [x] `connectorMsft.CalendarAdapter` (browse/download/search) +- [x] `connectorMsft.ContactsAdapter` (browse/download/search) +- [x] `connectorMsft._SERVICE_MAP`: `calendar`, `contact` registriert +- [x] `connectorMsft._eventToIcs`, `_contactToVcard`, Helpers +- [x] `connectorGoogle.CalendarAdapter` (browse/download/search) +- [x] `connectorGoogle.ContactsAdapter` (browse/download/search inkl. + Group-Resolution) +- [x] `connectorGoogle._SERVICE_MAP`: `calendar`, `contact` registriert +- [x] `connectorGoogle._googleEventToIcs`, `_googlePersonToVcard`, + Helpers +- [x] `routeFeatureWorkspace` Service-Label/Icon-Maps erweitert +- [x] `routeFeatureGraphicalEditor` Service-Label/Icon-Maps erweitert +- [x] `routeDataConnections.connect_service`: optionaler `reauth`-Body +- [x] `routeSecurityMsft.auth_connect`: `reauth` -> `prompt=consent` +- [x] `routeSecurityGoogle.auth_connect`: `reauth` -> drop + `include_granted_scopes` +- [x] Frontend `connectionApi.connectService(id, reauth?)` +- [x] Frontend `useConnections.connectWithPopup(id, reauth?)` (beide + Hooks) +- [x] Frontend `ConnectionsPage`: `Reconnect`-Button mit + `FaSyncAlt`-Icon und eigenem Loading-Set +- [ ] **Manuelle Verifikation:** existierende MSFT-Connection neu + durchklicken (Reconnect), Calendar+Contacts in der UDB + verifizieren; existierende Google-Connection genauso +- [ ] **Manuelle Verifikation:** Calendar-Download liefert eine + importierbare `.ics` (Outlook-Test); Contacts-Download liefert eine + importierbare `.vcf` +- [ ] Doc-Sync: `wiki/b-reference/auth-and-rbac.md`, + `wiki/b-reference/connectors.md` aktualisieren (neue Scopes, + Reconnect-Pfad), Topics-Liste pruefen + +## Akzeptanzkriterien + +1. Bestehende MSFT-/Google-Connection -> Reconnect-Button -> nach + Consent erscheinen `Calendar` und `Contacts` als zusaetzliche + Service-Knoten in der UDB. +2. Calendar-Browse listet die User-Calendars; Klick auf einen + Calendar listet die juengsten Events; Download eines Events + liefert eine `.ics` mit DTSTART/DTEND/SUMMARY/LOCATION. +3. Contacts-Browse listet ContactFolders (MSFT) bzw. ContactGroups + (Google); Klick listet die enthaltenen Contacts; Download liefert + eine `.vcf` mit FN, ORG, EMAIL, TEL. +4. Frischer Connect (neue MSFT-Connection) zieht die neuen Scopes ohne + Reconnect (weil sie im initialen `msftDataScopes` / + `googleDataScopes` enthalten sind). +5. ClickUp- und FTP-Connections sind unbeeintraechtigt; bei + Infomaniak ist der Reconnect-Button korrekt **nicht** sichtbar + (Authority-Whitelist). diff --git a/c-work/_CHANGELOG.md b/c-work/_CHANGELOG.md index 99ab652..b75d113 100644 --- a/c-work/_CHANGELOG.md +++ b/c-work/_CHANGELOG.md @@ -12,8 +12,28 @@ type: `feat` `fix` `refactor` `docs` `test` `chore` `build` · scope: `gateway Skip: reine Refactors, Formatting, Lint, Dep-Bumps, Test-only, Wiki-Tippfehler. +## 2026-04-29 + +- 2026-04-29 | fix | gateway, frontend-nyla, wiki | **Infomaniak-Connector: kDrive findet Standalone- und Free-tier-Drives (account_id-Resolution korrigiert).** Live-Beweis vom User mit echten kDrive-Files und Empty-Listing aus PowerOn: das Standalone-/Free-tier-kDrive haengt an einer **anderen** `account_id` als die kSuite, und `/2/drive?account_id=` antwortet sauber 200 mit leerer Liste -- die kSuite-`account_id` aus PIM Calendar/Contacts (heutige Identity-Quelle) trifft also den Drive-Account ueberhaupt nicht. Root-cause: es gibt unter den heutigen Scopes (`drive`, `workspace:*`) **keinen** Endpoint, der die volle Account-Liste eines PAT zurueckgibt -- der einzige PAT-faehige ist `GET /1/accounts`, der `403 all_scopes "scopes: ['accounts']"` antwortet wenn der `accounts`-Scope fehlt. Architektur-Change: (a) Neuer Helper `resolveAccessibleAccountIds(token)` im `connectorInfomaniak.py` -- ein einzelner `/1/accounts`-Call gibt **alle** account_ids zurueck, raised `InfomaniakIdentityError` mit klarer Scope-Message wenn der Scope fehlt. (b) `KdriveAdapter` haelt jetzt eine `_accountIds: List[int]` (statt `_accountId: int`); `_listDrives()` ruft `/2/drive?account_id=X` fuer **jede** account_id auf und unioniert die Drive-Ergebnisse mit Dedup ueber `driveId`. Damit deckt der Adapter sowohl den kSuite-eingebetteten als auch den Standalone-/Free-tier-kDrive ab, ohne dass der User irgendwas konfigurieren muss. (c) `resolveOwnerIdentity` ist jetzt rein UI-Identity (Display-Name + kSuite-`account_id` fuer das Connection-Label) -- nicht mehr fuer Drive-Lookup verwendet. (d) `submit_infomaniak_token` validiert jetzt drei Scopes deterministisch: `/1/accounts` (`accounts`-Scope), `resolveOwnerIdentity` (`workspace:calendar`/`workspace:contact`-Scope), `/2/drive?account_id={first}` (`drive`-Scope). Jeder Schritt liefert eine eigene 400-Message, die genau den fehlenden Scope nennt. (e) `grantedScopes` um `"accounts"` ergaenzt. Frontend: PAT-Setup-Modal listet jetzt **fuenf** Pflicht-Scopes (`accounts` neu) mit klarem "sonst findet kDrive deinen Drive nicht"-Hinweis. Doku: `infomaniak-token-setup.md` Status-Tabelle / Scope-Tabelle / Validation-Section / "How the kDrive adapter knows your account"-Section komplett umgeschrieben. **Konsequenz: bestehende Infomaniak-PATs ohne `accounts`-Scope muessen einmal im Infomaniak Manager neu erstellt werden -- der gestrige `resolveOwnerIdentity`-Pre-Flight-Check faellt sonst nie auf, das Listing bleibt leer.** (c-work: `c-work/2-build/2026-04-infomaniak-connector.md`) + +- 2026-04-29 | fix | gateway | **Infomaniak-Connector: Calendar-Events und Contacts-Download auf die echten PAT-faehigen Pfade gezogen.** Live-Tests gegen die Vendor-API zeigten zwei Pfad-Mismatches im gestrigen Build: (1) Calendar-Events: der nested Route `/api/pim/calendar/{id}/event` 302t zur OAuth-Login-Seite (also nicht PAT-faehig); korrekter Endpoint ist `/api/pim/event?calendar_id={id}&from=YYYY-MM-DD HH:MM:SS&to=...` mit Pflicht-Range max 3 Monate (Vendor-Constraint `range_must_be_lower_than_3_months`) -- Adapter waehlt jetzt fix 90-Tage-Window (heute -30 / +60), URL-Encoding via `urllib.parse.quote`. Event-Detail und `.ics`-Export laufen ueber `/api/pim/event/{eventId}` und `.../export` (also ohne calendar-Praefix). (2) Contacts-Download: `/addressbook/{book}/contact/{id}` antwortet mit `500 unexpected_error` und `.../export` 302t zu OAuth -- beide Detail-Endpoints sind also nicht PAT-faehig. Listing dagegen funktioniert, liefert aber per Default nur Stammdaten -- ohne `with=emails,phones,addresses,details` kommen Email/Phone/Adresse leer. Loesung: ContactAdapter holt das Listing mit dem `with`-Param und rendert die `.vcf` selbst via `_renderInfomaniakVcard()` (vCard 3.0, escaped, mit N/FN/ORG/EMAIL/TEL/ADR/URL/NOTE) -- konsistent mit MSFT/Google-Adapter, die ihre `.vcf`s ebenfalls selbst synthetisieren. Plus: Helper `_safeFileName` aus dem Calendar-Adapter zu modullokal hochgezogen, von beiden Adaptern genutzt; ungenutzte `import re`/`import json`-Inline-Imports raus. **kDrive-Adapter ist im Live-Test korrekt:** `/2/drive?account_id=1696919` antwortet 200 mit leerem Array fuer den Test-Account (kein kDrive-Produkt aktiviert). (c-work: `c-work/2-build/2026-04-infomaniak-connector.md`) + ## 2026-04-28 +- 2026-04-28 | refactor | gateway | **Infomaniak-Connector: `account_id` ist Adapter-State, nicht Connection-State.** Symptom im Log: `Infomaniak GET https://api.infomaniak.com/2/drive?account_id=pat-XXXXXXXX -> 422 validation_rule_integer "The account id must be an integer."`. Root cause: der `KdriveAdapter` las `connection.externalId` als `account_id`-Quelle, und bei kaputten Submits konnte dort der Token-Fingerprint ("pat-59ee48d9") statt der `account_id` stehen. Saubere Loesung statt Fingerprint-Migration: (a) Neuer modullokaler Helper `resolveOwnerIdentity(token) -> InfomaniakOwnerIdentity` im `connectorInfomaniak.py` -- versucht PIM Calendar (`workspace:calendar`-Scope), faellt auf PIM Contacts (`workspace:contact`-Scope) zurueck, raised `InfomaniakIdentityError` wenn keine Owner-Records gefunden. (b) `KdriveAdapter` hat keinen `accountId`-Konstruktor-Parameter mehr, sondern resolvt zur Laufzeit ueber `_ensureAccountId()` (gecached auf der Adapter-Instanz). Damit ist der Adapter self-contained und liest **nichts** mehr aus der Connection -- `externalId` ist reiner UI-State. (c) `submit_infomaniak_token` ruft denselben `resolveOwnerIdentity()` als Pre-Flight-Check, dann `/2/drive?account_id={resolved}` als sauberer 200-Probe. Der frueher noetige `_probeScope`-422-Tolerance-Hack ist entfallen. (d) `getServiceAdapter` hat keine kDrive-Sonderbehandlung mehr; alle drei Adapter werden uniform mit `accessToken` konstruiert. (e) `_PIM_PREFIX` ist auf Modul-Ebene definiert; CalendarAdapter und ContactAdapter haben keine Klassen-Konstanten mehr. **Konsequenz: jede existierende Infomaniak-Connection arbeitet ohne User-Aktion sofort wieder, auch wenn `externalId` historisch einen Fingerprint enthaelt -- der Adapter zieht die `account_id` deterministisch aus der API.** Setup-Guide um Architektur-Abschnitt erweitert. (c-work: `c-work/2-build/2026-04-infomaniak-connector.md`) + +- 2026-04-28 | feat | gateway, frontend-nyla | **MSFT- und Google-Connector: CalendarAdapter + ContactsAdapter neu, plus Reconnect-Button.** Nachdem Infomaniak heute Calendar/Contacts kann, ziehen MSFT und Google nach. Backend: (a) `oauthProviderConfig.googleDataScopes` um `calendar.readonly` + `contacts.readonly` erweitert, `msftDataScopes` um `Calendars.Read` + `Contacts.Read`. (b) `connectorMsft.CalendarAdapter` (Graph: `me/calendars`, `me/calendars/{id}/events?$top&$orderby=start/dateTime desc`, Pagination via `@odata.nextLink`, `.ics`-Download als selbstgebautes RFC-5545 VCALENDAR/VEVENT da Graph keinen `$value`-Endpoint fuer Events kennt; `$search` fuer query). (c) `connectorMsft.ContactsAdapter` (Graph: `me/contactFolders` + virtueller `default`-Ordner fuer `me/contacts`, `me/contactFolders/{id}/contacts?$orderby=displayName`, `.vcf` als vCard-3.0 selbstgebaut mit N/FN/ORG/TITLE/EMAIL/TEL/ADR/NOTE). Helper `_eventToIcs`, `_contactToVcard`, `_safeFileName`, `_personLabel` plus `_icsEscape`/`_icsDateTime`. (d) `connectorGoogle.CalendarAdapter` (Calendar v3: `users/me/calendarList`, `calendars/{id}/events?singleEvents=true&orderBy=startTime`, `.ics` aus Event-Detail). (e) `connectorGoogle.ContactsAdapter` (People API: `contactGroups` + virtueller `all`-Folder ueber `people/me/connections`, fuer Gruppen `people:batchGet?resourceNames=...`, Search via `people:searchContacts`, `.vcf` aus `personFields=names,emailAddresses,phoneNumbers,organizations,addresses,biographies`). (f) `_SERVICE_MAP` von beiden Connectoren um `"calendar"`/`"contact"` ergaenzt; `routeFeatureWorkspace` und `routeFeatureGraphicalEditor` Service-Label-/Icon-Maps um `kdrive`, `calendar`, `contact` ergaenzt, damit die UDB sinnvolle Anzeigen macht. (g) **Reconnect-Flow**: `POST /api/connections/{id}/connect` akzeptiert optionalen Body `{"reauth": true}` und haengt `&reauth=1` an die Auth-URL. `routeSecurityMsft.auth_connect` setzt bei `reauth=1` `prompt=consent` (sonst select_account/login), `routeSecurityGoogle.auth_connect` droppt bei `reauth=1` `include_granted_scopes=true` damit Google strikt fuer die aktuelle Scope-Liste neu signiert (sonst werden neue Scopes still uebersprungen). Frontend: `connectionApi.connectService(id, reauth?)` -> POST mit Body, `useConnections.connectWithPopup(id, reauth?)` reicht durch, `ConnectionsPage` zeigt fuer aktive MSFT/Google/ClickUp-Connections einen `FaSyncAlt`-Button "Erneut verbinden (neue Scopes erteilen)" mit eigenem Loading-Set. Bestehende Connections muessen einmal reconnected werden, damit Calendar/Contacts in der UDB auftauchen. (c-work: `c-work/2-build/2026-04-msft-google-calendar-contacts.md`) + +- 2026-04-28 | fix | gateway | **OpenAI-Connector: `temperature` fuer GPT-5.x / o-Serie aus dem Payload nehmen.** Symptom im Log: jede AI-Anfrage failt mit HTTP 400 `Unsupported value: 'temperature' does not support 0.2 with this model. Only the default (1) value is supported.`, der Failover spricht 14 Modelle durch und schreibt `Recorded failure for gpt-5.5, cooldown 60.0s`. Root cause: die GPT-5-Familie (gpt-5, gpt-5.4*, gpt-5.5*) und die o-Serie (o1/o3/o4) sind Reasoning-Modelle; OpenAI akzeptiert dort -- analog zur `max_tokens` -> `max_completion_tokens`-Restriction -- nur den Default (`1`). Wir senden aber unverhandelt `temperature=0.2` aus jedem `AiModel`-Eintrag. Fix: Helper `_supportsCustomTemperature(modelName)` und in `callAiBasic`/`callAiBasicStream`/`callAiImage` den Key nur conditional ins Payload aufnehmen (Modellnamen mit Praefix `gpt-5`, `o1`, `o3`, `o4` lassen ihn weg). Der vom User im UI gesetzte Override ueber `AiCallOptions.temperature` wird auf den nicht unterstuetzten Modellen still verworfen statt einen 400 zu erzwingen. Tests: `tests/unit/aicore/test_aicorePluginOpenai_temperature.py` (18 Tests, parametrisiert ueber Legacy-Modelle vs. Reasoning-Familie). Suite gesamt: 530 passed. + +- 2026-04-28 | docs | wiki | **Infomaniak-Connector: Mail-Adapter formal als "blocked by vendor" markiert.** Erschoepfende Pfad-Tests am 2026-04-28 zeigen: alle 7 plausiblen Mail-Endpoints sind heute nicht PAT-faehig. `api.infomaniak.com/{1,2}/mail` -> 404 nginx (existiert nicht); `mail.infomaniak.com/api/mail[?account_id=...]`, `/api/pim/mail`, `/api/pim/mailbox`, `/api/pim/folder` -> 302 zu `login.infomaniak.com/authorize` (nur OAuth-Web-Session, Bearer-PAT wird abgelehnt); `mail.infomaniak.com/api/mail/?account_id=...` -> 301 zu `http://mail.infomaniak.com:5000` (interner Cyrus-IMAP-Port, von aussen nicht erreichbar). Der `workspace:mail`-Scope bleibt im PAT-Standard-Setup, damit der MailAdapter spaeter ohne Token-Rotation freigeschaltet werden kann; Setup-Guide und c-work-Doku zementieren den Befund. Kein Code-Change. (c-work: `c-work/2-build/2026-04-infomaniak-connector.md`) + +- 2026-04-28 | feat | gateway, frontend-nyla | **Infomaniak-Connector: ContactAdapter neu.** Nachfolge-Tests gegen die Contacts-PIM-API zeigten, dass `https://contacts.infomaniak.com/api/pim/addressbook` (Singular!) mit dem PAT-Scope `workspace:contact` und Bearer-Auth `200` + JSON liefert -- gleiche Antwort-Struktur wie Calendar (`addressbooks[].id/name/account_id/...`). Die Plural- und `/contact*`-Pfade (`/api/pim/contacts`, `/api/pim/contact/addressbook`) sind weiterhin OAuth-only bzw. 404. Neuer `ContactAdapter` 1:1 analog `CalendarAdapter`: `browse("/")` -> Adressbuecher, `browse("/{bookId}")` -> Kontakte (Display-Name, Email, Phone, Organization in Metadata), `download("/{bookId}/{contactId}")` -> `.vcf` via `/contact/{id}/export` mit JSON-Fallback, `search()` als kostenguenstiger Client-Filter. Geteilte Organisations-Adressbuecher (`name=""`, `is_dynamic_organisation_member_directory=true`) bekommen "Organisation" als Anzeigename, sonst waere der Tree-Knoten leer. Neue Konstante `_CONTACTS_BASE`, `ContactAdapter` in `_SERVICE_MAP` registriert. Frontend: `SourcesTab` kennt `contact`-Icon (👤), -Color und Mapping; PAT-Modal nennt Contacts als heute aktiv (Mail bleibt "in Vorbereitung"). Setup-Guide: Status-Tabelle, UDB-Verifikations-Liste und Validation-Beschreibung aktualisiert. (c-work: `c-work/2-build/2026-04-infomaniak-connector.md`) + +- 2026-04-28 | feat | gateway, frontend-nyla | **Infomaniak-Connector: kDrive PAT-Fix + Calendar-Adapter neu.** Nach den ersten echten PAT-Test-Calls aufgeraeumt: (a) `/2/drive` braucht ein `account_id`-Query-Arg, sonst `422 account_id required`. Fix: `account_id` einmalig beim Token-Submit aus dem Calendar-PIM-Endpoint (`https://calendar.infomaniak.com/api/pim/calendar` -- liefert `account_id`, `user_id`, Anzeigename) ziehen, in `UserConnection.externalId` persistieren und ueber `InfomaniakConnector.getServiceAdapter` als Konstruktor-Parameter in den `KdriveAdapter` injizieren. `/1/profile` (haette `user_info`-Scope verlangt) und `/1/mail` (existiert nicht: 404 nginx) raus aus den Probes. (b) `_probeScope` toleriert jetzt 4xx ausser 401/403 (z.B. 422 `validation_failed`) als "Scope ist da, Endpoint braucht nur weitere Args". (c) Neuer `CalendarAdapter` (`browse` -> Calendars/Events ueber `calendar.infomaniak.com/api/pim/calendar`, `download` -> `.ics` via `/event/{id}/export`, JSON-Fallback). `_infomaniakGet` und `_infomaniakDownload` akzeptieren ein optionales `baseUrl`, sodass Calendar gegen `calendar.infomaniak.com` und kDrive gegen `api.infomaniak.com` laufen koennen. (d) `MailAdapter` aus `_SERVICE_MAP` entfernt: `mail.infomaniak.com/api/mail` redirected mit 302 zu `login.infomaniak.com/authorize`, akzeptiert also keine PATs; gleiches gilt fuer `contacts.infomaniak.com/api/pim/contact`. Beide Scopes werden weiterhin in `grantedScopes` gespeichert, damit kuenftige Adapter ohne Token-Rotation aufgeschaltet werden koennen. (e) Frontend: `SourcesTab` kennt `calendar`-Icon, -Color und `_SERVICE_TO_SOURCE_TYPE`-Mapping; PAT-Modal in `ConnectionsPage` zeigt Calendar als zweiten aktiven Service, Mail/Contact als "in Vorbereitung, Scope schon mitnehmen". (f) Setup-Guide aktualisiert (Status-Tabelle + Validation-Beschreibung). (c-work: `c-work/2-build/2026-04-infomaniak-connector.md`) + +- 2026-04-28 | refactor | gateway, frontend-nyla | **Infomaniak-Connector: OAuth -> Personal Access Token.** Infomaniaks `login.infomaniak.com/authorize` akzeptiert nur Identity-Scopes (`openid`, `profile`, `email`, `phone`); Versuche mit `kdrive`/`mail`-Scopes quittieren mit `error=invalid_scope`. Datenzugriff geht ausschliesslich ueber manuell im Manager erstellte PATs. Umbau: (a) Backend `routeSecurityInfomaniak` komplett neu -- ein Endpoint `POST /api/infomaniak/connections/{id}/token`, validiert PAT via `GET https://api.infomaniak.com/1/profile`, persistiert Bearer mit 10-Jahres-Horizont (analog ClickUp), entfernt OAuth-Connect/Callback-Pfade. (b) `tokenManager.refreshInfomaniakToken` + `tokenRefreshService._refresh_infomaniak_token` entfernt, AuthAuthority.INFOMANIAK aus den Background-Refresh-Filtern raus -- PATs sind langlebig. (c) `oauthProviderConfig.infomaniakDataScopes` + `infomaniakDataScopesForRefresh` entfernt. (d) `routeDataConnections.connect_service` antwortet bei Infomaniak jetzt mit 400 + Hinweis auf den PAT-Endpoint. (e) Env-Cleanup: `Service_INFOMANIAK_DATA_CLIENT_ID/SECRET` und `Service_INFOMANIAK_OAUTH_REDIRECT_URI` aus `.env` + `env_dev/int/prod[_forgejo].env` raus. (f) Frontend: `useConnections.createInfomaniakConnectionAndAuth` (OAuth-Popup) ersetzt durch `createInfomaniakConnection` + `submitInfomaniakToken`; `ConnectionsPage` zeigt PAT-Modal mit Schritt-Anleitung + Deeplink zu `manager.infomaniak.com/v3/ng/accounts/token/list`; Cancel rollt PENDING-Connection per DELETE zurueck. (g) Doku: `wiki/d-guides/infomaniak-oauth-setup.md` geloescht, `wiki/d-guides/infomaniak-token-setup.md` neu. (c-work: `c-work/2-build/2026-04-infomaniak-connector.md`) + - 2026-04-28 | refactor | gateway | **Cleanup der zwei tieferliegenden Defensive-Programming-Schichten, die den Trustee-Bug (vorheriger Eintrag) ueberhaupt erst durchgelassen haben.** - **(1) DB-Connector: fail-loud statt swallow.** `connectorDbPostgre.getRecord/getRecordset/getRecordsetPaginated/getDistinctColumnValues/_loadTable/semanticSearch` haben bisher jede Exception per `except Exception → log → return []` (bzw. `None`/leeres Pagination-Resultat) verschluckt. Folge: jeder echte DB-Fehler (Postgres-Adapt, UndefinedTable, UndefinedColumn, OperationalError, etc.) wurde fuer den Caller ununterscheidbar von "0 Rows" -- darauf basierten misleading downstream Errors wie "No active accounting configuration found". Neu: typisierte Exception `DatabaseQueryError(table, message, original)` plus zentrales `_rollbackQuietly(connection)` (Postgres setzt die Connection in Error-State nach jedem fehlgeschlagenen Statement). Empty Result Sets liefern weiterhin `[]`/`None`/`{items: [], totalItems: 0, totalPages: 0}` (= Normalpfad ueber `cursor.fetchall()/fetchone()`), aber jede Exception innerhalb des Query-Pfads wird hochgereicht. Tests: `tests/unit/connectors/test_connectorDbPostgre_failLoud.py` (9 Tests). - **(2) Action-Parameter: zentrale Validierung statt impliziter Kontrakt.** Workflow-Actions haben `parameters: Dict[str, Any]` ohne Schema-Enforcement bekommen; die Aktionsimplementationen mussten ad-hoc `isinstance`-Branches haben oder mit Postgres-Errors abstuerzen (siehe Trustee-Bug). Neues Modul `gateway/modules/workflows/processing/shared/parameterValidation.py` mit `InvalidActionParameterError(ValueError)` + `validateAndCoerceParameters(actionDef, parameters)`. Zentral aufgerufen in `ActionExecutor.executeAction` -- gilt fuer alle Aufrufpfade (Agent, Workflow-Graph, REST). Logik: diff --git a/d-guides/deployment/poweron-sec.kdbx b/d-guides/deployment/poweron-sec.kdbx index 6f9f22ed441da0d0fe77385b670e41779de1f0aa..0daaaed913253099c8f6b11aab18d9ada44c603a 100644 GIT binary patch literal 22910 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 literal 22590 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

3$-&iwxeWO1p0CuI!+@PYTQ$8dJ5aef0UYg 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 diff --git a/d-guides/google-oauth-setup.md b/d-guides/google-oauth-setup.md index a17bd52..051ee5b 100644 --- a/d-guides/google-oauth-setup.md +++ b/d-guides/google-oauth-setup.md @@ -27,10 +27,32 @@ This guide explains how to set up Google OAuth 2.0 authentication for the Porta 3. If prompted, configure the OAuth consent screen first: - Choose "External" user type - Fill in the required fields (App name, User support email, Developer contact information) - - Add scopes: `https://www.googleapis.com/auth/userinfo.profile`, `https://www.googleapis.com/auth/userinfo.email` + - Add scopes (see `gateway/modules/auth/oauthProviderConfig.py` -- `googleAuthScopes` and `googleDataScopes` are the source of truth): + - **Auth app** (login only): + - `openid` + - `https://www.googleapis.com/auth/userinfo.profile` + - `https://www.googleapis.com/auth/userinfo.email` + - **Data app** (UDB connectors -- Drive, Gmail, Calendar, Contacts): + - everything from the Auth app, **plus** + - `https://www.googleapis.com/auth/gmail.readonly` + - `https://www.googleapis.com/auth/drive.readonly` + - `https://www.googleapis.com/auth/calendar.readonly` + - `https://www.googleapis.com/auth/contacts.readonly` + - Enable the matching Google APIs under "APIs & Services > Library": + Gmail API, Google Drive API, **Google Calendar API**, **People API**. - Add test users if needed - Click "Save and Continue" through all sections +> **Adding new scopes to an existing app?** When you broaden the scope list +> (e.g. when Calendar/Contacts were rolled out on top of the original Drive + +> Gmail set), every existing UserConnection must go through the +> **Reconnect** action in the Connections page (`/basedata/connections`). The +> Reconnect button posts `{"reauth": true}` to the connect endpoint, which +> drops `include_granted_scopes=true` from the authorization URL so Google +> issues a token strictly for the **current** scope set. Without reconnecting +> the connection keeps working with the **old** scopes only -- the new +> services (Calendar, Contacts) silently return 403/empty. + 4. Back to creating OAuth client ID: - Application type: "Web application" - Name: "Porta Web Client" diff --git a/d-guides/infomaniak-oauth-setup.md b/d-guides/infomaniak-oauth-setup.md deleted file mode 100644 index d195d44..0000000 --- a/d-guides/infomaniak-oauth-setup.md +++ /dev/null @@ -1,105 +0,0 @@ -# Infomaniak OAuth 2.0 Setup Guide - -## Overview - -This guide explains how to register an Infomaniak OAuth application so that PowerOn -users can connect their Infomaniak account as a data source. The connection -exposes two services in the Unified Data Bar: - -- **kDrive** -- browse and download files from any drive accessible to the user. -- **Mail** -- browse mailboxes, folders, and download messages as `.eml`. - -Infomaniak is a **data-only** authority in PowerOn. It is **not** a login -provider; users still authenticate against PowerOn via Local / Google / Microsoft. - -## Prerequisites - -- An Infomaniak account with admin rights to create API credentials. -- Access to the Infomaniak Manager (https://manager.infomaniak.com). -- The PowerOn gateway publicly reachable (or `http://localhost:8000` for development). - -## Step 1: Create an Infomaniak API Application - -1. Login to https://manager.infomaniak.com. -2. Navigate to your account icon (top right) > **API**. -3. Click **Create a new application**. -4. Fill in: - - **Name:** PowerOn (or your project name) - - **Description:** Data connector for kDrive + Mail - - **Redirect URI:** must exactly match the gateway endpoint: - - Development: `http://localhost:8000/api/infomaniak/auth/connect/callback` - - Production: `https:///api/infomaniak/auth/connect/callback` -5. Select scopes: - - `user_info` -- required to read `/1/profile` for `externalId` / `email`. - - `kdrive` -- required for the kDrive ServiceAdapter. - - `mail` -- required for the Mail ServiceAdapter. -6. Save and copy the generated **Client ID** and **Client Secret**. - -## Step 2: Configure PowerOn Gateway - -Add the following keys to your gateway environment file (e.g. `gateway/env_dev.env`): - -```env -# Infomaniak OAuth -- Data App (kDrive + Mail) -Service_INFOMANIAK_DATA_CLIENT_ID = your-client-id -Service_INFOMANIAK_DATA_CLIENT_SECRET = your-client-secret -Service_INFOMANIAK_OAUTH_REDIRECT_URI = http://localhost:8000/api/infomaniak/auth/connect/callback -``` - -For production, replace the redirect URI with your HTTPS gateway host. The URI -must be identical (byte-for-byte) to the one registered in step 1. - -Restart the gateway after editing the env file so `APP_CONFIG` is reloaded. - -## Step 3: Verify the Flow - -1. Login to PowerOn and open **Basisdaten > Verbindungen**. -2. Click **Infomaniak** in the header actions. -3. A popup opens against `https://login.infomaniak.com/authorize`. -4. Sign in with your Infomaniak account, grant the requested scopes. -5. The popup closes automatically and the new connection appears with status - `connected`. -6. Open the **Unified Data Bar > Sources** tab; you should see the Infomaniak - authority node with `kdrive` and `mail` services. - -## Token Lifecycle - -- Access tokens are short-lived (refresh handled automatically by - `tokenRefreshService._refresh_infomaniak_token`). -- Refresh tokens are stored alongside the connection (`Token.tokenRefresh`). -- If a refresh returns `invalid_grant`, the user must reconnect (popup again - via the Infomaniak button on `ConnectionsPage`). - -## Troubleshooting - -### `invalid_redirect_uri` -The URI registered on Infomaniak Manager does not match `Service_INFOMANIAK_OAUTH_REDIRECT_URI`. -They must be byte-identical (including protocol, host, port, and path). - -### `invalid_client` -Client ID or secret in the env file does not match what Infomaniak issued. -Re-copy the values from Manager > API. - -### Profile lookup returns 401 -The `user_info` scope was not granted. Re-create the application with the -correct scope set, or reconnect the user. - -### Empty kDrive listing -The user has no kDrives associated. Verify on https://kdrive.infomaniak.com -that at least one drive is accessible. - -## Security Notes - -- Never commit the client secret. Use the encrypted env mechanism described in - `wiki/d-guides/encrypt-env-secrets.md`. -- Rotate the client secret if it leaks; update `Service_INFOMANIAK_DATA_CLIENT_SECRET` - and restart the gateway. Existing tokens remain valid only until they expire. -- The connector is scoped to `DATA_CONNECTION` only. Even with valid Infomaniak - credentials, no PowerOn login session is created. - -## Reference - -- Code: `gateway/modules/connectors/providerInfomaniak/connectorInfomaniak.py` -- OAuth route: `gateway/modules/routes/routeSecurityInfomaniak.py` -- Token refresh: `gateway/modules/auth/tokenManager.py::refreshInfomaniakToken` -- Frontend hook: `frontend_nyla/src/hooks/useConnections.ts::createInfomaniakConnectionAndAuth` diff --git a/d-guides/infomaniak-token-setup.md b/d-guides/infomaniak-token-setup.md new file mode 100644 index 0000000..d30f0f7 --- /dev/null +++ b/d-guides/infomaniak-token-setup.md @@ -0,0 +1,161 @@ +# Infomaniak Personal Access Token Setup + +## Overview + +PowerOn integrates Infomaniak as a **data-only** authority for the kSuite +services in the Unified Data Bar: + +| Service | API scope | Status in PowerOn | +|---|---|---| +| _account discovery_ -- enumerates the user's account_ids | `accounts` | required for kDrive | +| **kDrive** -- browse / download files | `drive` | active | +| **Calendar** -- agendas + events (.ics download) | `workspace:calendar` | active | +| **Contacts** -- address books + contacts (.vcf download) | `workspace:contact` | active | +| **Mail** -- mailboxes, folders, `.eml` download | `workspace:mail` | blocked by Infomaniak (no PAT-friendly endpoint) | + +You should tick **all five scopes** when creating the token even if only +kDrive, Calendar and Contacts are wired up today -- this avoids a token +rotation when Mail goes live. + +The `accounts` scope is non-negotiable: a standalone or free-tier +kDrive lives on a *different* `account_id` than its kSuite counterpart, +and `/1/accounts` is the only PAT-friendly endpoint that enumerates +**all** account_ids in one call. Without it the kDrive listing will +silently come back empty even though the PAT carries the `drive` +scope, because PowerOn would only know about the kSuite `account_id` +(via PIM Calendar / Contacts) and that one returns no drives. + +**Status of the Mail adapter (2026-04-28):** Infomaniak currently does +not expose a PAT-authenticated endpoint for mailboxes/folders/messages. +Every probed route either returns `404` (`/1/mail`, `/2/mail`) or +`302`-redirects to the OAuth authorize page +(`/api/mail`, `/api/pim/mail`, `/api/pim/mailbox`, `/api/pim/folder`), +which is the OAuth-Web-Session path -- bearer PATs are rejected. The +`/api/mail/?account_id=...` route 301-redirects to an internal Cyrus +server on `http://mail.infomaniak.com:5000` that is not reachable from +the public internet. The `workspace:mail` scope is stored on the PAT so +the Mail adapter can be enabled later with no token rotation, as soon +as Infomaniak opens a public PAT-friendly endpoint. + +Infomaniak does not expose its data APIs (`/2/drive/...`, `/1/mail/...`) over +OAuth 2.0. The OAuth endpoint at `login.infomaniak.com/authorize` only issues +identity tokens (scopes `openid`, `profile`, `email`, `phone`). Bearer tokens +that work against the data APIs must be issued manually by the user as a +**Personal Access Token (PAT)** in the Infomaniak Manager. + +This is the same model used by GitHub Personal Access Tokens, Notion +Integration Tokens, and many other vendors. PowerOn never sees the user's +Infomaniak password. + +## Prerequisites + +- An Infomaniak account that has access to at least one kDrive and one mailbox. +- The PowerOn frontend reachable in the browser; the backend reachable from + the frontend. + +## Step 1: Create the token in the Infomaniak Manager + +1. Open the API-Tokens page directly: + +2. Click **Token erstellen** (Create token). +3. Fill in the form: + - **Token name**: anything memorable, for example `PowerOn` or + `PowerOn DEV`. + - **Application**: leave it on `Default application`. The "PowerOn" + application registration is **not** used for PATs. + - **Scopes** (search box): add **all five** of the following, one by one: + + | Search term | Pick this entry | + |---|---| + | `accounts` | `accounts - Manage your accounts` | + | `drive` | `drive - Drive products` | + | `workspace:mail` | `workspace:mail - Manage your emails` | + | `workspace:calendar` | `workspace:calendar - Manage your calendars` | + | `workspace:contact` | `workspace:contact - Manage your contacts` | + + Do **not** tick `All` -- it grants every Infomaniak API (Hosting, + Billing, AI, ...). Do **not** add `user_info` -- PowerOn does not call + `/1/profile`. + - **Validity**: any value works. PowerOn does not auto-refresh PATs. +4. Click **Erstellen** (Create) and **copy the token immediately**. Infomaniak + shows the token value only once. + +## Step 2: Paste the token into PowerOn + +1. Sign in to PowerOn and open **Basisdaten -> Verbindungen**. +2. Click **Infomaniak**. PowerOn creates a pending connection and opens a + modal that asks for the Personal Access Token. +3. Paste the token from Step 1 and click **Verbinden**. + +PowerOn validates the token in three deterministic steps before +persisting anything -- each step probes exactly one scope: + +1. `GET https://api.infomaniak.com/1/accounts` (requires scope + `accounts`) -- enumerates **all** Infomaniak account_ids the PAT + can reach. Without this scope kDrive cannot find the owning + account; the submit fails with HTTP 400 and a message that names + the missing scope. +2. `GET https://calendar.infomaniak.com/api/pim/calendar` (requires + scope `workspace:calendar`; `workspace:contact` works as the + equivalent fallback) -- yields the user's display name and kSuite + account_id for the connection label in the UI. +3. `GET https://api.infomaniak.com/2/drive?account_id=` + (requires scope `drive`) -- a clean 200 confirms the `drive` + scope is on the PAT. + +On success the connection turns active and the token is stored +encrypted in the backend; on failure the modal shows which scope is +missing. The mail scope is stored as part of `grantedScopes` without +a probe -- it is only consumed once the Mail adapter lands. + +## Step 3: Verify in the Unified Data Bar + +Open the **Sources** tab in the Unified Data Bar. The connection appears with +the Infomaniak label and exposes (today) three child nodes: + +- **kDrive** -- expand to see drives, then folders and files. +- **Calendar** -- expand to see calendars, then events; downloads as `.ics`. +- **Contacts** -- expand to see address books, then contacts; downloads as `.vcf`. + +Mail will appear as an additional child node once a PAT-friendly endpoint +is identified; no token rotation needed because the scope is already on +the PAT. + +## Rotating or revoking the token + +- To rotate, repeat Step 1 with a new token, then in PowerOn delete the + existing Infomaniak connection and create a new one with the fresh token. +- To revoke, delete the token in the Infomaniak Manager. The PowerOn + connection will start to fail at the next call; delete it from the + Verbindungen page to remove the stored bearer. + +## How the kDrive adapter knows your account + +`/2/drive` requires an integer `account_id` query arg. A user can own +kDrives in several Infomaniak accounts -- typically a kSuite account +plus a standalone (or free-tier) kDrive that lives on its **own** +account_id. The kSuite account_id from PIM Calendar / Contacts only +covers the kSuite case, which is why naive PAT integrations show an +empty kDrive even though there are files. + +The `KdriveAdapter` therefore calls `GET /1/accounts` (the only +PAT-friendly endpoint that lists every account_id of a token) and +unions the `/2/drive?account_id=` listing across all returned +account_ids. The result is cached on the adapter instance for the +lifetime of the request, so each browse touches `/1/accounts` at most +once. + +The submit endpoint runs the same enumeration as a pre-flight check +before persisting the token; if `/1/accounts` rejects the PAT for +missing the `accounts` scope, the submit fails with a clear 400 +instead of producing a half-broken connection. + +## Security notes + +- PATs are stored exactly like OAuth access tokens for Google or Microsoft: + encrypted at rest in the gateway database, only ever sent over TLS to + `api.infomaniak.com`, and never returned to the frontend after submission. +- The PowerOn backend does not need any Infomaniak client ID or client + secret -- there is no `Service_INFOMANIAK_*` configuration. +- Each user manages their own tokens. There is no global "PowerOn Infomaniak + app" the operator has to register or maintain.