From 6b8b4e6650be57a54517421d4998bd2a3572dcad Mon Sep 17 00:00:00 2001 From: Yan Shliakhau Date: Tue, 25 Nov 2025 15:59:37 +0100 Subject: [PATCH 01/10] Backport issue for grafana --- nautobot_chatops/views.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nautobot_chatops/views.py b/nautobot_chatops/views.py index be474e6f..cc58e364 100644 --- a/nautobot_chatops/views.py +++ b/nautobot_chatops/views.py @@ -17,6 +17,11 @@ from nautobot_chatops import filters, forms, tables from nautobot_chatops.api import serializers +from nautobot_chatops.integrations.grafana.views import ( + GrafanaDashboardUIViewSet, + GrafanaPanelUIViewSet, + GrafanaPanelVariableUIViewSet, +) from nautobot_chatops.models import AccessGrant, ChatOpsAccountLink, CommandLog, CommandToken @@ -134,3 +139,14 @@ def get_object(self): self.request.user.email if self.request.user.is_authenticated else None ) return obj + + +__all__ = [ + "CommandLogUIViewSet", + "AccessGrantUIViewSet", + "CommandTokenUIViewSet", + "ChatOpsAccountLinkUIViewSet", + "GrafanaDashboardUIViewSet", + "GrafanaPanelUIViewSet", + "GrafanaPanelVariableUIViewSet", +] From 8a236069615fced7e17f6781ca12423dab0807ea Mon Sep 17 00:00:00 2001 From: Yan Shliakhau Date: Tue, 25 Nov 2025 16:02:00 +0100 Subject: [PATCH 02/10] Changelog --- .github/workflows/ci.yml | 6 +++--- changes/412.fixed | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changes/412.fixed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d99b3a87..b2dd81a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,14 +152,14 @@ jobs: matrix: python-version: ["3.10"] # 3.12 stable is tested in unittest_report stage. db-backend: ["postgresql"] - nautobot-version: ["stable"] + nautobot-version: ["2.4"] include: - python-version: "3.12" db-backend: "postgresql" nautobot-version: "2.4.20" - python-version: "3.12" db-backend: "mysql" - nautobot-version: "stable" + nautobot-version: "2.4" runs-on: "ubuntu-latest" env: INVOKE_NAUTOBOT_CHATOPS_PYTHON_VER: "${{ matrix.python-version }}" @@ -207,7 +207,7 @@ jobs: matrix: python-version: ["3.12"] db-backend: ["postgresql"] - nautobot-version: ["stable"] + nautobot-version: ["2.4"] runs-on: "ubuntu-latest" permissions: pull-requests: "write" diff --git a/changes/412.fixed b/changes/412.fixed new file mode 100644 index 00000000..9d29929b --- /dev/null +++ b/changes/412.fixed @@ -0,0 +1 @@ +Fixed bulk operations on Grafana views. From 00db0825c4df2884daed129620ae573295ac10ea Mon Sep 17 00:00:00 2001 From: Nautobot-Bot <79372327+nautobot-bot@users.noreply.github.com> Date: Fri, 5 Dec 2025 08:46:27 -0600 Subject: [PATCH 03/10] Cookie updated by NetworkToCode Cookie Drift Manager Tool (#413) * Cookie updated by NetworkToCode Cookie Drift Manager Tool Template: ``` { "template": "https://github.com/nautobot/cookiecutter-nautobot-app.git", "dir": "nautobot-app", "ref": "nautobot-app-v2.7.2", "path": null } ``` Cookie: ``` { "remote": "https://github.com/nautobot/nautobot-app-chatops.git", "path": "/tmp/tmp_8i7076w/nautobot-app-chatops", "repository_path": "/tmp/tmp_8i7076w/nautobot-app-chatops", "dir": "", "branch_prefix": "drift-manager/ltm-2.4", "context": { "codeowner_github_usernames": "@glennmatthews @jvanderaa @smk4664 @whitej6", "full_name": "Network to Code, LLC", "email": "opensource@networktocode.com", "github_org": "nautobot", "app_name": "nautobot_chatops", "verbose_name": "Nautobot ChatOps App", "app_slug": "nautobot-chatops", "project_slug": "nautobot-app-chatops", "repo_url": "https://github.com/nautobot/nautobot-app-chatops", "base_url": "chatops", "camel_name": "NautobotChatOpsApp", "project_short_description": "Nautobot ChatOps App", "model_class_name": "CommandLog", "open_source_license": "Apache-2.0", "docs_base_url": "https://docs.nautobot.com", "docs_app_url": "https://docs.nautobot.com/projects/chatops/en/latest", "_extensions": [ "local_extensions.camel_case_to_kebab", "local_extensions.camel_case_to_words", "local_extensions.NautobotVersions" ], "_template": "https://github.com/nautobot/cookiecutter-nautobot-app.git", "_output_dir": "/tmp/tmp_8i7076w", "_repo_dir": "/github/home/.cookiecutters/cookiecutter-nautobot-app/nautobot-app", "_checkout": "nautobot-app-v2.7.2" }, "drift_managed_branch": "ltm-2.4", "remote_name": "origin", "pull_request_strategy": "PullRequestStrategy.UPDATE_OR_CREATE", "post_actions": [], "baked_commit_ref": "2587cbc785137e99ad553737141363017f123aef", "draft": false } ``` CLI Arguments: ``` { "cookie_dir": "", "input": false, "json_filename": "", "output_dir": "", "push": true, "template": "", "template_dir": "", "template_ref": "nautobot-app-v2.7.2", "pull_request": "update-or-create", "post_action": [], "disable_post_actions": true, "draft": null, "drift_managed_branch": "ltm-2.4" } ``` * Apply suggestion from @gsnider2195 --------- Co-authored-by: bakebot Co-authored-by: Gary Snider <75227981+gsnider2195@users.noreply.github.com> --- .cookiecutter.json | 6 +++--- .github/workflows/ci.yml | 2 +- changes/+nautobot-app-v2.7.2.housekeeping | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 changes/+nautobot-app-v2.7.2.housekeeping diff --git a/.cookiecutter.json b/.cookiecutter.json index 4b7842c6..acc7d2f1 100644 --- a/.cookiecutter.json +++ b/.cookiecutter.json @@ -19,13 +19,13 @@ "_drift_manager": { "template": "https://github.com/nautobot/cookiecutter-nautobot-app.git", "template_dir": "nautobot-app", - "template_ref": "nautobot-app-v2.7.1", + "template_ref": "nautobot-app-v2.7.2", "cookie_dir": "", - "branch_prefix": "drift-manager", "pull_request_strategy": "update-or-create", "post_actions": [], "draft": false, - "baked_commit_ref": "2587cbc785137e99ad553737141363017f123aef" + "baked_commit_ref": "ba3925f4d8ca5134408f8393ecc00bcca8b17eaf", + "drift_managed_branch": "ltm-2.4" } } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2dd81a3..588aa14b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,7 +150,7 @@ jobs: strategy: fail-fast: true matrix: - python-version: ["3.10"] # 3.12 stable is tested in unittest_report stage. + python-version: ["3.10"] # 3.12 ltm-2.4 is tested in unittest_report stage. db-backend: ["postgresql"] nautobot-version: ["2.4"] include: diff --git a/changes/+nautobot-app-v2.7.2.housekeeping b/changes/+nautobot-app-v2.7.2.housekeeping new file mode 100644 index 00000000..6a890b6f --- /dev/null +++ b/changes/+nautobot-app-v2.7.2.housekeeping @@ -0,0 +1 @@ +Rebaked from the cookie `nautobot-app-v2.7.2`. From 7e9d2d5d94b1719f1f38151061bdcbe18a0a4fe4 Mon Sep 17 00:00:00 2001 From: Gary Snider <75227981+gsnider2195@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:04:03 -0800 Subject: [PATCH 04/10] release v3.3.0 --- changes/+nautobot-app-v2.7.0.housekeeping | 1 - changes/+nautobot-app-v2.7.1.housekeeping | 1 - changes/+nautobot-app-v2.7.2.housekeeping | 1 - changes/384.added | 1 - changes/384.housekeeping | 1 - changes/394.housekeeping | 1 - changes/403.dependencies | 1 - changes/412.fixed | 1 - docs/admin/release_notes/version_3.3.md | 31 +++++++++++++++++++++++ mkdocs.yml | 1 + pyproject.toml | 4 +-- 11 files changed, 34 insertions(+), 10 deletions(-) delete mode 100644 changes/+nautobot-app-v2.7.0.housekeeping delete mode 100644 changes/+nautobot-app-v2.7.1.housekeeping delete mode 100644 changes/+nautobot-app-v2.7.2.housekeeping delete mode 100644 changes/384.added delete mode 100644 changes/384.housekeeping delete mode 100644 changes/394.housekeeping delete mode 100644 changes/403.dependencies delete mode 100644 changes/412.fixed create mode 100644 docs/admin/release_notes/version_3.3.md diff --git a/changes/+nautobot-app-v2.7.0.housekeeping b/changes/+nautobot-app-v2.7.0.housekeeping deleted file mode 100644 index e26e51e1..00000000 --- a/changes/+nautobot-app-v2.7.0.housekeeping +++ /dev/null @@ -1 +0,0 @@ -Rebaked from the cookie `nautobot-app-v2.7.0`. diff --git a/changes/+nautobot-app-v2.7.1.housekeeping b/changes/+nautobot-app-v2.7.1.housekeeping deleted file mode 100644 index ce284b21..00000000 --- a/changes/+nautobot-app-v2.7.1.housekeeping +++ /dev/null @@ -1 +0,0 @@ -Rebaked from the cookie `nautobot-app-v2.7.1`. diff --git a/changes/+nautobot-app-v2.7.2.housekeeping b/changes/+nautobot-app-v2.7.2.housekeeping deleted file mode 100644 index 6a890b6f..00000000 --- a/changes/+nautobot-app-v2.7.2.housekeeping +++ /dev/null @@ -1 +0,0 @@ -Rebaked from the cookie `nautobot-app-v2.7.2`. diff --git a/changes/384.added b/changes/384.added deleted file mode 100644 index 189052a7..00000000 --- a/changes/384.added +++ /dev/null @@ -1 +0,0 @@ -Added Bulk Update functionality for the AccessGrant, CommandToken and ChatOpsAccountLink models. \ No newline at end of file diff --git a/changes/384.housekeeping b/changes/384.housekeeping deleted file mode 100644 index 59b8aeab..00000000 --- a/changes/384.housekeeping +++ /dev/null @@ -1 +0,0 @@ -Refactored AccessGrant model, ChatOpsAccountLink, CommandLog, CommandToken related UI views to use `NautobotUIViewSet` and `UI component framework`. \ No newline at end of file diff --git a/changes/394.housekeeping b/changes/394.housekeeping deleted file mode 100644 index c5aad8c8..00000000 --- a/changes/394.housekeeping +++ /dev/null @@ -1 +0,0 @@ -Refactored GrafanaDashboard related UI views to use `NautobotUIViewSet` and `UI component framework`. \ No newline at end of file diff --git a/changes/403.dependencies b/changes/403.dependencies deleted file mode 100644 index 54c65e22..00000000 --- a/changes/403.dependencies +++ /dev/null @@ -1 +0,0 @@ -Updated schema-enforcer and termcolor dependencies. diff --git a/changes/412.fixed b/changes/412.fixed deleted file mode 100644 index 9d29929b..00000000 --- a/changes/412.fixed +++ /dev/null @@ -1 +0,0 @@ -Fixed bulk operations on Grafana views. diff --git a/docs/admin/release_notes/version_3.3.md b/docs/admin/release_notes/version_3.3.md new file mode 100644 index 00000000..6ca66daa --- /dev/null +++ b/docs/admin/release_notes/version_3.3.md @@ -0,0 +1,31 @@ +# v3.3 Release Notes + +This document describes all new features and changes in the release. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Release Overview + +- Change minimum Nautobot version to 2.4.20. +- Dropped support for Python 3.9. + + +## [v3.3.0 (2025-12-05)](https://github.com/nautobot/nautobot-app-chatops/releases/tag/v3.3.0) + +### Added + +- [#384](https://github.com/nautobot/nautobot-app-chatops/issues/384) - Added Bulk Update functionality for the AccessGrant, CommandToken and ChatOpsAccountLink models. + +### Fixed + +- [#412](https://github.com/nautobot/nautobot-app-chatops/issues/412) - Fixed bulk operations on Grafana views. + +### Dependencies + +- [#403](https://github.com/nautobot/nautobot-app-chatops/issues/403) - Updated schema-enforcer and termcolor dependencies. + +### Housekeeping + +- [#384](https://github.com/nautobot/nautobot-app-chatops/issues/384) - Refactored AccessGrant model, ChatOpsAccountLink, CommandLog, CommandToken related UI views to use `NautobotUIViewSet` and `UI component framework`. +- [#394](https://github.com/nautobot/nautobot-app-chatops/issues/394) - Refactored GrafanaDashboard related UI views to use `NautobotUIViewSet` and `UI component framework`. +- Rebaked from the cookie `nautobot-app-v2.7.0`. +- Rebaked from the cookie `nautobot-app-v2.7.1`. +- Rebaked from the cookie `nautobot-app-v2.7.2`. diff --git a/mkdocs.yml b/mkdocs.yml index 813008ba..4c5fe9cd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -141,6 +141,7 @@ nav: - Compatibility Matrix: "admin/compatibility_matrix.md" - Release Notes: - "admin/release_notes/index.md" + - v3.3: "admin/release_notes/version_3.3.md" - v3.2: "admin/release_notes/version_3.2.md" - v3.1: "admin/release_notes/version_3.1.md" - v3.0: "admin/release_notes/version_3.0.md" diff --git a/pyproject.toml b/pyproject.toml index 8aa7335c..be1ba488 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "nautobot-chatops" -version = "3.2.1a0" +version = "3.3.0" description = "A app providing chatbot capabilities for Nautobot" authors = ["Network to Code, LLC "] license = "Apache-2.0" @@ -270,7 +270,7 @@ build-backend = "poetry.core.masonry.api" [tool.towncrier] package = "nautobot_chatops" directory = "changes" -filename = "docs/admin/release_notes/version_X.Y.md" +filename = "docs/admin/release_notes/version_3.3.md" template = "development/towncrier_template.j2" start_string = "" issue_format = "[#{issue}](https://github.com/nautobot/nautobot-app-chatops/issues/{issue})" From cfbec421d7f923826a7cbd8537e463671fef3620 Mon Sep 17 00:00:00 2001 From: cberetta Date: Mon, 5 Jan 2026 16:55:29 -0800 Subject: [PATCH 05/10] add slack token rotation support --- development/creds.example.env | 2 + development/development.env | 1 + development/nautobot_config.py | 3 + docs/admin/platforms/slack.md | 50 ++++++++++-- nautobot_chatops/__init__.py | 12 +++ nautobot_chatops/api/views/slack.py | 4 +- nautobot_chatops/dispatchers/slack.py | 4 +- nautobot_chatops/helpers/slack.py | 106 ++++++++++++++++++++++++++ nautobot_chatops/jobs.py | 44 +++++++++++ nautobot_chatops/sockets/slack.py | 10 +-- 10 files changed, 219 insertions(+), 17 deletions(-) create mode 100644 nautobot_chatops/helpers/slack.py create mode 100644 nautobot_chatops/jobs.py diff --git a/development/creds.example.env b/development/creds.example.env index e269244a..b7a8b19b 100644 --- a/development/creds.example.env +++ b/development/creds.example.env @@ -39,6 +39,8 @@ MATTERMOST_API_TOKEN="nsutx44ibbd69r5hjjmd3hx4sw" # SLACK_API_TOKEN="xoxb-changeme" # SLACK_APP_TOKEN="changeme" # SLACK_SIGNING_SECRET="changeme" +# SLACK_CLIENT_ID="changeme" +# SLACK_CLIENT_SECRET="changeme" # - Cisco Webex ---------------------- # WEBEX_ACCESS_TOKEN="changeme" diff --git a/development/development.env b/development/development.env index 023ec05d..f407688d 100644 --- a/development/development.env +++ b/development/development.env @@ -54,6 +54,7 @@ NAUTOBOT_CHATOPS_ENABLE_MS_TEAMS="False" # - Slack ---------------------------- NAUTOBOT_CHATOPS_ENABLE_SLACK="False" # SLACK_SLASH_COMMAND_PREFIX="/" +# SLACK_ENABLE_TOKEN_ROTATION="False" # - Cisco Webex ---------------------- NAUTOBOT_CHATOPS_ENABLE_WEBEX="False" diff --git a/development/nautobot_config.py b/development/nautobot_config.py index 5f4b9276..43c485d6 100644 --- a/development/nautobot_config.py +++ b/development/nautobot_config.py @@ -148,6 +148,9 @@ "slack_app_token": os.environ.get("SLACK_APP_TOKEN"), "slack_signing_secret": os.environ.get("SLACK_SIGNING_SECRET"), "slack_slash_command_prefix": os.environ.get("SLACK_SLASH_COMMAND_PREFIX", "/"), + "slack_enable_token_rotation": is_truthy(os.getenv("SLACK_ENABLE_TOKEN_ROTATION", "false")), + "slack_client_id": os.environ.get("SLACK_CLIENT_ID"), + "slack_client_secret": os.environ.get("SLACK_CLIENT_SECRET"), # - Cisco Webex ---------------------- "webex_msg_char_limit": int(os.getenv("WEBEX_MSG_CHAR_LIMIT", "7439")), "webex_signing_secret": os.environ.get("WEBEX_SIGNING_SECRET"), diff --git a/docs/admin/platforms/slack.md b/docs/admin/platforms/slack.md index 2cd677b8..f746dbd6 100644 --- a/docs/admin/platforms/slack.md +++ b/docs/admin/platforms/slack.md @@ -2,14 +2,17 @@ These are the distinct configuration values you will need to configure in `nautobot_config.py`. -| Configuration Setting | Mandatory? | Default | Available on Admin Config | -| ---------------------------- | ---------- | ------- | ------------------------- | -| `enable_slack` | **Yes** | False | Yes | -| `slack_api_token` | **Yes** | -- | No | -| `slack_app_token` | Socket Mode| -- | No | -| `slack_signing_secret` | **Yes** | -- | No | -| `slack_slash_command_prefix` | No | `"/"` | No | -| `slack_socket_static_host` | No | -- | No | +| Configuration Setting | Mandatory? | Default | Available on Admin Config | +| ---------------------------- | ------------- | ------- | ------------------------- | +| `enable_slack` | **Yes** | False | Yes | +| `slack_api_token` | **Yes** | -- | No | +| `slack_app_token` | Socket Mode | -- | No | +| `slack_signing_secret` | **Yes** | -- | No | +| `slack_slash_command_prefix` | No | `"/"` | No | +| `slack_socket_static_host` | No | -- | No | +| `slack_enable_token_rotation`| No | -- | No | +| `slack_client_id` | Token Rotation| -- | No | +| `slack_client_secret` | Token Rotation| -- | No | These values will be used in the `nautobot_config.py` file, once we get to the section where we cover server configuration. For now, take a mental note that in this section where we are configuring the Slack application, we will need to explicitly note the @@ -140,6 +143,37 @@ PLUGINS_CONFIG = { Once these steps are completed, you can proceed to the [Install Guide](../install.md#install-guide) section. +### Automatic Token Rotation +If your slack app has [token rotation](https://docs.slack.dev/authentication/using-token-rotation/) enabled, you'll need to configure Nautobot ChatOps as follows: + +```python +PLUGINS_CONFIG = { + "nautobot_chatops": { + # ... + "slack_enable_token_rotation": True, + "slack_api_token": "", + "slack_client_id": "", + "slack_client_secret": "", + # ... + } +} +``` + +Note that `slack_api_token` now contains the refresh token, and not a Slack bot token. +You can get the first refresh token after enabling token rotation with a call to the oauth.v2.exchange endpoint: + +```bash +BOT_TOKEN="" # from OAuth & Permission tab +CLIENT_ID="" # from Basic Information tab +CLIENT_SECRET="" # from Basic Information tab +curl -k -X POST -H "Content-type: application/x-www-form-urlencoded" "https://slack.com/api/oauth.v2.exchange" -d "client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&token=$BOT_TOKEN" +``` + +After the bot token is exchanged for the first time, the current refresh token can be retrieved from the "Oauth & Permission" tab of your slack app. +Slack refresh tokens don't expire, but are one-time use. Nautobot Chatops will consume the refresh token you specify in `PLUGINS_CONFIG` to acquire a new access and refresh token pair. +The new access token will be used to authenticate with Slack, and the new refresh token will be persisted in the Nautobot database and used when it's time to get a new access token. +To avoid token rotation delays, you should enable the `Rotate Slack Access Token` job to run periodically (like every hour). + ## Configuring Multiple Chatbots in a Workspace Chatbots from multiple Nautobot implementations can exist in a single Slack workspace and even channel. diff --git a/nautobot_chatops/__init__.py b/nautobot_chatops/__init__.py index 63ebfde3..c0b935c8 100644 --- a/nautobot_chatops/__init__.py +++ b/nautobot_chatops/__init__.py @@ -65,6 +65,10 @@ class NautobotChatOpsConfig(NautobotAppConfig): # this can be ignored. # If neither option is provided, then no static images (like Nautobot Logo) will be shown. "slack_socket_static_host": "", + # Enable Slack token rotation (use refresh token to rotate access token) + "slack_enable_token_rotation": False, + "slack_client_id": "", + "slack_client_secret": "", # - Cisco Webex ---------------------- "webex_token": "", "webex_signing_secret": "", @@ -142,6 +146,14 @@ class NautobotChatOpsConfig(NautobotAppConfig): ), "enable_nso": ConstanceConfigItem(default=False, help_text="Enable NSO Integration.", field_type=bool), "enable_slurpit": ConstanceConfigItem(default=False, help_text="Enable Slurpit Integration.", field_type=bool), + # Slack token rotation support + "slack_refresh_token": ConstanceConfigItem(default="", help_text="Current Slack refresh token. One-time use."), + "slack_access_token": ConstanceConfigItem( + default="", help_text="Current Slack access token. Expires after 12 hours." + ), + "slack_access_token_timestamp": ConstanceConfigItem( + default="", help_text="Creation timestamp of the current Slack access token (ISO8601 string)." + ), } caching_config = {} diff --git a/nautobot_chatops/api/views/slack.py b/nautobot_chatops/api/views/slack.py index 93c4e3cc..f2f835d3 100644 --- a/nautobot_chatops/api/views/slack.py +++ b/nautobot_chatops/api/views/slack.py @@ -11,9 +11,9 @@ from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt -from slack_sdk import WebClient from nautobot_chatops.dispatchers.slack import SlackDispatcher +from nautobot_chatops.helpers.slack import RotationAwareWebClient, get_slack_api_token from nautobot_chatops.metrics import signature_error_cntr from nautobot_chatops.utils import check_and_enqueue_command from nautobot_chatops.views import SettingsControlledViewMixin @@ -153,7 +153,7 @@ def post(self, request, *args, **kwargs): # Check for channel_name if channel_id is present if context["channel_name"] is None and context["channel_id"] is not None: # Build a Slack Client Object - slack_client = WebClient(token=settings.PLUGINS_CONFIG["nautobot_chatops"]["slack_api_token"]) + slack_client = RotationAwareWebClient(token=get_slack_api_token()) # Get the channel information from Slack API channel_info = slack_client.conversations_info(channel=context["channel_id"]) diff --git a/nautobot_chatops/dispatchers/slack.py b/nautobot_chatops/dispatchers/slack.py index 611abc52..437c32cc 100644 --- a/nautobot_chatops/dispatchers/slack.py +++ b/nautobot_chatops/dispatchers/slack.py @@ -8,10 +8,10 @@ from django.conf import settings from django.templatetags.static import static -from slack_sdk import WebClient from slack_sdk.errors import SlackApiError, SlackClientError from slack_sdk.webhook.client import WebhookClient +from nautobot_chatops.helpers.slack import RotationAwareWebClient, get_slack_api_token from nautobot_chatops.metrics import backend_action_sum from .base import Dispatcher @@ -45,7 +45,7 @@ class SlackDispatcher(Dispatcher): def __init__(self, *args, **kwargs): """Init a SlackDispatcher.""" super().__init__(*args, **kwargs) - self.slack_client = WebClient(token=settings.PLUGINS_CONFIG["nautobot_chatops"]["slack_api_token"]) + self.slack_client = RotationAwareWebClient(token=get_slack_api_token()) self.slack_menu_limit = int(os.getenv("SLACK_MENU_LIMIT", "100")) # pylint: disable=too-many-branches diff --git a/nautobot_chatops/helpers/slack.py b/nautobot_chatops/helpers/slack.py new file mode 100644 index 00000000..c40caba6 --- /dev/null +++ b/nautobot_chatops/helpers/slack.py @@ -0,0 +1,106 @@ +"""Helper functions and classes for Slack integration.""" + +import logging +from datetime import datetime, timedelta, timezone + +from constance import config +from django.conf import settings +from nautobot.apps.config import get_app_settings_or_config +from slack_sdk import WebClient +from slack_sdk.web.async_client import AsyncWebClient + +logger = logging.getLogger(__name__) + + +def get_slack_api_token() -> str: + """Return the current Slack token. + + Returns: + str: A valid access token if token rotation is enabled, otherwise the bot token. + """ + if not get_app_settings_or_config("nautobot_chatops", "slack_enable_token_rotation"): + return get_app_settings_or_config("nautobot_chatops", "slack_api_token") + + token_time_str = config.nautobot_chatops__slack_access_token_timestamp + now = datetime.now(timezone.utc) + token_time = None + if token_time_str: + try: + token_time = datetime.fromisoformat(token_time_str) + except Exception as e: + logger.warning(f"Could not parse slack_access_token_timestamp: {e}") + token_time = None + + is_access_token_expired = token_time is None or (now - token_time) > timedelta(hours=12) + if is_access_token_expired: + logger.info("Slack access token is expired or missing, attempting rotation. " + "Consider enabling the renewal job to avoid delays.") + return rotate_slack_access_token() + + logger.debug("Using existing Slack access token.") + return config.nautobot_chatops__slack_access_token + + +def rotate_slack_access_token() -> str | None: + """Rotate the Slack access token using the refresh token. + + Args: + refresh_token (str): Current Slack refresh token. + + Returns: + str: access_token or None on failure. + """ + slack_client_id = get_app_settings_or_config("nautobot_chatops", "slack_client_id") + if not slack_client_id: + logger.error("No Slack client ID found in Constance.") + return None + + slack_client_secret = get_app_settings_or_config("nautobot_chatops", "slack_client_secret") + if not slack_client_secret: + logger.error("No Slack client secret found in Constance.") + return None + + # not using get_app_settings_or_config here because we want to prioritize Constance for the refresh token + refresh_token = config.nautobot_chatops__slack_refresh_token or \ + settings.PLUGINS_CONFIG["nautobot_chatops"].get("slack_api_token", "") + if not refresh_token: + logger.error("No Slack refresh token found in Constance.") + return None + + new_timestamp = datetime.now(timezone.utc).isoformat() + try: + oauth_client = WebClient() + response = oauth_client.oauth_v2_access(client_id=slack_client_id, client_secret=slack_client_secret, + grant_type="refresh_token", refresh_token=refresh_token) + + new_access_token = response["access_token"] + new_refresh_token = response["refresh_token"] + + config.nautobot_chatops__slack_access_token = new_access_token + config.nautobot_chatops__slack_refresh_token = new_refresh_token + config.nautobot_chatops__slack_access_token_timestamp = new_timestamp + + logger.info("Slack access token rotated successfully.") + + return new_access_token + except Exception: + logger.exception("Slack token rotation error") + return None + + +class RotationAwareWebClient(WebClient): + """A WebClient that refreshes its token on each request if token rotation is enabled.""" + + def api_call(self, api_method: str, **kwargs): + """Override api_call to refresh token if needed before making the call.""" + self.token = get_slack_api_token() + return super().api_call(api_method, **kwargs) + + +class RotationAwareAsyncWebClient(AsyncWebClient): + """An AsyncWebClient that refreshes its token on each request if token rotation is enabled.""" + + async def api_call(self, api_method: str, **kwargs): + """Override api_call to refresh token if needed before making the call.""" + self.token = get_slack_api_token() + return await super().api_call(api_method, **kwargs) diff --git a/nautobot_chatops/jobs.py b/nautobot_chatops/jobs.py new file mode 100644 index 00000000..f5fb609c --- /dev/null +++ b/nautobot_chatops/jobs.py @@ -0,0 +1,44 @@ +""""Job to rotate Slack access token using refresh token. Schedule to run every hour or so.""" +from datetime import datetime, timedelta, timezone + +from constance import config +from nautobot.apps.config import get_app_settings_or_config +from nautobot.apps.jobs import register_jobs +from nautobot.extras.jobs import BooleanVar, Job + +from nautobot_chatops.helpers.slack import rotate_slack_access_token + + +class RotateSlackTokenJob(Job): + """Rotate the Slack access token using the refresh token.""" + force_rotate = BooleanVar( + description="Rotate the Slack access token now, regardless of expiration.", + default=False, + ) + + class Meta: + """Meta attributes for the RotateSlackTokenJob.""" + name = "Rotate Slack Access Token" + description = "Rotate the Slack access token if neeeded, using the refresh token." + has_sensitive_variables = False + + def run(self, force_rotate): + """__Run the job to rotate the Slack access token.""" + if not get_app_settings_or_config("nautobot_chatops", "slack_enable_token_rotation"): + self.logger.error("Slack token rotation is not enabled.") + return + + token_time = datetime.fromisoformat(config.nautobot_chatops__slack_access_token_timestamp) + if not force_rotate and datetime.now(timezone.utc) - token_time < timedelta(hours=3): + self.logger.info("Slack access token is still valid; no rotation needed.") + return + + self.logger.info("Attempting Slack access token rotation...") + new_token = rotate_slack_access_token() + if new_token: + self.logger.success("Slack access token rotated successfully.") + else: + self.logger.failure("Slack token rotation failed; will retry on next scheduled run.") + + +register_jobs(RotateSlackTokenJob) diff --git a/nautobot_chatops/sockets/slack.py b/nautobot_chatops/sockets/slack.py index e8a0f0c4..548d21ea 100644 --- a/nautobot_chatops/sockets/slack.py +++ b/nautobot_chatops/sockets/slack.py @@ -8,9 +8,9 @@ from slack_sdk.socket_mode.aiohttp import SocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse -from slack_sdk.web.async_client import AsyncWebClient from nautobot_chatops.dispatchers.slack import SlackDispatcher +from nautobot_chatops.helpers.slack import RotationAwareAsyncWebClient, get_slack_api_token from nautobot_chatops.utils import database_sync_to_async, socket_check_and_enqueue_command from nautobot_chatops.workers import commands_help, get_commands_registry, parse_command_string @@ -22,7 +22,7 @@ async def main(): SLASH_PREFIX = settings.PLUGINS_CONFIG["nautobot_chatops"].get("slack_slash_command_prefix") client = SocketModeClient( app_token=settings.PLUGINS_CONFIG["nautobot_chatops"].get("slack_app_token"), - web_client=AsyncWebClient(token=settings.PLUGINS_CONFIG["nautobot_chatops"]["slack_api_token"]), + web_client=RotationAwareAsyncWebClient(token=get_slack_api_token()), ) async def process(client: SocketModeClient, req: SocketModeRequest): @@ -49,7 +49,7 @@ async def process(client: SocketModeClient, req: SocketModeRequest): await client.send_socket_mode_response(response) await process_mention(client, req) - async def process_slash_command(client, req): + async def process_slash_command(client: SocketModeClient, req: SocketModeRequest): client.logger.debug("Processing slash command.") command = req.payload.get("command") command = command.replace(SLASH_PREFIX, "") @@ -79,7 +79,7 @@ async def process_slash_command(client, req): return await socket_check_and_enqueue_command(registry, command, subcommand, params, context, SlackDispatcher) # pylint: disable-next=too-many-locals,too-many-return-statements,too-many-branches,too-many-statements - async def process_interactive(client, req): + async def process_interactive(client: SocketModeClient, req: SocketModeRequest): client.logger.debug("Processing interactive.") payload = req.payload selected_value = "" @@ -223,7 +223,7 @@ async def process_interactive(client, req): return await socket_check_and_enqueue_command(registry, command, subcommand, params, context, SlackDispatcher) - async def process_mention(client, req): + async def process_mention(client: SocketModeClient, req: SocketModeRequest): context = { "org_id": req.payload.get("team_id"), "org_name": req.payload.get("team_domain"), From 474e5f4269f9955d70c366e67d49e589579ff019 Mon Sep 17 00:00:00 2001 From: cberetta Date: Tue, 6 Jan 2026 10:39:59 -0800 Subject: [PATCH 06/10] fix token rotation when using socket mode django.core.exceptions.SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async. --- nautobot_chatops/helpers/slack.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nautobot_chatops/helpers/slack.py b/nautobot_chatops/helpers/slack.py index c40caba6..2be96ce6 100644 --- a/nautobot_chatops/helpers/slack.py +++ b/nautobot_chatops/helpers/slack.py @@ -9,6 +9,8 @@ from slack_sdk import WebClient from slack_sdk.web.async_client import AsyncWebClient +from nautobot_chatops.utils import database_sync_to_async + logger = logging.getLogger(__name__) @@ -102,5 +104,5 @@ class RotationAwareAsyncWebClient(AsyncWebClient): async def api_call(self, api_method: str, **kwargs): """Override api_call to refresh token if needed before making the call.""" - self.token = get_slack_api_token() + self.token = await database_sync_to_async(get_slack_api_token) return await super().api_call(api_method, **kwargs) From 2cbf5817f8548816e96c1080244eb09c1c57162b Mon Sep 17 00:00:00 2001 From: Claudio Beretta Date: Tue, 6 Jan 2026 11:22:55 -0800 Subject: [PATCH 07/10] add changes fragment --- changes/421.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/421.added diff --git a/changes/421.added b/changes/421.added new file mode 100644 index 00000000..b9744bd5 --- /dev/null +++ b/changes/421.added @@ -0,0 +1 @@ +Add Slack token rotation support. From 9dc2f2f51cff9df9f52ebc68df03eda8b87fa0b7 Mon Sep 17 00:00:00 2001 From: cberetta Date: Tue, 6 Jan 2026 11:31:38 -0800 Subject: [PATCH 08/10] fix bad merge --- .github/workflows/ci.yml | 4 ++-- docs/admin/release_notes/version_3.3.md | 31 ------------------------- 2 files changed, 2 insertions(+), 33 deletions(-) delete mode 100644 docs/admin/release_notes/version_3.3.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a863b26b..1a7babb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,7 +194,7 @@ jobs: matrix: python-version: ["3.10"] # 3.13 stable is tested in unittest_report stage. db-backend: ["postgresql"] - nautobot-version: ["2.4"] + nautobot-version: ["stable"] include: - python-version: "3.10" db-backend: "postgresql" @@ -248,7 +248,7 @@ jobs: matrix: python-version: ["3.13"] db-backend: ["postgresql"] - nautobot-version: ["2.4"] + nautobot-version: ["stable"] runs-on: "ubuntu-latest" permissions: pull-requests: "write" diff --git a/docs/admin/release_notes/version_3.3.md b/docs/admin/release_notes/version_3.3.md deleted file mode 100644 index 6ca66daa..00000000 --- a/docs/admin/release_notes/version_3.3.md +++ /dev/null @@ -1,31 +0,0 @@ -# v3.3 Release Notes - -This document describes all new features and changes in the release. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## Release Overview - -- Change minimum Nautobot version to 2.4.20. -- Dropped support for Python 3.9. - - -## [v3.3.0 (2025-12-05)](https://github.com/nautobot/nautobot-app-chatops/releases/tag/v3.3.0) - -### Added - -- [#384](https://github.com/nautobot/nautobot-app-chatops/issues/384) - Added Bulk Update functionality for the AccessGrant, CommandToken and ChatOpsAccountLink models. - -### Fixed - -- [#412](https://github.com/nautobot/nautobot-app-chatops/issues/412) - Fixed bulk operations on Grafana views. - -### Dependencies - -- [#403](https://github.com/nautobot/nautobot-app-chatops/issues/403) - Updated schema-enforcer and termcolor dependencies. - -### Housekeeping - -- [#384](https://github.com/nautobot/nautobot-app-chatops/issues/384) - Refactored AccessGrant model, ChatOpsAccountLink, CommandLog, CommandToken related UI views to use `NautobotUIViewSet` and `UI component framework`. -- [#394](https://github.com/nautobot/nautobot-app-chatops/issues/394) - Refactored GrafanaDashboard related UI views to use `NautobotUIViewSet` and `UI component framework`. -- Rebaked from the cookie `nautobot-app-v2.7.0`. -- Rebaked from the cookie `nautobot-app-v2.7.1`. -- Rebaked from the cookie `nautobot-app-v2.7.2`. From e743c14e51b932564ad31856b393bb4cfb829dc1 Mon Sep 17 00:00:00 2001 From: cberetta Date: Tue, 6 Jan 2026 11:34:32 -0800 Subject: [PATCH 09/10] fix token rotation in socket mode --- nautobot_chatops/helpers/slack.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nautobot_chatops/helpers/slack.py b/nautobot_chatops/helpers/slack.py index 2be96ce6..b025e5a4 100644 --- a/nautobot_chatops/helpers/slack.py +++ b/nautobot_chatops/helpers/slack.py @@ -104,5 +104,5 @@ class RotationAwareAsyncWebClient(AsyncWebClient): async def api_call(self, api_method: str, **kwargs): """Override api_call to refresh token if needed before making the call.""" - self.token = await database_sync_to_async(get_slack_api_token) + self.token = await database_sync_to_async(get_slack_api_token)() return await super().api_call(api_method, **kwargs) From 4631a4da927997b3c88b37e694f628cd05a5ca55 Mon Sep 17 00:00:00 2001 From: cberetta Date: Tue, 6 Jan 2026 11:38:58 -0800 Subject: [PATCH 10/10] fix inaccurate error messages --- nautobot_chatops/helpers/slack.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nautobot_chatops/helpers/slack.py b/nautobot_chatops/helpers/slack.py index b025e5a4..ec333398 100644 --- a/nautobot_chatops/helpers/slack.py +++ b/nautobot_chatops/helpers/slack.py @@ -54,19 +54,19 @@ def rotate_slack_access_token() -> str | None: """ slack_client_id = get_app_settings_or_config("nautobot_chatops", "slack_client_id") if not slack_client_id: - logger.error("No Slack client ID found in Constance.") + logger.error("No Slack client ID found.") return None slack_client_secret = get_app_settings_or_config("nautobot_chatops", "slack_client_secret") if not slack_client_secret: - logger.error("No Slack client secret found in Constance.") + logger.error("No Slack client secret found.") return None # not using get_app_settings_or_config here because we want to prioritize Constance for the refresh token refresh_token = config.nautobot_chatops__slack_refresh_token or \ settings.PLUGINS_CONFIG["nautobot_chatops"].get("slack_api_token", "") if not refresh_token: - logger.error("No Slack refresh token found in Constance.") + logger.error("No Slack refresh token found.") return None new_timestamp = datetime.now(timezone.utc).isoformat()