feat: msft teams support for critical alerts (#5113)

* feat: msft teams support for critical alerts

* ee changes

* ee

* sqlx prep

* multiple teams channels

* commit file, not symlink

* improve reactivity

* docs link

* Update ee-repo-ref.txt
This commit is contained in:
Alexander Petric
2025-01-23 13:57:13 -05:00
committed by GitHub
parent e5308553cb
commit a88fbb238a
29 changed files with 387 additions and 42 deletions
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, account, is_oauth, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (workspace_id, path) DO UPDATE SET value = $3, expires_at = $7",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Int4",
"Bool",
"Timestamptz"
]
},
"nullable": []
},
"hash": "08dd2ea6b17a52bce352d6443d7d009cfc9da0d3b2bd1f40d422b550779e5324"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value, is_secret, expires_at FROM variable WHERE path = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_secret",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "44a12919fb154055f1142cc078ef131f8a0c9cdb37cfba6283a6718480b02a4b"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM global_settings WHERE name = 'teams'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "84d048ea323758842dc564c700b524d1a5b196a7b77afcb02f46b84b22088bbf"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO global_settings (name, value)\n VALUES ('teams', $1)\n ON CONFLICT (name) DO UPDATE SET value = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "9c72b2962d0919353cfe5af710e857d432dff44e343b8f0610208d42ff5afd14"
}
+1 -1
View File
@@ -1 +1 @@
ddcfdfc18a9833a5fc4e62ad62a265ef1e06a0aa
0c89b8974ff6e1c9eda2134f09d4a03f18b57c15
@@ -0,0 +1 @@
DELETE FROM global_settings WHERE name = 'teams';
@@ -0,0 +1 @@
INSERT INTO global_settings (name, value) VALUES ('teams', '{}');
+4 -1
View File
@@ -40,7 +40,7 @@ use windmill_common::{
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, PIP_INDEX_URL_SETTING,
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING,
TIMEOUT_WAIT_RESULT_SETTING,
TIMEOUT_WAIT_RESULT_SETTING, TEAMS_SETTING
},
scripts::ScriptLang,
stats_ee::schedule_stats,
@@ -734,6 +734,9 @@ Windmill Community Edition {GIT_VERSION}
SMTP_SETTING => {
reload_smtp_config(&db).await;
},
TEAMS_SETTING => {
tracing::info!("Teams setting changed.");
},
INDEXER_SETTING => {
reload_indexer_config(&db).await;
},
+1 -1
View File
@@ -1479,7 +1479,7 @@ pub async fn reload_base_url_setting(db: &DB) -> error::Result<()> {
#[cfg(feature = "oauth2")]
{
let mut l = windmill_api::OAUTH_CLIENTS.write().await;
*l = windmill_api::oauth2_ee::build_oauth_clients(&base_url, oauths)
*l = windmill_api::oauth2_ee::build_oauth_clients(&base_url, oauths, db).await
.map_err(|e| tracing::error!("Error building oauth clients (is the oauth.json mounted and in correct format? Use '{}' as minimal oauth.json): {}", "{}", e))
.unwrap();
}
+62
View File
@@ -3234,6 +3234,22 @@ paths:
type: array
items:
type: string
/teams/sync:
post:
operationId: syncTeams
summary: synchronize Microsoft Teams information (teams/channels)
tags:
- teams
responses:
'200':
description: Teams information successfully synchronized
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/TeamInfo'
/w/{workspace}/resources/create:
post:
@@ -14012,3 +14028,49 @@ components:
format: date-time
required:
- trigger_kind
TeamInfo:
type: object
required:
- team_id
- team_name
- channels
properties:
team_id:
type: string
description: The unique identifier of the Microsoft Teams team
example: "19:abc123def456@thread.tacv2"
team_name:
type: string
description: The display name of the Microsoft Teams team
example: "Engineering Team"
channels:
type: array
description: List of channels within the team
items:
$ref: '#/components/schemas/ChannelInfo'
ChannelInfo:
type: object
required:
- channel_id
- channel_name
- tenant_id
- service_url
properties:
channel_id:
type: string
description: The unique identifier of the channel
example: "19:channel123@thread.tacv2"
channel_name:
type: string
description: The display name of the channel
example: "General"
tenant_id:
type: string
description: The Microsoft Teams tenant identifier
example: "12345678-1234-1234-1234-123456789012"
service_url:
type: string
description: The service URL for the channel
example: "https://smba.trafficmanager.net/amer/12345678-1234-1234-1234-123456789012/"
+2 -4
View File
@@ -1,7 +1,5 @@
use crate::{
db::{ApiAuthed, DB},
variables::decrypt,
};
use crate::db::{ApiAuthed, DB};
use windmill_common::variables::decrypt;
use anthropic::AnthropicCache;
use axum::{
body::Bytes,
+1 -1
View File
@@ -13,10 +13,10 @@ use crate::{
resources::get_resource_value_interpolated_internal,
users::{require_owner_of_path, OptAuthed},
utils::WithStarredInfoQuery,
variables::encrypt,
webhook_util::{WebhookMessage, WebhookShared},
HTTP_CLIENT,
};
use windmill_common::variables::encrypt;
#[cfg(feature = "parquet")]
use crate::{
job_helpers_ee::{
+13
View File
@@ -96,6 +96,8 @@ mod scripts;
mod service_logs;
mod settings;
mod slack_approvals;
#[cfg(feature = "enterprise")]
mod teams_ee;
#[cfg(feature = "smtp")]
mod smtp_server_ee;
mod static_assets;
@@ -435,6 +437,17 @@ pub async fn run_server(
jobs::workspace_unauthed_service().layer(cors.clone()),
)
.route("/slack", post(slack_approvals::slack_app_callback_handler))
.nest("/teams", {
#[cfg(feature = "enterprise")]
{
teams_ee::teams_service()
}
#[cfg(not(feature = "enterprise"))]
{
Router::new()
}
})
.route(
"/w/:workspace_id/jobs/slack_approval/:job_id",
get(slack_approvals::request_slack_approval),
+2 -1
View File
@@ -80,9 +80,10 @@ pub struct AllClients {
}
#[cfg(feature = "oauth2")]
pub fn build_oauth_clients(
pub async fn build_oauth_clients(
_base_url: &str,
_oauths_from_config: Option<HashMap<String, OAuthClient>>,
_db: &DB,
) -> anyhow::Result<AllClients> {
// Implementation is not open source
return Ok(AllClients {
+5
View File
@@ -0,0 +1,5 @@
use axum::Router;
pub fn teams_service() -> Router {
Router::new()
}
+1 -15
View File
@@ -31,7 +31,7 @@ use windmill_common::{
};
use lazy_static::lazy_static;
use magic_crypt::{MagicCrypt256, MagicCryptError, MagicCryptTrait};
use windmill_common::variables::{decrypt, encrypt};
use serde::Deserialize;
use sqlx::{Postgres, Transaction};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
@@ -676,17 +676,3 @@ pub async fn get_value_internal<'c>(
Ok(r)
}
pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String {
mc.encrypt_str_to_base64(value)
}
pub fn decrypt(mc: &MagicCrypt256, value: String) -> Result<String> {
mc.decrypt_base64_to_string(value).map_err(|e| match e {
MagicCryptError::DecryptError(_) => Error::InternalErr(
"Could not decrypt value. The value may have been encrypted with a different key."
.to_string(),
),
_ => Error::InternalErr(e.to_string()),
})
}
+1 -1
View File
@@ -52,7 +52,7 @@ use windmill_git_sync::handle_deployment_metadata;
#[cfg(feature = "enterprise")]
use windmill_common::utils::require_admin_or_devops;
use crate::variables::{decrypt, encrypt};
use windmill_common::variables::{decrypt, encrypt};
use hyper::StatusCode;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Postgres, Transaction};
@@ -36,7 +36,7 @@ use windmill_common::{
variables::ExportableListableVariable,
};
use crate::variables::decrypt;
use windmill_common::variables::decrypt;
use hyper::header;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -17,6 +17,7 @@ pub const PIP_INDEX_URL_SETTING: &str = "pip_index_url";
pub const SCIM_TOKEN_SETTING: &str = "scim_token";
pub const SAML_METADATA_SETTING: &str = "saml_metadata";
pub const SMTP_SETTING: &str = "smtp_settings";
pub const TEAMS_SETTING: &str = "teams";
pub const INDEXER_SETTING: &str = "indexer_settings";
pub const TIMEOUT_WAIT_RESULT_SETTING: &str = "timeout_wait_result";
@@ -94,3 +95,21 @@ pub const ENV_SETTINGS: [&str; 54] = [
"OTEL_TRACING",
"OTEL_LOGS",
];
use crate::error;
use sqlx::Pool;
use sqlx::postgres::Postgres;
pub async fn load_value_from_global_settings(
db: &Pool<Postgres>,
setting_name: &str,
) -> error::Result<Option<serde_json::Value>> {
let r = sqlx::query!(
"SELECT value FROM global_settings WHERE name = $1",
setting_name
)
.fetch_optional(db)
.await?
.map(|x| x.value);
Ok(r)
}
+1
View File
@@ -36,6 +36,7 @@ pub mod job_s3_helpers_ee;
pub mod jobs;
pub mod more_serde;
pub mod oauth2;
pub mod teams_ee;
pub mod otel_ee;
pub mod queue;
pub mod s3_helpers;
+3
View File
@@ -18,6 +18,9 @@ pub const WORKSPACE_SLACK_BOT_TOKEN_PATH: &str = "f/slack_bot/bot_token";
pub const GLOBAL_SLACK_BOT_TOKEN_PATH: &str = "f/slack_bot/global_bot_token";
pub const GLOBAL_TEAMS_BOT_TOKEN_PATH: &str = "f/teams_bot/global_bot_token";
pub const GLOBAL_TEAMS_API_TOKEN_PATH: &str = "f/teams_bot/global_api_token";
lazy_static::lazy_static! {
pub static ref REQUIRE_PREEXISTING_USER_FOR_OAUTH: AtomicBool = AtomicBool::new(std::env::var("REQUIRE_PREEXISTING_USER_FOR_OAUTH")
+15
View File
@@ -9,6 +9,7 @@
use chrono::{SecondsFormat, Utc};
use magic_crypt::{MagicCrypt256, MagicCryptError, MagicCryptTrait};
use serde::{Deserialize, Serialize};
use crate::error;
use crate::{worker::WORKER_GROUP, BASE_URL, DB};
@@ -157,6 +158,20 @@ pub async fn decrypt_value_with_mc(
})?)
}
pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String {
mc.encrypt_str_to_base64(value)
}
pub fn decrypt(mc: &MagicCrypt256, value: String) -> error::Result<String> {
mc.decrypt_base64_to_string(value).map_err(|e| match e {
MagicCryptError::DecryptError(_) => error::Error::InternalErr(
"Could not decrypt value. The value may have been encrypted with a different key."
.to_string(),
),
_ => error::Error::InternalErr(e.to_string()),
})
}
pub const WM_SCHEDULED_FOR: &str = "WM_SCHEDULED_FOR";
pub async fn get_reserved_variables(
@@ -38,6 +38,7 @@
'visma',
'spotify',
'snowflake_oauth',
'teams',
'xero'
]
@@ -169,10 +170,12 @@
<div class="py-1" />
<OAuthSetting login={false} name="slack" bind:value={oauths['slack']} />
<div class="py-1" />
<OAuthSetting login={false} name="teams" bind:value={oauths['teams']} />
<div class="py-1" />
{#each Object.keys(oauths) as k}
{#if oauths[k] && !('login_config' in oauths[k])}
{#if !['slack'].includes(k) && oauths[k]}
{#if !['slack', 'teams'].includes(k) && oauths[k]}
<div class="flex flex-col gap-2 pb-4">
<div class="flex flex-row items-center gap-2">
<!-- svelte-ignore a11y-label-has-associated-control -->
@@ -9,7 +9,10 @@
export let center = false
export let isSelected = false
export let formatExtension = undefined
$: iconComponent = APP_TO_ICON_COMPONENT[name] || APP_TO_ICON_COMPONENT[name.split('_')[0]]
$: iconComponent = name === "teams"
? APP_TO_ICON_COMPONENT.ms_teams_webhook
: APP_TO_ICON_COMPONENT[name] || APP_TO_ICON_COMPONENT[name.split('_')[0]]
</script>
<div class="truncate flex flex-row gap-2 {center ? 'justify-center items-center' : ''} -pl-2">
@@ -8,6 +8,7 @@
BadgeX,
Info,
Plus,
RefreshCcw,
Slack,
X
} from 'lucide-svelte'
@@ -16,7 +17,7 @@
import ObjectStoreConfigSettings from './ObjectStoreConfigSettings.svelte'
import { sendUserToast } from '$lib/toast'
import ConfirmButton from './ConfirmButton.svelte'
import { IndexSearchService, SettingService } from '$lib/gen'
import { IndexSearchService, SettingService, TeamsService } from '$lib/gen'
import { Button, SecondsInput, Skeleton } from './common'
import Password from './Password.svelte'
import { classNames } from '$lib/utils'
@@ -40,6 +41,8 @@
attempted_at: string
} | null
let isFetching = false
function showSetting(setting: string, values: Record<string, any>) {
if (setting == 'dev_instance') {
if (values['license_key'] == undefined) {
@@ -111,6 +114,49 @@
valid: false
}
}
async function fetchTeams() {
if (isFetching) return
isFetching = true
try {
$values['teams'] = await TeamsService.syncTeams()
} catch (error) {
console.error('Error fetching teams:', error)
} finally {
isFetching = false
}
}
function handleTeamChange(event: Event, i: number) {
const teamId = (event.target as HTMLSelectElement).value
const team = $values['teams'].find((team) => team.team_id === teamId) || null
$values['critical_error_channels'][i] = {
teams_channel: {
team_id: team?.team_id,
team_name: team?.team_name,
channel_id: team?.channels[0]?.channel_id,
channel_name: team?.channels[0]?.channel_name
}
}
}
function handleChannelChange(event: Event, setting: Setting, i: number) {
const channelId = (event.target as HTMLSelectElement).value
const team = $values['teams'].find(
(team) => team.team_id === $values['critical_error_channels'][i]?.teams_channel?.team_id
)
const channel = team?.channels.find((channel) => channel.channel_id === channelId) || null
if (channelId) {
$values['critical_error_channels'][i] = {
teams_channel: {
team_id: team?.team_id,
team_name: team?.team_name,
channel_id: channel?.channel_id,
channel_name: channel?.channel_name
}
}
}
}
</script>
{#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null)}
@@ -339,7 +385,7 @@
{#each $values[setting.key] ?? [] as v, i}
<div class="flex w-full max-w-lg mt-1 gap-2 items-center">
<select
class="w-20"
class="max-w-24"
on:change={(e) => {
if (e.target?.['value']) {
$values[setting.key][i] = {
@@ -347,10 +393,14 @@
}
}
}}
value={v && 'slack_channel' in v ? 'slack_channel' : 'email'}
value={(() => {
if (!v) return 'email'
return ['slack_channel', 'teams_channel'].find((type) => type in v) || 'email'
})()}
>
<option value="email">Email</option>
<option value="slack_channel">Slack</option>
<option value="teams_channel">Teams</option>
</select>
{#if v && 'slack_channel' in v}
<input
@@ -365,6 +415,53 @@
}}
value={v?.slack_channel ?? ''}
/>
{:else if v && 'teams_channel' in v}
<div class="flex flex-row gap-2 w-full">
<select on:change={(e) => handleTeamChange(e, i)}>
<option
value=""
disabled
selected={!$values['critical_error_channels'][i]?.teams_channel?.team_id}
>Select team</option
>
{#each $values['teams'] as team}
<option
value={team.team_id}
selected={$values['critical_error_channels'][i]?.teams_channel
?.team_id === team.team_id}
>
{team.team_name}
</option>
{/each}
</select>
{#if $values['critical_error_channels'][i]?.teams_channel?.team_id}
<select
id="channel-select"
on:change={(e) => handleChannelChange(e, setting, i)}
>
<option
value=""
disabled
selected={!$values['critical_error_channels'][i]?.teams_channel
?.channel_id}>Select channel</option
>
{#each $values['teams'].find((team) => team.team_id === $values['critical_error_channels'][i]?.teams_channel?.team_id)?.channels ?? [] as channel}
<option
value={channel.channel_id}
selected={$values['critical_error_channels'][i]?.teams_channel
?.channel_id === channel.channel_id}
>
{channel.channel_name}
</option>
{/each}
</select>
{/if}
<div>
<button on:click={fetchTeams} class="flex items-center gap-1 mt-2">
<RefreshCcw size={16} class={isFetching ? 'animate-spin' : ''} />
</button>
</div>
</div>
{:else}
<input
type="email"
@@ -2,6 +2,7 @@
import { scimSamlSetting, settings, settingsKeys, type SettingStorage } from './instanceSettings'
import { Button, Tab, TabContent, Tabs } from '$lib/components/common'
import { SettingService, SettingsService } from '$lib/gen'
import type { TeamInfo } from '$lib/gen/types.gen'
import { sendUserToast } from '$lib/toast'
import { deepEqual } from 'fast-equals'
@@ -83,6 +84,34 @@
if (nvalues['indexer_settings'] == undefined) {
nvalues['indexer_settings'] = {}
}
if (nvalues['critical_error_channels'] == undefined) {
nvalues['critical_error_channels'] = []
} else {
let teams = ((await SettingService.getGlobal({ key: 'teams' })) as TeamInfo[]) ?? []
nvalues['teams'] = teams
nvalues['critical_error_channels'] = nvalues['critical_error_channels'].map((el) => {
if (el.teams_channel) {
const team = teams.find((team) => team.team_name === el.teams_channel.team_name) || null
return {
teams_channel: {
team_id: team?.team_id,
team_name: team?.team_name,
channel_id: team?.channels.find(
(channel) => channel.channel_id === el.teams_channel.channel_id
)?.channel_id,
channel_name: team?.channels.find(
(channel) => channel.channel_id === el.teams_channel.channel_id
)?.channel_name
}
}
}
return el
})
}
$values = nvalues
loading = false
+27 -10
View File
@@ -12,7 +12,7 @@
$: enabled = value != undefined
let tenant: string = ''
$: name == 'microsoft' && changeTenantId(tenant)
$: (name == 'microsoft' || name == 'teams') && changeTenantId(tenant)
onMount(() => {
try {
@@ -24,6 +24,9 @@
) {
tenant = value['login_config']['auth_url'].split('/')[3]
}
if (name === 'teams' && value?.tenant) {
tenant = value.tenant
}
} catch (e) {
console.error('Could not set tenantId', e)
}
@@ -32,13 +35,20 @@
function changeTenantId(tenant: string) {
if (value && tenant) {
if (tenant != '') {
value = {
...value,
login_config: {
auth_url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
token_url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
userinfo_url: `https://graph.microsoft.com/oidc/userinfo`,
scopes: ['openid', 'profile', 'email']
if (name === "teams") {
value = {
...value,
tenant
}
} else {
value = {
...value,
login_config: {
auth_url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
token_url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
userinfo_url: `https://graph.microsoft.com/oidc/userinfo`,
scopes: ['openid', 'profile', 'email']
}
}
}
} else {
@@ -67,7 +77,7 @@
>
{#if enabled}
<div class="p-2 rounded border mb-4">
{#if name != 'slack'}
{#if name != 'slack' && name != 'teams'}
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
@@ -81,7 +91,7 @@
<span class="text-primary font-semibold text-sm">Client Secret</span>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
</label>
{#if name == 'microsoft'}
{#if name == 'microsoft' || name == 'teams'}
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Tenant Id</span>
<input type="text" placeholder="Tenant Id" bind:value={tenant} />
@@ -166,6 +176,13 @@
under "Delegated Permissions".
</div>
</CollapseLink>
{:else if name == 'teams'}
<CollapseLink text="Instructions">
<div class="text-sm text-secondary border p-2">
Follow this guide on <a href="https://www.windmill.dev/docs/misc/setup_oauth#microsoft-teams" target="_blank"
>Windmill Docs</a> to create a new Microsoft Teams App. Then paste Client ID, Tenant ID, and Client Secret here.
</div>
</CollapseLink>
{/if}
</div>
{/if}
@@ -271,7 +271,7 @@ export const settings: Record<string, Setting[]> = {
{
label: 'Critical alert channels',
description:
'Channels to send critical alerts to. SMTP and Slack must be configured below. <a href="https://www.windmill.dev/docs/core_concepts/critical_alerts">Learn more</a>',
'Channels to send critical alerts to. SMTP, Slack or Microsoft Teams must be configured below. <a href="https://www.windmill.dev/docs/core_concepts/critical_alerts">Learn more</a>',
key: 'critical_error_channels',
fieldType: 'critical_error_channels',
storage: 'setting',