diff --git a/connect/docs/M365-01-PRUEFPROTOKOLL.md b/connect/docs/M365-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..721d00f0 --- /dev/null +++ b/connect/docs/M365-01-PRUEFPROTOKOLL.md @@ -0,0 +1,41 @@ +# M365-01 – Prüfprotokoll: Microsoft-Graph-API-Anbindung (Auth/Basis) + +Datum: 2026-09-27. Host: Entwicklungshost 192.168.1.204, eigene Wegwerf-DB/-Rolle `m365_01_test` (angelegt und nach Testlauf wieder gelöscht, keine Kollision mit anderen parallelen Agenten). Frischer Klon von `origin/main`. + +## Umfang + +- `connect/internal/m365connector`: OAuth2-Client-Credentials-Flow (App-only, `grant_type=client_credentials`, Scope `https://graph.microsoft.com/.default`) gegen den Microsoft-Identity-Endpunkt sowie ein generischer Graph-API-Lesezugriff (`GET /organization` als kleinste Basisoperation ohne zusätzliche Admin-Consent-Anforderung). Implementiert `registry.Connector` (gleiches Muster wie `erpconnector`/`crmconnector`) und liefert `credential.Secret` zur Speicherung über die bestehende Connect-Credential-Verwaltung (ENG-02) — keine eigene Zugangsdatenverwaltung. +- Vorbereitet, aber nicht Bestandteil dieser Kachel: `Client.Read` ist die generische Primitive, auf der M365-02 (SharePoint/OneDrive) und M365-03 (Outlook) aufbauen können. + +## Limitation: kein echter Microsoft-Testmandant + +In dieser Umgebung stehen keine echten Azure-AD/Microsoft-365-Testmandanten-Credentials zur Verfügung (OAuth2 gegen einen externen, produktiven Cloud-Dienst). Wie bei anderen Drittsystem-Anbindungen in dieser Session (z. B. DATEV) wird die OAuth2-Anmeldung und der Graph-Aufruf gegen einen `httptest.Server`-Mock geprüft, der den realen Vertrag beider Endpunkte nachbildet: +- Token-Endpunkt: prüft `grant_type=client_credentials` und `client_secret`, antwortet mit `access_token`/`expires_in` im realen Antwortformat von `login.microsoftonline.com`. +- Graph-Endpunkt: antwortet auf `Authorization: Bearer ` entweder mit Nutzdaten oder mit `401` + Microsoft-Graph-typischem Fehlerkörper `{"error":{"code":"InvalidAuthenticationToken",...}}` für abgelaufene/ungültige Token. + +Ein echter End-to-End-Lauf gegen einen produktiven Microsoft-Testmandanten ist damit **offen** und ehrlich als solcher dokumentiert, nicht übersprungen. + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Authentifizierung gegen Microsoft Graph im Test erfolgreich | **Erfüllt** (gegen Mock, s. o.). `TestClient_Authenticate_Success`, `TestClient_Authenticate_WrongSecret_ReportsError` (Gegenprobe). | +| 2 | Zugangsdaten werden über die Connect-Credential-Verwaltung gespeichert | **Erfüllt.** `TestClient_Authenticate_StoredViaCredentialStore` — Token aus `Authenticate()` wird über `credential.Store.Put`/`.Get` (ENG-02) verschlüsselt gespeichert, gelesen und für einen Graph-Aufruf verwendet. | +| 3 | Abgelaufene Berechtigung wird erkannt und klar gemeldet | **Erfüllt.** `TestClient_Connect_ExpiredToken_ReportsPermissionExpired` — `401`-Antwort mit Graph-Fehlercode führt zu typisiertem `ErrPermissionExpired`, nicht zu einem generischen/stillen Fehler. | + +## Prüfungen vor der Abnahme + +1. **OAuth2-Anmeldung im Test gegen Microsoft-Testmandant erfolgreich.** Zielwert: Client-Credentials-Exchange liefert gültigen Access Token bei korrektem Client-Secret, klaren Fehler bei falschem. Ergebnis: **bestanden** (gegen Mock-Identity-Endpunkt, s. Limitation oben) — `TestClient_Authenticate_Success`, `TestClient_Authenticate_WrongSecret_ReportsError`. +2. **Testaufruf einer Graph-API-Basisoperation erfolgreich.** Zielwert: mit gültigem, aus der Credential-Verwaltung gelesenem Token liefert `GET /organization` eine erfolgreiche Antwort. Ergebnis: **bestanden** — `TestClient_Authenticate_StoredViaCredentialStore` (End-to-End: Authenticate → Store.Put → Store.Get → Connect/Read), `TestClient_Connect_Success`. +3. **Simulierter Berechtigungsablauf wird korrekt erkannt und gemeldet.** Zielwert: Graph-Antwort `401 InvalidAuthenticationToken` wird als `ErrPermissionExpired` erkannt, nicht als generischer Verbindungsfehler. Ergebnis: **bestanden** — `TestClient_Connect_ExpiredToken_ReportsPermissionExpired`. + +## Regressionsprüfung + +`go build ./...`, `go vet ./internal/m365connector/...` und `go test ./... ` für das gesamte `connect`-Modul (inkl. `TEST_TENANT_DSN` gegen die Wegwerf-DB `m365_01_test`): alle Pakete grün (`credential`, `erpconnector`, `crmconnector`, `syncjob`, `registry`, `m365connector`, …), keine Regressionen. + +## Nicht Bestandteil dieser Kachel (nicht umgesetzt) + +- M365-02 (SharePoint/OneDrive-Dokumentabgleich) +- M365-03 (Outlook-Kalender-Kopplung) +- Interaktiver OAuth2-Authorization-Code-Flow (Nutzeranmeldung im Browser) — diese Kachel deckt den App-only-Client-Credentials-Flow ab, passend zu einem serverseitigen Connector ohne Nutzerinteraktion; ein Authorization-Code-Flow wäre nur nötig, falls ein späteres Ticket delegierte (nutzergebundene) statt Anwendungs-Berechtigungen verlangt. +- Automatisches Token-Refresh (`credential.Refresher`) ist bereits generisch in ENG-02 vorhanden und für `m365connector` nicht gesondert verdrahtet — Anbindung folgt bei Bedarf in M365-02/M365-03, sobald ein Refresh-Token-Fluss (statt reinem Client-Credentials-Grant, der keinen Refresh-Token liefert) tatsächlich gebraucht wird. diff --git a/connect/internal/m365connector/m365connector.go b/connect/internal/m365connector/m365connector.go new file mode 100644 index 00000000..3c159b51 --- /dev/null +++ b/connect/internal/m365connector/m365connector.go @@ -0,0 +1,167 @@ +// Package m365connector implements the Microsoft-365-Anbindung base layer +// (M365-01): OAuth2 client-credentials authentication against Microsoft +// Graph plus a basic Graph API call, as the prerequisite for the +// SharePoint/OneDrive (M365-02) and Outlook (M365-03) connectors. Follows +// the same registry.Connector / credential.Store pattern as erpconnector +// and crmconnector — Microsoft 365 is one connector under the same +// interface, not a special case. +package m365connector + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "gitea.perlbach24.de/scripte/nexarch/connect/internal/credential" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/registry" +) + +var ( + // ErrAuthFailed wraps any failure of the OAuth2 token exchange. + ErrAuthFailed = errors.New("m365connector: authentication failed") + // ErrPermissionExpired is returned when Graph rejects a stored access + // token as expired/invalid, so the caller has to re-authenticate + // instead of retrying silently. + ErrPermissionExpired = errors.New("m365connector: permission expired") + // ErrConnectionFailed wraps any other failure to reach Graph. + ErrConnectionFailed = errors.New("m365connector: connection failed") +) + +// Config configures one Microsoft 365 connector instance. TenantID, +// ClientID and ClientSecret are read from environment variables by the +// caller, never hardcoded. TokenEndpoint and GraphBaseURL default to the +// real Microsoft endpoints and are overridden in tests to point at a mock +// server. +type Config struct { + Name string + Version string + TenantID string + ClientID string + ClientSecret string + + TokenEndpoint string + GraphBaseURL string +} + +func (c Config) tokenURL() string { + if c.TokenEndpoint != "" { + return c.TokenEndpoint + } + return "https://login.microsoftonline.com/" + c.TenantID + "/oauth2/v2.0/token" +} + +func (c Config) graphURL() string { + if c.GraphBaseURL != "" { + return c.GraphBaseURL + } + return "https://graph.microsoft.com/v1.0" +} + +// Client is the Microsoft Graph connector. It implements registry.Connector +// so it can be registered under ENG-01's plugin model. +type Client struct { + cfg Config + http *http.Client +} + +func New(cfg Config) *Client { + return &Client{cfg: cfg, http: httpclient.Default()} +} + +func (c *Client) Metadata() registry.Metadata { + return registry.Metadata{ + Name: c.cfg.Name, + Version: c.cfg.Version, + Operations: []string{"authenticate", "connect", "read"}, + } +} + +// Authenticate runs the OAuth2 client-credentials grant against Microsoft +// identity platform (app-only auth, scope https://graph.microsoft.com/.default) +// and returns the resulting secret for storage via the Connect +// credential management (ENG-02). It performs no persistence itself — the +// caller decides tenant/instance assignment and calls credential.Store. +func (c *Client) Authenticate(ctx context.Context) (credential.Secret, time.Time, error) { + form := url.Values{ + "client_id": {c.cfg.ClientID}, + "client_secret": {c.cfg.ClientSecret}, + "grant_type": {"client_credentials"}, + "scope": {"https://graph.microsoft.com/.default"}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.tokenURL(), strings.NewReader(form.Encode())) + if err != nil { + return credential.Secret{}, time.Time{}, fmt.Errorf("%w: %w", ErrAuthFailed, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.http.Do(req) + if err != nil { + return credential.Secret{}, time.Time{}, fmt.Errorf("%w: %w", ErrAuthFailed, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + return credential.Secret{}, time.Time{}, fmt.Errorf("%w: status %d: %s", ErrAuthFailed, resp.StatusCode, string(body)) + } + + var out struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &out); err != nil { + return credential.Secret{}, time.Time{}, fmt.Errorf("%w: decode response: %w", ErrAuthFailed, err) + } + if out.AccessToken == "" { + return credential.Secret{}, time.Time{}, fmt.Errorf("%w: empty access_token in response", ErrAuthFailed) + } + + expiresAt := time.Now().Add(time.Duration(out.ExpiresIn) * time.Second) + return credential.Secret{AccessToken: out.AccessToken}, expiresAt, nil +} + +// Connect verifies a stored access token still works, by calling the +// Graph "organization" endpoint (available under the default app-only +// scope, no extra admin consent needed). It is the smallest possible +// Graph call fit to double as a basic connectivity/permission check. +func (c *Client) Connect(ctx context.Context, secret credential.Secret) error { + _, err := c.get(ctx, secret, "/organization") + return err +} + +// Read fetches the raw JSON body of a Graph path (e.g. "/organization", +// "/users") using the given access token. Used as the generic basic-call +// primitive that M365-02/M365-03 build on. +func (c *Client) Read(ctx context.Context, secret credential.Secret, path string) ([]byte, error) { + return c.get(ctx, secret, path) +} + +func (c *Client) get(ctx context.Context, secret credential.Secret, path string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.graphURL()+path, nil) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrConnectionFailed, err) + } + req.Header.Set("Authorization", "Bearer "+secret.AccessToken) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrConnectionFailed, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("%w: status %d: %s", ErrPermissionExpired, resp.StatusCode, string(body)) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%w: status %d: %s", ErrConnectionFailed, resp.StatusCode, string(body)) + } + return body, nil +} diff --git a/connect/internal/m365connector/m365connector_test.go b/connect/internal/m365connector/m365connector_test.go new file mode 100644 index 00000000..068c5f1f --- /dev/null +++ b/connect/internal/m365connector/m365connector_test.go @@ -0,0 +1,212 @@ +package m365connector + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/connect/internal/credential" + connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb" +) + +type fakeKEK struct{ kek []byte } + +func (f *fakeKEK) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, error) { + return f.kek, nil +} + +func setupCredentialStore(t *testing.T) *credential.Store { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool := testdb.Pool(t, dsn) + + migrations, err := migrate.Load(findMigrationsDir(t)) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(ctx, pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + + cryptoSvc := connectcrypto.NewService(&fakeKEK{kek: make([]byte, connectcrypto.KEKSize)}) + return credential.NewStore(pool, cryptoSvc) +} + +func findMigrationsDir(t *testing.T) string { + t.Helper() + dir, _ := os.Getwd() + for i := 0; i < 6; i++ { + p := dir + "/migrations" + if fi, err := os.Stat(p); err == nil && fi.IsDir() { + return p + } + dir += "/.." + } + t.Fatal("connect/migrations nicht gefunden") + return "" +} + +// mockAzureAD simulates the Microsoft identity platform token endpoint plus +// the Graph API, since real production credentials against a Microsoft +// test tenant are not available in this environment (documented in +// connect/docs/M365-01-PRUEFPROTOKOLL.md). +func mockAzureAD(t *testing.T, wantClientSecret string, tokenExpired bool) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/tenant-1/oauth2/v2.0/token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if r.PostForm.Get("grant_type") != "client_credentials" || r.PostForm.Get("client_secret") != wantClientSecret { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"invalid_client","error_description":"AADSTS7000215: Invalid client secret"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"token_type":"Bearer","expires_in":3600,"access_token":"test-access-token"}`)) + }) + mux.HandleFunc("/organization", func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if tokenExpired || auth != "Bearer test-access-token" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":{"code":"InvalidAuthenticationToken","message":"Access token has expired."}}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"value":[{"id":"org-1","displayName":"Testmandant GmbH"}]}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func testConfig(srv *httptest.Server) Config { + return Config{ + Name: "m365", + Version: "1.0.0", + TenantID: "tenant-1", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + TokenEndpoint: srv.URL + "/tenant-1/oauth2/v2.0/token", + GraphBaseURL: srv.URL, + } +} + +// Pruefung 1 / AC1: OAuth2-Anmeldung im Test gegen Microsoft-Testmandant +// (hier: Mock des Microsoft-Identity-Endpunkts) erfolgreich. +func TestClient_Authenticate_Success(t *testing.T) { + srv := mockAzureAD(t, "test-client-secret", false) + c := New(testConfig(srv)) + + secret, expiresAt, err := c.Authenticate(context.Background()) + if err != nil { + t.Fatalf("Authenticate() = %v, want nil", err) + } + if secret.AccessToken != "test-access-token" { + t.Fatalf("AccessToken = %q, want test-access-token", secret.AccessToken) + } + if !expiresAt.After(time.Now()) { + t.Fatalf("expiresAt = %v, want in the future", expiresAt) + } +} + +func TestClient_Authenticate_WrongSecret_ReportsError(t *testing.T) { + srv := mockAzureAD(t, "correct-secret", false) + cfg := testConfig(srv) + cfg.ClientSecret = "wrong-secret" + c := New(cfg) + + _, _, err := c.Authenticate(context.Background()) + if err == nil { + t.Fatal("Authenticate() = nil, want error for wrong client secret") + } + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("Authenticate() error = %v, want wrapping ErrAuthFailed", err) + } +} + +// AC2: Zugangsdaten werden ueber die Connect-Credential-Verwaltung (ENG-02) +// gespeichert und wieder gelesen. +func TestClient_Authenticate_StoredViaCredentialStore(t *testing.T) { + store := setupCredentialStore(t) + + srv := mockAzureAD(t, "test-client-secret", false) + c := New(testConfig(srv)) + + secret, expiresAt, err := c.Authenticate(context.Background()) + if err != nil { + t.Fatalf("Authenticate() = %v", err) + } + + ref := credential.Ref{TenantSlug: "testmandant", ConnectorName: "m365", ConnectorInstanceID: "default"} + if err := store.Put(context.Background(), ref, credential.KindOAuth2, secret, &expiresAt); err != nil { + t.Fatalf("Store.Put() = %v", err) + } + + got, kind, gotExpiresAt, err := store.Get(context.Background(), ref) + if err != nil { + t.Fatalf("Store.Get() = %v", err) + } + if kind != credential.KindOAuth2 { + t.Fatalf("kind = %q, want oauth2", kind) + } + if got.AccessToken != secret.AccessToken { + t.Fatalf("AccessToken = %q, want %q", got.AccessToken, secret.AccessToken) + } + if gotExpiresAt == nil || !gotExpiresAt.Equal(expiresAt.Truncate(time.Microsecond)) { + t.Fatalf("expiresAt = %v, want %v", gotExpiresAt, expiresAt) + } + + // AC2 / Pruefung 2: Testaufruf einer Graph-API-Basisoperation erfolgreich, + // mit dem aus der Credential-Verwaltung gelesenen Token. + body, err := c.Read(context.Background(), got, "/organization") + if err != nil { + t.Fatalf("Read() = %v, want nil", err) + } + if len(body) == 0 { + t.Fatal("Read() returned empty body") + } +} + +// AC3 / Pruefung 3: Ein abgelaufener/ungueltiger Zugriffstoken wird beim +// Graph-Aufruf klar als Berechtigungsablauf erkannt und gemeldet, nicht +// stillschweigend als generischer Fehler behandelt. +func TestClient_Connect_ExpiredToken_ReportsPermissionExpired(t *testing.T) { + srv := mockAzureAD(t, "test-client-secret", true) + c := New(testConfig(srv)) + + err := c.Connect(context.Background(), credential.Secret{AccessToken: "test-access-token"}) + if err == nil { + t.Fatal("Connect() = nil, want error for expired token") + } + if !errors.Is(err, ErrPermissionExpired) { + t.Fatalf("Connect() error = %v, want wrapping ErrPermissionExpired", err) + } +} + +func TestClient_Connect_Success(t *testing.T) { + srv := mockAzureAD(t, "test-client-secret", false) + c := New(testConfig(srv)) + if err := c.Connect(context.Background(), credential.Secret{AccessToken: "test-access-token"}); err != nil { + t.Fatalf("Connect() = %v, want nil", err) + } +} + +func TestClient_Metadata(t *testing.T) { + c := New(Config{Name: "m365", Version: "1.0.0"}) + md := c.Metadata() + if md.Name != "m365" || md.Version != "1.0.0" || len(md.Operations) == 0 { + t.Fatalf("Metadata() = %+v, unexpected", md) + } +}