feat: msft teams support for critical alerts

This commit is contained in:
Alexander Petric
2025-01-21 18:38:52 -05:00
parent 010928b37f
commit 1cd347df77
20 changed files with 257 additions and 40 deletions
@@ -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
@@ -3183,6 +3183,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:
@@ -13936,3 +13952,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::{
+3
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,7 @@ pub async fn run_server(
jobs::workspace_unauthed_service().layer(cors.clone()),
)
.route("/slack", post(slack_approvals::slack_app_callback_handler))
.nest("/teams", teams_ee::teams_service())
.route(
"/w/:workspace_id/jobs/slack_approval/:job_id",
get(slack_approvals::request_slack_approval),
+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)
}
+2
View File
@@ -36,6 +36,8 @@ pub mod job_s3_helpers_ee;
pub mod jobs;
pub mod more_serde;
pub mod oauth2;
#[cfg(feature = "enterprise")]
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(
@@ -37,6 +37,7 @@
'visma',
'spotify',
'snowflake_oauth',
'teams',
'xero'
]
@@ -170,10 +171,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'
@@ -27,6 +28,8 @@
import { fade } from 'svelte/transition'
import { base } from '$lib/base'
import SimpleEditor from './SimpleEditor.svelte'
import { onMount } from 'svelte'
import type { TeamInfo, ChannelInfo } from '../gen/types.gen'
export let setting: Setting
export let version: string
@@ -40,6 +43,11 @@
attempted_at: string
} | null
let teams: TeamInfo[] = []
let selectedTeam: TeamInfo | null = null
let selectedChannel: ChannelInfo | null = null
let isFetching = false
function showSetting(setting: string, values: Record<string, any>) {
if (setting == 'dev_instance') {
if (values['license_key'] == undefined) {
@@ -111,6 +119,55 @@
valid: false
}
}
async function fetchTeams() {
isFetching = true
try {
teams = await TeamsService.syncTeams()
} catch (error) {
console.error('Error fetching teams:', error)
} finally {
isFetching = false
}
}
onMount(async () => {
await fetchTeams()
const storedTeams = $values['critical_error_channels']?.find((el) =>
el.hasOwnProperty('teams_channel')
)?.teams_channel
if (storedTeams) {
selectedTeam = teams.find((team) => team.team_name === storedTeams.team_name) || null
selectedChannel =
selectedTeam?.channels.find((channel) => channel.channel_id === storedTeams.channel_id) ||
null
}
})
function handleTeamChange(event: Event) {
const teamId = (event.target as HTMLSelectElement).value
selectedTeam = teams.find((team) => team.team_id === teamId) || null
selectedChannel = null
console.log(selectedTeam)
}
function handleChannelChange(event: Event, setting: Setting, i: number) {
const channelId = (event.target as HTMLSelectElement).value
if (selectedTeam) {
selectedChannel =
selectedTeam.channels.find((channel) => channel.channel_id === channelId) || null
}
if (event.target?.['value']) {
$values[setting.key][i] = {
teams_channel: {
team_id: selectedTeam?.team_id,
team_name: selectedTeam?.team_name,
channel_id: channelId,
channel_name: selectedChannel?.channel_name
}
}
}
}
</script>
{#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null)}
@@ -339,7 +396,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 +404,17 @@
}
}
}}
value={v && 'slack_channel' in v ? 'slack_channel' : 'email'}
value={!v
? 'email'
: 'slack_channel' in v
? 'slack_channel'
: 'teams_channel' in v
? 'teams_channel'
: '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 +429,43 @@
}}
value={v?.slack_channel ?? ''}
/>
{:else if v && 'teams_channel' in v}
<div class="flex flex-row gap-2 w-full">
<select on:change={handleTeamChange}>
<option value="" disabled selected={!selectedTeam}>Select team</option>
{#each teams as team}
<option
value={team.team_id}
selected={selectedTeam?.team_id === team.team_id}
>
{team.team_name}
</option>
{/each}
</select>
{#if selectedTeam}
<select
id="channel-select"
on:change={(e) => handleChannelChange(e, setting, i)}
>
<option value="" disabled selected={!selectedChannel}
>Select channel</option
>
{#each selectedTeam.channels as channel}
<option
value={channel.channel_id}
selected={selectedChannel?.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"
+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#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',