sadpandajoe commented on code in PR #45508: URL: https://github.com/apache/superset/pull/45508#discussion_r4363963358
########## superset/integrations/teams/adapter.py: ########## @@ -0,0 +1,538 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Microsoft Teams SDK boundary for Flask callbacks and root channel delivery.""" + +from __future__ import annotations + +import asyncio +import math +from collections.abc import AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from time import monotonic +from typing import Any, Literal, Protocol +from uuid import UUID + +import httpx +import requests +from microsoft_teams.api import ( + ApiClient, + Attachment, + ClientCredentials, + InstalledActivity, + InvokeResponse, + JsonWebToken, + MessageActivityInput, + UninstalledActivity, +) +from microsoft_teams.api.auth.cloud_environment import PUBLIC +from microsoft_teams.api.clients.conversation import CreateConversationParams +from microsoft_teams.apps import HttpServer, HttpServerAdapter +from microsoft_teams.apps.events import ActivityEvent +from microsoft_teams.apps.http import HttpRequest, HttpResponse, HttpRouteHandler +from microsoft_teams.common import Client, ClientOptions +from msal import ConfidentialClientApplication +from pydantic import ValidationError + +from superset.integrations.teams.config import TeamsConfig, TeamsDestination + +EVENTS_PATH = "/api/v1/teams/events/" + + +@dataclass(frozen=True) +class TeamsInstallationEvent: + """Authenticated, deployment-scoped installation data, with no SDK objects.""" + + team_id: str + service_url: str + active: bool + event_at: datetime + + +@dataclass(frozen=True) +class TeamsEventResult: + """Callback outcome; only an authenticated event may reach persistence.""" + + status: int + event: TeamsInstallationEvent | None = None + + +class _FlaskServerAdapter(HttpServerAdapter): + """Capture the SDK route for one invocation of Flask's existing POST route. + + Flask registers its route once at startup. Each request creates a fresh SDK + authentication server; its handler runs to completion in an operation-scoped + event loop. No SDK App, outbound clients, tasks, or separate server are created. + """ + + def __init__(self) -> None: + """Hold only the handler for this request.""" + self.handler: HttpRouteHandler | None = None + + def register_route( + self, method: Literal["POST"], path: str, handler: HttpRouteHandler + ) -> None: + """Accept only the independently authenticated Teams callback.""" + if method != "POST" or path != EVENTS_PATH or self.handler is not None: + raise ValueError("Unexpected Teams SDK route registration") + self.handler = handler + + async def start(self, port: int) -> None: + """Prevent the SDK from starting its own HTTP server.""" + raise RuntimeError("Flask manages the Teams callback server") + + async def stop(self) -> None: + """Release the operation's handler; Flask owns the HTTP server.""" + self.handler = None + + +def _same_uuid(value: object, expected: str) -> bool: + """Compare concrete UUIDs without accepting tenant aliases.""" + try: + return isinstance(value, str) and UUID(value) == UUID(expected) + except ValueError: + return False + + +def _allowed_token(config: TeamsConfig, event: ActivityEvent) -> bool: + """Restrict the SDK's broader auth support to expiring bot service tokens.""" + # Only Bot Framework service tokens bind serviceurl to a signed claim. + # Agent 365/Entra activities and bot-to-bot callers are outside this integration. + token = event.token + return ( + isinstance(token, JsonWebToken) + and token.issuer == PUBLIC.token_issuer + and token.expiration is not None + and (not token.tenant_id or _same_uuid(token.tenant_id, config.tenant_id)) + ) + + +def _installation_event( + config: TeamsConfig, event: ActivityEvent, body: dict[str, Any] +) -> TeamsEventResult: + """Apply deployment restrictions only after the SDK has authenticated the token.""" + if not _allowed_token(config, event): + return TeamsEventResult(403) + token = event.token + if body.get("type") != "installationUpdate" or body.get("action") not in ( Review Comment: This returns 400 for any action other than `add` or `remove`, but Teams also sends `add-upgrade` and `remove-upgrade` installation updates when an app upgrade adds or removes the bot. If the bot is added to a team through an upgrade, no installation record is created and alerts keep failing with "No active Teams bot installation" until it is uninstalled and reinstalled. Should the upgrade variants be accepted and mapped to add/remove? ########## superset/migrations/versions/2026-10-19_00-00_7b9d2f8a6c31_add_teams_installations.py: ########## @@ -0,0 +1,58 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Add Teams bot installation persistence. + +Revision ID: 7b9d2f8a6c31 +Revises: d623a0cb6bb0 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy_utils import UUIDType + +from superset.migrations.shared.utils import create_table + +revision: str = "7b9d2f8a6c31" +down_revision: str = "d623a0cb6bb0" + + +def upgrade() -> None: + """Add installation metadata without changing alerts or destinations.""" + create_table( + "teams_installations", + sa.Column("id", UUIDType(binary=True), nullable=False), + sa.Column("tenant_id", UUIDType(binary=True), nullable=False), + sa.Column("application_id", UUIDType(binary=True), nullable=False), + sa.Column("team_id", sa.String(255), nullable=False), + sa.Column("service_url", sa.String(2048), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("event_at", sa.DateTime(), nullable=False), Review Comment: On MySQL, `sa.DateTime()` creates a `DATETIME` column with no fractional seconds, but `TeamsInstallationStore._apply()` compares full-precision event timestamps against `event_at`. A removal at `12:00:00.200Z` is stored as `12:00:00`, so a delayed install stamped `12:00:00.100Z` still passes the `event_at < incoming` check and reactivates a bot that was already removed. Should `event_at` (here and in `models.py`) use a fractional-seconds type, for example `mysql.DATETIME(fsp=6)` via `with_variant`, and should the lifecycle tests cover sub-second timestamps? -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
